Repository: PrefectHQ/fastmcp Branch: main Commit: f01c8fd7f467 Files: 1240 Total size: 9.4 MB Directory structure: gitextract_xwrq15g3/ ├── .ccignore ├── .claude/ │ ├── hooks/ │ │ └── session-init.sh │ ├── settings.json │ └── skills/ │ ├── code-review/ │ │ └── SKILL.md │ ├── python-tests/ │ │ └── SKILL.md │ └── review-pr/ │ └── SKILL.md ├── .coderabbit.yaml ├── .cursor/ │ └── rules/ │ └── core-mcp-objects.mdc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug.yml │ │ ├── config.yml │ │ └── enhancement.yml │ ├── actions/ │ │ ├── run-claude/ │ │ │ └── action.yml │ │ ├── run-pytest/ │ │ │ └── action.yml │ │ └── setup-uv/ │ │ └── action.yml │ ├── dependabot.yml │ ├── pull_request_template.md │ ├── release.yml │ ├── scripts/ │ │ ├── mention/ │ │ │ ├── gh-get-review-threads.sh │ │ │ └── gh-resolve-review-thread.sh │ │ └── pr-review/ │ │ ├── pr-comment.sh │ │ ├── pr-diff.sh │ │ ├── pr-existing-comments.sh │ │ ├── pr-remove-comment.sh │ │ └── pr-review.sh │ └── workflows/ │ ├── auto-close-duplicates.yml │ ├── auto-close-needs-mre.yml │ ├── martian-test-failure.yml │ ├── martian-triage-issue.yml │ ├── marvin-comment-on-issue.yml │ ├── marvin-comment-on-pr.yml │ ├── marvin-dedupe-issues.yml │ ├── marvin-label-triage.yml │ ├── minimize-resolved-reviews.yml │ ├── publish.yml │ ├── run-static.yml │ ├── run-tests.yml │ ├── run-upgrade-checks.yml │ ├── update-config-schema.yml │ └── update-sdk-docs.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docs/ │ ├── .ccignore │ ├── .cursor/ │ │ └── rules/ │ │ └── mintlify.mdc │ ├── apps/ │ │ ├── development.mdx │ │ ├── low-level.mdx │ │ ├── overview.mdx │ │ ├── patterns.mdx │ │ └── prefab.mdx │ ├── assets/ │ │ └── schemas/ │ │ └── mcp_server_config/ │ │ ├── latest.json │ │ └── v1.json │ ├── changelog.mdx │ ├── cli/ │ │ ├── auth.mdx │ │ ├── client.mdx │ │ ├── generate-cli.mdx │ │ ├── inspecting.mdx │ │ ├── install-mcp.mdx │ │ ├── overview.mdx │ │ └── running.mdx │ ├── clients/ │ │ ├── auth/ │ │ │ ├── bearer.mdx │ │ │ ├── cimd.mdx │ │ │ └── oauth.mdx │ │ ├── cli.mdx │ │ ├── client.mdx │ │ ├── elicitation.mdx │ │ ├── generate-cli.mdx │ │ ├── logging.mdx │ │ ├── notifications.mdx │ │ ├── progress.mdx │ │ ├── prompts.mdx │ │ ├── resources.mdx │ │ ├── roots.mdx │ │ ├── sampling.mdx │ │ ├── tasks.mdx │ │ ├── tools.mdx │ │ └── transports.mdx │ ├── community/ │ │ ├── README.md │ │ └── showcase.mdx │ ├── css/ │ │ ├── banner.css │ │ ├── python-sdk.css │ │ ├── style.css │ │ └── version-badge.css │ ├── deployment/ │ │ ├── http.mdx │ │ ├── prefect-horizon.mdx │ │ ├── running-server.mdx │ │ └── server-configuration.mdx │ ├── development/ │ │ ├── contributing.mdx │ │ ├── releases.mdx │ │ ├── tests.mdx │ │ └── v3-notes/ │ │ ├── auth-provider-env-vars.mdx │ │ └── v3-features.mdx │ ├── docs.json │ ├── getting-started/ │ │ ├── installation.mdx │ │ ├── quickstart.mdx │ │ ├── upgrading/ │ │ │ ├── from-fastmcp-2.mdx │ │ │ ├── from-low-level-sdk.mdx │ │ │ └── from-mcp-sdk.mdx │ │ └── welcome.mdx │ ├── integrations/ │ │ ├── anthropic.mdx │ │ ├── auth0.mdx │ │ ├── authkit.mdx │ │ ├── aws-cognito.mdx │ │ ├── azure.mdx │ │ ├── chatgpt.mdx │ │ ├── claude-code.mdx │ │ ├── claude-desktop.mdx │ │ ├── cursor.mdx │ │ ├── descope.mdx │ │ ├── discord.mdx │ │ ├── eunomia-authorization.mdx │ │ ├── fastapi.mdx │ │ ├── gemini-cli.mdx │ │ ├── gemini.mdx │ │ ├── github.mdx │ │ ├── google.mdx │ │ ├── goose.mdx │ │ ├── mcp-json-configuration.mdx │ │ ├── oci.mdx │ │ ├── openai.mdx │ │ ├── openapi.mdx │ │ ├── permit.mdx │ │ ├── propelauth.mdx │ │ ├── scalekit.mdx │ │ ├── supabase.mdx │ │ └── workos.mdx │ ├── more/ │ │ └── settings.mdx │ ├── patterns/ │ │ ├── cli.mdx │ │ ├── contrib.mdx │ │ └── testing.mdx │ ├── public/ │ │ └── schemas/ │ │ └── fastmcp.json/ │ │ ├── latest.json │ │ └── v1.json │ ├── python-sdk/ │ │ ├── fastmcp-cli-__init__.mdx │ │ ├── fastmcp-cli-apps_dev.mdx │ │ ├── fastmcp-cli-auth.mdx │ │ ├── fastmcp-cli-cimd.mdx │ │ ├── fastmcp-cli-cli.mdx │ │ ├── fastmcp-cli-client.mdx │ │ ├── fastmcp-cli-discovery.mdx │ │ ├── fastmcp-cli-generate.mdx │ │ ├── fastmcp-cli-install-__init__.mdx │ │ ├── fastmcp-cli-install-claude_code.mdx │ │ ├── fastmcp-cli-install-claude_desktop.mdx │ │ ├── fastmcp-cli-install-cursor.mdx │ │ ├── fastmcp-cli-install-gemini_cli.mdx │ │ ├── fastmcp-cli-install-goose.mdx │ │ ├── fastmcp-cli-install-mcp_json.mdx │ │ ├── fastmcp-cli-install-shared.mdx │ │ ├── fastmcp-cli-install-stdio.mdx │ │ ├── fastmcp-cli-run.mdx │ │ ├── fastmcp-cli-tasks.mdx │ │ ├── fastmcp-client-__init__.mdx │ │ ├── fastmcp-client-auth-__init__.mdx │ │ ├── fastmcp-client-auth-bearer.mdx │ │ ├── fastmcp-client-auth-oauth.mdx │ │ ├── fastmcp-client-client.mdx │ │ ├── fastmcp-client-elicitation.mdx │ │ ├── fastmcp-client-logging.mdx │ │ ├── fastmcp-client-messages.mdx │ │ ├── fastmcp-client-mixins-__init__.mdx │ │ ├── fastmcp-client-mixins-prompts.mdx │ │ ├── fastmcp-client-mixins-resources.mdx │ │ ├── fastmcp-client-mixins-task_management.mdx │ │ ├── fastmcp-client-mixins-tools.mdx │ │ ├── fastmcp-client-oauth_callback.mdx │ │ ├── fastmcp-client-progress.mdx │ │ ├── fastmcp-client-roots.mdx │ │ ├── fastmcp-client-sampling-__init__.mdx │ │ ├── fastmcp-client-sampling-handlers-__init__.mdx │ │ ├── fastmcp-client-sampling-handlers-anthropic.mdx │ │ ├── fastmcp-client-sampling-handlers-google_genai.mdx │ │ ├── fastmcp-client-sampling-handlers-openai.mdx │ │ ├── fastmcp-client-tasks.mdx │ │ ├── fastmcp-client-telemetry.mdx │ │ ├── fastmcp-client-transports-__init__.mdx │ │ ├── fastmcp-client-transports-base.mdx │ │ ├── fastmcp-client-transports-config.mdx │ │ ├── fastmcp-client-transports-http.mdx │ │ ├── fastmcp-client-transports-inference.mdx │ │ ├── fastmcp-client-transports-memory.mdx │ │ ├── fastmcp-client-transports-sse.mdx │ │ ├── fastmcp-client-transports-stdio.mdx │ │ ├── fastmcp-decorators.mdx │ │ ├── fastmcp-dependencies.mdx │ │ ├── fastmcp-exceptions.mdx │ │ ├── fastmcp-experimental-__init__.mdx │ │ ├── fastmcp-experimental-sampling-__init__.mdx │ │ ├── fastmcp-experimental-sampling-handlers.mdx │ │ ├── fastmcp-experimental-transforms-__init__.mdx │ │ ├── fastmcp-experimental-transforms-code_mode.mdx │ │ ├── fastmcp-mcp_config.mdx │ │ ├── fastmcp-prompts-__init__.mdx │ │ ├── fastmcp-prompts-base.mdx │ │ ├── fastmcp-prompts-function_prompt.mdx │ │ ├── fastmcp-resources-__init__.mdx │ │ ├── fastmcp-resources-base.mdx │ │ ├── fastmcp-resources-function_resource.mdx │ │ ├── fastmcp-resources-template.mdx │ │ ├── fastmcp-resources-types.mdx │ │ ├── fastmcp-server-__init__.mdx │ │ ├── fastmcp-server-app.mdx │ │ ├── fastmcp-server-apps.mdx │ │ ├── fastmcp-server-auth-__init__.mdx │ │ ├── fastmcp-server-auth-auth.mdx │ │ ├── fastmcp-server-auth-authorization.mdx │ │ ├── fastmcp-server-auth-cimd.mdx │ │ ├── fastmcp-server-auth-jwt_issuer.mdx │ │ ├── fastmcp-server-auth-middleware.mdx │ │ ├── fastmcp-server-auth-oauth_proxy-__init__.mdx │ │ ├── fastmcp-server-auth-oauth_proxy-consent.mdx │ │ ├── fastmcp-server-auth-oauth_proxy-models.mdx │ │ ├── fastmcp-server-auth-oauth_proxy-proxy.mdx │ │ ├── fastmcp-server-auth-oauth_proxy-ui.mdx │ │ ├── fastmcp-server-auth-oidc_proxy.mdx │ │ ├── fastmcp-server-auth-providers-__init__.mdx │ │ ├── fastmcp-server-auth-providers-auth0.mdx │ │ ├── fastmcp-server-auth-providers-aws.mdx │ │ ├── fastmcp-server-auth-providers-azure.mdx │ │ ├── fastmcp-server-auth-providers-debug.mdx │ │ ├── fastmcp-server-auth-providers-descope.mdx │ │ ├── fastmcp-server-auth-providers-discord.mdx │ │ ├── fastmcp-server-auth-providers-github.mdx │ │ ├── fastmcp-server-auth-providers-google.mdx │ │ ├── fastmcp-server-auth-providers-in_memory.mdx │ │ ├── fastmcp-server-auth-providers-introspection.mdx │ │ ├── fastmcp-server-auth-providers-jwt.mdx │ │ ├── fastmcp-server-auth-providers-oci.mdx │ │ ├── fastmcp-server-auth-providers-propelauth.mdx │ │ ├── fastmcp-server-auth-providers-scalekit.mdx │ │ ├── fastmcp-server-auth-providers-supabase.mdx │ │ ├── fastmcp-server-auth-providers-workos.mdx │ │ ├── fastmcp-server-auth-redirect_validation.mdx │ │ ├── fastmcp-server-auth-ssrf.mdx │ │ ├── fastmcp-server-context.mdx │ │ ├── fastmcp-server-dependencies.mdx │ │ ├── fastmcp-server-elicitation.mdx │ │ ├── fastmcp-server-event_store.mdx │ │ ├── fastmcp-server-http.mdx │ │ ├── fastmcp-server-lifespan.mdx │ │ ├── fastmcp-server-low_level.mdx │ │ ├── fastmcp-server-middleware-__init__.mdx │ │ ├── fastmcp-server-middleware-authorization.mdx │ │ ├── fastmcp-server-middleware-caching.mdx │ │ ├── fastmcp-server-middleware-dereference.mdx │ │ ├── fastmcp-server-middleware-error_handling.mdx │ │ ├── fastmcp-server-middleware-logging.mdx │ │ ├── fastmcp-server-middleware-middleware.mdx │ │ ├── fastmcp-server-middleware-ping.mdx │ │ ├── fastmcp-server-middleware-rate_limiting.mdx │ │ ├── fastmcp-server-middleware-response_limiting.mdx │ │ ├── fastmcp-server-middleware-timing.mdx │ │ ├── fastmcp-server-middleware-tool_injection.mdx │ │ ├── fastmcp-server-mixins-__init__.mdx │ │ ├── fastmcp-server-mixins-lifespan.mdx │ │ ├── fastmcp-server-mixins-mcp_operations.mdx │ │ ├── fastmcp-server-mixins-transport.mdx │ │ ├── fastmcp-server-openapi-__init__.mdx │ │ ├── fastmcp-server-openapi-components.mdx │ │ ├── fastmcp-server-openapi-routing.mdx │ │ ├── fastmcp-server-openapi-server.mdx │ │ ├── fastmcp-server-providers-__init__.mdx │ │ ├── fastmcp-server-providers-aggregate.mdx │ │ ├── fastmcp-server-providers-base.mdx │ │ ├── fastmcp-server-providers-fastmcp_provider.mdx │ │ ├── fastmcp-server-providers-filesystem.mdx │ │ ├── fastmcp-server-providers-filesystem_discovery.mdx │ │ ├── fastmcp-server-providers-local_provider-__init__.mdx │ │ ├── fastmcp-server-providers-local_provider-decorators-__init__.mdx │ │ ├── fastmcp-server-providers-local_provider-decorators-prompts.mdx │ │ ├── fastmcp-server-providers-local_provider-decorators-resources.mdx │ │ ├── fastmcp-server-providers-local_provider-decorators-tools.mdx │ │ ├── fastmcp-server-providers-local_provider-local_provider.mdx │ │ ├── fastmcp-server-providers-openapi-__init__.mdx │ │ ├── fastmcp-server-providers-openapi-components.mdx │ │ ├── fastmcp-server-providers-openapi-provider.mdx │ │ ├── fastmcp-server-providers-openapi-routing.mdx │ │ ├── fastmcp-server-providers-proxy.mdx │ │ ├── fastmcp-server-providers-skills-__init__.mdx │ │ ├── fastmcp-server-providers-skills-claude_provider.mdx │ │ ├── fastmcp-server-providers-skills-directory_provider.mdx │ │ ├── fastmcp-server-providers-skills-skill_provider.mdx │ │ ├── fastmcp-server-providers-skills-vendor_providers.mdx │ │ ├── fastmcp-server-providers-wrapped_provider.mdx │ │ ├── fastmcp-server-proxy.mdx │ │ ├── fastmcp-server-sampling-__init__.mdx │ │ ├── fastmcp-server-sampling-run.mdx │ │ ├── fastmcp-server-sampling-sampling_tool.mdx │ │ ├── fastmcp-server-server.mdx │ │ ├── fastmcp-server-tasks-__init__.mdx │ │ ├── fastmcp-server-tasks-capabilities.mdx │ │ ├── fastmcp-server-tasks-config.mdx │ │ ├── fastmcp-server-tasks-elicitation.mdx │ │ ├── fastmcp-server-tasks-handlers.mdx │ │ ├── fastmcp-server-tasks-keys.mdx │ │ ├── fastmcp-server-tasks-notifications.mdx │ │ ├── fastmcp-server-tasks-requests.mdx │ │ ├── fastmcp-server-tasks-routing.mdx │ │ ├── fastmcp-server-tasks-subscriptions.mdx │ │ ├── fastmcp-server-telemetry.mdx │ │ ├── fastmcp-server-transforms-__init__.mdx │ │ ├── fastmcp-server-transforms-catalog.mdx │ │ ├── fastmcp-server-transforms-namespace.mdx │ │ ├── fastmcp-server-transforms-prompts_as_tools.mdx │ │ ├── fastmcp-server-transforms-resources_as_tools.mdx │ │ ├── fastmcp-server-transforms-search-__init__.mdx │ │ ├── fastmcp-server-transforms-search-base.mdx │ │ ├── fastmcp-server-transforms-search-bm25.mdx │ │ ├── fastmcp-server-transforms-search-regex.mdx │ │ ├── fastmcp-server-transforms-tool_transform.mdx │ │ ├── fastmcp-server-transforms-version_filter.mdx │ │ ├── fastmcp-server-transforms-visibility.mdx │ │ ├── fastmcp-settings.mdx │ │ ├── fastmcp-telemetry.mdx │ │ ├── fastmcp-tools-__init__.mdx │ │ ├── fastmcp-tools-base.mdx │ │ ├── fastmcp-tools-function_parsing.mdx │ │ ├── fastmcp-tools-function_tool.mdx │ │ ├── fastmcp-tools-tool_transform.mdx │ │ ├── fastmcp-utilities-__init__.mdx │ │ ├── fastmcp-utilities-async_utils.mdx │ │ ├── fastmcp-utilities-auth.mdx │ │ ├── fastmcp-utilities-cli.mdx │ │ ├── fastmcp-utilities-components.mdx │ │ ├── fastmcp-utilities-exceptions.mdx │ │ ├── fastmcp-utilities-http.mdx │ │ ├── fastmcp-utilities-inspect.mdx │ │ ├── fastmcp-utilities-json_schema.mdx │ │ ├── fastmcp-utilities-json_schema_type.mdx │ │ ├── fastmcp-utilities-lifespan.mdx │ │ ├── fastmcp-utilities-logging.mdx │ │ ├── fastmcp-utilities-mcp_server_config-__init__.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-__init__.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-environments-base.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-sources-base.mdx │ │ ├── fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx │ │ ├── fastmcp-utilities-openapi-__init__.mdx │ │ ├── fastmcp-utilities-openapi-director.mdx │ │ ├── fastmcp-utilities-openapi-formatters.mdx │ │ ├── fastmcp-utilities-openapi-json_schema_converter.mdx │ │ ├── fastmcp-utilities-openapi-models.mdx │ │ ├── fastmcp-utilities-openapi-parser.mdx │ │ ├── fastmcp-utilities-openapi-schemas.mdx │ │ ├── fastmcp-utilities-pagination.mdx │ │ ├── fastmcp-utilities-skills.mdx │ │ ├── fastmcp-utilities-tests.mdx │ │ ├── fastmcp-utilities-timeout.mdx │ │ ├── fastmcp-utilities-token_cache.mdx │ │ ├── fastmcp-utilities-types.mdx │ │ ├── fastmcp-utilities-ui.mdx │ │ ├── fastmcp-utilities-version_check.mdx │ │ └── fastmcp-utilities-versions.mdx │ ├── servers/ │ │ ├── auth/ │ │ │ ├── authentication.mdx │ │ │ ├── full-oauth-server.mdx │ │ │ ├── multi-auth.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-proxy.mdx │ │ │ ├── remote-oauth.mdx │ │ │ └── token-verification.mdx │ │ ├── authorization.mdx │ │ ├── composition.mdx │ │ ├── context.mdx │ │ ├── dependency-injection.mdx │ │ ├── elicitation.mdx │ │ ├── icons.mdx │ │ ├── lifespan.mdx │ │ ├── logging.mdx │ │ ├── middleware.mdx │ │ ├── pagination.mdx │ │ ├── progress.mdx │ │ ├── prompts.mdx │ │ ├── providers/ │ │ │ ├── custom.mdx │ │ │ ├── filesystem.mdx │ │ │ ├── local.mdx │ │ │ ├── overview.mdx │ │ │ ├── proxy.mdx │ │ │ └── skills.mdx │ │ ├── resources.mdx │ │ ├── sampling.mdx │ │ ├── server.mdx │ │ ├── storage-backends.mdx │ │ ├── tasks.mdx │ │ ├── telemetry.mdx │ │ ├── testing.mdx │ │ ├── tools.mdx │ │ ├── transforms/ │ │ │ ├── code-mode.mdx │ │ │ ├── namespace.mdx │ │ │ ├── namespacing.mdx │ │ │ ├── prompts-as-tools.mdx │ │ │ ├── resources-as-tools.mdx │ │ │ ├── tool-search.mdx │ │ │ ├── tool-transformation.mdx │ │ │ └── transforms.mdx │ │ ├── versioning.mdx │ │ └── visibility.mdx │ ├── snippets/ │ │ ├── local-focus.mdx │ │ ├── version-badge.mdx │ │ └── youtube-embed.mdx │ ├── tutorials/ │ │ ├── create-mcp-server.mdx │ │ ├── mcp.mdx │ │ └── rest-api.mdx │ ├── unify-intent.js │ ├── updates.mdx │ ├── v2/ │ │ ├── changelog.mdx │ │ ├── clients/ │ │ │ ├── auth/ │ │ │ │ ├── bearer.mdx │ │ │ │ └── oauth.mdx │ │ │ ├── client.mdx │ │ │ ├── elicitation.mdx │ │ │ ├── logging.mdx │ │ │ ├── messages.mdx │ │ │ ├── progress.mdx │ │ │ ├── prompts.mdx │ │ │ ├── resources.mdx │ │ │ ├── roots.mdx │ │ │ ├── sampling.mdx │ │ │ ├── tasks.mdx │ │ │ ├── tools.mdx │ │ │ └── transports.mdx │ │ ├── community/ │ │ │ └── showcase.mdx │ │ ├── deployment/ │ │ │ ├── http.mdx │ │ │ ├── running-server.mdx │ │ │ └── server-configuration.mdx │ │ ├── development/ │ │ │ ├── contributing.mdx │ │ │ ├── releases.mdx │ │ │ ├── tests.mdx │ │ │ └── upgrade-guide.mdx │ │ ├── getting-started/ │ │ │ ├── installation.mdx │ │ │ ├── quickstart.mdx │ │ │ └── welcome.mdx │ │ ├── integrations/ │ │ │ ├── anthropic.mdx │ │ │ ├── auth0.mdx │ │ │ ├── authkit.mdx │ │ │ ├── aws-cognito.mdx │ │ │ ├── azure.mdx │ │ │ ├── chatgpt.mdx │ │ │ ├── claude-code.mdx │ │ │ ├── claude-desktop.mdx │ │ │ ├── cursor.mdx │ │ │ ├── descope.mdx │ │ │ ├── discord.mdx │ │ │ ├── eunomia-authorization.mdx │ │ │ ├── fastapi.mdx │ │ │ ├── gemini-cli.mdx │ │ │ ├── gemini.mdx │ │ │ ├── github.mdx │ │ │ ├── google.mdx │ │ │ ├── mcp-json-configuration.mdx │ │ │ ├── oci.mdx │ │ │ ├── openai.mdx │ │ │ ├── openapi.mdx │ │ │ ├── permit.mdx │ │ │ ├── scalekit.mdx │ │ │ ├── supabase.mdx │ │ │ └── workos.mdx │ │ ├── patterns/ │ │ │ ├── cli.mdx │ │ │ ├── contrib.mdx │ │ │ ├── decorating-methods.mdx │ │ │ ├── testing.mdx │ │ │ └── tool-transformation.mdx │ │ ├── servers/ │ │ │ ├── auth/ │ │ │ │ ├── authentication.mdx │ │ │ │ ├── full-oauth-server.mdx │ │ │ │ ├── oauth-proxy.mdx │ │ │ │ ├── oidc-proxy.mdx │ │ │ │ ├── remote-oauth.mdx │ │ │ │ └── token-verification.mdx │ │ │ ├── composition.mdx │ │ │ ├── context.mdx │ │ │ ├── elicitation.mdx │ │ │ ├── icons.mdx │ │ │ ├── logging.mdx │ │ │ ├── middleware.mdx │ │ │ ├── progress.mdx │ │ │ ├── prompts.mdx │ │ │ ├── proxy.mdx │ │ │ ├── resources.mdx │ │ │ ├── sampling.mdx │ │ │ ├── server.mdx │ │ │ ├── storage-backends.mdx │ │ │ ├── tasks.mdx │ │ │ └── tools.mdx │ │ ├── tutorials/ │ │ │ ├── create-mcp-server.mdx │ │ │ ├── mcp.mdx │ │ │ └── rest-api.mdx │ │ └── updates.mdx │ └── v2-banner.js ├── examples/ │ ├── apps/ │ │ ├── chart_server.py │ │ ├── contacts/ │ │ │ └── contacts_server.py │ │ ├── datatable_server.py │ │ ├── greet_server.py │ │ ├── patterns_server.py │ │ └── qr_server/ │ │ ├── README.md │ │ ├── fastmcp.json │ │ ├── pyproject.toml │ │ └── qr_server.py │ ├── atproto_mcp/ │ │ ├── README.md │ │ ├── demo.py │ │ ├── fastmcp.json │ │ ├── pyproject.toml │ │ └── src/ │ │ └── atproto_mcp/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── _atproto/ │ │ │ ├── __init__.py │ │ │ ├── _client.py │ │ │ ├── _posts.py │ │ │ ├── _profile.py │ │ │ ├── _read.py │ │ │ └── _social.py │ │ ├── py.typed │ │ ├── server.py │ │ ├── settings.py │ │ └── types.py │ ├── auth/ │ │ ├── authkit_dcr/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── aws_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ ├── requirements.txt │ │ │ └── server.py │ │ ├── azure_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── discord_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── github_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── google_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── mounted/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── propelauth_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ ├── scalekit_oauth/ │ │ │ ├── README.md │ │ │ ├── client.py │ │ │ └── server.py │ │ └── workos_oauth/ │ │ ├── README.md │ │ ├── client.py │ │ └── server.py │ ├── code_mode/ │ │ ├── README.md │ │ ├── client.py │ │ └── server.py │ ├── complex_inputs.py │ ├── config_server.py │ ├── custom_tool_serializer_decorator.py │ ├── desktop.py │ ├── diagnostics/ │ │ ├── client_with_tracing.py │ │ └── server.py │ ├── echo.py │ ├── elicitation.py │ ├── fastmcp_config/ │ │ ├── env_interpolation_example.json │ │ ├── fastmcp.json │ │ ├── full_example.fastmcp.json │ │ ├── server.py │ │ └── simple.fastmcp.json │ ├── fastmcp_config_demo/ │ │ ├── README.md │ │ ├── fastmcp.json │ │ └── server.py │ ├── filesystem-provider/ │ │ ├── mcp/ │ │ │ ├── prompts/ │ │ │ │ └── assistant.py │ │ │ ├── resources/ │ │ │ │ └── config.py │ │ │ └── tools/ │ │ │ ├── calculator.py │ │ │ └── greeting.py │ │ └── server.py │ ├── get_file.py │ ├── in_memory_proxy_example.py │ ├── memory.fastmcp.json │ ├── memory.py │ ├── mount_example.fastmcp.json │ ├── mount_example.py │ ├── namespace_activation/ │ │ ├── README.md │ │ ├── client.py │ │ └── server.py │ ├── persistent_state/ │ │ ├── README.md │ │ ├── client.py │ │ ├── client_stdio.py │ │ └── server.py │ ├── prompts_as_tools/ │ │ ├── client.py │ │ └── server.py │ ├── providers/ │ │ └── sqlite/ │ │ ├── README.md │ │ ├── server.py │ │ └── setup_db.py │ ├── resources_as_tools/ │ │ ├── client.py │ │ └── server.py │ ├── run_with_tracing.py │ ├── sampling/ │ │ ├── README.md │ │ ├── server_fallback.py │ │ ├── structured_output.py │ │ ├── text.py │ │ └── tool_use.py │ ├── screenshot.fastmcp.json │ ├── screenshot.py │ ├── search/ │ │ ├── README.md │ │ ├── client_bm25.py │ │ ├── client_regex.py │ │ ├── server_bm25.py │ │ └── server_regex.py │ ├── simple_echo.py │ ├── skills/ │ │ ├── README.md │ │ ├── client.py │ │ ├── download_skills.py │ │ ├── sample_skills/ │ │ │ ├── code-review/ │ │ │ │ └── SKILL.md │ │ │ └── pdf-processing/ │ │ │ ├── SKILL.md │ │ │ └── reference.md │ │ └── server.py │ ├── smart_home/ │ │ ├── README.md │ │ ├── hub.fastmcp.json │ │ ├── lights.fastmcp.json │ │ ├── pyproject.toml │ │ └── src/ │ │ └── smart_home/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── hub.py │ │ ├── lights/ │ │ │ ├── __init__.py │ │ │ ├── hue_utils.py │ │ │ └── server.py │ │ ├── py.typed │ │ └── settings.py │ ├── tags_example.py │ ├── task_elicitation.py │ ├── tasks/ │ │ ├── README.md │ │ ├── client.py │ │ ├── docker-compose.yml │ │ └── server.py │ ├── testing_demo/ │ │ ├── README.md │ │ ├── pyproject.toml │ │ ├── server.py │ │ └── tests/ │ │ └── test_server.py │ ├── text_me.py │ ├── tool_result_echo.py │ └── versioning/ │ ├── client_version_selection.py │ ├── version_filters.py │ └── versioned_components.py ├── justfile ├── logo.py ├── loq.toml ├── pyproject.toml ├── scripts/ │ ├── auto_close_duplicates.py │ ├── auto_close_needs_mre.py │ └── benchmark_imports.py ├── skills/ │ └── fastmcp-client-cli/ │ └── SKILL.md ├── src/ │ └── fastmcp/ │ ├── __init__.py │ ├── cli/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── apps_dev.py │ │ ├── auth.py │ │ ├── cimd.py │ │ ├── cli.py │ │ ├── client.py │ │ ├── discovery.py │ │ ├── generate.py │ │ ├── install/ │ │ │ ├── __init__.py │ │ │ ├── claude_code.py │ │ │ ├── claude_desktop.py │ │ │ ├── cursor.py │ │ │ ├── gemini_cli.py │ │ │ ├── goose.py │ │ │ ├── mcp_json.py │ │ │ ├── shared.py │ │ │ └── stdio.py │ │ ├── run.py │ │ └── tasks.py │ ├── client/ │ │ ├── __init__.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── bearer.py │ │ │ └── oauth.py │ │ ├── client.py │ │ ├── elicitation.py │ │ ├── logging.py │ │ ├── messages.py │ │ ├── mixins/ │ │ │ ├── __init__.py │ │ │ ├── prompts.py │ │ │ ├── resources.py │ │ │ ├── task_management.py │ │ │ └── tools.py │ │ ├── oauth_callback.py │ │ ├── progress.py │ │ ├── roots.py │ │ ├── sampling/ │ │ │ ├── __init__.py │ │ │ └── handlers/ │ │ │ ├── __init__.py │ │ │ ├── anthropic.py │ │ │ ├── google_genai.py │ │ │ └── openai.py │ │ ├── tasks.py │ │ ├── telemetry.py │ │ └── transports/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── config.py │ │ ├── http.py │ │ ├── inference.py │ │ ├── memory.py │ │ ├── sse.py │ │ └── stdio.py │ ├── contrib/ │ │ ├── README.md │ │ ├── bulk_tool_caller/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── bulk_tool_caller.py │ │ │ └── example.py │ │ ├── component_manager/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ ├── component_manager.py │ │ │ └── example.py │ │ └── mcp_mixin/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── example.py │ │ └── mcp_mixin.py │ ├── decorators.py │ ├── dependencies.py │ ├── exceptions.py │ ├── experimental/ │ │ ├── __init__.py │ │ ├── sampling/ │ │ │ ├── __init__.py │ │ │ └── handlers/ │ │ │ ├── __init__.py │ │ │ └── openai.py │ │ ├── server/ │ │ │ └── openapi/ │ │ │ └── __init__.py │ │ ├── transforms/ │ │ │ ├── __init__.py │ │ │ └── code_mode.py │ │ └── utilities/ │ │ └── openapi/ │ │ └── __init__.py │ ├── mcp_config.py │ ├── prompts/ │ │ ├── __init__.py │ │ ├── base.py │ │ └── function_prompt.py │ ├── py.typed │ ├── resources/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── function_resource.py │ │ ├── template.py │ │ └── types.py │ ├── server/ │ │ ├── __init__.py │ │ ├── app.py │ │ ├── apps.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── authorization.py │ │ │ ├── cimd.py │ │ │ ├── handlers/ │ │ │ │ └── authorize.py │ │ │ ├── jwt_issuer.py │ │ │ ├── middleware.py │ │ │ ├── oauth_proxy/ │ │ │ │ ├── __init__.py │ │ │ │ ├── consent.py │ │ │ │ ├── models.py │ │ │ │ ├── proxy.py │ │ │ │ └── ui.py │ │ │ ├── oidc_proxy.py │ │ │ ├── providers/ │ │ │ │ ├── __init__.py │ │ │ │ ├── auth0.py │ │ │ │ ├── aws.py │ │ │ │ ├── azure.py │ │ │ │ ├── debug.py │ │ │ │ ├── descope.py │ │ │ │ ├── discord.py │ │ │ │ ├── github.py │ │ │ │ ├── google.py │ │ │ │ ├── in_memory.py │ │ │ │ ├── introspection.py │ │ │ │ ├── jwt.py │ │ │ │ ├── oci.py │ │ │ │ ├── propelauth.py │ │ │ │ ├── scalekit.py │ │ │ │ ├── supabase.py │ │ │ │ └── workos.py │ │ │ ├── redirect_validation.py │ │ │ └── ssrf.py │ │ ├── context.py │ │ ├── dependencies.py │ │ ├── elicitation.py │ │ ├── event_store.py │ │ ├── http.py │ │ ├── lifespan.py │ │ ├── low_level.py │ │ ├── middleware/ │ │ │ ├── __init__.py │ │ │ ├── authorization.py │ │ │ ├── caching.py │ │ │ ├── dereference.py │ │ │ ├── error_handling.py │ │ │ ├── logging.py │ │ │ ├── middleware.py │ │ │ ├── ping.py │ │ │ ├── rate_limiting.py │ │ │ ├── response_limiting.py │ │ │ ├── timing.py │ │ │ └── tool_injection.py │ │ ├── mixins/ │ │ │ ├── __init__.py │ │ │ ├── lifespan.py │ │ │ ├── mcp_operations.py │ │ │ └── transport.py │ │ ├── openapi/ │ │ │ ├── __init__.py │ │ │ ├── components.py │ │ │ ├── routing.py │ │ │ └── server.py │ │ ├── providers/ │ │ │ ├── __init__.py │ │ │ ├── aggregate.py │ │ │ ├── base.py │ │ │ ├── fastmcp_provider.py │ │ │ ├── filesystem.py │ │ │ ├── filesystem_discovery.py │ │ │ ├── local_provider/ │ │ │ │ ├── __init__.py │ │ │ │ ├── decorators/ │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── prompts.py │ │ │ │ │ ├── resources.py │ │ │ │ │ └── tools.py │ │ │ │ └── local_provider.py │ │ │ ├── openapi/ │ │ │ │ ├── README.md │ │ │ │ ├── __init__.py │ │ │ │ ├── components.py │ │ │ │ ├── provider.py │ │ │ │ └── routing.py │ │ │ ├── proxy.py │ │ │ ├── skills/ │ │ │ │ ├── __init__.py │ │ │ │ ├── _common.py │ │ │ │ ├── claude_provider.py │ │ │ │ ├── directory_provider.py │ │ │ │ ├── skill_provider.py │ │ │ │ └── vendor_providers.py │ │ │ └── wrapped_provider.py │ │ ├── proxy.py │ │ ├── sampling/ │ │ │ ├── __init__.py │ │ │ ├── run.py │ │ │ └── sampling_tool.py │ │ ├── server.py │ │ ├── tasks/ │ │ │ ├── __init__.py │ │ │ ├── capabilities.py │ │ │ ├── config.py │ │ │ ├── elicitation.py │ │ │ ├── handlers.py │ │ │ ├── keys.py │ │ │ ├── notifications.py │ │ │ ├── requests.py │ │ │ ├── routing.py │ │ │ └── subscriptions.py │ │ ├── telemetry.py │ │ └── transforms/ │ │ ├── __init__.py │ │ ├── catalog.py │ │ ├── namespace.py │ │ ├── prompts_as_tools.py │ │ ├── resources_as_tools.py │ │ ├── search/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── bm25.py │ │ │ └── regex.py │ │ ├── tool_transform.py │ │ ├── version_filter.py │ │ └── visibility.py │ ├── settings.py │ ├── telemetry.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── function_parsing.py │ │ ├── function_tool.py │ │ └── tool_transform.py │ └── utilities/ │ ├── __init__.py │ ├── async_utils.py │ ├── auth.py │ ├── cli.py │ ├── components.py │ ├── exceptions.py │ ├── http.py │ ├── inspect.py │ ├── json_schema.py │ ├── json_schema_type.py │ ├── lifespan.py │ ├── logging.py │ ├── mcp_server_config/ │ │ ├── __init__.py │ │ └── v1/ │ │ ├── __init__.py │ │ ├── environments/ │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ └── uv.py │ │ ├── mcp_server_config.py │ │ ├── schema.json │ │ └── sources/ │ │ ├── __init__.py │ │ ├── base.py │ │ └── filesystem.py │ ├── openapi/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── director.py │ │ ├── formatters.py │ │ ├── json_schema_converter.py │ │ ├── models.py │ │ ├── parser.py │ │ └── schemas.py │ ├── pagination.py │ ├── skills.py │ ├── tests.py │ ├── timeout.py │ ├── token_cache.py │ ├── types.py │ ├── ui.py │ ├── version_check.py │ └── versions.py ├── tests/ │ ├── __init__.py │ ├── cli/ │ │ ├── __init__.py │ │ ├── test_cimd_cli.py │ │ ├── test_cli.py │ │ ├── test_client_commands.py │ │ ├── test_config.py │ │ ├── test_cursor.py │ │ ├── test_discovery.py │ │ ├── test_generate_cli.py │ │ ├── test_goose.py │ │ ├── test_install.py │ │ ├── test_mcp_server_config_integration.py │ │ ├── test_mcp_server_config_schema.py │ │ ├── test_project_prepare.py │ │ ├── test_run.py │ │ ├── test_run_config.py │ │ ├── test_server_args.py │ │ ├── test_shared.py │ │ ├── test_tasks.py │ │ └── test_with_argv.py │ ├── client/ │ │ ├── __init__.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── test_oauth_cimd.py │ │ │ ├── test_oauth_client.py │ │ │ └── test_oauth_static_client.py │ │ ├── client/ │ │ │ ├── __init__.py │ │ │ ├── test_auth.py │ │ │ ├── test_client.py │ │ │ ├── test_error_handling.py │ │ │ ├── test_initialize.py │ │ │ ├── test_session.py │ │ │ ├── test_timeout.py │ │ │ └── test_transport.py │ │ ├── sampling/ │ │ │ ├── __init__.py │ │ │ └── handlers/ │ │ │ ├── __init__.py │ │ │ ├── test_anthropic_handler.py │ │ │ ├── test_google_genai_handler.py │ │ │ └── test_openai_handler.py │ │ ├── tasks/ │ │ │ ├── conftest.py │ │ │ ├── test_client_prompt_tasks.py │ │ │ ├── test_client_resource_tasks.py │ │ │ ├── test_client_task_notifications.py │ │ │ ├── test_client_task_protocol.py │ │ │ ├── test_client_tool_tasks.py │ │ │ ├── test_task_context_validation.py │ │ │ └── test_task_result_caching.py │ │ ├── telemetry/ │ │ │ ├── __init__.py │ │ │ └── test_client_tracing.py │ │ ├── test_elicitation.py │ │ ├── test_elicitation_enums.py │ │ ├── test_logs.py │ │ ├── test_notifications.py │ │ ├── test_oauth_callback_race.py │ │ ├── test_oauth_callback_xss.py │ │ ├── test_openapi.py │ │ ├── test_progress.py │ │ ├── test_roots.py │ │ ├── test_sampling.py │ │ ├── test_sampling_result_types.py │ │ ├── test_sampling_tool_loop.py │ │ ├── test_sse.py │ │ ├── test_stdio.py │ │ ├── test_streamable_http.py │ │ └── transports/ │ │ ├── __init__.py │ │ ├── test_memory_transport.py │ │ ├── test_no_redirect.py │ │ ├── test_transports.py │ │ └── test_uv_transport.py │ ├── conftest.py │ ├── contrib/ │ │ ├── __init__.py │ │ ├── test_bulk_tool_caller.py │ │ ├── test_component_manager.py │ │ └── test_mcp_mixin.py │ ├── deprecated/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── openapi/ │ │ │ └── test_openapi.py │ │ ├── server/ │ │ │ ├── __init__.py │ │ │ └── test_include_exclude_tags.py │ │ ├── test_add_tool_transformation.py │ │ ├── test_deprecated.py │ │ ├── test_exclude_args.py │ │ ├── test_function_component_imports.py │ │ ├── test_import_server.py │ │ ├── test_openapi_deprecations.py │ │ ├── test_settings.py │ │ ├── test_tool_injection_middleware.py │ │ └── test_tool_serializer.py │ ├── experimental/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── transforms/ │ │ ├── test_code_mode.py │ │ ├── test_code_mode_discovery.py │ │ └── test_code_mode_serialization.py │ ├── fs/ │ │ ├── test_discovery.py │ │ └── test_provider.py │ ├── integration_tests/ │ │ ├── __init__.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ └── test_github_provider_integration.py │ │ ├── conftest.py │ │ ├── test_github_mcp_remote.py │ │ └── test_timeout_fix.py │ ├── prompts/ │ │ ├── __init__.py │ │ ├── test_prompt.py │ │ └── test_standalone_decorator.py │ ├── resources/ │ │ ├── __init__.py │ │ ├── test_file_resources.py │ │ ├── test_function_resources.py │ │ ├── test_resource_template.py │ │ ├── test_resource_template_meta.py │ │ ├── test_resource_template_query_params.py │ │ ├── test_resources.py │ │ └── test_standalone_decorator.py │ ├── server/ │ │ ├── __init__.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── oauth_proxy/ │ │ │ │ ├── __init__.py │ │ │ │ ├── conftest.py │ │ │ │ ├── test_authorization.py │ │ │ │ ├── test_client_registration.py │ │ │ │ ├── test_config.py │ │ │ │ ├── test_e2e.py │ │ │ │ ├── test_oauth_proxy.py │ │ │ │ ├── test_tokens.py │ │ │ │ └── test_ui.py │ │ │ ├── providers/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_auth0.py │ │ │ │ ├── test_aws.py │ │ │ │ ├── test_azure.py │ │ │ │ ├── test_azure_scopes.py │ │ │ │ ├── test_descope.py │ │ │ │ ├── test_discord.py │ │ │ │ ├── test_github.py │ │ │ │ ├── test_google.py │ │ │ │ ├── test_http_client.py │ │ │ │ ├── test_introspection.py │ │ │ │ ├── test_propelauth.py │ │ │ │ ├── test_scalekit.py │ │ │ │ ├── test_supabase.py │ │ │ │ └── test_workos.py │ │ │ ├── test_auth_provider.py │ │ │ ├── test_authorization.py │ │ │ ├── test_cimd.py │ │ │ ├── test_cimd_validators.py │ │ │ ├── test_debug_verifier.py │ │ │ ├── test_enhanced_error_responses.py │ │ │ ├── test_jwt_issuer.py │ │ │ ├── test_jwt_provider.py │ │ │ ├── test_jwt_provider_bearer.py │ │ │ ├── test_multi_auth.py │ │ │ ├── test_oauth_consent_flow.py │ │ │ ├── test_oauth_consent_page.py │ │ │ ├── test_oauth_mounting.py │ │ │ ├── test_oauth_proxy_redirect_validation.py │ │ │ ├── test_oauth_proxy_storage.py │ │ │ ├── test_oidc_proxy.py │ │ │ ├── test_oidc_proxy_token.py │ │ │ ├── test_redirect_validation.py │ │ │ ├── test_remote_auth_provider.py │ │ │ ├── test_ssrf_protection.py │ │ │ └── test_static_token_verifier.py │ │ ├── http/ │ │ │ ├── __init__.py │ │ │ ├── test_bearer_auth_backend.py │ │ │ ├── test_custom_routes.py │ │ │ ├── test_http_auth_middleware.py │ │ │ ├── test_http_dependencies.py │ │ │ ├── test_http_middleware.py │ │ │ └── test_stale_access_token.py │ │ ├── middleware/ │ │ │ ├── __init__.py │ │ │ ├── test_caching.py │ │ │ ├── test_dereference.py │ │ │ ├── test_error_handling.py │ │ │ ├── test_initialization_middleware.py │ │ │ ├── test_logging.py │ │ │ ├── test_middleware.py │ │ │ ├── test_middleware_nested.py │ │ │ ├── test_ping.py │ │ │ ├── test_rate_limiting.py │ │ │ ├── test_response_limiting.py │ │ │ ├── test_timing.py │ │ │ └── test_tool_injection.py │ │ ├── mount/ │ │ │ ├── __init__.py │ │ │ ├── test_advanced.py │ │ │ ├── test_filtering.py │ │ │ ├── test_mount.py │ │ │ ├── test_prompts.py │ │ │ ├── test_proxy.py │ │ │ └── test_resources.py │ │ ├── providers/ │ │ │ ├── __init__.py │ │ │ ├── local_provider_tools/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_context.py │ │ │ │ ├── test_decorator.py │ │ │ │ ├── test_enabled.py │ │ │ │ ├── test_local_provider_tools.py │ │ │ │ ├── test_output_schema.py │ │ │ │ ├── test_parameters.py │ │ │ │ └── test_tags.py │ │ │ ├── openapi/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_comprehensive.py │ │ │ │ ├── test_deepobject_style.py │ │ │ │ ├── test_end_to_end_compatibility.py │ │ │ │ ├── test_openapi_features.py │ │ │ │ ├── test_openapi_performance.py │ │ │ │ ├── test_parameter_collisions.py │ │ │ │ ├── test_performance_comparison.py │ │ │ │ └── test_server.py │ │ │ ├── proxy/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_proxy_client.py │ │ │ │ ├── test_proxy_server.py │ │ │ │ └── test_stateful_proxy_client.py │ │ │ ├── test_base_provider.py │ │ │ ├── test_fastmcp_provider.py │ │ │ ├── test_local_provider.py │ │ │ ├── test_local_provider_prompts.py │ │ │ ├── test_local_provider_resources.py │ │ │ ├── test_skills_provider.py │ │ │ ├── test_skills_vendor_providers.py │ │ │ └── test_transforming_provider.py │ │ ├── sampling/ │ │ │ ├── __init__.py │ │ │ ├── test_prepare_tools.py │ │ │ └── test_sampling_tool.py │ │ ├── tasks/ │ │ │ ├── __init__.py │ │ │ ├── conftest.py │ │ │ ├── test_context_background_task.py │ │ │ ├── test_custom_subclass_tasks.py │ │ │ ├── test_notifications.py │ │ │ ├── test_progress_dependency.py │ │ │ ├── test_resource_task_meta_parameter.py │ │ │ ├── test_server_tasks_parameter.py │ │ │ ├── test_sync_function_task_disabled.py │ │ │ ├── test_task_capabilities.py │ │ │ ├── test_task_config.py │ │ │ ├── test_task_dependencies.py │ │ │ ├── test_task_elicitation_relay.py │ │ │ ├── test_task_meta_parameter.py │ │ │ ├── test_task_metadata.py │ │ │ ├── test_task_methods.py │ │ │ ├── test_task_mount.py │ │ │ ├── test_task_prompts.py │ │ │ ├── test_task_protocol.py │ │ │ ├── test_task_proxy.py │ │ │ ├── test_task_resources.py │ │ │ ├── test_task_return_types.py │ │ │ ├── test_task_security.py │ │ │ ├── test_task_status_notifications.py │ │ │ ├── test_task_tools.py │ │ │ └── test_task_ttl.py │ │ ├── telemetry/ │ │ │ ├── __init__.py │ │ │ ├── test_provider_tracing.py │ │ │ └── test_server_tracing.py │ │ ├── test_app_state.py │ │ ├── test_auth_integration.py │ │ ├── test_auth_integration_errors.py │ │ ├── test_context.py │ │ ├── test_dependencies.py │ │ ├── test_dependencies_advanced.py │ │ ├── test_event_store.py │ │ ├── test_file_server.py │ │ ├── test_icons.py │ │ ├── test_input_validation.py │ │ ├── test_log_level.py │ │ ├── test_logging.py │ │ ├── test_pagination.py │ │ ├── test_providers.py │ │ ├── test_run_server.py │ │ ├── test_server.py │ │ ├── test_server_docket.py │ │ ├── test_server_lifespan.py │ │ ├── test_session_visibility.py │ │ ├── test_streamable_http_no_redirect.py │ │ ├── test_tool_annotations.py │ │ ├── test_tool_transformation.py │ │ ├── transforms/ │ │ │ ├── test_catalog.py │ │ │ ├── test_prompts_as_tools.py │ │ │ ├── test_resources_as_tools.py │ │ │ ├── test_search.py │ │ │ └── test_visibility.py │ │ └── versioning/ │ │ ├── __init__.py │ │ ├── test_calls.py │ │ ├── test_filtering.py │ │ ├── test_mounting.py │ │ ├── test_versioning.py │ │ └── test_visibility_version_fallback.py │ ├── telemetry/ │ │ ├── __init__.py │ │ └── test_module.py │ ├── test_apps.py │ ├── test_apps_prefab.py │ ├── test_fastmcp_app.py │ ├── test_json_schema_generation.py │ ├── test_mcp_config.py │ ├── tools/ │ │ ├── __init__.py │ │ ├── test_standalone_decorator.py │ │ ├── test_tool_future_annotations.py │ │ ├── test_tool_timeout.py │ │ ├── tool/ │ │ │ ├── __init__.py │ │ │ ├── test_callable.py │ │ │ ├── test_content.py │ │ │ ├── test_output_schema.py │ │ │ ├── test_results.py │ │ │ ├── test_title.py │ │ │ └── test_tool.py │ │ └── tool_transform/ │ │ ├── __init__.py │ │ ├── test_args.py │ │ ├── test_metadata.py │ │ ├── test_schemas.py │ │ └── test_tool_transform.py │ └── utilities/ │ ├── __init__.py │ ├── json_schema_type/ │ │ ├── __init__.py │ │ ├── test_advanced.py │ │ ├── test_constraints.py │ │ ├── test_containers.py │ │ ├── test_formats.py │ │ ├── test_json_schema_type.py │ │ └── test_unions.py │ ├── openapi/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_allof_requestbody.py │ │ ├── test_circular_references.py │ │ ├── test_direct_array_schemas.py │ │ ├── test_director.py │ │ ├── test_legacy_compatibility.py │ │ ├── test_models.py │ │ ├── test_nullable_fields.py │ │ ├── test_parser.py │ │ ├── test_propertynames_ref_rewrite.py │ │ ├── test_schemas.py │ │ └── test_transitive_references.py │ ├── test_async_utils.py │ ├── test_auth.py │ ├── test_cli.py │ ├── test_components.py │ ├── test_inspect.py │ ├── test_inspect_icons.py │ ├── test_json_schema.py │ ├── test_logging.py │ ├── test_skills.py │ ├── test_tests.py │ ├── test_token_cache.py │ ├── test_typeadapter.py │ ├── test_types.py │ └── test_version_check.py └── v3-notes/ ├── get-methods-consolidation.md ├── prompt-internal-types.md ├── provider-architecture.md ├── provider-test-pattern.md ├── resource-internal-types.md ├── task-meta-parameter.md └── visibility.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .ccignore ================================================ .pre-commit-config.yaml .github/ docs/changelog.mdx docs/python-sdk/ examples/ src/fastmcp/contrib/ tests/contrib/ ================================================ FILE: .claude/hooks/session-init.sh ================================================ #!/bin/bash set -e # Only run in remote/cloud environments if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then exit 0 fi command -v gh &> /dev/null && exit 0 LOCAL_BIN="$HOME/.local/bin" mkdir -p "$LOCAL_BIN" ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') VERSION=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4) TARBALL="gh_${VERSION#v}_linux_${ARCH}.tar.gz" echo "Installing gh ${VERSION}..." TEMP=$(mktemp -d) trap 'rm -rf "$TEMP"' EXIT curl -fsSL "https://github.com/cli/cli/releases/download/${VERSION}/${TARBALL}" | tar -xz -C "$TEMP" cp "$TEMP"/gh_*/bin/gh "$LOCAL_BIN/gh" chmod 755 "$LOCAL_BIN/gh" [ -n "$CLAUDE_ENV_FILE" ] && echo "export PATH=\"$LOCAL_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE" echo "gh installed: $("$LOCAL_BIN/gh" --version | head -1)" ================================================ FILE: .claude/settings.json ================================================ { "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", "command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh", "timeout": 120 } ] } ] } } ================================================ FILE: .claude/skills/code-review/SKILL.md ================================================ --- name: reviewing-code description: Review code for quality, maintainability, and correctness. Use when reviewing pull requests, evaluating code changes, or providing feedback on implementations. Focuses on API design, patterns, and actionable feedback. --- # Code Review ## Philosophy Code review maintains a healthy codebase while helping contributors succeed. The burden of proof is on the PR to demonstrate it adds value. Your job is to help it get there through actionable feedback. **Critical**: A perfectly written PR that adds unwanted functionality must still be rejected. The code must advance the codebase in the intended direction. When rejecting, provide clear guidance on how to align with project goals. Be friendly and welcoming while maintaining high standards. Call out what works well. When code needs improvement, be specific about why and how to fix it. ## What to Focus On ### Does this advance the codebase correctly? Even perfect code for unwanted features should be rejected. ### Dependency version compatibility When a PR adapts code to a new version of a dependency (e.g., removing a parameter that was dropped upstream, using a new API): - **The version pin in `pyproject.toml` must match.** If the change breaks compatibility with the previously-pinned minimum version, the minimum version must be bumped. Otherwise users on the old version get a regression. - **If backwards compatibility with the old version is desired**, the code must handle both versions (e.g., try/except, version check). Simply deleting the old API usage without bumping the pin is always wrong — it silently breaks users on the old version. - **Lock file (`uv.lock`) changes should be scoped to the PR's purpose.** A PR fixing a ty compatibility issue should not also include unrelated dependency version bumps (anthropic, google-auth, etc.) from running `uv sync --upgrade`. These create noise and make the diff harder to review. ### API design and naming Identify confusing patterns or non-idiomatic code: - Parameter values that contradict defaults - Mutable default arguments - Unclear naming that will confuse future readers - Inconsistent patterns with the rest of the codebase ### Specific improvements Provide actionable feedback, not generic observations. ### User ergonomics Think about the API from a user's perspective. Is it intuitive? What's the learning curve? ## For Agent Reviewers 1. **Read the full context**: Examine related files, tests, and documentation before reviewing 2. **Check against established patterns**: Look for consistency with codebase conventions 3. **Verify functionality claims**: Understand what the code actually does, not just what it claims 4. **Consider edge cases**: Think through error conditions and boundary scenarios ## What to Avoid - Generic feedback without specifics - Hypothetical problems unlikely to occur - Nitpicking organizational choices without strong reason - Summarizing what the PR already describes - Star ratings or excessive emojis - Bikeshedding style preferences when functionality is correct - Requesting changes without suggesting solutions - Focusing on personal coding style over project conventions ## Tone - Acknowledge good decisions: "This API design is clean" - Be direct but respectful - Explain impact: "This will confuse users because..." - Remember: Someone else maintains this code forever ## Decision Framework Before approving, ask: 1. Does this PR achieve its stated purpose? 2. Is that purpose aligned with where the codebase should go? 3. Would I be comfortable maintaining this code? 4. Have I actually understood what it does, not just what it claims? 5. Does this change introduce technical debt? If something needs work, your review should help it get there through specific, actionable feedback. If it's solving the wrong problem, say so clearly. ## Comment Examples **Good comments:** | Instead of | Write | |------------|-------| | "Add more tests" | "The `handle_timeout` method needs tests for the edge case where timeout=0" | | "This API is confusing" | "The parameter name `data` is ambiguous - consider `message_content` to match the MCP specification" | | "This could be better" | "This approach works but creates a circular dependency. Consider moving the validation to `utils/validators.py`" | ## Checklist Before approving, verify: - [ ] All required development workflow steps completed (uv sync, prek, pytest) - [ ] Changes align with repository patterns and conventions - [ ] API changes are documented and backwards-compatible where possible - [ ] Error handling follows project patterns (specific exception types) - [ ] Tests cover new functionality and edge cases - [ ] The change advances the codebase in the intended direction ================================================ FILE: .claude/skills/python-tests/SKILL.md ================================================ --- name: testing-python description: Write and evaluate effective Python tests using pytest. Use when writing tests, reviewing test code, debugging test failures, or improving test coverage. Covers test design, fixtures, parameterization, mocking, and async testing. --- # Writing Effective Python Tests ## Core Principles Every test should be **atomic**, **self-contained**, and test **single functionality**. A test that tests multiple things is harder to debug and maintain. ## Test Structure ### Atomic unit tests Each test should verify a single behavior. The test name should tell you what's broken when it fails. Multiple assertions are fine when they all verify the same behavior. ```python # Good: Name tells you what's broken def test_user_creation_sets_defaults(): user = User(name="Alice") assert user.role == "member" assert user.id is not None assert user.created_at is not None # Bad: If this fails, what behavior is broken? def test_user(): user = User(name="Alice") assert user.role == "member" user.promote() assert user.role == "admin" assert user.can_delete_others() ``` ### Use parameterization for variations of the same concept ```python import pytest @pytest.mark.parametrize("input,expected", [ ("hello", "HELLO"), ("World", "WORLD"), ("", ""), ("123", "123"), ]) def test_uppercase_conversion(input, expected): assert input.upper() == expected ``` ### Use separate tests for different functionality Don't parameterize unrelated behaviors. If the test logic differs, write separate tests. ## Project-Specific Rules ### No async markers needed This project uses `asyncio_mode = "auto"` globally. Write async tests without decorators: ```python # Correct async def test_async_operation(): result = await some_async_function() assert result == expected # Wrong - don't add this @pytest.mark.asyncio async def test_async_operation(): ... ``` ### Imports at module level Put ALL imports at the top of the file: ```python # Correct import pytest from fastmcp import FastMCP from fastmcp.client import Client async def test_something(): mcp = FastMCP("test") ... # Wrong - no local imports async def test_something(): from fastmcp import FastMCP # Don't do this ... ``` ### Use in-memory transport for testing Pass FastMCP servers directly to clients: ```python from fastmcp import FastMCP from fastmcp.client import Client mcp = FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" async def test_greet_tool(): async with Client(mcp) as client: result = await client.call_tool("greet", {"name": "World"}) assert result[0].text == "Hello, World!" ``` Only use HTTP transport when explicitly testing network features. ### Inline snapshots for complex data Use `inline-snapshot` for testing JSON schemas and complex structures: ```python from inline_snapshot import snapshot def test_schema_generation(): schema = generate_schema(MyModel) assert schema == snapshot() # Will auto-populate on first run ``` Commands: - `pytest --inline-snapshot=create` - populate empty snapshots - `pytest --inline-snapshot=fix` - update after intentional changes ## Fixtures ### Prefer function-scoped fixtures ```python @pytest.fixture def client(): return Client() async def test_with_client(client): result = await client.ping() assert result is not None ``` ### Use `tmp_path` for file operations ```python def test_file_writing(tmp_path): file = tmp_path / "test.txt" file.write_text("content") assert file.read_text() == "content" ``` ## Mocking ### Mock at the boundary ```python from unittest.mock import patch, AsyncMock async def test_external_api_call(): with patch("mymodule.external_client.fetch", new_callable=AsyncMock) as mock: mock.return_value = {"data": "test"} result = await my_function() assert result == {"data": "test"} ``` ### Don't mock what you own Test your code with real implementations when possible. Mock external services, not internal classes. ## Test Naming Use descriptive names that explain the scenario: ```python # Good def test_login_fails_with_invalid_password(): def test_user_can_update_own_profile(): def test_admin_can_delete_any_user(): # Bad def test_login(): def test_update(): def test_delete(): ``` ## Error Testing ```python import pytest def test_raises_on_invalid_input(): with pytest.raises(ValueError, match="must be positive"): calculate(-1) async def test_async_raises(): with pytest.raises(ConnectionError): await connect_to_invalid_host() ``` ## Running Tests ```bash uv run pytest -n auto # Run all tests in parallel uv run pytest -n auto -x # Stop on first failure uv run pytest path/to/test.py # Run specific file uv run pytest -k "test_name" # Run tests matching pattern uv run pytest -m "not integration" # Exclude integration tests ``` ## Checklist Before submitting tests: - [ ] Each test tests one thing - [ ] No `@pytest.mark.asyncio` decorators - [ ] Imports at module level - [ ] Descriptive test names - [ ] Using in-memory transport (not HTTP) unless testing networking - [ ] Parameterization for variations of same behavior - [ ] Separate tests for different behaviors ================================================ FILE: .claude/skills/review-pr/SKILL.md ================================================ --- name: review-pr description: Monitor and respond to automated PR reviews (Codex bot). Use when pushing a PR, checking review status, or responding to bot feedback. Handles the full cycle of push -> wait for review -> evaluate comments -> fix -> re-push. --- # PR Review Workflow This repo has `chatgpt-codex-connector[bot]` configured as an automated reviewer. After every push to a PR branch, Codex reviews the diff and either: - Reacts with a thumbs-up on its review body (no suggestions — PR is clean) - Posts inline comments with suggestions (each tagged with a priority badge) ## Checking review status After pushing, check whether Codex has reviewed the latest commit: ```bash # Get the latest commit SHA on the branch LATEST=$(git rev-parse HEAD) # Check if Codex has reviewed that specific commit gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \ | jq "[.[] | select(.user.login == \"chatgpt-codex-connector[bot]\" and .commit_id == \"$LATEST\")] | length" ``` If the count is 0, Codex hasn't reviewed the latest push yet. Wait and check again. If the count is > 0, check for inline comments on the latest review: ```bash # Get the review body to check for thumbs-up gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \ | jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {state, body: .body[:300], commit_id: .commit_id}] | last' ``` A clean review from Codex looks like a review body that contains a thumbs-up reaction or says "no suggestions." If the body contains "Here are some automated review suggestions," there are inline comments to evaluate. ## Evaluating Codex comments Fetch all inline comments from Codex: ```bash gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \ | jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {body, path, line, created_at}]' ``` Codex comments include priority badges: - `P0` (red) — Critical issue, likely a real bug - `P1` (orange) — Important, worth fixing - `P2` (yellow) — Moderate, evaluate on merit **How to evaluate Codex comments:** 1. **Treat Codex as a competent but sometimes overzealous reviewer.** It catches real bugs (cache eviction ordering, silent data loss, missing validation) but also suggests scope expansions and hypothetical improvements. 2. **Fix real bugs** — issues in code you actually changed where behavior is incorrect or data is silently lost. 3. **Dismiss scope expansion** — if a comment points out a pre-existing limitation unrelated to your diff, note it as a potential follow-up but don't block the PR. 4. **Dismiss speculative concerns** — if a comment describes a scenario that requires very specific conditions and the existing behavior is acceptable, dismiss it. 5. **When fixing, be proactive** — if Codex found one instance of a pattern bug (e.g., missing role validation in one handler), check all similar code paths before pushing. Codex will find the next instance on the next review cycle, so get ahead of it. ## Responding to every comment **Every Codex comment must get a visible response** — either a fix or a reply explaining why it was dismissed. The maintainer can't see your reasoning otherwise. - **If fixing**: The fix itself is the response. No reply needed unless the fix is non-obvious. - **If dismissing**: Reply to the comment thread with a brief explanation of why. Keep it to 1-2 sentences. Examples: - "This is pre-existing behavior unrelated to this diff — the scope lookup fallback existed before caching was added. Worth a follow-up issue but not blocking this PR." - "The AsyncExitStack handles cleanup when the session exits, so the subprocess isn't leaked — just kept alive slightly longer than necessary in this edge case." - "Gemini supports a much wider range of media types than OpenAI/Anthropic, so a restrictive allowlist would be inaccurate here." Use `gh api` to reply (note: use `in_reply_to`, not a `/replies` sub-path): ```bash # Reply to a specific review comment gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \ -f body="Your reply here" \ -F in_reply_to={COMMENT_ID} ``` ## The fix-push-review cycle After evaluating comments: 1. Fix all real issues in one batch 2. Reply to all dismissed comments with reasoning 3. Think about what patterns Codex might flag next — check similar code paths proactively 4. Commit and push 5. Check that Codex reviews the new commit 6. Repeat until Codex gives a clean review (thumbs-up) or only has dismissible comments ## Responding to stale comments Codex sometimes re-posts old comments that reference code you've already fixed (they appear on the old commit's diff). These are stale — verify the fix is in the latest commit and reply noting the fix is already in place. ## When a PR is ready A PR is ready for human review when: - All Codex comments are either fixed or replied to with dismissal reasoning - CI checks pass - The diff is clean and focused on the stated purpose ================================================ FILE: .coderabbit.yaml ================================================ reviews: path_filters: - "!docs/python-sdk/**" ================================================ FILE: .cursor/rules/core-mcp-objects.mdc ================================================ --- description: globs: alwaysApply: true --- There are four major MCP object types: - Tools (src/tools/) - Resources (src/resources/) - Resource Templates (src/resources/) - Prompts (src/prompts) While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Note that while resources and resource templates are different objects, they are both in `src/resources/`. ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: 🐛 Bug Report description: Report a bug or unexpected behavior in FastMCP labels: [bug, pending] body: - type: markdown attributes: value: | Thanks for reporting a bug! A good bug report is one of the most valuable contributions you can make — see [CONTRIBUTING.md](../../CONTRIBUTING.md). If the fix is straightforward, a PR is also welcome. ### Before you submit - Make sure you're testing on the **latest version** of FastMCP — many issues are already fixed in newer releases - Check if someone else has **already reported this** or if it's been fixed on the main branch - You **must** include a copy/pasteable, properly formatted MRE (minimal reproducible example) or your issue may be closed without response - **The ideal issue is a clear problem description and an MRE — that's it.** If you've done genuine investigation and have a non-obvious insight into the root cause, include it. But please don't speculate or ask an LLM to generate a diagnosis. We have LLMs too, and an incorrect analysis is harder to work with than none at all. - **Keep it short.** A clear description plus a concise MRE is ideal — aim to fit in a single screen. Issues that include unsolicited root cause analysis, proposed fixes, or multi-section diagnostic writeups will be labeled `too-long` and not triaged until condensed. - **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md). - type: textarea id: description attributes: label: What happened? description: | Describe the bug in a few sentences. What did you do, what happened, and what did you expect instead? Do NOT include root cause analysis, proposed fixes, or diagnostic writeups — just describe the problem. validations: required: true - type: textarea id: example attributes: label: Example Code description: > If applicable, please provide a self-contained, [minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) demonstrating the bug. If possible, your example should be a single-file script. placeholder: | import asyncio from fastmcp import FastMCP, Client mcp = FastMCP() async def demo(): async with Client(mcp) as client: ... # show the bug here if __name__ == "__main__": asyncio.run(demo()) render: Python - type: textarea id: version attributes: label: Version Information description: | Please tell us about your FastMCP version, MCP version, Python version, and OS, as well as any other relevant details about your environment. To get the basic information, run the following command in your terminal and paste the output below: ```bash fastmcp version --copy ``` render: Text validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: FastMCP Documentation url: https://gofastmcp.com about: Please review the documentation before opening an issue. - name: MCP Python SDK url: https://github.com/modelcontextprotocol/python-sdk/issues about: Issues related to the low-level MCP Python SDK, including the FastMCP 1.0 module that is included in the `mcp` package, should be filed on the official MCP repository. ================================================ FILE: .github/ISSUE_TEMPLATE/enhancement.yml ================================================ name: 💡 Enhancement Request description: Suggest an idea or improvement for FastMCP labels: [enhancement, pending] body: - type: markdown attributes: value: | Thanks for suggesting an improvement to FastMCP! Enhancement issues are the **primary way** features and improvements get into FastMCP. Maintainers use well-written issues to implement changes that fit the codebase's patterns and ship quickly. A clear issue here is more impactful than a PR — see [CONTRIBUTING.md](../../CONTRIBUTING.md) for why. ### Before you submit - 🔍 **Check if this has already been requested** — search existing issues first - 🎯 **Describe the problem you're trying to solve**, not the solution you want — we'll figure out the best implementation - ✂️ **Keep it short.** A motivating description and a concrete use case is the ideal request — aim to fit in a single screen. Skip proposed implementations, API designs, or multi-option analyses — maintainers will figure out the approach. Requests that are difficult to parse will be labeled `too-long` and not triaged until condensed. - 🤖 **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md). - type: textarea id: description attributes: label: Enhancement description: | What problem or use case does this solve? How does current behavior fall short? Focus on the *what* and *why* — the motivating scenario. You don't need to propose an API or implementation. validations: required: true ================================================ FILE: .github/actions/run-claude/action.yml ================================================ # Composite Action for running Claude Code Action # # Wraps anthropics/claude-code-action with MCP server configuration. # Template based on elastic/ai-github-actions base action. # # Usage: # - uses: ./.github/actions/run-claude # with: # prompt: "Your prompt here" # claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # github-token: ${{ steps.marvin-token.outputs.token }} # allowed-tools: "Edit,Read,Write,Bash(*),mcp__github__add_issue_comment" # name: "Run Claude" description: "Run Claude Code with MCP servers" author: "FastMCP" branding: icon: "cpu" color: "orange" inputs: prompt: description: "Prompt to pass to Claude" required: true claude-oauth-token: description: "Claude Code OAuth token for authentication" required: true github-token: description: "GitHub token for Claude to operate with" required: true allowed-tools: description: "Comma-separated list of allowed tools (e.g. Edit,Write,Bash(npm test))" required: false default: "" model: description: "Model to use for Claude" required: false default: "claude-opus-4-6" allowed-bots: description: "Allowed bot usernames, or '*' for all bots" required: false default: "" track-progress: description: "Whether Claude should track progress" required: false default: "true" mcp-servers: description: "MCP server configuration JSON" required: false default: '{"mcpServers":{"agents-md-generator":{"type":"http","url":"https://agents-md-generator.fastmcp.app/mcp"},"public-code-search":{"type":"http","url":"https://public-code-search.fastmcp.app/mcp"}}}' trigger-phrase: description: "Trigger phrase (for mention workflows)" required: false default: "/marvin" outputs: conclusion: description: "The conclusion of the Claude Code run" value: ${{ steps.claude.outputs.conclusion }} runs: using: "composite" steps: - name: Clean up stale Claude locks shell: bash run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true - name: Run Claude Code id: claude env: GITHUB_TOKEN: ${{ inputs.github-token }} uses: anthropics/claude-code-action@v1 with: github_token: ${{ inputs.github-token }} claude_code_oauth_token: ${{ inputs.claude-oauth-token }} bot_name: "Marvin Context Protocol" trigger_phrase: ${{ inputs.trigger-phrase }} allowed_bots: ${{ inputs.allowed-bots }} track_progress: ${{ inputs.track-progress }} prompt: ${{ inputs.prompt }} claude_args: | ${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }} ${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }} --model ${{ inputs.model }} settings: | {"model": "${{ inputs.model }}"} ================================================ FILE: .github/actions/run-pytest/action.yml ================================================ name: "Run Pytest" description: "Run pytest with appropriate flags for the test type and platform" inputs: test-type: description: "Type of tests to run: unit, integration, or client_process" required: false default: "unit" runs: using: "composite" steps: - name: Run pytest shell: bash run: | if [ "${{ inputs.test-type }}" == "integration" ]; then MARKER="integration" TIMEOUT="30" MAX_PROCS="2" EXTRA_FLAGS="" elif [ "${{ inputs.test-type }}" == "client_process" ]; then MARKER="client_process" TIMEOUT="5" MAX_PROCS="0" EXTRA_FLAGS="-x" else MARKER="not integration and not client_process" TIMEOUT="5" MAX_PROCS="4" EXTRA_FLAGS="" fi PARALLEL_FLAGS="" if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal" fi uv run --no-sync pytest \ --inline-snapshot=disable \ --timeout=$TIMEOUT \ --durations=50 \ -m "$MARKER" \ $PARALLEL_FLAGS \ $EXTRA_FLAGS \ tests ================================================ FILE: .github/actions/setup-uv/action.yml ================================================ name: "Setup UV Environment" description: "Install uv and dependencies (requires checkout first)" inputs: python-version: description: "Python version to use" required: false default: "3.10" resolution: description: "Dependency resolution: locked, upgrade, or lowest-direct" required: false default: "locked" runs: using: "composite" steps: - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" python-version: ${{ inputs.python-version }} - name: Install dependencies shell: bash run: | if [ "${{ inputs.resolution }}" == "locked" ]; then uv sync --locked elif [ "${{ inputs.resolution }}" == "upgrade" ]; then uv sync --upgrade else uv sync --resolution ${{ inputs.resolution }} fi ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "pip" directory: "/" schedule: interval: "daily" labels: - "dependencies" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" labels: - "dependencies" ================================================ FILE: .github/pull_request_template.md ================================================ ## Description Closes # ## Contribution type - [ ] Bug fix (simple, well-scoped fix for a clearly broken behavior) - [ ] Documentation improvement - [ ] Enhancement (maintainers typically implement enhancements — see [CONTRIBUTING.md](../CONTRIBUTING.md)) ## Checklist - [ ] This PR addresses an existing issue (or fixes a self-evident bug) - [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md) - [ ] I have added tests that cover my changes - [ ] I have run `uv run prek run --all-files` and all checks pass - [ ] I have self-reviewed my changes - [ ] If I used an LLM, it followed the repo's contributing conventions (not generic output) ================================================ FILE: .github/release.yml ================================================ changelog: exclude: labels: - ignore in release notes categories: - title: New Features 🎉 labels: - feature - title: Breaking Changes ⚠️ labels: - breaking change exclude: labels: - contrib - security - title: Enhancements ✨ labels: - enhancement exclude: labels: - breaking change - security - title: Security 🔒 labels: - security - title: Fixes 🐞 labels: - bug exclude: labels: - contrib - security - title: Docs 📚 labels: - documentation - title: Examples & Contrib 💡 labels: - example - contrib - title: Dependencies 📦 labels: - dependencies exclude: labels: - security - title: Other Changes 🦾 labels: - "*" ================================================ FILE: .github/scripts/mention/gh-get-review-threads.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Get PR review threads with comments via GitHub GraphQL API # # Usage: # gh-get-review-threads.sh [FILTER] # # Arguments: # FILTER - Optional: filter for unresolved threads from specific author # # Environment (set by composite action): # MENTION_REPO - Repository (owner/repo format) # MENTION_PR_NUMBER - Pull request number # GITHUB_TOKEN - GitHub API token # # Output: # JSON array of review threads with nested comments # Parse OWNER and REPO from MENTION_REPO REPO_FULL="${MENTION_REPO:?MENTION_REPO environment variable is required}" OWNER="${REPO_FULL%/*}" REPO="${REPO_FULL#*/}" PR_NUMBER="${MENTION_PR_NUMBER:?MENTION_PR_NUMBER environment variable is required}" FILTER="${1:-}" gh api graphql -f query=' query($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { reviewThreads(first: 100) { nodes { id isResolved isOutdated path line comments(first: 50) { nodes { id body author { login } createdAt } } } } } } }' -F owner="$OWNER" \ -F repo="$REPO" \ -F prNumber="$PR_NUMBER" \ --jq '.data.repository.pullRequest.reviewThreads.nodes' | \ if [ -n "$FILTER" ]; then jq --arg author "$FILTER" ' map(select( .isResolved == false and .comments.nodes | any(.author.login == $author) ))' else cat fi ================================================ FILE: .github/scripts/mention/gh-resolve-review-thread.sh ================================================ #!/usr/bin/env bash set -euo pipefail # Resolve a GitHub PR review thread, optionally posting a comment first # # Usage: # gh-resolve-review-thread.sh THREAD_ID [COMMENT] # # Arguments: # THREAD_ID - The GraphQL node ID of the review thread to resolve # COMMENT - Optional: Comment body to post before resolving # # Environment (set by composite action): # MENTION_REPO - Repository (owner/repo format) # MENTION_PR_NUMBER - Pull request number # GITHUB_TOKEN - GitHub API token # # Behavior: # 1. If COMMENT is provided, posts it as a reply to the thread # 2. Resolves the thread # Validate required environment variables : "${MENTION_REPO:?MENTION_REPO environment variable is required}" : "${MENTION_PR_NUMBER:?MENTION_PR_NUMBER environment variable is required}" THREAD_ID="${1:?Thread ID required}" COMMENT="${2:-}" # Step 1: Post comment if provided if [ -n "$COMMENT" ]; then echo "Posting comment to thread..." >&2 COMMENT_RESULT=$(gh api graphql -f query=' mutation($threadId: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { comment { id } } }' -f threadId="$THREAD_ID" -f body="$COMMENT") if echo "$COMMENT_RESULT" | jq -e '.errors' > /dev/null 2>&1; then echo "Error posting comment: $COMMENT_RESULT" >&2 exit 1 fi fi # Step 2: Resolve the thread echo "Resolving thread..." >&2 RESOLVE_RESULT=$(gh api graphql -f query=' mutation($threadId: ID!) { resolveReviewThread(input: {threadId: $threadId}) { thread { id isResolved } } }' -f threadId="$THREAD_ID" --jq '.data.resolveReviewThread.thread') echo "$RESOLVE_RESULT" echo "✓ Thread resolved" >&2 ================================================ FILE: .github/scripts/pr-review/pr-comment.sh ================================================ #!/bin/bash # pr-comment.sh - Queue a structured inline review comment for the PR review # # Usage: # pr-comment.sh --severity --title --why [suggestion via stdin] # pr-comment.sh --severity --title --why --no-suggestion # # Arguments: # file File path (required) # line Line number (required) # --severity Severity level: critical, high, medium, low, nitpick (required) # --title Brief description for comment heading (required) # --why One sentence explaining the risk/impact (required) # --no-suggestion Explicitly skip suggestion (use for architectural issues) # # The suggestion code is read from stdin (use heredoc). If no stdin and no --no-suggestion, errors. # # Examples: # # With suggestion (preferred) # pr-comment.sh src/main.go 42 --severity high --title "Missing error check" --why "Errors are silently ignored" <<'EOF' # if err != nil { # return fmt.Errorf("operation failed: %w", err) # } # EOF # # # Without suggestion (for issues requiring broader changes) # pr-comment.sh src/main.go 42 --severity medium --title "Consider extracting to function" \ # --why "This logic is duplicated in 3 places" --no-suggestion # # Environment variables (set by the composite action): # PR_REVIEW_REPO - Repository (owner/repo) # PR_REVIEW_PR_NUMBER - Pull request number # PR_REVIEW_COMMENTS_DIR - Directory to cache comments (default: /tmp/pr-review-comments) set -e # Configuration from environment REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}" PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}" COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}" # Severity emoji mapping declare -A SEVERITY_EMOJI=( [critical]="🔴 CRITICAL" [high]="🟠 HIGH" [medium]="🟡 MEDIUM" [low]="⚪ LOW" [nitpick]="💬 NITPICK" ) # Parse arguments FILE="" LINE="" SEVERITY="" TITLE="" WHY="" NO_SUGGESTION=false # First two positional args are file and line if [ $# -lt 2 ]; then echo "Error: file and line are required" echo "Usage: pr-comment.sh --severity --title --why [<<'EOF' ... EOF]" exit 1 fi FILE="$1" LINE="$2" shift 2 # Parse named arguments while [ $# -gt 0 ]; do case "$1" in --severity) SEVERITY="$2" shift 2 ;; --title) TITLE="$2" shift 2 ;; --why) WHY="$2" shift 2 ;; --no-suggestion) NO_SUGGESTION=true shift ;; *) echo "Error: Unknown argument: $1" exit 1 ;; esac done # Read suggestion from stdin if available SUGGESTION="" if [ ! -t 0 ]; then SUGGESTION=$(cat) fi # Validate required arguments if [ -z "$SEVERITY" ]; then echo "Error: --severity is required (critical, high, medium, low, nitpick)" exit 1 fi if [ -z "$TITLE" ]; then echo "Error: --title is required" exit 1 fi if [ -z "$WHY" ]; then echo "Error: --why is required" exit 1 fi # Validate severity level if [ -z "${SEVERITY_EMOJI[$SEVERITY]}" ]; then echo "Error: Invalid severity '$SEVERITY'. Must be one of: critical, high, medium, low, nitpick" exit 1 fi # Require either suggestion or explicit --no-suggestion if [ -z "$SUGGESTION" ] && [ "$NO_SUGGESTION" = false ]; then echo "Error: Suggestion required. Provide code via stdin (heredoc) or use --no-suggestion" echo "" echo "Example with suggestion:" echo " pr-comment.sh file.go 42 --severity high --title \"desc\" --why \"reason\" <<'EOF'" echo " fixed code here" echo " EOF" echo "" echo "Example without suggestion:" echo " pr-comment.sh file.go 42 --severity medium --title \"desc\" --why \"reason\" --no-suggestion" exit 1 fi # Validate line is a positive integer (>= 1) if ! [[ "$LINE" =~ ^[1-9][0-9]*$ ]]; then echo "Error: Line number must be a positive integer (>= 1), got: $LINE" exit 1 fi # Get the diff for this file to validate the comment location DIFF_DATA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate | jq --arg f "$FILE" '.[] | select(.filename==$f)') if [ -z "$DIFF_DATA" ]; then echo "Error: File '${FILE}' not found in PR diff" echo "" echo "Files changed in this PR:" gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' exit 1 fi PATCH=$(echo "$DIFF_DATA" | jq -r '.patch // empty') if [ -z "$PATCH" ]; then echo "Error: No patch data for file '${FILE}' (file may be binary or too large)" exit 1 fi # Verify the line exists in the diff LINE_IN_DIFF=$(echo "$PATCH" | awk -v target_line="$LINE" ' BEGIN { current_line = 0; found = 0 } /^@@/ { line = $0 gsub(/.*\+/, "", line) gsub(/[^0-9].*/, "", line) current_line = line - 1 next } { if (substr($0, 1, 1) != "-") { current_line++ if (current_line == target_line) { found = 1 exit } } } END { if (found) print "1"; else print "0" } ') if [ "$LINE_IN_DIFF" != "1" ]; then echo "Error: Line ${LINE} not found in the diff for '${FILE}'" echo "" echo "Note: You can only comment on lines that appear in the diff (added, modified, or context lines)" echo "" echo "First 50 lines of diff for this file:" echo "$PATCH" | head -50 exit 1 fi # Create comments directory if it doesn't exist mkdir -p "${COMMENTS_DIR}" # Assemble the comment body SEVERITY_LABEL="${SEVERITY_EMOJI[$SEVERITY]}" BODY="**${SEVERITY_LABEL}** ${TITLE} Why: ${WHY}" # Add suggestion block if provided if [ -n "$SUGGESTION" ]; then BODY="${BODY} \`\`\`suggestion ${SUGGESTION} \`\`\`" fi # Append standard footer FOOTER=' --- Marvin Context Protocol | Type `/marvin` to interact further Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.' BODY_WITH_FOOTER="${BODY}${FOOTER}" # Generate unique comment ID COMMENT_ID="comment-$(date +%s)-$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')" COMMENT_FILE="${COMMENTS_DIR}/${COMMENT_ID}.json" # Create the comment JSON object jq -n \ --arg path "$FILE" \ --argjson line "$LINE" \ --arg side "RIGHT" \ --arg body "$BODY_WITH_FOOTER" \ --arg id "$COMMENT_ID" \ '{ path: $path, line: $line, side: $side, body: $body, _meta: { id: $id, file: $path, line: $line } }' > "${COMMENT_FILE}" echo "✓ Queued review comment for ${FILE}:${LINE}" echo " Severity: ${SEVERITY_LABEL}" echo " Title: ${TITLE}" echo " Comment ID: ${COMMENT_ID}" echo " Comment will be submitted with pr-review.sh" echo " Remove with: pr-remove-comment.sh ${FILE} ${LINE}" ================================================ FILE: .github/scripts/pr-review/pr-diff.sh ================================================ #!/bin/bash # pr-diff.sh - Show changed files or diff for a specific file # # Usage: # pr-diff.sh - List all changed files (shows full diff if small enough) # pr-diff.sh - Show diff for a specific file with line numbers # # Environment variables (set by the composite action): # PR_REVIEW_REPO - Repository (owner/repo) # PR_REVIEW_PR_NUMBER - Pull request number set -e # Configuration from environment REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}" PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}" EXPECTED_HEAD="${PR_REVIEW_HEAD_SHA:-}" # Check if HEAD has changed since review started (race condition detection) if [ -n "$EXPECTED_HEAD" ]; then CURRENT_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha') if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then echo "⚠️ WARNING: PR head has changed since review started!" echo " Review started at: ${EXPECTED_HEAD:0:7}" echo " Current head: ${CURRENT_HEAD:0:7}" echo " Line numbers below may not match the commit being reviewed." echo "" fi fi # Thresholds for "too big" - show file list only if exceeded MAX_FILES=25 MAX_TOTAL_LINES=1500 FILE="$1" # Function to add line numbers to a patch # Format: [LINE] +added | [LINE] context | [----] -deleted add_line_numbers() { awk ' BEGIN { new_line = 0 } /^@@/ { # Parse hunk header: @@ -old_start,old_count +new_start,new_count @@ match($0, /\+([0-9]+)/) new_line = substr($0, RSTART+1, RLENGTH-1) - 1 print "" print $0 next } /^-/ { # Deleted line - cannot comment on these printf "[----] %s\n", $0 next } /^\+/ { # Added line - can comment, show line number new_line++ printf "[%4d] %s\n", new_line, $0 next } { # Context line (space prefix) - can comment, show line number new_line++ printf "[%4d] %s\n", new_line, $0 } ' } if [ -z "$FILE" ]; then # Get file list with stats FILES_DATA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate) FILE_COUNT=$(echo "$FILES_DATA" | jq 'length') TOTAL_ADDITIONS=$(echo "$FILES_DATA" | jq '[.[].additions] | add // 0') TOTAL_DELETIONS=$(echo "$FILES_DATA" | jq '[.[].deletions] | add // 0') TOTAL_LINES=$((TOTAL_ADDITIONS + TOTAL_DELETIONS)) echo "PR #${PR_NUMBER} Summary: ${FILE_COUNT} files changed (+${TOTAL_ADDITIONS}/-${TOTAL_DELETIONS})" echo "" # Check if diff is too large if [ "$FILE_COUNT" -gt "$MAX_FILES" ] || [ "$TOTAL_LINES" -gt "$MAX_TOTAL_LINES" ]; then echo "⚠️ Large diff detected (>${MAX_FILES} files or >${MAX_TOTAL_LINES} lines changed)" echo " Review files individually using: pr-diff.sh " echo "" echo "Files changed:" echo "$FILES_DATA" | jq -r '.[] | " \(.filename) (+\(.additions)/-\(.deletions))"' else # Small enough - show all diffs with line numbers echo "Files changed:" echo "$FILES_DATA" | jq -r '.[] | " \(.filename) (+\(.additions)/-\(.deletions))"' echo "" echo "─────────────────────────────────────────────────────────────────────" echo "" # Show each file's diff by iterating over indices for i in $(seq 0 $((FILE_COUNT - 1))); do FNAME=$(echo "$FILES_DATA" | jq -r ".[$i].filename") PATCH=$(echo "$FILES_DATA" | jq -r ".[$i].patch // empty") if [ -n "$PATCH" ]; then echo "## ${FNAME}" echo "Use: pr-comment.sh ${FNAME} --severity --title \"desc\" --why \"reason\" <<'EOF' ... EOF" echo "Format: [LINE] +added | [LINE] context | [----] -deleted (can't comment)" echo "$PATCH" | add_line_numbers echo "" echo "─────────────────────────────────────────────────────────────────────" echo "" fi done fi else # Show specific file diff PATCH=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq --arg file "$FILE" '.[] | select(.filename==$file) | .patch') if [ -z "$PATCH" ]; then echo "Error: File '${FILE}' not found in PR diff" echo "" echo "Files changed in this PR:" gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename' exit 1 fi echo "## ${FILE}" echo "Use: pr-comment.sh ${FILE} --severity --title \"desc\" --why \"reason\" <<'EOF' ... EOF" echo "Format: [LINE] +added | [LINE] context | [----] -deleted (can't comment)" echo "$PATCH" | add_line_numbers fi ================================================ FILE: .github/scripts/pr-review/pr-existing-comments.sh ================================================ #!/bin/bash # pr-existing-comments.sh - Fetch existing review threads on a PR # # Usage: # pr-existing-comments.sh - Show all review threads with full details # pr-existing-comments.sh --summary - Show per-file summary only (for large PRs) # pr-existing-comments.sh --unresolved - Show only unresolved threads # pr-existing-comments.sh --file - Show threads for a specific file # pr-existing-comments.sh --full - Show full comment text (no truncation) # # Output: Formatted summary of existing review threads grouped by file, # showing thread status, comments, and whether issues were addressed. # # For large PRs, use --summary first to see the overview, then --file # to get full thread details when reviewing each file. # # Environment variables (set by the composite action): # PR_REVIEW_REPO - Repository (owner/repo) # PR_REVIEW_PR_NUMBER - Pull request number set -e # Configuration from environment REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}" PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}" OWNER="${REPO%/*}" REPO_NAME="${REPO#*/}" # Parse arguments FILTER_UNRESOLVED=false FILTER_FILE="" SUMMARY_ONLY=false FULL_TEXT=false while [ $# -gt 0 ]; do case "$1" in --unresolved) FILTER_UNRESOLVED=true shift ;; --file) FILTER_FILE="$2" shift 2 ;; --summary) SUMMARY_ONLY=true shift ;; --full) FULL_TEXT=true shift ;; *) echo "Usage: pr-existing-comments.sh [--summary] [--unresolved] [--file ] [--full]" exit 1 ;; esac done # Fetch review threads via GraphQL THREADS=$(gh api graphql -f query=' query($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { reviewThreads(first: 100) { nodes { id isResolved isOutdated path line originalLine startLine originalStartLine diffSide comments(first: 50) { nodes { id body author { login } createdAt originalCommit { abbreviatedOid } } } } } } } }' -F owner="$OWNER" \ -F repo="$REPO_NAME" \ -F prNumber="$PR_NUMBER" \ --jq '.data.repository.pullRequest.reviewThreads.nodes') if [ -z "$THREADS" ] || [ "$THREADS" = "null" ]; then echo "No existing review threads found." exit 0 fi # Apply filters FILTERED="$THREADS" if [ "$FILTER_UNRESOLVED" = true ]; then FILTERED=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == false)]') fi if [ -n "$FILTER_FILE" ]; then FILTERED=$(echo "$FILTERED" | jq --arg file "$FILTER_FILE" '[.[] | select(.path == $file)]') fi THREAD_COUNT=$(echo "$FILTERED" | jq 'length') if [ "$THREAD_COUNT" -eq 0 ]; then if [ "$FILTER_UNRESOLVED" = true ]; then echo "No unresolved review threads found." elif [ -n "$FILTER_FILE" ]; then echo "No review threads found for ${FILTER_FILE}." else echo "No existing review threads found." fi exit 0 fi # Count resolved vs unresolved RESOLVED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == true)] | length') UNRESOLVED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == false)] | length') OUTDATED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isOutdated == true)] | length') echo "Existing review threads: ${THREAD_COUNT} total (${UNRESOLVED_COUNT} unresolved, ${RESOLVED_COUNT} resolved, ${OUTDATED_COUNT} outdated)" echo "" # Summary mode: show per-file counts only if [ "$SUMMARY_ONLY" = true ]; then echo "Threads by file:" echo "$FILTERED" | jq -r ' group_by(.path) | .[] | . as $threads | ($threads | length) as $total | ([$threads[] | select(.isResolved == false)] | length) as $unresolved | ([$threads[] | select(.isResolved == true)] | length) as $resolved | ([$threads[] | select(.isOutdated == true)] | length) as $outdated | ([$threads[] | select(.comments.nodes | length > 1)] | length) as $has_replies | " " + $threads[0].path + " — " + ($total | tostring) + " threads" + " (" + ($unresolved | tostring) + " unresolved, " + ($resolved | tostring) + " resolved" + (if $outdated > 0 then ", " + ($outdated | tostring) + " outdated" else "" end) + ")" + (if $has_replies > 0 then " ⚠️ " + ($has_replies | tostring) + " with replies" else "" end) ' echo "" echo "Use: pr-existing-comments.sh --file to see full thread details for a file" exit 0 fi # Full detail mode: output threads grouped by file # Show full conversation for threads with replies FIRST_LIMIT=200 REPLY_LIMIT=300 if [ "$FULL_TEXT" = true ]; then FIRST_LIMIT=999999 REPLY_LIMIT=999999 fi echo "$FILTERED" | jq -r --argjson first_limit "$FIRST_LIMIT" --argjson reply_limit "$REPLY_LIMIT" ' group_by(.path) | .[] | "## " + .[0].path + " (" + (length | tostring) + " threads)\n" + ([.[] | " " + (if .isResolved then "✅ RESOLVED" elif .isOutdated then "⚠️ OUTDATED" else "🔴 UNRESOLVED" end) + " (line " + (if .line then (.line | tostring) elif .startLine then (.startLine | tostring) elif .originalLine then ("~" + (.originalLine | tostring)) elif .originalStartLine then ("~" + (.originalStartLine | tostring)) else "?" end) + ")" + # Show the commit the comment was originally made on (if .comments.nodes[0].originalCommit.abbreviatedOid then " [" + .comments.nodes[0].originalCommit.abbreviatedOid + "]" else "" end) + # Flag threads with replies — indicates a conversation happened (if (.comments.nodes | length) > 1 then " ← has replies" else "" end) + "\n" + ([.comments.nodes | to_entries[] | .value as $comment | .key as $idx | ($comment.body | gsub("\n"; " ")) as $flat | if $idx == 0 then " @" + ($comment.author.login // "unknown") + ": " + $flat[0:$first_limit] + (if ($flat | length) > $first_limit then " [truncated]" else "" end) else " ↳ @" + ($comment.author.login // "unknown") + ": " + $flat[0:$reply_limit] + (if ($flat | length) > $reply_limit then " [truncated]" else "" end) end ] | join("\n")) + "\n" ] | join("\n")) ' ================================================ FILE: .github/scripts/pr-review/pr-remove-comment.sh ================================================ #!/bin/bash # pr-remove-comment.sh - Remove a queued review comment # # Usage: # pr-remove-comment.sh # pr-remove-comment.sh # # Examples: # pr-remove-comment.sh src/main.go 42 # pr-remove-comment.sh comment-1234567890-1234567890 # # This script removes a previously queued comment before it's submitted. # Useful if the agent realizes it made a mistake or wants to update a comment. # # Environment variables (set by the composite action): # PR_REVIEW_COMMENTS_DIR - Directory containing comment files (default: /tmp/pr-review-comments) set -e COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}" if [ ! -d "${COMMENTS_DIR}" ]; then echo "No comments directory found: ${COMMENTS_DIR}" exit 0 fi # Check if first argument looks like a comment ID if [[ "$1" =~ ^comment- ]]; then COMMENT_ID="$1" COMMENT_FILE="${COMMENTS_DIR}/${COMMENT_ID}.json" if [ -f "${COMMENT_FILE}" ]; then FILE=$(jq -r '._meta.file // .path' "${COMMENT_FILE}") LINE=$(jq -r '._meta.line // .line' "${COMMENT_FILE}") rm -f "${COMMENT_FILE}" echo "✓ Removed comment ${COMMENT_ID} for ${FILE}:${LINE}" else echo "Comment not found: ${COMMENT_ID}" exit 1 fi else # Treat as file and line number FILE="$1" LINE="$2" if [ -z "$FILE" ] || [ -z "$LINE" ]; then echo "Usage:" echo " pr-remove-comment.sh " echo " pr-remove-comment.sh " echo "" echo "Examples:" echo " pr-remove-comment.sh src/main.go 42" echo " pr-remove-comment.sh comment-1234567890-1234567890" exit 1 fi # Validate line is a positive integer (>= 1) if ! [[ "$LINE" =~ ^[1-9][0-9]*$ ]]; then echo "Error: Line number must be a positive integer (>= 1), got: $LINE" exit 1 fi # Find and remove matching comment files # Use nullglob to handle case where no files match shopt -s nullglob REMOVED=0 for COMMENT_FILE in "${COMMENTS_DIR}"/comment-*.json; do COMMENT_FILE_PATH=$(jq -r '._meta.file // .path' "${COMMENT_FILE}") COMMENT_LINE=$(jq -r '._meta.line // .line' "${COMMENT_FILE}") if [ "$COMMENT_FILE_PATH" = "$FILE" ] && [ "$COMMENT_LINE" = "$LINE" ]; then COMMENT_ID=$(basename "${COMMENT_FILE}" .json) rm -f "${COMMENT_FILE}" echo "✓ Removed comment ${COMMENT_ID} for ${FILE}:${LINE}" REMOVED=$((REMOVED + 1)) fi done if [ "$REMOVED" -eq 0 ]; then echo "No comment found for ${FILE}:${LINE}" exit 1 fi fi ================================================ FILE: .github/scripts/pr-review/pr-review.sh ================================================ #!/bin/bash # pr-review.sh - Submit a PR review (approve, request changes, or comment) # # Usage: pr-review.sh [review-body] # Example: pr-review.sh REQUEST_CHANGES "Please fix the issues noted above" # # This script creates and submits a review with any queued inline comments. # Comments are read from individual files in PR_REVIEW_COMMENTS_DIR (created by pr-comment.sh). # # The review body can contain special characters (backticks, dollar signs, etc.) # and will be safely passed to the GitHub API without shell interpretation. # # Environment variables (set by the composite action): # PR_REVIEW_REPO - Repository (owner/repo) # PR_REVIEW_PR_NUMBER - Pull request number # PR_REVIEW_HEAD_SHA - HEAD commit SHA # PR_REVIEW_COMMENTS_DIR - Directory containing queued comment files (default: /tmp/pr-review-comments) set -e # Configuration from environment REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}" PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}" HEAD_SHA="${PR_REVIEW_HEAD_SHA:?PR_REVIEW_HEAD_SHA environment variable is required}" COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}" # Arguments EVENT="$1" shift 2>/dev/null || true # Read body from remaining arguments # Join all remaining arguments with spaces, preserving the string as-is BODY="$*" if [ -z "$EVENT" ]; then echo "Usage: pr-review.sh [review-body]" echo "Example: pr-review.sh REQUEST_CHANGES 'Please fix the issues noted in the inline comments'" exit 1 fi # Validate event type case "$EVENT" in APPROVE|REQUEST_CHANGES|COMMENT) ;; *) echo "Error: Invalid event type '${EVENT}'" echo "Must be one of: APPROVE, REQUEST_CHANGES, COMMENT" exit 1 ;; esac # Read queued comments from individual files COMMENTS="[]" COMMENT_COUNT=0 if [ -d "${COMMENTS_DIR}" ]; then # Collect all comment files and merge into a single JSON array # Remove _meta fields before submitting (they're only for internal use) COMMENT_FILES=("${COMMENTS_DIR}"/comment-*.json) if [ -f "${COMMENT_FILES[0]}" ]; then # Use jq to read all comment files, extract the comment data (without _meta), and combine COMMENTS=$(jq -s '[.[] | del(._meta)]' "${COMMENTS_DIR}"/comment-*.json) COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length') if [ "$COMMENT_COUNT" -gt 0 ]; then echo "Found ${COMMENT_COUNT} queued inline comment(s)" fi fi fi # Append standard footer to the review body (if body is provided) FOOTER=' --- Marvin Context Protocol | Type `/marvin` to interact further Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.' if [ -n "$BODY" ]; then BODY_WITH_FOOTER="${BODY}${FOOTER}" else BODY_WITH_FOOTER="" fi # Build the review request JSON # Use jq to safely construct the JSON with all special characters handled REVIEW_JSON=$(jq -n \ --arg commit_id "$HEAD_SHA" \ --arg event "$EVENT" \ --arg body "$BODY_WITH_FOOTER" \ --argjson comments "$COMMENTS" \ '{ commit_id: $commit_id, event: $event, comments: $comments } + (if $body != "" then {body: $body} else {} end)') # Check if HEAD has changed since review started (race condition detection) CURRENT_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha') if [ "$CURRENT_HEAD" != "$HEAD_SHA" ]; then echo "⚠️ WARNING: PR head has changed since review started!" echo " Review started at: ${HEAD_SHA:0:7}" echo " Current head: ${CURRENT_HEAD:0:7}" echo "" echo " New commits may have shifted line numbers. Review will be submitted" echo " against the original commit (${HEAD_SHA:0:7}) but comments may be outdated." echo "" fi echo "Submitting ${EVENT} review for commit ${HEAD_SHA:0:7}..." # Create and submit the review in one API call # Use a temp file to safely pass the JSON body TEMP_JSON=$(mktemp) trap "rm -f ${TEMP_JSON}" EXIT echo "$REVIEW_JSON" > "${TEMP_JSON}" RESPONSE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \ -X POST \ --input "${TEMP_JSON}" 2>&1) || { echo "Error submitting review:" echo "$RESPONSE" exit 1 } # Clean up the comments directory after successful submission if [ -d "${COMMENTS_DIR}" ] && [ "$COMMENT_COUNT" -gt 0 ]; then rm -f "${COMMENTS_DIR}"/comment-*.json # Remove directory if empty rmdir "${COMMENTS_DIR}" 2>/dev/null || true fi REVIEW_URL=$(echo "$RESPONSE" | jq -r '.html_url // empty') REVIEW_STATE=$(echo "$RESPONSE" | jq -r '.state // empty') if [ -n "$REVIEW_URL" ]; then echo "✓ Review submitted (${REVIEW_STATE}): ${REVIEW_URL}" if [ "$COMMENT_COUNT" -gt 0 ]; then echo " Included ${COMMENT_COUNT} inline comment(s)" fi else echo "✓ Review submitted successfully" fi ================================================ FILE: .github/workflows/auto-close-duplicates.yml ================================================ name: Auto-close duplicate issues description: Auto-closes issues that are duplicates of existing issues on: schedule: - cron: "0 9 * * *" # Run daily at 9 AM UTC workflow_dispatch: jobs: auto-close-duplicates: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write id-token: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv uses: astral-sh/setup-uv@v7 - name: Auto-close duplicate issues run: uv run scripts/auto_close_duplicates.py env: GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} ================================================ FILE: .github/workflows/auto-close-needs-mre.yml ================================================ name: Auto-close needs MRE issues description: Auto-closes issues that need minimal reproducible examples after 7 days of author inactivity on: schedule: - cron: "0 9 * * *" # Run daily at 9 AM UTC workflow_dispatch: jobs: auto-close-needs-mre: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write id-token: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Install uv uses: astral-sh/setup-uv@v7 - name: Auto-close needs MRE issues run: uv run scripts/auto_close_needs_mre.py env: GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} ================================================ FILE: .github/workflows/martian-test-failure.yml ================================================ name: Marvin Test Failure Analysis on: workflow_run: workflows: ["Tests", "Run static analysis"] types: - completed concurrency: group: marvin-test-failure-${{ github.event.workflow_run.head_branch }} cancel-in-progress: true jobs: martian-test-failure: # Only run if the test workflow failed if: ${{ github.event.workflow_run.conclusion == 'failure' }} runs-on: ubuntu-latest permissions: contents: read pull-requests: write issues: read id-token: write actions: read # Required for Claude to read CI results steps: - name: Checkout repository uses: actions/checkout@v6 with: fetch-depth: 1 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Set up Python 3.10 uses: actions/setup-python@v6 with: python-version: "3.10" # Install UV package manager - name: Install UV uses: astral-sh/setup-uv@v7 # Install dependencies - name: Install dependencies run: uv sync --all-packages --group dev - name: Set analysis prompt id: analysis-prompt run: | cat >> $GITHUB_OUTPUT << 'EOF' PROMPT< Detailed Analysis Include here: - Relevant log excerpts showing the failure - Code snippets that are causing the issue - Any related issues or PRs that might be relevant
Related Files List files that are relevant to the failure with brief explanations of their relevance.
# Important Guidelines - Be concise and actionable - developers want to quickly understand and fix the issue. Provide additional context, references, etc in collapsible details blocks to ensure that the comment you're adding is short and easy to read but additional information is a click away. - Focus on facts from the logs and code, not speculation - If you can't determine the root cause, say so clearly - If your only suggestion is a bad suggestion (disable the test, change the timeout, etc), indicate that you've run out of ideas and that they probably don't want to do that. - Provide specific file names, line numbers, and code references when possible - You can run make commands (e.g., `make lint`, `make typecheck`, `make sync`) to build, test, or lint the code - You can also run git commands (e.g., `git status`, `git log`, `git diff`) to inspect the repository - You can use WebSearch and WebFetch to research errors, stack traces, or related issues - For bash commands, you are limited to make and git commands only # CRITICAL: ANGRY USERS **IMPORTANT**: If the user is angry with you, the triage bot, don't respond. Just exit immediately without further action. If at any point in the conversation the user has asked you to stop replying to the thread, just exit immediately. If you are posting the same suggestion as you have previously made, do not post the suggestion again. # IMPORTANT: EDIT YOUR COMMENT Do not post a new comment every time you triage a failing workflow. If a previous comment has been posted by you (marvin) in a previous triage, edit that comment do not add a new comment for each failure. Be sure to include a note that you've edited your comment to reflect the latest analysis. Don't worry about keeping the old content around, there's comment history for that. # Problems Encountered If you encounter any problems during your analysis (e.g., unable to fetch logs, tools not working), document them clearly so the team knows what limitations you faced. PROMPT_END EOF - name: Setup GitHub MCP Server run: | mkdir -p /tmp/mcp-config cat > /tmp/mcp-config/mcp-servers.json << 'EOF' { "mcpServers": { "repository-summary": { "type": "http", "url": "https://agents-md-generator.fastmcp.app/mcp" }, "code-search": { "type": "http", "url": "https://public-code-search.fastmcp.app/mcp" }, "github-research": { "type": "stdio", "command": "uvx", "args": [ "github-research-mcp" ], "env": { "DISABLE_SUMMARIES": "true", "GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" } } } } EOF - name: Clean up stale Claude locks run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 with: github_token: ${{ steps.marvin-token.outputs.token }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }} bot_name: "Marvin Context Protocol" claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} additional_permissions: | actions: read prompt: ${{ steps.analysis-prompt.outputs.PROMPT }} claude_args: | --allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,Bash(make:*,git:*) --mcp-config /tmp/mcp-config/mcp-servers.json ================================================ FILE: .github/workflows/martian-triage-issue.yml ================================================ # Triage new issues: investigate, recommend, apply labels # Calls run-claude directly with triage prompt (elastic issue-triage style) name: Triage Issue on: issues: types: [opened] jobs: triage: if: | github.event.issue.user.login == 'strawgate' || (github.event.issue.user.login == 'jlowin' && contains(toJSON(github.event.issue.labels.*.name), 'bug')) concurrency: group: triage-issue-${{ github.event.issue.number }} cancel-in-progress: true runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write pull-requests: read id-token: write steps: - name: Checkout repository uses: actions/checkout@v6 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: React to issue with eyes env: GH_TOKEN: ${{ steps.marvin-token.outputs.token }} run: | gh api "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/reactions" -f content=eyes 2>/dev/null || true - name: Run Claude for Triage uses: ./.github/actions/run-claude with: claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github-token: ${{ steps.marvin-token.outputs.token }} allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code" prompt: | Repository: ${{ github.repository }} Issue Number: #${{ github.event.issue.number }} Issue Title: ${{ github.event.issue.title }} Issue Author: ${{ github.event.issue.user.login }} ${{ github.event.issue.body }} Triage this new GitHub issue and provide a helpful, actionable response. You can write files and execute commands to test, verify, or investigate the issue. This workflow is for investigation, testing, and planning. You CANNOT: Create branches, checkout branches, commit code to the repository Do not push changes to the repository. You CAN: Read/analyze code, search repository, review git history, search for similar issues, write files, verify behavior, provide analysis and recommendations You have access to the following tools (comma-separated list): Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available. Use `mcp__agents-md-generator__generate_agents_md` to get repository context before triaging. - `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo) - `WebSearch`: Search the web for documentation, best practices, or solutions - `WebFetch`: Fetch and read content from URLs - Git commands: You have access to git commands, but write commands (commit, push, checkout, branch creation) are blocked - Write: You can write files (e.g., test files, temporary files for verification) - Execution: See `` section above for exact list of available execution commands If execution commands are available (check `` section), you can: - Run tests to verify reported bugs or test proposed solutions - Execute scripts to understand behavior - Run linters or static analysis tools - Verify environment setup or dependencies - Test specific code paths or scenarios - Write test files to confirm behavior When executing commands: - Explain what you're testing and why - Include command output in your response when relevant - Use execution to validate your findings and recommendations - Only use commands that are explicitly listed in `` Your number one priority is to provide a great response to the issue. A great response is a response that is clear, concise, accurate, and actionable. You will avoid long paragraphs, flowery language, and overly verbose responses. Your readers have limited time and attention, so you will be concise and to the point. In priority order your goal is to: 1. Provide context about the request or issue (related issues, pull requests, files, etc.) 2. Layout a single high-quality and actionable recommendation for how to address the issue based on your knowledge of the project, codebase, and issue 3. Provide a high quality and detailed plan that a junior developer could follow to implement the recommendation 4. Use execution to verify findings when appropriate (check `` section for available commands) Populate the following sections in your response: Recommendation (or "No recommendation" with reason) Findings Verification (if you executed tests or commands - check `` section) Detailed Action Plan Related Items Related Files Related Webpages You may not be able to do all of these things, sometimes you may find that all you can do is provide in-depth context of the issue and related items. That's perfectly acceptable and expected. Your performance is judged by how accurate your findings are, do the investigation required to have high confidence in your findings and recommendations. "I don't know" or "I'm unable to recommend a course of action" is better than a bad or wrong answer. When formulating your response, you will never "bury the lede", you will always provide a clear and concise tl;dr as the first thing in your response. As your response grows in length you can organize the more detailed parts of your response collapsible sections using
and tags. You shouldn't put everything in collapsible sections, especially if the response is short. Use your discretion to determine when to use collapsible sections to avoid overwhelming the reader with too much detail -- think of them like an appendix that can be expanded if the reader is interested. # Example output for "Recommendation" part of the response PR #654 already implements the requested feature but is incomplete. The Pull Request is not in a mergeable state yet, the remaining work should be completed: 1) update the Calculator.divide method to utilize the new DivisionByZeroError or the safe_divide function, and 2) update the tests to ensure that the Calculator.divide method raises the new DivisionByZeroError when the divisor is 0.
Findings ...details from the code analysis that are relevant to the issue and the recommendation...
Verification I ran the existing tests (if execution commands are available in ``) and confirmed the current behavior: ```bash $ pytest test_calculator.py::test_divide_by_zero FAILED - raises ValueError instead of DivisionByZeroError ``` This confirms the issue report is accurate.
Detailed Action Plan ...a detailed plan that a junior developer could follow to implement the recommendation...
# Example Output for "Related Items" part of the response
Related Issues and Pull Requests | Repository | Issue or PR | Relevance | | --- | --- | --- | | PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | This pull request directly addresses the feature request for adding matrix operations to the calculator. | | PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/issues/681) | This issue directly addresses the feature request for adding matrix operations to the calculator. |
Related Files | Repository | File | Relevance | Sections | | --- | --- | --- | --- | | modelcontextprotocol/python-sdk | [test_calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py) | This file contains the test cases for the Calculator class, including a test that specifically asserts a ValueError is raised for division by zero, confirming the current intended behavior. | [25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) | | modelcontextprotocol/python-sdk | [calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py) | This file contains the implementation of the Calculator class, specifically the `divide` method which raises the ValueError when dividing by zero, matching the bug report. | [29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) |
Related Webpages | Name | URL | Relevance | | --- | --- | --- | | Handling Division by Zero Best Practices | https://my-blog-about-division-by-zero.com/handling+division+by+zero+in+calculator | This webpage provides general best practices for handling division by zero in calculator applications and in Python, which is directly relevant to the issue and potential solutions. |
Always end your comment with a new line, three dashes, and the footer message: --- Marvin Context Protocol | Type `/marvin` to interact further Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. When writing GitHub comments, wrap branch names, tags, or other @-references in backticks (e.g., `@main`, `@v1.0`) to avoid accidentally pinging users. Do not add backticks around terms that are already inside backticks or code blocks. ================================================ FILE: .github/workflows/marvin-comment-on-issue.yml ================================================ # Respond to /marvin mentions in issue comments (elastic mention-in-issue style) # Calls run-claude directly name: Comment on Issue on: issue_comment: types: [created] permissions: actions: read contents: write issues: write pull-requests: write id-token: write jobs: comment: if: | !github.event.issue.pull_request && contains(github.event.comment.body, '/marvin') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest timeout-minutes: 60 steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install UV uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Install dependencies run: uv sync --python 3.12 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: React to comment with eyes env: GH_TOKEN: ${{ steps.marvin-token.outputs.token }} run: | gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes 2>/dev/null || true - name: Run Claude for Issue Comment uses: ./.github/actions/run-claude with: claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github-token: ${{ steps.marvin-token.outputs.token }} trigger-phrase: "/marvin" allowed-bots: "*" allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code" prompt: | Repository: ${{ github.repository }} Issue Number: #${{ github.event.issue.number }} Issue Title: ${{ github.event.issue.title }} Issue Author: ${{ github.event.issue.user.login }} Comment Author: ${{ github.event.comment.user.login }} ${{ github.event.comment.body }} You have been mentioned in a GitHub issue comment. Understand the request, gather context, complete the task, and respond with results. You CAN: Read/analyze code, modify files, write code, run tests, execute commands You CAN: Commit code, push changes, create branches, create pull requests You have access to the following tools (comma-separated list): Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available. Use `mcp__agents-md-generator__generate_agents_md` to get repository context before responding. Be thorough in your investigations: - Understand the full context of the repository - Review related code, issues, and PRs - Consider edge cases and implications - Gather all relevant information before responding Available tools: - `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo) - `WebSearch`: Search the web for documentation, best practices, or solutions - `WebFetch`: Fetch and read content from URLs - Answer questions about the codebase - Help debug reported problems (make changes locally to test, cannot push) - Suggest solutions or workarounds - Provide code examples - Help clarify requirements - Link to relevant documentation or code - Be concise and actionable - If the request is unclear, ask clarifying questions - If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do - When making code changes, explain that they are local only and cannot be pushed Always end your comment with a new line, three dashes, and the footer message: --- Marvin Context Protocol | Type `/marvin` to interact further Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. When writing GitHub comments, wrap branch names, tags, or other @-references in backticks (e.g., `@main`, `@v1.0`) to avoid accidentally pinging users. Do not add backticks around terms that are already inside backticks or code blocks. ================================================ FILE: .github/workflows/marvin-comment-on-pr.yml ================================================ # Respond to /marvin mentions in PR review comments and issue comments on PRs # Calls run-claude directly name: Comment on PR on: issue_comment: types: [created] permissions: contents: write pull-requests: write issues: read id-token: write jobs: comment: if: | github.event.issue.pull_request && contains(github.event.comment.body, '/marvin') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout PR head branch uses: actions/checkout@v6 with: # do not set to pull_request.head.ref, claude will pull the branch if needed fetch-depth: 0 - name: Install UV uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Install dependencies run: uv sync --python 3.12 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: React to comment with eyes env: GH_TOKEN: ${{ steps.marvin-token.outputs.token }} run: | gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes 2>/dev/null || true - name: Get PR HEAD SHA id: pr-info env: GH_TOKEN: ${{ steps.marvin-token.outputs.token }} run: | PR_NUMBER="${{ github.event.issue.number }}" HEAD_SHA=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.head.sha') echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT" echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - name: Run Claude for PR Comment uses: ./.github/actions/run-claude env: MENTION_REPO: ${{ github.repository }} MENTION_PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }} MENTION_SCRIPTS: ${{ github.workspace }}/.github/scripts/mention PR_REVIEW_REPO: ${{ github.repository }} PR_REVIEW_PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }} PR_REVIEW_HEAD_SHA: ${{ steps.pr-info.outputs.head_sha }} PR_REVIEW_COMMENTS_DIR: /tmp/pr-review-comments PR_REVIEW_HELPERS_DIR: ${{ github.workspace }}/.github/scripts/pr-review with: claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github-token: ${{ steps.marvin-token.outputs.token }} trigger-phrase: "/marvin" allowed-bots: "*" allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code" prompt: | Repository: ${{ github.repository }} PR Number: #${{ steps.pr-info.outputs.pr_number }} PR Title: ${{ github.event.issue.title }} PR Author: ${{ github.event.issue.user.login }} Comment Author: ${{ github.event.comment.user.login }} **Note**: The PR head branch has already been checked out. The workspace is ready - you can immediately start working on the PR code. ${{ github.event.comment.body }} You have been mentioned in a Pull Request comment. Understand the request, gather context, complete the task, and respond with results. This workflow allows read, write, and execute capabilities but cannot push changes. You CAN: Read/analyze code, modify files, write code, run tests, execute commands, resolve review threads You CANNOT: Commit code, push changes, create branches, checkout branches, create pull requests **Important**: You cannot push changes to the repository - you can only make changes locally and provide feedback or recommendations. You have access to the following tools (comma-separated list): Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available. Use `mcp__agents-md-generator__generate_agents_md` to get repository context before responding. Be thorough in your investigations: - Understand the full context of the repository - Review related code, issues, and PRs - Consider edge cases and implications - Gather all relevant information before responding Available tools: - `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo) - `WebSearch`: Search the web for documentation, best practices, or solutions - `WebFetch`: Fetch and read content from URLs - Address review feedback and fix issues (make changes locally, cannot push) - Answer questions about the changes - Make additional code changes (local only) - Resolve review threads after addressing feedback (if changes are made separately) - Perform PR reviews when asked (use the PR review process below) When asked to review this PR, follow this structured review process. The `$PR_REVIEW_HELPERS_DIR` environment variable is pre-configured for all scripts below. Follow these steps in order: **Step 1: Gather context** - Use `mcp__agents-md-generator__generate_agents_md` to get repository context (if this fails, explore the repository to understand the codebase — read key files like README, CONTRIBUTING, etc.) - Run `$PR_REVIEW_HELPERS_DIR/pr-existing-comments.sh --summary` to see existing review threads per file - Run `$PR_REVIEW_HELPERS_DIR/pr-diff.sh` to see changed files with line-numbered diffs (for large PRs, this lists files only — review each with `pr-diff.sh `) **Step 2: Review each file** For each changed file: a. If the summary showed existing threads for this file, first run: `$PR_REVIEW_HELPERS_DIR/pr-existing-comments.sh --file ` Read the full thread details. The output uses these conventions: - `← has replies` — a conversation happened; read carefully before commenting - `[truncated]` — comment was cut short; add `--full` if you need the complete text to understand the comment - `[abc1234]` — commit the comment was made on; use `git show abc1234` if needed - `~42` — approximate line from an older revision (exact line no longer maps to current diff) b. Review the diff. Use `Read` to see full file contents when you need more context. Identify issues matching review_criteria. Do NOT flag: - Issues in unchanged code (only review the diff) - Style preferences handled by linters - Pre-existing issues not introduced by this PR - Issues already covered by existing threads (see below) **Existing thread rules** (check BEFORE leaving any comment): - Resolved with reviewer reply → reviewer's decision is final. Do NOT re-flag. Examples: "It should remain as X", "This is intentional", "No need to do this change" - Resolved without reply → author likely fixed it. Do NOT re-raise unless the fix introduced a new problem. - Unresolved → already flagged. Do NOT re-comment. Mention in review body if you have more to add. - Outdated → code changed. Only re-flag if the issue still applies to the current diff. When in doubt, do not duplicate. Redundant comments erode trust in the review process. **Step 3: Leave comments for NEW issues only** For each genuinely new issue not covered by existing threads: ```bash $PR_REVIEW_HELPERS_DIR/pr-comment.sh \ --severity \ --title "Brief description" \ --why "Risk or impact" <<'EOF' corrected code here EOF ``` Always provide suggestion code. Use `--no-suggestion` only when the fix requires changes across multiple locations. Broader architectural concerns belong in the review body, not inline comments. To remove a queued comment: `$PR_REVIEW_HELPERS_DIR/pr-remove-comment.sh ` **Step 4: Submit the review** ```bash $PR_REVIEW_HELPERS_DIR/pr-review.sh "" ``` - REQUEST_CHANGES: Any 🔴 CRITICAL or 🟠 HIGH issues found - COMMENT: 🟡 MEDIUM issues found (but no critical/high) - APPROVE: No issues, or only ⚪ LOW / 💬 NITPICK suggestions The review body should include broader architectural concerns not suited for inline comments. Avoid summarizing the PR or offering praise. If approving with no issues, omit the review body. A standard footer is automatically appended to all comments and reviews. 🔴 CRITICAL - Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs) 🟠 HIGH - Should fix before merge (logic errors, missing validation, significant performance issues) 🟡 MEDIUM - Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases) ⚪ LOW - Author discretion, non-blocking (minor improvements, documentation, style not covered by linters) 💬 NITPICK - Truly optional (stylistic preferences, alternative approaches — safe to ignore) Focus on these categories, in priority order: 1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure) 2. Logic bugs that could cause runtime failures or incorrect behavior 3. Data integrity issues (race conditions, missing transactions, corruption risk) 4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations) 5. Error handling gaps (unhandled exceptions, missing validation) 6. Breaking changes to public APIs without migration path 7. Missing or incorrect test coverage for critical paths View unresolved review threads: ```bash $MENTION_SCRIPTS/gh-get-review-threads.sh ``` Filter for unresolved threads from a specific reviewer: ```bash $MENTION_SCRIPTS/gh-get-review-threads.sh "reviewer-username" ``` Resolve a review thread after addressing feedback: ```bash $MENTION_SCRIPTS/gh-resolve-review-thread.sh "THREAD_ID" "Fixed by updating the error handling" ``` - `THREAD_ID` is the GraphQL node ID from the review threads output (e.g., `PRRT_kwDOABC123`) - The comment is optional - use it to explain what you did Note: Since you cannot push changes, you can resolve threads to acknowledge feedback, but actual fixes would need to be applied separately. - Be concise and actionable - If the request is unclear, ask clarifying questions - If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do - When making code changes, explain that they are local only and cannot be pushed **When performing a PR review**: Your substantive feedback belongs in the PR review submission (via pr-review.sh), not in the comment response. The comment should only report: - That you've submitted the review (with the outcome: approved, requested changes, etc.) - Any issues encountered during the review process - Brief status updates Do NOT duplicate the review content in your comment - the review itself contains all the details. Keep the comment short, e.g., "I've submitted my review requesting changes. See the review for details." Always end your comment with a new line, three dashes, and the footer message: --- Marvin Context Protocol | Type `/marvin` to interact further Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. When writing GitHub comments, wrap branch names, tags, or other @-references in backticks (e.g., `@main`, `@v1.0`) to avoid accidentally pinging users. Do not add backticks around terms that are already inside backticks or code blocks. ================================================ FILE: .github/workflows/marvin-dedupe-issues.yml ================================================ name: Marvin Issue Dedupe # description: Automatically dedupe GitHub issues using Marvin on: issues: types: [opened] workflow_dispatch: inputs: issue_number: description: "Issue number to process for duplicate detection" required: true type: string jobs: marvin-dedupe-issues: runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write id-token: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - name: Set dedupe prompt id: dedupe-prompt run: | cat >> $GITHUB_OUTPUT << 'EOF' PROMPT</dev/null \ || date -u -v-10M '+%Y-%m-%dT%H:%M:%SZ') HAS_RECENT=$(gh api "repos/${{ github.repository }}/issues/${ISSUE}/comments?sort=created&direction=desc&per_page=10" \ --jq "[.[] | select( .user.type == \"Bot\" and (.body | test(\"possible duplicate issues\"; \"i\")) and .created_at >= \"${CUTOFF}\" )] | length") if [ "$HAS_RECENT" -gt 0 ]; then gh issue edit "$ISSUE" --add-label "potential-duplicate" -R "${{ github.repository }}" echo "Added potential-duplicate label to #${ISSUE}" else echo "No recent duplicate comment found, skipping label" fi ================================================ FILE: .github/workflows/marvin-label-triage.yml ================================================ name: Marvin Label Triage # Automatically triage GitHub issues and PRs using Marvin on: issues: types: [opened] pull_request_target: types: [opened] workflow_dispatch: inputs: issue_number: description: "Issue or PR number to triage" required: true type: string concurrency: group: triage-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }} cancel-in-progress: false jobs: label-issue-or-pr: if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read issues: write pull-requests: write steps: - name: Checkout base repository uses: actions/checkout@v6 with: repository: ${{ github.repository }} ref: ${{ github.event.repository.default_branch }} - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} owner: PrefectHQ - name: Set triage prompt id: triage-prompt run: | cat >> $GITHUB_OUTPUT << 'EOF' PROMPT<- ( github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/tidy') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ) || ( github.event_name != 'issue_comment' && github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name ) runs-on: ubuntu-latest steps: - name: Minimize resolved review comments uses: strawgate/minimize-resolved-pr-reviews@v0 with: github-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish FastMCP to PyPI on: release: types: [published] workflow_dispatch: jobs: pypi-publish: name: Upload to PyPI runs-on: ubuntu-latest permissions: id-token: write # For PyPI's trusted publishing steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: "Install uv" uses: astral-sh/setup-uv@v7 - name: Build run: uv build - name: Publish to PyPi run: uv publish -v dist/* ================================================ FILE: .github/workflows/run-static.yml ================================================ name: Run static analysis env: PY_COLORS: 1 on: push: branches: ["main"] paths: - "src/**" - "tests/**" - "uv.lock" - "pyproject.toml" - ".github/workflows/**" # run on all pull requests because these checks are required and will block merges otherwise pull_request: workflow_dispatch: permissions: contents: read jobs: static_analysis: timeout-minutes: 2 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup uv uses: ./.github/actions/setup-uv with: resolution: locked - name: Run prek uses: j178/prek-action@v1 env: SKIP: no-commit-to-branch ================================================ FILE: .github/workflows/run-tests.yml ================================================ name: Tests env: PY_COLORS: 1 on: push: branches: ["main"] paths: - "src/**" - "tests/**" - "uv.lock" - "pyproject.toml" - ".github/workflows/**" # run on all pull requests because these checks are required and will block merges otherwise pull_request: workflow_dispatch: permissions: contents: read jobs: run_tests: name: "Tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.10"] include: - os: ubuntu-latest python-version: "3.13" fail-fast: false timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup uv uses: ./.github/actions/setup-uv with: python-version: ${{ matrix.python-version }} resolution: locked - name: Run unit tests uses: ./.github/actions/run-pytest - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process run_tests_lowest_direct: name: "Tests with lowest-direct dependencies" runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup uv (lowest-direct) uses: ./.github/actions/setup-uv with: resolution: lowest-direct - name: Run unit tests uses: ./.github/actions/run-pytest - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process run_integration_tests: name: "Integration tests" runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup uv uses: ./.github/actions/setup-uv with: resolution: locked - name: Run integration tests uses: ./.github/actions/run-pytest with: test-type: integration env: FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET }} ================================================ FILE: .github/workflows/run-upgrade-checks.yml ================================================ name: Upgrade checks env: PY_COLORS: 1 on: push: branches: ["main"] paths: - "src/**" - "tests/**" - "uv.lock" - "pyproject.toml" - ".github/workflows/**" schedule: # Run daily at 2 AM UTC - cron: "0 2 * * *" workflow_dispatch: permissions: contents: read issues: write jobs: static_analysis: name: Static analysis timeout-minutes: 2 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Setup uv (upgrade) uses: ./.github/actions/setup-uv with: resolution: upgrade - name: Run prek uses: j178/prek-action@v1 env: SKIP: no-commit-to-branch run_tests: name: "Tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.10"] include: - os: ubuntu-latest python-version: "3.13" fail-fast: false timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup uv (upgrade) uses: ./.github/actions/setup-uv with: python-version: ${{ matrix.python-version }} resolution: upgrade - name: Run unit tests uses: ./.github/actions/run-pytest - name: Run client process tests uses: ./.github/actions/run-pytest with: test-type: client_process run_integration_tests: name: "Integration tests" runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup uv (upgrade) uses: ./.github/actions/setup-uv with: resolution: upgrade - name: Run integration tests uses: ./.github/actions/run-pytest with: test-type: integration env: FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET }} notify: name: Notify on failure needs: [static_analysis, run_tests, run_integration_tests] if: failure() && github.event.pull_request == null runs-on: ubuntu-latest steps: - name: Create or update failure issue uses: jayqi/failed-build-issue-action@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} label: "build failed" title-template: "Upgrade checks failing on main branch" body-template: | ## Upgrade Checks Failure on Main Branch The upgrade checks workflow has failed on the main branch. **Workflow Run**: [#{{runNumber}}]({{serverUrl}}/{{repo.owner}}/{{repo.repo}}/actions/runs/{{runId}}) **Commit**: {{sha}} **Branch**: {{ref}} **Event**: {{eventName}} ### Common causes - **ty (type checker)**: New ty releases frequently add stricter checks that flag previously-accepted code. Run `uv run ty check` locally with the latest ty to reproduce. Fix the type errors or bump the ty version floor in `pyproject.toml`. - **ruff**: New lint rules or stricter defaults in a ruff upgrade. - **mcp SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types). ### What to do 1. Check the workflow logs to identify which job failed (static analysis vs tests) 2. Reproduce locally with `uv sync --upgrade && uv run prek run --all-files && uv run pytest -n auto` 3. Fix the code or adjust dependency constraints as needed --- *This issue was automatically created by a GitHub Action.* close-on-success: name: Close issue on success needs: [static_analysis, run_tests, run_integration_tests] if: success() && github.event.pull_request == null && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - name: Close resolved failure issue env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | issue=$(gh issue list \ --repo "$GITHUB_REPOSITORY" \ --label "build failed" \ --state open \ --json number \ --jq '.[0].number // empty') if [ -n "$issue" ]; then gh issue close "$issue" \ --repo "$GITHUB_REPOSITORY" \ --comment "Upgrade checks are passing again as of [\`${GITHUB_SHA::7}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA})." fi ================================================ FILE: .github/workflows/update-config-schema.yml ================================================ name: Update MCPServerConfig Schema # Regenerates config schema on pushes to main and opens a long-lived PR # with the changes, so contributor PRs stay clean. on: push: branches: ["main"] paths: - "src/fastmcp/utilities/mcp_server_config/**" - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" workflow_dispatch: permissions: contents: write pull-requests: write jobs: update-config-schema: timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - uses: actions/checkout@v6 with: token: ${{ steps.marvin-token.outputs.token }} - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Install dependencies run: uv sync --python 3.12 - name: Generate config schema run: | uv run python -c " from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/latest.json') generate_schema('docs/public/schemas/fastmcp.json/v1.json') generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') " - name: Create Pull Request uses: peter-evans/create-pull-request@v8 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/mcp_server_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. 🤖 Generated by Marvin branch: marvin/update-config-schema labels: | ignore in release notes 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>" ================================================ FILE: .github/workflows/update-sdk-docs.yml ================================================ name: Update SDK Documentation # Regenerates SDK docs on pushes to main and opens a long-lived PR # with the changes, so contributor PRs stay clean. on: push: branches: ["main"] paths: - "src/**" - "pyproject.toml" workflow_dispatch: permissions: contents: write pull-requests: write jobs: update-sdk-docs: timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Generate Marvin App token id: marvin-token uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.MARVIN_APP_ID }} private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }} - uses: actions/checkout@v6 with: token: ${{ steps.marvin-token.outputs.token }} - name: Install uv uses: astral-sh/setup-uv@v7 with: enable-cache: true cache-dependency-glob: "uv.lock" - name: Install dependencies run: uv sync --python 3.12 - name: Install just uses: extractions/setup-just@v3 - name: Generate SDK documentation run: just api-ref-all - name: Create Pull Request uses: peter-evans/create-pull-request@v8 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. 📚 Documentation is automatically generated from the source code docstrings and type annotations. **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. 🤖 Generated by Marvin branch: marvin/update-sdk-docs labels: | ignore in release notes 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>" ================================================ FILE: .gitignore ================================================ # Python-generated files __pycache__/ *.py[cod] *$py.class build/ dist/ wheels/ *.egg-info/ *.egg MANIFEST .pytest_cache/ .loq_cache .coverage htmlcov/ .tox/ nosetests.xml coverage.xml *.cover # Virtual environments .venv venv/ env/ ENV/ .env # System files .DS_Store # Version file src/fastmcp/_version.py # Editors and IDEs .cursorrules .vscode/ .idea/ *.swp *.swo *~ .project .pydevproject .settings/ # Jupyter Notebook .ipynb_checkpoints # Type checking .mypy_cache/ .dmypy.json dmypy.json .pyre/ .pytype/ # Local development .python-version .envrc .envrc.private .direnv/ # Logs and databases *.log *.sqlite *.db *.ddb # Claude worktree management .claude-wt/worktrees .claude/worktrees/ # Agents /PLAN.md /TODO.md /STATUS.md plans/ # Common FastMCP test files /test.py /server.py /client.py /test.json ================================================ FILE: .pre-commit-config.yaml ================================================ fail_fast: false repos: - repo: https://github.com/abravalheri/validate-pyproject rev: v0.24.1 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier types_or: [yaml, json5] - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. rev: v0.14.10 hooks: # Run the linter. - id: ruff-check args: [--fix, --exit-non-zero-on-fix] # Run the formatter. - id: ruff-format - repo: local hooks: - id: ty name: ty check entry: uv run --isolated ty check language: system types: [python] files: ^src/|^tests/ pass_filenames: false require_serial: true - id: loq name: loq (file size limits) entry: bash -c 'uv run loq || printf "\nloq violations not enforced... yet!\n"' language: system pass_filenames: false verbose: true - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: no-commit-to-branch name: prevent commits to main args: [--branch, main] - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell # See pyproject.toml for args additional_dependencies: - tomli ================================================ FILE: CLAUDE.md ================================================ # FastMCP Development Guidelines > **Audience**: LLM-driven engineering agents and human developers > **Note**: `AGENTS.md` is a symlink to this file. Edit `CLAUDE.md` directly. FastMCP is a comprehensive Python framework (Python ≥3.10) for building Model Context Protocol (MCP) servers and clients. This is the actively maintained v2.0 providing a complete toolkit for the MCP ecosystem. ## Required Development Workflow **CRITICAL**: Always run these commands in sequence before committing. ```bash uv sync # Install dependencies uv run pytest -n auto # Run full test suite ``` In addition, you must pass static checks. This is generally done as a pre-commit hook with `prek` but you can run it manually with: ```bash uv run prek run --all-files # Ruff + Prettier + ty ``` **Tests must pass and lint/typing must be clean before committing.** ## Repository Structure | Path | Purpose | | ----------------- | -------------------------------------- | | `src/fastmcp/` | Library source code | | `├─server/` | Server implementation | | `│ ├─auth/` | Authentication providers | | `│ └─middleware/` | Error handling, logging, rate limiting | | `├─client/` | Client SDK | | `│ └─auth/` | Client authentication | | `├─tools/` | Tool definitions | | `├─resources/` | Resources and resource templates | | `├─prompts/` | Prompt templates | | `├─cli/` | CLI commands | | `└─utilities/` | Shared utilities | | `tests/` | Pytest suite | | `docs/` | Mintlify docs (gofastmcp.com) | ## Core MCP Objects When modifying MCP functionality, changes typically need to be applied across all object types: - **Tools** (`src/tools/`) - **Resources** (`src/resources/`) - **Resource Templates** (`src/resources/`) - **Prompts** (`src/prompts/`) ## Development Rules ### Git & CI - Prek hooks are required (run automatically on commits) - Never amend commits to fix prek failures - Apply PR labels: bugs/breaking/enhancements/features - Improvements = enhancements (not features) unless specified - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs - **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so. ### Commit Messages and Agent Attribution - **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "🤖 Generated with Claude Code" in commits/PRs) - Keep commit messages brief - ideally just headlines, not detailed messages - Focus on what changed, not how or why - Always read issue comments for follow-up information (treat maintainers as authoritative) - **Treat proposed solutions in issues skeptically.** This applies to solutions proposed by *users* in issue reports — not to feedback from configured review bots (CodeRabbit, chatgpt-codex-connector, etc.), which should be evaluated on their merits. The ideal issue contains a concise problem description and an MRE — nothing more. Proposed solutions are only worth considering if they clearly reflect genuine, non-obvious investigation of the codebase. If a solution reads like speculation, or like it was generated by an LLM without deep framework knowledge, ignore it and diagnose from the repro. Most reporters — human or AI — do not have sufficient understanding of FastMCP internals to correctly diagnose anything beyond a trivial bug. We can ask the same questions of an LLM when implementing; we don't need the reporter to do it for us, and a wrong diagnosis is worse than none. ### PR Messages - Required Structure - 1-2 paragraphs: problem/tension + solution (PRs are documentation!) - Focused code example showing key capability - **Avoid:** bullet summaries, exhaustive change lists, verbose closes/fixes, marketing language - **Do:** Be opinionated about why change matters, show before/after scenarios - Minor fixes: keep body short and concise - No "test plan" sections or testing summaries ### Code Standards - Python ≥ 3.10 with full type annotations - Follow existing patterns and maintain consistency - **Prioritize readable, understandable code** - clarity over cleverness - Avoid obfuscated or confusing patterns even if they're shorter - Each feature needs corresponding tests ### Module Exports - **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces - Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`) - Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`) - Only re-export to `fastmcp.*` for the most fundamental types (e.g., `FastMCP`, `Client`) - When in doubt, prefer users importing from the specific submodule over re-exporting ### Documentation - Uses Mintlify framework - Files must be in docs.json to be included - Do not manually modify `docs/python-sdk/**` — these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs. - Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR. - **Core Principle:** A feature doesn't exist unless it is documented! - When adding or modifying settings in `src/fastmcp/settings.py`, update `docs/more/settings.mdx` to match. ### Documentation Guidelines - **Code Examples:** Explain before showing code, make blocks fully runnable (include imports) - **Code Formatting:** Keep code blocks visually clean — avoid deeply nested function calls. Extract intermediate values into named variables rather than inlining everything into one expression. Code in docs is read more than it's run; optimize for scannability. - **Structure:** Headers form navigation guide, logical H2/H3 hierarchy - **Content:** User-focused sections, motivate features (why) before mechanics (how) - **Style:** Prose over code comments for important information - **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead. ## Critical Patterns - Never use bare `except` - be specific with exception types - File sizes enforced by [loq](https://github.com/jakekaplan/loq). Edit `loq.toml` to raise limits; `loq baseline` to ratchet down. - Always `uv sync` first when debugging build issues - Default test timeout is 5s - optimize or mark as integration tests ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at chris@prefect.io. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to FastMCP FastMCP is an actively maintained, high-traffic project. We welcome contributions — but the most impactful way to contribute might not be what you expect. ## The best contribution is a great issue FastMCP is an opinionated framework, and its maintainers use AI-assisted tooling that is deeply tuned to those opinions — the design philosophy, the API patterns, the way the framework is meant to evolve. A well-written issue with a clear problem description is often more valuable than a pull request, because it lets maintainers produce a solution that isn't just correct, but consistent with how the framework wants to work. That matters more than speed, though it's faster too. **A great issue looks like this:** 1. A short, motivating description of the problem or gap 2. A minimal reproducible example (for bugs) or a concrete use case (for enhancements) 3. A brief note on expected vs. actual behavior That's it. No need to diagnose root causes, propose API designs, or suggest implementations. If you've done genuine investigation and have a non-obvious insight, include it. ## Using AI to contribute We encourage you to use LLMs to help identify bugs, write MREs, and prepare contributions. But if you do, your LLM must take into account the conventions and contributing guidelines of this repo — including how we want issues formatted and when it's appropriate to open a PR. Generic LLM output that ignores these guidelines tells us the contribution wasn't made thoughtfully, and we will close it. A good AI-assisted contribution is indistinguishable from a good human one. A bad one is obvious. ## When to open a pull request **Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue instead. **Documentation** — Typo fixes, clarifications, and improvements to examples are always welcome as PRs. **Enhancements and features** — For changes that affect the behavior or design of the framework, please open an issue first. Maintainers will typically implement these themselves. FastMCP is opinionated, and enhancements need to reflect those opinions — not just solve the problem, but solve it in a way that's consistent with the framework's design. That's hard to do from the outside, and it's why a clear problem description is more useful than a proposed solution. **Integrations** — FastMCP generally does not accept PRs that add third-party integrations (custom middleware, provider-specific adapters, etc.). If you're building something for your users, ship it as a standalone package — that's a feature, not a limitation. Authentication providers are an exception, since auth is tightly coupled to the framework. ## PR guidelines If you do open a PR: - **Reference an issue.** Every PR should address a tracked issue. If there isn't one, open an issue first. This isn't a permission step — you don't need to wait for a response. But the issue gives us context on the problem, and if a maintainer is already working on it, we can let you know before you invest time in code. - **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors. - **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting. - **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior. - **Don't submit generated boilerplate.** We review every line. PRs that read like unedited LLM output — verbose descriptions, speculative changes, shotgun-style fixes — will be closed. ## What we'll close without review To keep the project maintainable, we will close PRs that: - Don't reference an issue or address a clearly self-evident bug - Make sweeping changes without prior discussion - Add third-party integrations that belong in a separate package - Are difficult to review due to size, scope, or generated content This isn't personal. FastMCP receives a high volume of contributions and we need to focus maintainer time where it has the most impact — which is why a good issue is often the best thing you can do for the project. ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================
FastMCP Logo # FastMCP 🚀 Move fast and make things. *Made with 💙 by [Prefect](https://www.prefect.io/)* [![Docs](https://img.shields.io/badge/docs-gofastmcp.com-blue)](https://gofastmcp.com) [![Discord](https://img.shields.io/badge/community-discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/uu8dJCgttd) [![PyPI - Version](https://img.shields.io/pypi/v/fastmcp.svg)](https://pypi.org/project/fastmcp) [![Tests](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml/badge.svg)](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml) [![License](https://img.shields.io/github/license/PrefectHQ/fastmcp.svg)](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE) prefecthq%2Ffastmcp | Trendshift
--- The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production: ```python from fastmcp import FastMCP mcp = FastMCP("Demo 🚀") @mcp.tool def add(a: int, b: int) -> int: """Add two numbers""" return a + b if __name__ == "__main__": mcp.run() ``` ## Why FastMCP Building an effective MCP application is harder than it looks. FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.** **That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages. FastMCP has three pillars:
Servers
Servers

Expose tools, resources, and prompts to LLMs.
Apps
Apps

Give your tools interactive UIs rendered directly in the conversation.
Clients
Clients

Connect to any MCP server — local or remote, programmatic or CLI.
**[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation. Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). When you're ready to deploy, [Prefect Horizon](https://www.prefect.io/horizon) offers free hosting for FastMCP users. ## Installation We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/): ```bash uv pip install fastmcp ``` For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation). **Upgrading?** We have guides for: - [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) - [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) - [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk) ## 📚 Documentation FastMCP's complete documentation is available at **[gofastmcp.com](https://gofastmcp.com)**, including detailed guides, API references, and advanced patterns. Documentation is also available in [llms.txt format](https://llmstxt.org/), which is a simple markdown standard that LLMs can consume easily: - [`llms.txt`](https://gofastmcp.com/llms.txt) is essentially a sitemap, listing all the pages in the documentation. - [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the entire documentation. Note this may exceed the context window of your LLM. **Community:** Join our [Discord server](https://discord.gg/uu8dJCgttd) to connect with other FastMCP developers and share what you're building. ## Contributing We welcome contributions! See the [Contributing Guide](https://gofastmcp.com/development/contributing) for setup instructions, testing requirements, and PR guidelines. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 3.x | :white_check_mark: | | 2.x | :x: | | 1.x | :x: | | 0.x | :x: | ## Reporting a Vulnerability Please report security vulnerabilities privately using [GitHub's security advisory feature](https://github.com/PrefectHQ/fastmcp/security/advisories/new). Do not open public issues for security concerns. ## Scope We accept reports for vulnerabilities in FastMCP itself — the library code in this repository. The following are **out of scope**: - Vulnerabilities in third-party dependencies or the MCP SDK itself. We'll bump version floors for known CVEs, but the fix belongs upstream. - Limitations of upstream identity providers that FastMCP cannot control. - Issues that require the attacker to already have server-side access or control of the MCP server configuration. ## Disclosure Process When we receive a valid report: 1. We triage the report and determine whether it affects FastMCP directly. 2. We develop and test a fix on a private branch. 3. We coordinate CVE assignment through GitHub's advisory process when warranted. 4. We publish the advisory and release a patched version. 5. We credit the reporter in the advisory (unless they prefer otherwise). ================================================ FILE: docs/.ccignore ================================================ changelog.mdx python-sdk/ ================================================ FILE: docs/.cursor/rules/mintlify.mdc ================================================ --- description: globs: *.mdx alwaysApply: false --- # Mintlify technical writing assistant You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. ## Core writing principles ### Language and style requirements - Use clear, direct language appropriate for technical audiences - Write in second person ("you") for instructions and procedures - Use active voice over passive voice - Employ present tense for current states, future tense for outcomes - Maintain consistent terminology throughout all documentation - Keep sentences concise while providing necessary context - Use parallel structure in lists, headings, and procedures ### Content organization standards - Lead with the most important information (inverted pyramid structure) - Use progressive disclosure: basic concepts before advanced ones - Break complex procedures into numbered steps - Include prerequisites and context before instructions - Provide expected outcomes for each major step - End sections with next steps or related information - Use descriptive, keyword-rich headings for navigation and SEO ### User-centered approach - Focus on user goals and outcomes rather than system features - Anticipate common questions and address them proactively - Include troubleshooting for likely failure points - Provide multiple pathways when appropriate (beginner vs advanced), but offer an opinionated path for people to follow to avoid overwhelming with options ## Mintlify component reference ### Callout components #### Note - Additional helpful information Supplementary information that supports the main content without interrupting flow #### Tip - Best practices and pro tips Expert advice, shortcuts, or best practices that enhance user success #### Warning - Important cautions Critical information about potential issues, breaking changes, or destructive actions #### Info - Neutral contextual information Background information, context, or neutral announcements #### Check - Success confirmations Positive confirmations, successful completions, or achievement indicators ### Code components #### Single code block ```javascript config.js const apiConfig = { baseURL: 'https://api.example.com', timeout: 5000, headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}` } }; ``` #### Code group with multiple languages ```javascript Node.js const response = await fetch('/api/endpoint', { headers: { Authorization: `Bearer ${apiKey}` } }); ``` ```python Python import requests response = requests.get('/api/endpoint', headers={'Authorization': f'Bearer {api_key}'}) ``` ```curl cURL curl -X GET '/api/endpoint' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` #### Request/Response examples ```bash cURL curl -X POST 'https://api.example.com/users' \ -H 'Content-Type: application/json' \ -d '{"name": "John Doe", "email": "john@example.com"}' ``` ```json Success { "id": "user_123", "name": "John Doe", "email": "john@example.com", "created_at": "2024-01-15T10:30:00Z" } ``` ### Structural components #### Steps for procedures Run `npm install` to install required packages. Verify installation by running `npm list`. Create a `.env` file with your API credentials. ```bash API_KEY=your_api_key_here ``` Never commit API keys to version control. #### Tabs for alternative content ```bash brew install node npm install -g package-name ``` ```powershell choco install nodejs npm install -g package-name ``` ```bash sudo apt install nodejs npm npm install -g package-name ``` #### Accordions for collapsible content - **Firewall blocking**: Ensure ports 80 and 443 are open - **Proxy configuration**: Set HTTP_PROXY environment variable - **DNS resolution**: Try using 8.8.8.8 as DNS server ```javascript const config = { performance: { cache: true, timeout: 30000 }, security: { encryption: 'AES-256' } }; ``` ### API documentation components #### Parameter fields Unique identifier for the user. Must be a valid UUID v4 format. User's email address. Must be valid and unique within the system. Maximum number of results to return. Range: 1-100. Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` #### Response fields Unique identifier assigned to the newly created user. ISO 8601 formatted timestamp of when the user was created. List of permission strings assigned to this user. #### Expandable nested fields Complete user object with all associated data. User profile information including personal details. User's first name as entered during registration. URL to user's profile picture. Returns null if no avatar is set. ### Interactive components #### Cards for navigation Complete walkthrough from installation to your first API call in under 10 minutes. Learn how to authenticate requests using API keys or JWT tokens. Understand rate limits and best practices for high-volume usage. ### Media and advanced components #### Frames for images Wrap all images in frames. Main dashboard showing analytics overview Analytics dashboard with charts #### Tooltips and updates API ## New features - Added bulk user import functionality - Improved error messages with actionable suggestions ## Bug fixes - Fixed pagination issue with large datasets - Resolved authentication timeout problems ## Required page structure Every documentation page must begin with YAML frontmatter: ```yaml --- title: "Clear, specific, keyword-rich title" description: "Concise description explaining page purpose and value" --- ``` ## Content quality standards ### Code examples requirements - Always include complete, runnable examples that users can copy and execute - Show proper error handling and edge case management - Use realistic data instead of placeholder values - Include expected outputs and results for verification - Test all code examples thoroughly before publishing - Specify language and include filename when relevant - Add explanatory comments for complex logic ### API documentation requirements - Document all parameters including optional ones with clear descriptions - Show both success and error response examples with realistic data - Include rate limiting information with specific limits - Provide authentication examples showing proper format - Explain all HTTP status codes and error handling - Cover complete request/response cycles ### Accessibility requirements - Include descriptive alt text for all images and diagrams - Use specific, actionable link text instead of "click here" - Ensure proper heading hierarchy starting with H2 - Provide keyboard navigation considerations - Use sufficient color contrast in examples and visuals - Structure content for easy scanning with headers and lists ## AI assistant instructions ### Component selection logic - Use **Steps** for procedures, tutorials, setup guides, and sequential instructions - Use **Tabs** for platform-specific content or alternative approaches - Use **CodeGroup** when showing the same concept in multiple languages - Use **Accordions** for supplementary information that might interrupt flow - Use **Cards and CardGroup** for navigation, feature overviews, and related resources - Use **RequestExample/ResponseExample** specifically for API endpoint documentation - Use **ParamField** for API parameters, **ResponseField** for API responses - Use **Expandable** for nested object properties or hierarchical information ### Quality assurance checklist - Verify all code examples are syntactically correct and executable - Test all links to ensure they are functional and lead to relevant content - Validate Mintlify component syntax with all required properties - Confirm proper heading hierarchy with H2 for main sections, H3 for subsections - Ensure content flows logically from basic concepts to advanced topics - Check for consistency in terminology, formatting, and component usage ### Error prevention strategies - Always include realistic error handling in code examples - Provide dedicated troubleshooting sections for complex procedures - Explain prerequisites clearly before beginning instructions - Include verification and testing steps with expected outcomes - Add appropriate warnings for destructive or security-sensitive actions - Validate all technical information through testing before publication ================================================ FILE: docs/apps/development.mdx ================================================ --- title: Development sidebarTitle: Development description: Preview and test your app tools locally without a full MCP host. icon: flask --- import { VersionBadge } from '/snippets/version-badge.mdx' `fastmcp dev apps` launches a browser-based preview for your app tools. It starts your MCP server and a local dev UI side by side — you pick a tool, fill in its arguments, and see the rendered result in a new tab. No MCP host client needed. This works with both [Prefab apps](/apps/prefab) and [custom HTML apps](/apps/low-level). ## Quick Start ```bash pip install "fastmcp[apps]" fastmcp dev apps server.py ``` The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically. ## How It Works The dev server does three things: The **picker page** connects to your MCP server, finds all tools with UI metadata, and renders a form for each one. The forms are auto-generated from the tool's input schema — text fields, dropdowns, checkboxes, all wired up. When you submit a form, the dev server **calls your tool** via the MCP protocol and opens the result in a new tab. The result page loads the tool's UI resource (the Prefab renderer or your custom HTML) inside an AppBridge — the same protocol that real MCP hosts use. A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port. ## Options ```bash fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload ``` | Option | Flag | Default | Description | | ------ | ---- | ------- | ----------- | | MCP Port | `--mcp-port` | `8000` | Port for your MCP server | | Dev Port | `--dev-port` | `8080` | Port for the dev UI | | Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes | ## Multiple Tools If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name. ```bash # Server with multiple app tools fastmcp dev apps examples/apps/contacts/contacts_server.py ``` ================================================ FILE: docs/apps/low-level.mdx ================================================ --- title: Custom HTML Apps sidebarTitle: Custom HTML description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly. icon: code tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control — custom rendering, a specific JavaScript framework, maps, 3D, video — you can use the MCP Apps extension directly. This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management. ## How It Works An MCP App has two parts: 1. A **tool** that does the work and returns data 2. A **`ui://` resource** containing the HTML that renders that data The tool declares which resource to use via `AppConfig`. When the host calls the tool, it also fetches the linked resource, renders it in a sandboxed iframe, and pushes the tool result into the app via `postMessage`. The app can also call tools back, enabling interactive workflows. ```python import json from fastmcp import FastMCP from fastmcp.server.apps import AppConfig, ResourceCSP mcp = FastMCP("My App Server") # The tool does the work @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def generate_chart(data: list[float]) -> str: return json.dumps({"values": data}) # The resource provides the UI @mcp.resource("ui://my-app/view.html") def chart_view() -> str: return "..." ``` ## AppConfig `AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`: ```python from fastmcp.server.apps import AppConfig ``` On **tools**, you'll typically set `resource_uri` to point to the UI resource: ```python @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def my_tool() -> str: return "result" ``` You can also pass a raw dict with camelCase keys, matching the wire format: ```python @mcp.tool(app={"resourceUri": "ui://my-app/view.html"}) def my_tool() -> str: return "result" ``` ### Tool Visibility The `visibility` field controls where a tool appears: - `["model"]` — visible to the LLM (the default behavior) - `["app"]` — only callable from within the app UI, hidden from the LLM - `["model", "app"]` — both This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions. ```python @mcp.tool( app=AppConfig( resource_uri="ui://my-app/view.html", visibility=["app"], ) ) def refresh_data() -> str: """Only callable from the app UI, not by the LLM.""" return fetch_latest() ``` ### AppConfig Fields | Field | Type | Description | |-------|------|-------------| | `resource_uri` | `str` | URI of the UI resource. Tools only. | | `visibility` | `list[str]` | Where the tool appears: `"model"`, `"app"`, or both. Tools only. | | `csp` | `ResourceCSP` | Content Security Policy for the iframe. | | `permissions` | `ResourcePermissions` | Iframe sandbox permissions. | | `domain` | `str` | Stable sandbox origin for the iframe. | | `prefers_border` | `bool` | Whether the UI prefers a visible border. | On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings. ## UI Resources Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. You don't need to set this manually. ```python @mcp.resource("ui://my-app/view.html") def my_view() -> str: return "..." ``` The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication. ### Writing the App HTML Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN: ```html ``` The `App` object provides: - **`app.ontoolresult`** — callback that receives tool results pushed by the host - **`app.callServerTool({name, arguments})`** — call a tool on the server from within the app - **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets) - **`app.getHostContext()`** — get current host context If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below. ## Security Apps run in sandboxed iframes with a deny-by-default Content Security Policy. By default, only inline scripts and styles are allowed — no external network access. ### Content Security Policy If your app needs to load external resources (CDN scripts, API calls, embedded iframes), declare the allowed domains with `ResourceCSP`: ```python from fastmcp.server.apps import AppConfig, ResourceCSP @mcp.resource( "ui://my-app/view.html", app=AppConfig( csp=ResourceCSP( resource_domains=["https://unpkg.com", "https://cdn.example.com"], connect_domains=["https://api.example.com"], ) ), ) def my_view() -> str: return "..." ``` | CSP Field | Controls | |-----------|----------| | `connect_domains` | `fetch`, XHR, WebSocket (`connect-src`) | | `resource_domains` | Scripts, images, styles, fonts (`script-src`, etc.) | | `frame_domains` | Nested iframes (`frame-src`) | | `base_uri_domains` | Document base URI (`base-uri`) | ### Permissions If your app needs browser capabilities like camera or clipboard access, request them via `ResourcePermissions`: ```python from fastmcp.server.apps import AppConfig, ResourcePermissions @mcp.resource( "ui://my-app/view.html", app=AppConfig( permissions=ResourcePermissions( camera={}, clipboard_write={}, ) ), ) def my_view() -> str: return "..." ``` Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback. ## Example: QR Code Server This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package. ```python expandable import base64 import io import qrcode from mcp import types from fastmcp import FastMCP from fastmcp.server.apps import AppConfig, ResourceCSP from fastmcp.tools import ToolResult mcp = FastMCP("QR Code Server") VIEW_URI = "ui://qr-server/view.html" @mcp.tool(app=AppConfig(resource_uri=VIEW_URI)) def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult: """Generate a QR code from text.""" qr = qrcode.QRCode(version=1, box_size=10, border=4) qr.add_data(text) qr.make(fit=True) img = qr.make_image() buffer = io.BytesIO() img.save(buffer, format="PNG") b64 = base64.b64encode(buffer.getvalue()).decode() return ToolResult( content=[types.ImageContent(type="image", data=b64, mimeType="image/png")] ) @mcp.resource( VIEW_URI, app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), ) def view() -> str: """Interactive QR code viewer.""" return """\
""" ``` The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation. ## Checking Client Support Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context): ```python from fastmcp import Context from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) async def my_tool(ctx: Context) -> str: if ctx.client_supports_extension(UI_EXTENSION_ID): # Return data optimized for UI rendering return rich_response() else: # Fall back to plain text return plain_text_response() ``` ================================================ FILE: docs/apps/overview.mdx ================================================ --- title: Apps sidebarTitle: Overview description: Give your tools interactive UIs rendered directly in the conversation. icon: grid-2 tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' MCP Apps let your tools return interactive UIs — rendered in a sandboxed iframe right inside the host client's conversation. Instead of returning plain text, a tool can show a chart, a sortable table, a form, or anything you can build with HTML. FastMCP implements the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and provides two approaches: ## Prefab Apps (Recommended) [Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always [pin `prefab-ui` to a specific version](/apps/prefab#getting-started) in your dependencies. [Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe layouts, charts, tables, forms, and interactive behaviors using a Python DSL — and the framework compiles them to a JSON protocol that a shared renderer interprets. It started as a component library inside FastMCP and grew into its own framework with [comprehensive documentation](https://prefab.prefect.io). ```python from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Dashboard") @mcp.tool(app=True) def sales_chart(year: int) -> PrefabApp: """Show sales data as an interactive chart.""" data = get_sales_data(year) with Column(gap=4, css_class="p-6") as view: Heading(f"{year} Sales") BarChart( data=data, series=[ChartSeries(data_key="revenue", label="Revenue")], x_axis="month", ) return PrefabApp(view=view) ``` Install with `pip install "fastmcp[apps]"` and see [Prefab Apps](/apps/prefab) for the integration guide. ## Custom HTML Apps The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol, and you can use it directly when you need full control. You write your own HTML/CSS/JavaScript and communicate with the host via the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) SDK. This is the right choice for custom rendering (maps, 3D, video), specific JavaScript frameworks, or capabilities beyond what the component library offers. ```python from fastmcp import FastMCP from fastmcp.server.apps import AppConfig, ResourceCSP mcp = FastMCP("Custom App") @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) def my_tool() -> str: return '{"values": [1, 2, 3]}' @mcp.resource( "ui://my-app/view.html", app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])), ) def view() -> str: return "..." ``` See [Custom HTML Apps](/apps/low-level) for the full reference. ================================================ FILE: docs/apps/patterns.mdx ================================================ --- title: Patterns sidebarTitle: Patterns description: Charts, tables, forms, and other common tool UIs. icon: grid-2-plus tag: SOON --- import { VersionBadge } from '/snippets/version-badge.mdx' [Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies. The most common use of Prefab is giving your tools a visual representation — a chart instead of raw numbers, a sortable table instead of a text dump, a status dashboard instead of a list of booleans. Each pattern below is a complete, copy-pasteable tool. ## Charts Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They all render client-side with tooltips, legends, and responsive sizing. ### Bar Chart ```python from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Charts") @mcp.tool(app=True) def quarterly_revenue(year: int) -> PrefabApp: """Show quarterly revenue as a bar chart.""" data = [ {"quarter": "Q1", "revenue": 42000, "costs": 28000}, {"quarter": "Q2", "revenue": 51000, "costs": 31000}, {"quarter": "Q3", "revenue": 47000, "costs": 29000}, {"quarter": "Q4", "revenue": 63000, "costs": 35000}, ] with Column(gap=4, css_class="p-6") as view: Heading(f"{year} Revenue vs Costs") BarChart( data=data, series=[ ChartSeries(data_key="revenue", label="Revenue"), ChartSeries(data_key="costs", label="Costs"), ], x_axis="quarter", show_legend=True, ) return PrefabApp(view=view) ``` Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes. ### Area Chart `LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation (`"linear"`, `"smooth"`, `"step"`) and `show_dots` for data points: ```python from prefab_ui.components import Column, Heading, AreaChart, ChartSeries from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Charts") @mcp.tool(app=True) def usage_trend() -> PrefabApp: """Show API usage over time.""" data = [ {"date": "Feb 1", "requests": 1200}, {"date": "Feb 2", "requests": 1350}, {"date": "Feb 3", "requests": 980}, {"date": "Feb 4", "requests": 1500}, {"date": "Feb 5", "requests": 1420}, ] with Column(gap=4, css_class="p-6") as view: Heading("API Usage") AreaChart( data=data, series=[ChartSeries(data_key="requests", label="Requests")], x_axis="date", curve="smooth", height=250, ) return PrefabApp(view=view) ``` ### Pie and Donut Charts `PieChart` uses `data_key` (the numeric value) and `name_key` (the label) instead of series. Set `inner_radius` for a donut: ```python from prefab_ui.components import Column, Heading, PieChart from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Charts") @mcp.tool(app=True) def ticket_breakdown() -> PrefabApp: """Show open tickets by category.""" data = [ {"category": "Bug", "count": 23}, {"category": "Feature", "count": 15}, {"category": "Docs", "count": 8}, {"category": "Infra", "count": 12}, ] with Column(gap=4, css_class="p-6") as view: Heading("Open Tickets") PieChart( data=data, data_key="count", name_key="category", show_legend=True, inner_radius=60, ) return PrefabApp(view=view) ``` ## Data Tables [DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all running client-side in the browser. ```python from prefab_ui.components import Column, Heading, DataTable, DataTableColumn from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Directory") @mcp.tool(app=True) def employee_directory() -> PrefabApp: """Show a searchable, sortable employee directory.""" employees = [ {"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"}, {"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"}, {"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"}, {"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"}, {"name": "Eva Müller", "department": "Engineering", "role": "Engineer", "location": "Berlin"}, ] with Column(gap=4, css_class="p-6") as view: Heading("Employee Directory") DataTable( columns=[ DataTableColumn(key="name", header="Name", sortable=True), DataTableColumn(key="department", header="Department", sortable=True), DataTableColumn(key="role", header="Role"), DataTableColumn(key="location", header="Office", sortable=True), ], rows=employees, searchable=True, paginated=True, page_size=15, ) return PrefabApp(view=view) ``` ## Forms A form collects input, but it needs somewhere to send that input. The [`CallTool`](https://prefab.prefect.io/docs/concepts/actions) action connects a form to a tool on your MCP server — so you need two tools: one that renders the form, and one that handles the submission. ```python from prefab_ui.components import ( Column, Heading, Row, Muted, Badge, Input, Select, Textarea, Button, Form, ForEach, Separator, ) from prefab_ui.actions import ShowToast from prefab_ui.actions.mcp import CallTool from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Contacts") contacts_db: list[dict] = [ {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"}, ] @mcp.tool(app=True) def contact_form() -> PrefabApp: """Show a contact list with a form to add new contacts.""" with Column(gap=6, css_class="p-6") as view: Heading("Contacts") with ForEach("contacts"): with Row(gap=2, align="center"): Muted("{{ name }}") Muted("{{ email }}") Badge("{{ category }}") Separator() with Form( on_submit=CallTool( "save_contact", result_key="contacts", on_success=ShowToast("Contact saved!", variant="success"), on_error=ShowToast("{{ $error }}", variant="error"), ) ): Input(name="name", label="Full Name", required=True) Input(name="email", label="Email", input_type="email", required=True) Select( name="category", label="Category", options=["Customer", "Vendor", "Partner", "Other"], ) Textarea(name="notes", label="Notes", placeholder="Optional notes...") Button("Save Contact") return PrefabApp(view=view, state={"contacts": list(contacts_db)}) @mcp.tool def save_contact( name: str, email: str, category: str = "Other", notes: str = "", ) -> list[dict]: """Save a new contact and return the updated list.""" contacts_db.append({"name": name, "email": email, "category": category, "notes": notes}) return list(contacts_db) ``` When the user submits the form, the renderer calls `save_contact` on the server with all named input values as arguments. Because `result_key="contacts"` is set, the returned list replaces the `contacts` state — and the `ForEach` re-renders with the new data automatically. The `save_contact` tool is a regular MCP tool. The LLM can also call it directly in conversation. Your UI actions and your conversational tools are the same thing. ### Pydantic Model Forms For complex forms, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, validation, and submit wiring: ```python from typing import Literal from pydantic import BaseModel, Field from prefab_ui.components import Column, Heading, Form from prefab_ui.actions.mcp import CallTool from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Bug Tracker") class BugReport(BaseModel): title: str = Field(title="Bug Title") severity: Literal["low", "medium", "high", "critical"] = Field( title="Severity", default="medium" ) description: str = Field(title="Description") steps_to_reproduce: str = Field(title="Steps to Reproduce") @mcp.tool(app=True) def report_bug() -> PrefabApp: """Show a bug report form.""" with Column(gap=4, css_class="p-6") as view: Heading("Report a Bug") Form.from_model(BugReport, on_submit=CallTool("create_bug_report")) return PrefabApp(view=view) @mcp.tool def create_bug_report(data: dict) -> str: """Create a bug report from the form submission.""" report = BugReport(**data) # save to database... return f"Created bug report: {report.title}" ``` `str` fields become text inputs, `Literal` becomes a select, `bool` becomes a checkbox. The `on_submit` CallTool receives all field values under a `data` key. ## Status Displays Cards, badges, progress bars, and grids combine naturally for dashboards. See the [Prefab layout](https://prefab.prefect.io/docs/concepts/composition) and [container](https://prefab.prefect.io/docs/components/containers) docs for the full set of layout and display components. ```python from prefab_ui.components import ( Column, Row, Grid, Heading, Text, Muted, Badge, Card, CardContent, Progress, Separator, ) from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Monitoring") @mcp.tool(app=True) def system_status() -> PrefabApp: """Show current system health.""" services = [ {"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9}, {"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99}, {"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2}, {"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8}, ] all_ok = all(s["ok"] for s in services) with Column(gap=4, css_class="p-6") as view: with Row(gap=2, align="center"): Heading("System Status") Badge( "All Healthy" if all_ok else "Degraded", variant="success" if all_ok else "destructive", ) Separator() with Grid(columns=2, gap=4): for svc in services: with Card(): with CardContent(): with Row(gap=2, align="center"): Text(svc["name"], css_class="font-medium") Badge( svc["status"], variant="success" if svc["ok"] else "destructive", ) Muted(f"Response: {svc['latency_ms']}ms") Progress(value=svc["uptime_pct"]) return PrefabApp(view=view) ``` ## Conditional Content [`If`, `Elif`, and `Else`](https://prefab.prefect.io/docs/concepts/composition#conditional-rendering) show or hide content based on state. Changes are instant — no server round-trip. ```python from prefab_ui.components import Column, Heading, Switch, Separator, Alert, If from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Flags") @mcp.tool(app=True) def feature_flags() -> PrefabApp: """Toggle feature flags with live preview.""" with Column(gap=4, css_class="p-6") as view: Heading("Feature Flags") Switch(name="dark_mode", label="Dark Mode") Switch(name="beta_features", label="Beta Features") Separator() with If("{{ dark_mode }}"): Alert(title="Dark mode enabled", description="UI will use dark theme.") with If("{{ beta_features }}"): Alert( title="Beta features active", description="Experimental features are now visible.", variant="warning", ) return PrefabApp(view=view, state={"dark_mode": False, "beta_features": False}) ``` ## Tabs [Tabs](https://prefab.prefect.io/docs/components/containers/tabs) organize content into switchable views. Switching is client-side — no server round-trip. ```python from prefab_ui.components import ( Column, Heading, Text, Muted, Badge, Row, DataTable, DataTableColumn, Tabs, Tab, ForEach, ) from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Projects") @mcp.tool(app=True) def project_overview(project_id: str) -> PrefabApp: """Show project details organized in tabs.""" project = { "name": "FastMCP v3", "description": "Next generation MCP framework with Apps support.", "status": "Active", "created_at": "2025-01-15", "members": [ {"name": "Alice Chen", "role": "Lead"}, {"name": "Bob Martinez", "role": "Design"}, ], "activity": [ {"timestamp": "2 hours ago", "message": "Merged PR #342"}, {"timestamp": "1 day ago", "message": "Released v3.0.1"}, ], } with Column(gap=4, css_class="p-6") as view: Heading(project["name"]) with Tabs(): with Tab("Overview"): Text(project["description"]) with Row(gap=4): Badge(project["status"]) Muted(f"Created: {project['created_at']}") with Tab("Members"): DataTable( columns=[ DataTableColumn(key="name", header="Name", sortable=True), DataTableColumn(key="role", header="Role"), ], rows=project["members"], ) with Tab("Activity"): with ForEach("activity"): with Row(gap=2): Muted("{{ timestamp }}") Text("{{ message }}") return PrefabApp(view=view, state={"activity": project["activity"]}) ``` ## Accordion [Accordion](https://prefab.prefect.io/docs/components/containers/accordion) collapses sections to save space. `multiple=True` lets users expand several items at once: ```python from prefab_ui.components import ( Column, Heading, Row, Text, Badge, Progress, Accordion, AccordionItem, ) from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("API Monitor") @mcp.tool(app=True) def api_health() -> PrefabApp: """Show health details for each API endpoint.""" endpoints = [ {"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9}, {"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7}, {"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5}, {"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1}, ] with Column(gap=4, css_class="p-6") as view: Heading("API Health") with Accordion(multiple=True): for ep in endpoints: with AccordionItem(ep["path"]): with Row(gap=4): Badge( f"{ep['status']}", variant="success" if ep["healthy"] else "destructive", ) Text(f"Avg: {ep['avg_ms']}ms") Text(f"P99: {ep['p99_ms']}ms") Progress(value=ep["uptime_pct"]) return PrefabApp(view=view) ``` ## Next Steps - **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript - **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions ================================================ FILE: docs/apps/prefab.mdx ================================================ --- title: Prefab Apps sidebarTitle: Prefab Apps description: Build interactive tool UIs in pure Python — no HTML or JavaScript required. icon: palette tag: SOON --- import { VersionBadge } from '/snippets/version-badge.mdx' [Prefab](https://prefab.prefect.io) is in extremely early, active development — its API changes frequently and breaking changes can occur with any release. The FastMCP integration is equally new and under rapid development. These docs are included for users who want to work on the cutting edge; production use is not recommended. Always pin `prefab-ui` to a specific version in your dependencies (see below). [Prefab UI](https://prefab.prefect.io) is a declarative UI framework for Python. You describe what your interface should look like — a chart, a table, a form — and return it from your tool. FastMCP takes care of everything else: registering the renderer, wiring the protocol metadata, and delivering the component tree to the host. Prefab started as a component library inside FastMCP and grew into a full framework for building interactive applications — with its own state management, reactive expression system, and action model. The [Prefab documentation](https://prefab.prefect.io) covers all of this in depth. This page focuses on the FastMCP integration: what you return from a tool, and what FastMCP does with it. ```bash pip install "fastmcp[apps]" ``` Prefab UI is in active early development and its API changes frequently. We strongly recommend pinning `prefab-ui` to a specific version in your project's dependencies. Installing `fastmcp[apps]` pulls in `prefab-ui` but won't pin it — so a routine `pip install --upgrade` could introduce breaking changes. ```toml # pyproject.toml dependencies = [ "fastmcp[apps]", "prefab-ui==0.8.0", # pin to a known working version ] ``` Here's the simplest possible Prefab App — a tool that returns a bar chart: ```python from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Dashboard") @mcp.tool(app=True) def revenue_chart(year: int) -> PrefabApp: """Show annual revenue as an interactive bar chart.""" data = [ {"quarter": "Q1", "revenue": 42000}, {"quarter": "Q2", "revenue": 51000}, {"quarter": "Q3", "revenue": 47000}, {"quarter": "Q4", "revenue": 63000}, ] with Column(gap=4, css_class="p-6") as view: Heading(f"{year} Revenue") BarChart( data=data, series=[ChartSeries(data_key="revenue", label="Revenue")], x_axis="quarter", ) return PrefabApp(view=view) ``` That's it — you declare a layout using Python's `with` statement, and return it. When the host calls this tool, the user sees an interactive bar chart instead of a JSON blob. The [Patterns](/apps/patterns) page has more examples: area charts, data tables, forms, status dashboards, and more. ## What You Return ### Components The simplest way to get started. If you're returning a visual representation of data and don't need Prefab's more advanced features like initial state or stylesheets, just return the components directly. FastMCP wraps them in a `PrefabApp` automatically: ```python from prefab_ui.components import Column, Heading, Badge from fastmcp import FastMCP mcp = FastMCP("Status") @mcp.tool(app=True) def status_badge() -> Column: """Show system status.""" with Column(gap=2) as view: Heading("All Systems Operational") Badge("Healthy", variant="success") return view ``` Want a chart? Return a chart. Want a table? Return a table. FastMCP handles the wiring. ### PrefabApp When you need more control — setting initial state values that components can read and react to, or configuring the rendering engine — return a `PrefabApp` explicitly: ```python from prefab_ui.components import Column, Heading, Text, Button, If, Badge from prefab_ui.actions import ToggleState from prefab_ui.app import PrefabApp from fastmcp import FastMCP mcp = FastMCP("Demo") @mcp.tool(app=True) def toggle_demo() -> PrefabApp: """Interactive toggle with state.""" with Column(gap=4, css_class="p-6") as view: Button("Toggle", on_click=ToggleState("show")) with If("{{ show }}"): Badge("Visible!", variant="success") return PrefabApp(view=view, state={"show": False}) ``` The `state` dict provides the initial values. Components reference state with `{{ expression }}` templates. State mutations like `ToggleState` happen entirely in the browser — no server round-trip. The [Prefab state guide](https://prefab.prefect.io/docs/concepts/state) covers this in detail. ### ToolResult Every tool result has two audiences: the renderer (which displays the UI) and the LLM (which reads the text content to understand what happened). By default, Prefab Apps send `"[Rendered Prefab UI]"` as the text content, which tells the LLM almost nothing. If you want the LLM to understand the result — so it can reference the data in conversation, summarize it, or decide what to do next — wrap your return in a `ToolResult` with a meaningful `content` string: ```python from prefab_ui.components import Column, Heading, BarChart, ChartSeries from prefab_ui.app import PrefabApp from fastmcp import FastMCP from fastmcp.tools import ToolResult mcp = FastMCP("Sales") @mcp.tool(app=True) def sales_overview(year: int) -> ToolResult: """Show sales data visually and summarize for the model.""" data = get_sales_data(year) total = sum(row["revenue"] for row in data) with Column(gap=4, css_class="p-6") as view: Heading("Sales Overview") BarChart(data=data, series=[ChartSeries(data_key="revenue")]) return ToolResult( content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters", structured_content=view, ) ``` The user sees the chart. The LLM sees `"Total revenue for 2025: $203,000 across 4 quarters"` and can reason about it. ## Type Inference If your tool's return type annotation is a Prefab type — `PrefabApp`, `Component`, or their `Optional` variants — FastMCP detects this and enables app rendering automatically: ```python @mcp.tool def greet(name: str) -> PrefabApp: return PrefabApp(view=Heading(f"Hello, {name}!")) ``` This is equivalent to `@mcp.tool(app=True)`. Explicit `app=True` is recommended for clarity, and is required when the return type doesn't reveal a Prefab type (e.g., `-> ToolResult`). ## How It Works Behind the scenes, when a tool returns a Prefab component or `PrefabApp`, FastMCP: 1. **Registers a shared renderer** — a `ui://prefab/renderer.html` resource containing the JavaScript rendering engine, fetched once by the host and reused across all your Prefab tools. 2. **Wires the tool metadata** — so the host knows to load the renderer iframe when displaying the tool result. 3. **Serializes the component tree** — your Python components become `structuredContent` on the tool result, which the renderer interprets and displays. None of this requires any configuration. The `app=True` flag (or type inference) is the only thing you need. ## Mixing with Custom HTML Apps Prefab tools and [custom HTML tools](/apps/low-level) coexist in the same server. Prefab tools share a single renderer resource; custom tools point to their own. Both use the same MCP Apps protocol: ```python from fastmcp.server.apps import AppConfig @mcp.tool(app=True) def team_directory() -> PrefabApp: ... @mcp.tool(app=AppConfig(resource_uri="ui://my-app/map.html")) def map_view() -> str: ... ``` ## Next Steps - **[Patterns](/apps/patterns)** — Charts, tables, forms, and other common tool UIs - **[Development](/apps/development)** — Preview and test app tools locally with `fastmcp dev apps` - **[Custom HTML Apps](/apps/low-level)** — When you need your own HTML, CSS, and JavaScript - **[Prefab UI Docs](https://prefab.prefect.io)** — Components, state, expressions, and actions ================================================ FILE: docs/assets/schemas/mcp_server_config/latest.json ================================================ { "$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" } ================================================ FILE: docs/assets/schemas/mcp_server_config/v1.json ================================================ { "$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" } ================================================ FILE: docs/changelog.mdx ================================================ --- title: "Changelog" icon: "list-check" rss: true tag: NEW --- **[v3.0.2: Threecovery Mode II](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.2)** Two community-contributed fixes: auth headers from MCP transport no longer leak through to downstream OpenAPI APIs, and background task workers now correctly receive the originating request ID. Plus a new docs example for context-aware tool factories. ### Fixes 🐞 * fix: prevent MCP transport auth header from leaking to downstream OpenAPI APIs by [@stakeswky](https://github.com/stakeswky) in [#3262](https://github.com/PrefectHQ/fastmcp/pull/3262) * fix: propagate origin_request_id to background task workers by [@gfortaine](https://github.com/gfortaine) in [#3175](https://github.com/PrefectHQ/fastmcp/pull/3175) ### Docs 📚 * Add v3.0.1 release notes by [@jlowin](https://github.com/jlowin) in [#3259](https://github.com/PrefectHQ/fastmcp/pull/3259) * docs: add context-aware tool factory example by [@machov](https://github.com/machov) in [#3264](https://github.com/PrefectHQ/fastmcp/pull/3264) **Full Changelog**: [v3.0.1...v3.0.2](https://github.com/PrefectHQ/fastmcp/compare/v3.0.1...v3.0.2) **[v3.0.1: Three-covery Mode](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.1)** First patch after 3.0 — mostly smoothing out rough edges discovered in the wild. The big ones: middleware state that wasn't surviving the trip to tool handlers now does, `Tool.from_tool()` accepts callables again, OpenAPI schemas with circular references no longer crash discovery, and decorator overloads now return the correct types in function mode. Also adds `verify_id_token` to OIDCProxy for providers (like some Azure AD configs) that issue opaque access tokens but standard JWT id_tokens. ### Enhancements 🔧 * Add verify_id_token option to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#3248](https://github.com/PrefectHQ/fastmcp/pull/3248) ### Fixes 🐞 * Fix v3.0.0 changelog compare link by [@jlowin](https://github.com/jlowin) in [#3223](https://github.com/PrefectHQ/fastmcp/pull/3223) * Fix MDX parse error in upgrade guide prompts by [@jlowin](https://github.com/jlowin) in [#3227](https://github.com/PrefectHQ/fastmcp/pull/3227) * Fix non-serializable state lost between middleware and tools by [@jlowin](https://github.com/jlowin) in [#3234](https://github.com/PrefectHQ/fastmcp/pull/3234) * Accept callables in Tool.from_tool() by [@jlowin](https://github.com/jlowin) in [#3235](https://github.com/PrefectHQ/fastmcp/pull/3235) * Preserve skill metadata through provider wrapping by [@jlowin](https://github.com/jlowin) in [#3237](https://github.com/PrefectHQ/fastmcp/pull/3237) * Fix circular reference crash in OpenAPI schemas by [@jlowin](https://github.com/jlowin) in [#3245](https://github.com/PrefectHQ/fastmcp/pull/3245) * Fix NameError with future annotations and Context/Depends parameters by [@jlowin](https://github.com/jlowin) in [#3243](https://github.com/PrefectHQ/fastmcp/pull/3243) * Fix ty ignore syntax in OpenAPI provider by [@jlowin](https://github.com/jlowin) in [#3253](https://github.com/PrefectHQ/fastmcp/pull/3253) * Use max_completion_tokens instead of deprecated max_tokens in OpenAI handler by [@jlowin](https://github.com/jlowin) in [#3254](https://github.com/PrefectHQ/fastmcp/pull/3254) * Fix ty compatibility with upgraded deps by [@jlowin](https://github.com/jlowin) in [#3257](https://github.com/PrefectHQ/fastmcp/pull/3257) * Fix decorator overload return types for function mode by [@jlowin](https://github.com/jlowin) in [#3258](https://github.com/PrefectHQ/fastmcp/pull/3258) ### Docs 📚 * Sync README with welcome.mdx, fix install count by [@jlowin](https://github.com/jlowin) in [#3224](https://github.com/PrefectHQ/fastmcp/pull/3224) * Document dict-to-Message prompt migration in upgrade guides by [@jlowin](https://github.com/jlowin) in [#3225](https://github.com/PrefectHQ/fastmcp/pull/3225) * Fix v2 upgrade guide: remove incorrect v1 import advice by [@jlowin](https://github.com/jlowin) in [#3226](https://github.com/PrefectHQ/fastmcp/pull/3226) * Animated banner by [@jlowin](https://github.com/jlowin) in [#3231](https://github.com/PrefectHQ/fastmcp/pull/3231) * Document mounted server state store isolation in upgrade guide by [@jlowin](https://github.com/jlowin) in [#3236](https://github.com/PrefectHQ/fastmcp/pull/3236) **Full Changelog**: [v3.0.0...v3.0.1](https://github.com/PrefectHQ/fastmcp/compare/v3.0.0...v3.0.1) **[v3.0.0: Three at Last](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0)** FastMCP 3.0 is stable. Two betas, two release candidates, 21 new contributors, and more than 100,000 pre-release installs later — the architecture held up, the upgrade path was smooth, and we're shipping it. The surface API is largely unchanged — `@mcp.tool()` still works exactly as before. What changed is everything underneath: a provider/transform architecture that makes FastMCP extensible, observable, and composable in ways v2 couldn't support. If we did our jobs right, you'll barely notice the redesign. You'll just notice that more is possible. This is also the release where FastMCP moves from [jlowin/fastmcp](https://github.com/jlowin/fastmcp) to [PrefectHQ/fastmcp](https://github.com/PrefectHQ/fastmcp). GitHub forwards all links, PyPI is the same, imports are the same. A major version felt like the right moment to make it official. ### Build servers from anything 🔌 Components no longer have to live in one file with one server. `FileSystemProvider` discovers tools from directories with hot-reload. `OpenAPIProvider` wraps REST APIs. `ProxyProvider` proxies remote MCP servers. `SkillsProvider` delivers agent skills as resources. Write your own provider for whatever source makes sense. Compose multiple providers into one server, share one across many, or chain them with **transforms** that rename, namespace, filter, version, and secure components as they flow to clients. `ResourcesAsTools` and `PromptsAsTools` expose non-tool components to tool-only clients. ### Ship to production 🔐 Component versioning: serve `@tool(version="2.0")` alongside older versions from one codebase. Granular authorization on individual components with async auth checks, server-wide policies via `AuthMiddleware`, and scope-based access control. OAuth gets CIMD, Static Client Registration, Azure OBO via dependency injection, JWT audience validation, and confused-deputy protections. OpenTelemetry tracing with MCP semantic conventions. Response size limiting. Background tasks with distributed Redis notification and `ctx.elicit()` relay. Security fixes include dropping `diskcache` (CVE-2025-69872) and upgrading `python-multipart` and `protobuf` for additional CVEs. ### Adapt per session 💾 Session state persists across requests via `ctx.set_state()` / `ctx.get_state()`. `ctx.enable_components()` and `ctx.disable_components()` let servers adapt dynamically per client — show admin tools after authentication, progressively reveal capabilities, or scope access by role. ### Develop faster ⚡ `--reload` auto-restarts on file changes. Standalone decorators return the original function, so decorated tools stay callable in tests and non-MCP contexts. Sync functions auto-dispatch to a threadpool. Tool timeouts, MCP-compliant pagination, composable lifespans, `PingMiddleware` for keepalive, and concurrent tool execution when the LLM returns multiple calls in one response. ### Use FastMCP as a CLI 🖥️ `fastmcp list` and `fastmcp call` query and invoke tools on any server from a terminal. `fastmcp discover` scans your editor configs (Claude Desktop, Cursor, Goose, Gemini CLI) and finds configured servers by name. `fastmcp generate-cli` writes a standalone typed CLI where every tool is a subcommand. `fastmcp install` registers your server with Claude Desktop, Cursor, or Goose in one command. ### Build apps (3.1 preview) 📱 Spec-level support for MCP Apps is in: `ui://` resource scheme, typed UI metadata via `AppConfig`, extension negotiation, and runtime detection. The full Apps experience lands in 3.1. --- If you hit 3.0 because you didn't pin your dependencies and something breaks — the [upgrade guides](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) will get you sorted. We minimized breaking changes, but a major version is a major version. ```bash pip install fastmcp -U ``` 📖 [Documentation](https://gofastmcp.com) 🚀 [Upgrade from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2) 🔀 [Upgrade from MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk) ## What's Changed ### New Features 🎉 * Refactor resource behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2611](https://github.com/PrefectHQ/fastmcp/pull/2611) * Refactor prompt behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2610](https://github.com/PrefectHQ/fastmcp/pull/2610) * feat: Provider abstraction for dynamic MCP components by [@jlowin](https://github.com/jlowin) in [#2622](https://github.com/PrefectHQ/fastmcp/pull/2622) * Unify component storage in LocalProvider by [@jlowin](https://github.com/jlowin) in [#2680](https://github.com/PrefectHQ/fastmcp/pull/2680) * Introduce ResourceResult as canonical resource return type by [@jlowin](https://github.com/jlowin) in [#2734](https://github.com/PrefectHQ/fastmcp/pull/2734) * Introduce Message and PromptResult as canonical prompt types by [@jlowin](https://github.com/jlowin) in [#2738](https://github.com/PrefectHQ/fastmcp/pull/2738) * Add --reload flag for auto-restart on file changes by [@jlowin](https://github.com/jlowin) in [#2816](https://github.com/PrefectHQ/fastmcp/pull/2816) * Add FileSystemProvider for filesystem-based component discovery by [@jlowin](https://github.com/jlowin) in [#2823](https://github.com/PrefectHQ/fastmcp/pull/2823) * Add standalone decorators and eliminate fastmcp.fs module by [@jlowin](https://github.com/jlowin) in [#2832](https://github.com/PrefectHQ/fastmcp/pull/2832) * Add authorization checks to components and servers by [@jlowin](https://github.com/jlowin) in [#2855](https://github.com/PrefectHQ/fastmcp/pull/2855) * Decorators return functions instead of component objects by [@jlowin](https://github.com/jlowin) in [#2856](https://github.com/PrefectHQ/fastmcp/pull/2856) * Add transform system for modifying components in provider chains by [@jlowin](https://github.com/jlowin) in [#2836](https://github.com/PrefectHQ/fastmcp/pull/2836) * Add OpenTelemetry tracing support by [@chrisguidry](https://github.com/chrisguidry) in [#2869](https://github.com/PrefectHQ/fastmcp/pull/2869) * Add component versioning and VersionFilter transform by [@jlowin](https://github.com/jlowin) in [#2894](https://github.com/PrefectHQ/fastmcp/pull/2894) * Add version discovery and calling a certain version for components by [@jlowin](https://github.com/jlowin) in [#2897](https://github.com/PrefectHQ/fastmcp/pull/2897) * Refactor visibility to mark-based enabled system by [@jlowin](https://github.com/jlowin) in [#2912](https://github.com/PrefectHQ/fastmcp/pull/2912) * Add session-specific visibility control via Context by [@jlowin](https://github.com/jlowin) in [#2917](https://github.com/PrefectHQ/fastmcp/pull/2917) * Add Skills Provider for exposing agent skills as MCP resources by [@jlowin](https://github.com/jlowin) in [#2944](https://github.com/PrefectHQ/fastmcp/pull/2944) * Add MCP Apps Phase 1 — SDK compatibility (SEP-1865) by [@jlowin](https://github.com/jlowin) in [#3009](https://github.com/PrefectHQ/fastmcp/pull/3009) * Add `fastmcp list` and `fastmcp call` CLI commands by [@jlowin](https://github.com/jlowin) in [#3054](https://github.com/PrefectHQ/fastmcp/pull/3054) * Add `fastmcp generate-cli` command by [@jlowin](https://github.com/jlowin) in [#3065](https://github.com/PrefectHQ/fastmcp/pull/3065) * Add CIMD (Client ID Metadata Document) support for OAuth by [@jlowin](https://github.com/jlowin) in [#2871](https://github.com/PrefectHQ/fastmcp/pull/2871) ### Enhancements 🔧 * Convert mounted servers to MountedProvider by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) * Simplify .key as computed property by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) * Refactor MountedProvider into FastMCPProvider + TransformingProvider by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) * Enable background task support for custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2657](https://github.com/PrefectHQ/fastmcp/pull/2657) * Use CreateTaskResult for background task creation by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) * Refactor provider execution: components own their execution by [@jlowin](https://github.com/jlowin) in [#2663](https://github.com/PrefectHQ/fastmcp/pull/2663) * Add supports_tasks() method to replace string mode checks by [@jlowin](https://github.com/jlowin) in [#2664](https://github.com/PrefectHQ/fastmcp/pull/2664) * Replace type: ignore[attr-defined] with isinstance assertions in tests by [@jlowin](https://github.com/jlowin) in [#2665](https://github.com/PrefectHQ/fastmcp/pull/2665) * Add poll_interval to TaskConfig by [@jlowin](https://github.com/jlowin) in [#2666](https://github.com/PrefectHQ/fastmcp/pull/2666) * Refactor task module: rename protocol.py to requests.py and reduce redundancy by [@jlowin](https://github.com/jlowin) in [#2667](https://github.com/PrefectHQ/fastmcp/pull/2667) * Refactor FastMCPProxy into ProxyProvider by [@jlowin](https://github.com/jlowin) in [#2669](https://github.com/PrefectHQ/fastmcp/pull/2669) * Move OpenAPI to providers/openapi submodule by [@jlowin](https://github.com/jlowin) in [#2672](https://github.com/PrefectHQ/fastmcp/pull/2672) * Use ergonomic provider initialization pattern by [@jlowin](https://github.com/jlowin) in [#2675](https://github.com/PrefectHQ/fastmcp/pull/2675) * Fix ty 0.0.5 type errors by [@jlowin](https://github.com/jlowin) in [#2676](https://github.com/PrefectHQ/fastmcp/pull/2676) * Remove execution methods from Provider base class by [@jlowin](https://github.com/jlowin) in [#2681](https://github.com/PrefectHQ/fastmcp/pull/2681) * Add type-prefixed keys for globally unique component identification by [@jlowin](https://github.com/jlowin) in [#2704](https://github.com/PrefectHQ/fastmcp/pull/2704) * Consolidate notification system with unified API by [@jlowin](https://github.com/jlowin) in [#2710](https://github.com/PrefectHQ/fastmcp/pull/2710) * Parallelize provider operations by [@jlowin](https://github.com/jlowin) in [#2716](https://github.com/PrefectHQ/fastmcp/pull/2716) * Consolidate get_* and _list_* methods into single API by [@jlowin](https://github.com/jlowin) in [#2719](https://github.com/PrefectHQ/fastmcp/pull/2719) * Consolidate execution method chains into single public API by [@jlowin](https://github.com/jlowin) in [#2728](https://github.com/PrefectHQ/fastmcp/pull/2728) * Parallelize list_* calls in Provider.get_tasks() by [@jlowin](https://github.com/jlowin) in [#2731](https://github.com/PrefectHQ/fastmcp/pull/2731) * Consistent decorator-based MCP handler registration by [@jlowin](https://github.com/jlowin) in [#2732](https://github.com/PrefectHQ/fastmcp/pull/2732) * Make ToolResult a BaseModel for serialization support by [@jlowin](https://github.com/jlowin) in [#2736](https://github.com/PrefectHQ/fastmcp/pull/2736) * Align prompt handler with resource pattern by [@jlowin](https://github.com/jlowin) in [#2740](https://github.com/PrefectHQ/fastmcp/pull/2740) * Update classes to inherit from FastMCPBaseModel instead of BaseModel by [@jlowin](https://github.com/jlowin) in [#2739](https://github.com/PrefectHQ/fastmcp/pull/2739) * Add explicit task_meta parameter to FastMCP.call_tool() by [@jlowin](https://github.com/jlowin) in [#2749](https://github.com/PrefectHQ/fastmcp/pull/2749) * Add task_meta parameter to read_resource() for explicit task control by [@jlowin](https://github.com/jlowin) in [#2750](https://github.com/PrefectHQ/fastmcp/pull/2750) * Add task_meta to prompts and centralize fn_key enrichment by [@jlowin](https://github.com/jlowin) in [#2751](https://github.com/PrefectHQ/fastmcp/pull/2751) * Remove unused include_tags/exclude_tags settings by [@jlowin](https://github.com/jlowin) in [#2756](https://github.com/PrefectHQ/fastmcp/pull/2756) * Parallelize provider access when executing components by [@jlowin](https://github.com/jlowin) in [#2744](https://github.com/PrefectHQ/fastmcp/pull/2744) * Deprecate tool_serializer parameter by [@jlowin](https://github.com/jlowin) in [#2753](https://github.com/PrefectHQ/fastmcp/pull/2753) * Feature/supabase custom auth route by [@EloiZalczer](https://github.com/EloiZalczer) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) * Remove deprecated WSTransport by [@jlowin](https://github.com/jlowin) in [#2826](https://github.com/PrefectHQ/fastmcp/pull/2826) * Add composable lifespans by [@jlowin](https://github.com/jlowin) in [#2828](https://github.com/PrefectHQ/fastmcp/pull/2828) * Replace FastMCP.as_proxy() with create_proxy() function by [@jlowin](https://github.com/jlowin) in [#2829](https://github.com/PrefectHQ/fastmcp/pull/2829) * Add PingMiddleware for keepalive connections by [@jlowin](https://github.com/jlowin) in [#2838](https://github.com/PrefectHQ/fastmcp/pull/2838) * Run sync tools/resources/prompts in threadpool automatically by [@jlowin](https://github.com/jlowin) in [#2865](https://github.com/PrefectHQ/fastmcp/pull/2865) * Add timeout parameter for tool foreground execution by [@jlowin](https://github.com/jlowin) in [#2872](https://github.com/PrefectHQ/fastmcp/pull/2872) * Adopt OpenTelemetry MCP semantic conventions by [@chrisguidry](https://github.com/chrisguidry) in [#2886](https://github.com/PrefectHQ/fastmcp/pull/2886) * Add client_secret_post authentication to IntrospectionTokenVerifier by [@shulkx](https://github.com/shulkx) in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) * Add enable_rich_logging setting to disable rich formatting by [@strawgate](https://github.com/strawgate) in [#2893](https://github.com/PrefectHQ/fastmcp/pull/2893) * Rename _fastmcp metadata namespace to fastmcp and make non-optional by [@jlowin](https://github.com/jlowin) in [#2895](https://github.com/PrefectHQ/fastmcp/pull/2895) * Refactor FastMCP to inherit from Provider by [@jlowin](https://github.com/jlowin) in [#2901](https://github.com/PrefectHQ/fastmcp/pull/2901) * Swap public/private method naming in Provider by [@jlowin](https://github.com/jlowin) in [#2902](https://github.com/PrefectHQ/fastmcp/pull/2902) * Add MCP-compliant pagination support by [@jlowin](https://github.com/jlowin) in [#2903](https://github.com/PrefectHQ/fastmcp/pull/2903) * Support VersionSpec in enable/disable for range-based filtering by [@jlowin](https://github.com/jlowin) in [#2914](https://github.com/PrefectHQ/fastmcp/pull/2914) * Immutable transform wrapping for providers by [@jlowin](https://github.com/jlowin) in [#2913](https://github.com/PrefectHQ/fastmcp/pull/2913) * Unify discovery API: deduplicate at protocol layer only by [@jlowin](https://github.com/jlowin) in [#2919](https://github.com/PrefectHQ/fastmcp/pull/2919) * Add ResourcesAsTools transform by [@jlowin](https://github.com/jlowin) in [#2943](https://github.com/PrefectHQ/fastmcp/pull/2943) * Add PromptsAsTools transform by [@jlowin](https://github.com/jlowin) in [#2946](https://github.com/PrefectHQ/fastmcp/pull/2946) * Rename Enabled transform to Visibility by [@jlowin](https://github.com/jlowin) in [#2950](https://github.com/PrefectHQ/fastmcp/pull/2950) * feat: option to add upstream claims to the FastMCP proxy JWT by [@JonasKs](https://github.com/JonasKs) in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) * fix: automatically include offline_access as a scope in the Azure provider by [@JonasKs](https://github.com/JonasKs) in [#3001](https://github.com/PrefectHQ/fastmcp/pull/3001) * feat: expand --reload to watch frontend file types by [@jlowin](https://github.com/jlowin) in [#3028](https://github.com/PrefectHQ/fastmcp/pull/3028) * Add `fastmcp install stdio` command by [@jlowin](https://github.com/jlowin) in [#3032](https://github.com/PrefectHQ/fastmcp/pull/3032) * feat: Goose integration + dedicated install command by [@jlowin](https://github.com/jlowin) in [#3040](https://github.com/PrefectHQ/fastmcp/pull/3040) * Add `fastmcp discover` and name-based server resolution by [@jlowin](https://github.com/jlowin) in [#3055](https://github.com/PrefectHQ/fastmcp/pull/3055) * feat(context): Add background task support for Context by [@gfortaine](https://github.com/gfortaine) in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) * Add server version to banner by [@richardkmichael](https://github.com/richardkmichael) in [#3076](https://github.com/PrefectHQ/fastmcp/pull/3076) * Add @handle_tool_errors decorator for standardized error handling by [@dgenio](https://github.com/dgenio) in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) * Add ResponseLimitingMiddleware for tool response size control by [@dgenio](https://github.com/dgenio) in [#3072](https://github.com/PrefectHQ/fastmcp/pull/3072) * Infer MIME types from OpenAPI response definitions by [@jlowin](https://github.com/jlowin) in [#3101](https://github.com/PrefectHQ/fastmcp/pull/3101) * Remove require_auth in favor of scope-based authorization by [@jlowin](https://github.com/jlowin) in [#3103](https://github.com/PrefectHQ/fastmcp/pull/3103) * generate-cli: auto-generate SKILL.md agent skill by [@jlowin](https://github.com/jlowin) in [#3115](https://github.com/PrefectHQ/fastmcp/pull/3115) * Add Azure OBO dependencies, auth token injection, and documentation by [@jlowin](https://github.com/jlowin) in [#2918](https://github.com/PrefectHQ/fastmcp/pull/2918) * feat: add Static Client Registration by [@martimfasantos](https://github.com/martimfasantos) in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) * Add concurrent tool execution with sequential flag by [@strawgate](https://github.com/strawgate) in [#3022](https://github.com/PrefectHQ/fastmcp/pull/3022) * Add validate_output option for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#3134](https://github.com/PrefectHQ/fastmcp/pull/3134) * Relay task elicitation through standard MCP protocol by [@chrisguidry](https://github.com/chrisguidry) in [#3136](https://github.com/PrefectHQ/fastmcp/pull/3136) * Support async auth checks by [@jlowin](https://github.com/jlowin) in [#3152](https://github.com/PrefectHQ/fastmcp/pull/3152) * Make $ref dereferencing optional via FastMCP(dereference_refs=...) by [@jlowin](https://github.com/jlowin) in [#3151](https://github.com/PrefectHQ/fastmcp/pull/3151) * Expose local_provider property, deprecate FastMCP.remove_tool() by [@jlowin](https://github.com/jlowin) in [#3155](https://github.com/PrefectHQ/fastmcp/pull/3155) * Add helpers for converting FunctionTool and TransformedTool to SamplingTool by [@strawgate](https://github.com/strawgate) in [#3062](https://github.com/PrefectHQ/fastmcp/pull/3062) ### Fixes 🐞 * Let FastMCPError propagate from dependencies by [@chrisguidry](https://github.com/chrisguidry) in [#2646](https://github.com/PrefectHQ/fastmcp/pull/2646) * Fix task execution for tools with custom names by [@chrisguidry](https://github.com/chrisguidry) in [#2645](https://github.com/PrefectHQ/fastmcp/pull/2645) * fix: check the cause of the tool error by [@rjolaverria](https://github.com/rjolaverria) in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) * Fix uvicorn 0.39+ test timeouts and FastMCPError propagation by [@jlowin](https://github.com/jlowin) in [#2699](https://github.com/PrefectHQ/fastmcp/pull/2699) * Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) * Fix Proxy provider to return all resource contents by [@jlowin](https://github.com/jlowin) in [#2742](https://github.com/PrefectHQ/fastmcp/pull/2742) * fix: Client OAuth async_auth_flow() method causing MCP-SDK lock error by [@lgndluke](https://github.com/lgndluke) in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) * Fix rate limit detection during teardown phase by [@jlowin](https://github.com/jlowin) in [#2757](https://github.com/PrefectHQ/fastmcp/pull/2757) * Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2764](https://github.com/PrefectHQ/fastmcp/pull/2764) * Fix `openapi_version` check so 3.1 is included by [@deeleeramone](https://github.com/deeleeramone) in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) * Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) * Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2784](https://github.com/PrefectHQ/fastmcp/pull/2784) * Fix OAuth token storage TTL calculation by [@jlowin](https://github.com/jlowin) in [#2796](https://github.com/PrefectHQ/fastmcp/pull/2796) * Fix client hanging on HTTP 4xx/5xx errors by [@jlowin](https://github.com/jlowin) in [#2803](https://github.com/PrefectHQ/fastmcp/pull/2803) * Fix keep_alive passthrough in StdioMCPServer.to_transport() by [@jlowin](https://github.com/jlowin) in [#2791](https://github.com/PrefectHQ/fastmcp/pull/2791) * Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2808](https://github.com/PrefectHQ/fastmcp/pull/2808) * Fix timeout not propagating to proxy clients in multi-server MCPConfig by [@jlowin](https://github.com/jlowin) in [#2809](https://github.com/PrefectHQ/fastmcp/pull/2809) * Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2844](https://github.com/PrefectHQ/fastmcp/pull/2844) * Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2849](https://github.com/PrefectHQ/fastmcp/pull/2849) * Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2875](https://github.com/PrefectHQ/fastmcp/pull/2875) * fix: broaden combine_lifespans type to accept Mapping return types by [@aminsamir45](https://github.com/aminsamir45) in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) * fix: correctly send resource when exchanging code for upstream by [@JonasKs](https://github.com/JonasKs) in [#3013](https://github.com/PrefectHQ/fastmcp/pull/3013) * chore: upgrade python-multipart to 0.0.22 (CVE-2026-24486) by [@jlowin](https://github.com/jlowin) in [#3042](https://github.com/PrefectHQ/fastmcp/pull/3042) * chore: upgrade protobuf to 6.33.5 (CVE-2026-0994) by [@jlowin](https://github.com/jlowin) in [#3043](https://github.com/PrefectHQ/fastmcp/pull/3043) * fix: use MCP spec error code -32002 for resource not found by [@jlowin](https://github.com/jlowin) in [#3041](https://github.com/PrefectHQ/fastmcp/pull/3041) * Fix tool_choice reset for structured output sampling by [@strawgate](https://github.com/strawgate) in [#3014](https://github.com/PrefectHQ/fastmcp/pull/3014) * fix: Preserve metadata in FastMCPProvider component wrappers by [@NeelayS](https://github.com/NeelayS) in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) * fix: enforce redirect URI validation when allowed_client_redirect_uris is supplied by [@nathanwelsh8](https://github.com/nathanwelsh8) in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) * Fix --reload port conflict when using explicit port by [@jlowin](https://github.com/jlowin) in [#3070](https://github.com/PrefectHQ/fastmcp/pull/3070) * Fix compress_schema to preserve additionalProperties: false by [@jlowin](https://github.com/jlowin) in [#3102](https://github.com/PrefectHQ/fastmcp/pull/3102) * Fix CIMD redirect allowlist bypass and cache revalidation by [@jlowin](https://github.com/jlowin) in [#3098](https://github.com/PrefectHQ/fastmcp/pull/3098) * Fix session visibility marks leaking across sessions by [@jlowin](https://github.com/jlowin) in [#3132](https://github.com/PrefectHQ/fastmcp/pull/3132) * Fix unhandled exceptions in OpenAPI POST tool calls by [@jlowin](https://github.com/jlowin) in [#3133](https://github.com/PrefectHQ/fastmcp/pull/3133) * feat: distributed notification queue + BLPOP elicitation for background tasks by [@gfortaine](https://github.com/gfortaine) in [#2906](https://github.com/PrefectHQ/fastmcp/pull/2906) * fix: snapshot access token for background tasks by [@gfortaine](https://github.com/gfortaine) in [#3138](https://github.com/PrefectHQ/fastmcp/pull/3138) * fix: guard client pagination loops against misbehaving servers by [@jlowin](https://github.com/jlowin) in [#3167](https://github.com/PrefectHQ/fastmcp/pull/3167) * Support non-serializable values in Context.set_state by [@jlowin](https://github.com/jlowin) in [#3171](https://github.com/PrefectHQ/fastmcp/pull/3171) * Fix stale request context in StatefulProxyClient handlers by [@jlowin](https://github.com/jlowin) in [#3172](https://github.com/PrefectHQ/fastmcp/pull/3172) * Drop diskcache dependency (CVE-2025-69872) by [@jlowin](https://github.com/jlowin) in [#3185](https://github.com/PrefectHQ/fastmcp/pull/3185) * Fix confused deputy attack via consent binding cookie by [@jlowin](https://github.com/jlowin) in [#3201](https://github.com/PrefectHQ/fastmcp/pull/3201) * Add JWT audience validation and RFC 8707 warnings to auth providers by [@jlowin](https://github.com/jlowin) in [#3204](https://github.com/PrefectHQ/fastmcp/pull/3204) * Cache OBO credentials on AzureProvider for token reuse by [@jlowin](https://github.com/jlowin) in [#3212](https://github.com/PrefectHQ/fastmcp/pull/3212) * Fix invalid uv add command in upgrade guide by [@jlowin](https://github.com/jlowin) in [#3217](https://github.com/PrefectHQ/fastmcp/pull/3217) * Use standard traceparent/tracestate keys per OTel MCP semconv by [@chrisguidry](https://github.com/chrisguidry) in [#3221](https://github.com/PrefectHQ/fastmcp/pull/3221) ### Breaking Changes 🛫 * Add VisibilityFilter for hierarchical enable/disable by [@jlowin](https://github.com/jlowin) in [#2708](https://github.com/PrefectHQ/fastmcp/pull/2708) * Remove automatic environment variable loading from auth providers by [@jlowin](https://github.com/jlowin) in [#2752](https://github.com/PrefectHQ/fastmcp/pull/2752) * Make pydocket optional and unify DI systems by [@jlowin](https://github.com/jlowin) in [#2835](https://github.com/PrefectHQ/fastmcp/pull/2835) * Add session-scoped state persistence by [@jlowin](https://github.com/jlowin) in [#2873](https://github.com/PrefectHQ/fastmcp/pull/2873) * Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig by [@jlowin](https://github.com/jlowin) in [#3117](https://github.com/PrefectHQ/fastmcp/pull/3117) * Remove deprecated FastMCP() constructor kwargs by [@jlowin](https://github.com/jlowin) in [#3148](https://github.com/PrefectHQ/fastmcp/pull/3148) * Move `fastmcp dev` to `fastmcp dev inspector` by [@jlowin](https://github.com/jlowin) in [#3188](https://github.com/PrefectHQ/fastmcp/pull/3188) ## New Contributors * [@ivanbelenky](https://github.com/ivanbelenky) made their first contribution in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) * [@rjolaverria](https://github.com/rjolaverria) made their first contribution in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) * [@mgoldsborough](https://github.com/mgoldsborough) made their first contribution in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) * [@Ashif4354](https://github.com/Ashif4354) made their first contribution in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) * [@majiayu000](https://github.com/majiayu000) made their first contribution in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) * [@lgndluke](https://github.com/lgndluke) made their first contribution in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) * [@EloiZalczer](https://github.com/EloiZalczer) made their first contribution in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) * [@deeleeramone](https://github.com/deeleeramone) made their first contribution in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) * [@shea-parkes](https://github.com/shea-parkes) made their first contribution in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) * [@bryankthompson](https://github.com/bryankthompson) made their first contribution in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) * [@bhbs](https://github.com/bhbs) made their first contribution in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) * [@shulkx](https://github.com/shulkx) made their first contribution in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) * [@abhijeethp](https://github.com/abhijeethp) made their first contribution in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) * [@aminsamir45](https://github.com/aminsamir45) made their first contribution in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) * [@JonasKs](https://github.com/JonasKs) made their first contribution in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) * [@NeelayS](https://github.com/NeelayS) made their first contribution in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) * [@gfortaine](https://github.com/gfortaine) made their first contribution in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) * [@nathanwelsh8](https://github.com/nathanwelsh8) made their first contribution in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) * [@dgenio](https://github.com/dgenio) made their first contribution in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) * [@martimfasantos](https://github.com/martimfasantos) made their first contribution in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) * [@jfBiswajit](https://github.com/jfBiswajit) made their first contribution in [#3193](https://github.com/PrefectHQ/fastmcp/pull/3193) **Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v2.14.5...v3.0.0 **[v3.0.0rc1: RC-ing is Believing](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0rc1)** FastMCP 3 RC1 means we believe the API is stable. Beta 2 drew a wave of real-world adoption — production deployments, migration reports, integration testing — and the feedback overwhelmingly confirmed that the architecture works. This release closes gaps that surfaced under load: auth flows that needed to be async, background tasks that needed reliable notification delivery, and APIs still carrying beta-era naming. If nothing unexpected surfaces, this is what 3.0.0 looks like. 🚨 **Breaking Changes** — The `ui=` parameter is now `app=` with a unified `AppConfig` class (matching the feature's actual name), and 16 `FastMCP()` constructor kwargs have finally been removed. If you've been ignoring months of deprecation warnings, you'll get a `TypeError` with specific migration instructions. 🔐 **Auth Improvements** — Three changes that together round out FastMCP's auth story for production. `auth=` checks can now be `async`, so you can hit databases or external services during authorization — previously, passing an async function silently passed because the unawaited coroutine was truthy. Static Client Registration lets clients provide a pre-registered `client_id`/`client_secret` directly, bypassing DCR for servers that don't support it. And Azure OBO flows are now declarative via dependency injection: ```python from fastmcp.server.auth.providers.azure import EntraOBOToken @mcp.tool() async def get_emails( graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), ): # OBO exchange already happened — just use the token ... ``` ⚡ **Concurrent Sampling** — When an LLM returns multiple tool calls in a single response, `context.sample()` can now execute them in parallel. Opt in with `tool_concurrency=0` for unlimited parallelism, or set a bound. Tools that aren't safe to parallelize can declare `sequential=True`. 📡 **Background Task Notifications** — Background tasks now reliably push progress updates and elicit user input through the standard MCP protocol. A distributed Redis queue replaces polling (7,200 round-trips/hour → one blocking call), and `ctx.elicit()` in background tasks automatically relays through the client's standard `elicitation_handler`. ✅ **OpenAPI Output Validation** — When backends don't conform to their own OpenAPI schemas, the MCP SDK rejects the response and the tool fails. `validate_output=False` disables strict schema checking while still passing structured JSON to clients — a necessary escape hatch for imperfect APIs. ## What's Changed ### Enhancements 🔧 * generate-cli: auto-generate SKILL.md agent skill by [@jlowin](https://github.com/jlowin) in [#3115](https://github.com/PrefectHQ/fastmcp/pull/3115) * Scope Martian triage to bug-labeled issues for jlowin by [@jlowin](https://github.com/jlowin) in [#3124](https://github.com/PrefectHQ/fastmcp/pull/3124) * Add Azure OBO dependencies, auth token injection, and documentation by [@jlowin](https://github.com/jlowin) in [#2918](https://github.com/PrefectHQ/fastmcp/pull/2918) * feat: add Static Client Registration (#3085) by [@martimfasantos](https://github.com/martimfasantos) in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) * Add concurrent tool execution with sequential flag by [@strawgate](https://github.com/strawgate) in [#3022](https://github.com/PrefectHQ/fastmcp/pull/3022) * Add validate_output option for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#3134](https://github.com/PrefectHQ/fastmcp/pull/3134) * Relay task elicitation through standard MCP protocol by [@chrisguidry](https://github.com/chrisguidry) in [#3136](https://github.com/PrefectHQ/fastmcp/pull/3136) * Bump py-key-value-aio to `>=0.4.0,<0.5.0` by [@strawgate](https://github.com/strawgate) in [#3143](https://github.com/PrefectHQ/fastmcp/pull/3143) * Support async auth checks by [@jlowin](https://github.com/jlowin) in [#3152](https://github.com/PrefectHQ/fastmcp/pull/3152) * Make $ref dereferencing optional via FastMCP(dereference_refs=...) by [@jlowin](https://github.com/jlowin) in [#3151](https://github.com/PrefectHQ/fastmcp/pull/3151) * Expose local_provider property, deprecate FastMCP.remove_tool() by [@jlowin](https://github.com/jlowin) in [#3155](https://github.com/PrefectHQ/fastmcp/pull/3155) * Add helpers for converting FunctionTool and TransformedTool to SamplingTool by [@strawgate](https://github.com/strawgate) in [#3062](https://github.com/PrefectHQ/fastmcp/pull/3062) * Updates to github actions / workflows for claude by [@strawgate](https://github.com/strawgate) in [#3157](https://github.com/PrefectHQ/fastmcp/pull/3157) ### Fixes 🐞 * Updated deprecation URL for V3 by [@SrzStephen](https://github.com/SrzStephen) in [#3108](https://github.com/PrefectHQ/fastmcp/pull/3108) * Fix Windows test timeouts in OAuth proxy provider tests by [@strawgate](https://github.com/strawgate) in [#3123](https://github.com/PrefectHQ/fastmcp/pull/3123) * Fix session visibility marks leaking across sessions by [@jlowin](https://github.com/jlowin) in [#3132](https://github.com/PrefectHQ/fastmcp/pull/3132) * Fix unhandled exceptions in OpenAPI POST tool calls by [@jlowin](https://github.com/jlowin) in [#3133](https://github.com/PrefectHQ/fastmcp/pull/3133) * feat: distributed notification queue + BLPOP elicitation for background tasks by [@gfortaine](https://github.com/gfortaine) in [#2906](https://github.com/PrefectHQ/fastmcp/pull/2906) * fix: snapshot access token for background tasks (#3095) by [@gfortaine](https://github.com/gfortaine) in [#3138](https://github.com/PrefectHQ/fastmcp/pull/3138) * Stop duplicating path parameter descriptions into tool prose by [@jlowin](https://github.com/jlowin) in [#3149](https://github.com/PrefectHQ/fastmcp/pull/3149) * fix: guard client pagination loops against misbehaving servers by [@jlowin](https://github.com/jlowin) in [#3167](https://github.com/PrefectHQ/fastmcp/pull/3167) * Fix stale get_* references in docs and examples by [@jlowin](https://github.com/jlowin) in [#3168](https://github.com/PrefectHQ/fastmcp/pull/3168) * Support non-serializable values in Context.set_state by [@jlowin](https://github.com/jlowin) in [#3171](https://github.com/PrefectHQ/fastmcp/pull/3171) * Fix stale request context in StatefulProxyClient handlers by [@jlowin](https://github.com/jlowin) in [#3172](https://github.com/PrefectHQ/fastmcp/pull/3172) ### Breaking Changes 🛫 * Rename ui= to app= and consolidate ToolUI/ResourceUI into AppConfig by [@jlowin](https://github.com/jlowin) in [#3117](https://github.com/PrefectHQ/fastmcp/pull/3117) * Remove deprecated FastMCP() constructor kwargs by [@jlowin](https://github.com/jlowin) in [#3148](https://github.com/PrefectHQ/fastmcp/pull/3148) ### Docs 📚 * Update docs to reference beta 2 by [@jlowin](https://github.com/jlowin) in [#3112](https://github.com/PrefectHQ/fastmcp/pull/3112) * docs: add pre-registered OAuth clients to v3-features by [@jlowin](https://github.com/jlowin) in [#3129](https://github.com/PrefectHQ/fastmcp/pull/3129) ### Dependencies 📦 * chore(deps): bump cryptography from 46.0.3 to 46.0.5 in /examples/testing_demo in the uv group across 1 directory by @dependabot in [#3140](https://github.com/PrefectHQ/fastmcp/pull/3140) ### Other Changes 🦾 * docs: add v3.0.0rc1 features to v3-features tracking by [@jlowin](https://github.com/jlowin) in [#3145](https://github.com/PrefectHQ/fastmcp/pull/3145) * docs: remove nonexistent MSALApp from rc1 notes by [@jlowin](https://github.com/jlowin) in [#3146](https://github.com/PrefectHQ/fastmcp/pull/3146) ## New Contributors * [@martimfasantos](https://github.com/martimfasantos) made their first contribution in [#3086](https://github.com/PrefectHQ/fastmcp/pull/3086) **Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v3.0.0b2...v3.0.0rc1 **[v3.0.0b2: 2 Fast 2 Beta](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b2)** FastMCP 3 Beta 2 reflects the huge number of people that kicked the tires on Beta 1. Seven new contributors landed changes in this release, and early migration reports went smoother than expected, including teams on Prefect Horizon upgrading from v2. Most of Beta 2 is refinement: fixing what people found, filling gaps from real usage, hardening edges. But a few new features did land along the way. 🖥️ **Client CLI** — `fastmcp list`, `fastmcp call`, `fastmcp discover`, and `fastmcp generate-cli` turn any MCP server into something you can poke at from a terminal. Discover servers configured in Claude Desktop, Cursor, Goose, or project-level `mcp.json` files and reference them by name. `generate-cli` reads a server's schemas and writes a standalone typed CLI script where every tool is a proper subcommand with flags and help text. 🔐 **CIMD** (Client ID Metadata Documents) adds an alternative to Dynamic Client Registration for OAuth. Clients host a static JSON document at an HTTPS URL; that URL becomes the `client_id`. Server-side support includes SSRF-hardened fetching, cache-aware revalidation, and `private_key_jwt` validation. Enabled by default on `OAuthProxy`. 📱 **MCP Apps** — Spec-level compliance for the MCP Apps extension: `ui://` resource scheme, typed UI metadata on tools and resources, extension negotiation, and `ctx.client_supports_extension()` for runtime detection. ⏳ **Background Task Context** — `Context` now works transparently in Docket workers. `ctx.elicit()` routes through Redis-based coordination so background tasks can pause for user input without any code changes. 🛡️ **ResponseLimitingMiddleware** caps tool response sizes with UTF-8-safe truncation for text and schema-aware error handling for structured outputs. 🪿 **Goose Integration** — `fastmcp install goose` generates deeplink URLs for one-command server installation into Goose. ## What's Changed ### New Features 🎉 * Add MCP Apps Phase 1 — SDK compatibility (SEP-1865) by [@jlowin](https://github.com/jlowin) in [#3009](https://github.com/PrefectHQ/fastmcp/pull/3009) * Add `fastmcp list` and `fastmcp call` CLI commands by [@jlowin](https://github.com/jlowin) in [#3054](https://github.com/PrefectHQ/fastmcp/pull/3054) * Add `fastmcp generate-cli` command by [@jlowin](https://github.com/jlowin) in [#3065](https://github.com/PrefectHQ/fastmcp/pull/3065) * Add CIMD (Client ID Metadata Document) support for OAuth by [@jlowin](https://github.com/jlowin) in [#2871](https://github.com/PrefectHQ/fastmcp/pull/2871) ### Enhancements 🔧 * Make duplicate bot less aggressive by [@jlowin](https://github.com/jlowin) in [#2981](https://github.com/PrefectHQ/fastmcp/pull/2981) * Remove uv lockfile monitoring from Dependabot by [@jlowin](https://github.com/jlowin) in [#2986](https://github.com/PrefectHQ/fastmcp/pull/2986) * Run static checks with --upgrade, remove lockfile check by [@jlowin](https://github.com/jlowin) in [#2988](https://github.com/PrefectHQ/fastmcp/pull/2988) * Adjust workflow triggers for Marvin by [@strawgate](https://github.com/strawgate) in [#3010](https://github.com/PrefectHQ/fastmcp/pull/3010) * Move tests to a reusable action and enable nightly checks by [@strawgate](https://github.com/strawgate) in [#3017](https://github.com/PrefectHQ/fastmcp/pull/3017) * feat: option to add upstream claims to the FastMCP proxy JWT by [@JonasKs](https://github.com/JonasKs) in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) * Fix ty 0.0.14 compatibility and upgrade dependencies by [@jlowin](https://github.com/jlowin) in [#3027](https://github.com/PrefectHQ/fastmcp/pull/3027) * fix: automatically include offline_access as a scope in the Azure provider to enable automatic token refreshing by [@JonasKs](https://github.com/JonasKs) in [#3001](https://github.com/PrefectHQ/fastmcp/pull/3001) * feat: expand --reload to watch frontend file types by [@jlowin](https://github.com/jlowin) in [#3028](https://github.com/PrefectHQ/fastmcp/pull/3028) * Add `fastmcp install stdio` command by [@jlowin](https://github.com/jlowin) in [#3032](https://github.com/PrefectHQ/fastmcp/pull/3032) * Update martian-issue-triage.yml for Workflow editing guidance by [@strawgate](https://github.com/strawgate) in [#3033](https://github.com/PrefectHQ/fastmcp/pull/3033) * feat: Goose integration + dedicated install command by [@jlowin](https://github.com/jlowin) in [#3040](https://github.com/PrefectHQ/fastmcp/pull/3040) * Fixing spelling issues in multiple files by [@didier-durand](https://github.com/didier-durand) in [#2996](https://github.com/PrefectHQ/fastmcp/pull/2996) * Add `fastmcp discover` and name-based server resolution by [@jlowin](https://github.com/jlowin) in [#3055](https://github.com/PrefectHQ/fastmcp/pull/3055) * feat(context): Add background task support for Context (SEP-1686) by [@gfortaine](https://github.com/gfortaine) in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) * Add server version to banner by [@richardkmichael](https://github.com/richardkmichael) in [#3076](https://github.com/PrefectHQ/fastmcp/pull/3076) * Add @handle_tool_errors decorator for standardized error handling by [@dgenio](https://github.com/dgenio) in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) * Update Anthropic and OpenAI clients to use Omit instead of NotGiven by [@jlowin](https://github.com/jlowin) in [#3088](https://github.com/PrefectHQ/fastmcp/pull/3088) * Add ResponseLimitingMiddleware for tool response size control by [@dgenio](https://github.com/dgenio) in [#3072](https://github.com/PrefectHQ/fastmcp/pull/3072) * Infer MIME types from OpenAPI response definitions by [@jlowin](https://github.com/jlowin) in [#3101](https://github.com/PrefectHQ/fastmcp/pull/3101) * Remove require_auth in favor of scope-based authorization by [@jlowin](https://github.com/jlowin) in [#3103](https://github.com/PrefectHQ/fastmcp/pull/3103) ### Fixes 🐞 * Fix FastAPI mounting examples in docs by [@jlowin](https://github.com/jlowin) in [#2962](https://github.com/PrefectHQ/fastmcp/pull/2962) * Remove outdated 'FastMCP 3.0 is coming!' CLI banner by [@jlowin](https://github.com/jlowin) in [#2974](https://github.com/PrefectHQ/fastmcp/pull/2974) * Pin httpx `< 1.0` and simplify beta install docs by [@jlowin](https://github.com/jlowin) in [#2975](https://github.com/PrefectHQ/fastmcp/pull/2975) * Add enabled field to ToolTransformConfig by [@jlowin](https://github.com/jlowin) in [#2991](https://github.com/PrefectHQ/fastmcp/pull/2991) * fix phue2 import in smart_home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#2999](https://github.com/PrefectHQ/fastmcp/pull/2999) * fix: broaden combine_lifespans type to accept Mapping return types by [@aminsamir45](https://github.com/aminsamir45) in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) * fix: type narrowing for skills resource contents by [@strawgate](https://github.com/strawgate) in [#3023](https://github.com/PrefectHQ/fastmcp/pull/3023) * fix: correctly send resource when exchanging code for the upstream by [@JonasKs](https://github.com/JonasKs) in [#3013](https://github.com/PrefectHQ/fastmcp/pull/3013) * MCP Apps: structured CSP/permissions types, resource meta propagation fix, QR example by [@jlowin](https://github.com/jlowin) in [#3031](https://github.com/PrefectHQ/fastmcp/pull/3031) * chore: upgrade python-multipart to 0.0.22 (CVE-2026-24486) by [@jlowin](https://github.com/jlowin) in [#3042](https://github.com/PrefectHQ/fastmcp/pull/3042) * chore: upgrade protobuf to 6.33.5 (CVE-2026-0994) by [@jlowin](https://github.com/jlowin) in [#3043](https://github.com/PrefectHQ/fastmcp/pull/3043) * fix: use MCP spec error code -32002 for resource not found by [@jlowin](https://github.com/jlowin) in [#3041](https://github.com/PrefectHQ/fastmcp/pull/3041) * Fix tool_choice reset for structured output sampling by [@strawgate](https://github.com/strawgate) in [#3014](https://github.com/PrefectHQ/fastmcp/pull/3014) * Fix workflow notification URL formatting in upgrade checks by [@strawgate](https://github.com/strawgate) in [#3047](https://github.com/PrefectHQ/fastmcp/pull/3047) * Fix Field() handling in prompts by [@strawgate](https://github.com/strawgate) in [#3050](https://github.com/PrefectHQ/fastmcp/pull/3050) * fix: use SkipJsonSchema to exclude callable fields from JSON schema generation by [@strawgate](https://github.com/strawgate) in [#3048](https://github.com/PrefectHQ/fastmcp/pull/3048) * fix: Preserve metadata in FastMCPProvider component wrappers by [@NeelayS](https://github.com/NeelayS) in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) * Mock network calls in CLI tests and use MemoryStore for OAuth tests by [@strawgate](https://github.com/strawgate) in [#3051](https://github.com/PrefectHQ/fastmcp/pull/3051) * Remove OpenAPI timeout parameter, make client optional, surface timeout errors by [@jlowin](https://github.com/jlowin) in [#3067](https://github.com/PrefectHQ/fastmcp/pull/3067) * fix: enforce redirect URI validation when allowed_client_redirect_uris is supplied by [@nathanwelsh8](https://github.com/nathanwelsh8) in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) * Fix --reload port conflict when using explicit port by [@jlowin](https://github.com/jlowin) in [#3070](https://github.com/PrefectHQ/fastmcp/pull/3070) * Fix compress_schema to preserve additionalProperties: false for MCP compatibility by [@jlowin](https://github.com/jlowin) in [#3102](https://github.com/PrefectHQ/fastmcp/pull/3102) * Fix CIMD redirect allowlist bypass and cache revalidation by [@jlowin](https://github.com/jlowin) in [#3098](https://github.com/PrefectHQ/fastmcp/pull/3098) * Exclude content-type from get_http_headers() to prevent HTTP 415 errors by [@jlowin](https://github.com/jlowin) in [#3104](https://github.com/PrefectHQ/fastmcp/pull/3104) ### Docs 📚 * Prepare docs for v3.0 beta release by [@jlowin](https://github.com/jlowin) in [#2954](https://github.com/PrefectHQ/fastmcp/pull/2954) * Restructure docs: move transforms to dedicated section by [@jlowin](https://github.com/jlowin) in [#2956](https://github.com/PrefectHQ/fastmcp/pull/2956) * Remove unnecessary pip warning by [@jlowin](https://github.com/jlowin) in [#2958](https://github.com/PrefectHQ/fastmcp/pull/2958) * Update example MCP version in installation docs by [@jlowin](https://github.com/jlowin) in [#2959](https://github.com/PrefectHQ/fastmcp/pull/2959) * Update brand images by [@jlowin](https://github.com/jlowin) in [#2960](https://github.com/PrefectHQ/fastmcp/pull/2960) * Restructure README and welcome page with motivated narrative by [@jlowin](https://github.com/jlowin) in [#2963](https://github.com/PrefectHQ/fastmcp/pull/2963) * Restructure README and docs with motivated narrative by [@jlowin](https://github.com/jlowin) in [#2964](https://github.com/PrefectHQ/fastmcp/pull/2964) * Favicon update and Prefect Horizon docs by [@jlowin](https://github.com/jlowin) in [#2978](https://github.com/PrefectHQ/fastmcp/pull/2978) * Add dependency injection documentation and DI-style dependencies by [@jlowin](https://github.com/jlowin) in [#2980](https://github.com/PrefectHQ/fastmcp/pull/2980) * docs: document expanded reload behavior and restructure beta sections by [@jlowin](https://github.com/jlowin) in [#3039](https://github.com/PrefectHQ/fastmcp/pull/3039) * Add output_schema caveat to response limiting docs by [@jlowin](https://github.com/jlowin) in [#3099](https://github.com/PrefectHQ/fastmcp/pull/3099) * Document token passthrough security in OAuth Proxy docs by [@jlowin](https://github.com/jlowin) in [#3100](https://github.com/PrefectHQ/fastmcp/pull/3100) ### Dependencies 📦 * Bump ty from 0.0.12 to 0.0.13 by @dependabot in [#2984](https://github.com/PrefectHQ/fastmcp/pull/2984) * Bump prek from 0.2.30 to 0.3.0 by @dependabot in [#2982](https://github.com/PrefectHQ/fastmcp/pull/2982) ### Other Changes 🦾 * Normalize resource URLs before comparison to support RFC 8707 query parameters by [@abhijeethp](https://github.com/abhijeethp) in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) * Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2998](https://github.com/PrefectHQ/fastmcp/pull/2998) * Add AzureJWTVerifier for Managed Identity token verification by [@jlowin](https://github.com/jlowin) in [#3058](https://github.com/PrefectHQ/fastmcp/pull/3058) * Add release notes for v2.14.4 and v2.14.5 by [@jlowin](https://github.com/jlowin) in [#3064](https://github.com/PrefectHQ/fastmcp/pull/3064) * Add missing beta2 features to v3 release tracking by [@jlowin](https://github.com/jlowin) in [#3105](https://github.com/PrefectHQ/fastmcp/pull/3105) ## New Contributors * [@abhijeethp](https://github.com/abhijeethp) made their first contribution in [#2967](https://github.com/PrefectHQ/fastmcp/pull/2967) * [@aminsamir45](https://github.com/aminsamir45) made their first contribution in [#3005](https://github.com/PrefectHQ/fastmcp/pull/3005) * [@JonasKs](https://github.com/JonasKs) made their first contribution in [#2997](https://github.com/PrefectHQ/fastmcp/pull/2997) * [@NeelayS](https://github.com/NeelayS) made their first contribution in [#3057](https://github.com/PrefectHQ/fastmcp/pull/3057) * [@gfortaine](https://github.com/gfortaine) made their first contribution in [#2905](https://github.com/PrefectHQ/fastmcp/pull/2905) * [@nathanwelsh8](https://github.com/nathanwelsh8) made their first contribution in [#3066](https://github.com/PrefectHQ/fastmcp/pull/3066) * [@dgenio](https://github.com/dgenio) made their first contribution in [#2885](https://github.com/PrefectHQ/fastmcp/pull/2885) **Full Changelog**: https://github.com/PrefectHQ/fastmcp/compare/v3.0.0b1...v3.0.0b2 **[v3.0.0b1: This Beta Work](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.0.0b1)** FastMCP 3.0 rebuilds the framework around three primitives: components, providers, and transforms. Providers source components dynamically—from decorators, filesystems, OpenAPI specs, remote servers, or anywhere else. Transforms modify components as they flow to clients—renaming, namespacing, filtering, securing. The features that required specialized subsystems in v2 now compose naturally from these building blocks. 🔌 **Provider Architecture** unifies how components are sourced. `FileSystemProvider` discovers decorated functions from directories with optional hot-reload. `SkillsProvider` exposes agent skill files as MCP resources. `OpenAPIProvider` and `ProxyProvider` get cleaner integrations. Providers are composable—share one across servers, or attach many to one server. 🔄 **Transforms** add middleware for components. Namespace mounted servers, rename verbose tools, filter by version, control visibility—all without touching source code. `ResourcesAsTools` and `PromptsAsTools` expose non-tool components to tool-only clients. 📋 **Component Versioning** lets you register `@tool(version="2.0")` alongside older versions. Clients see the highest version by default but can request specific versions. `VersionFilter` serves different API versions from one codebase. 💾 **Session-Scoped State** persists across requests. `await ctx.set_state()` and `await ctx.get_state()` now survive the full session. Per-session visibility via `ctx.enable_components()` lets servers adapt dynamically to each client. ⚡ **DX Improvements** include `--reload` for auto-restart during development, automatic threadpool dispatch for sync functions, tool timeouts, pagination for large component lists, and OpenTelemetry tracing. 🔐 **Component Authorization** via `@tool(auth=require_scopes("admin"))` and `AuthMiddleware` for server-wide policies. Breaking changes are minimal: for most servers, updating the import statement is all you need. See the [migration guide](https://github.com/PrefectHQ/fastmcp/blob/main/docs/getting-started/upgrading/from-fastmcp-2.mdx) for details. ## What's Changed ### New Features 🎉 * Refactor resource behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2611](https://github.com/PrefectHQ/fastmcp/pull/2611) * Refactor prompt behavior and add meta support by [@jlowin](https://github.com/jlowin) in [#2610](https://github.com/PrefectHQ/fastmcp/pull/2610) * feat: Provider abstraction for dynamic MCP components by [@jlowin](https://github.com/jlowin) in [#2622](https://github.com/PrefectHQ/fastmcp/pull/2622) * Unify component storage in LocalProvider by [@jlowin](https://github.com/jlowin) in [#2680](https://github.com/PrefectHQ/fastmcp/pull/2680) * Introduce ResourceResult as canonical resource return type by [@jlowin](https://github.com/jlowin) in [#2734](https://github.com/PrefectHQ/fastmcp/pull/2734) * Introduce Message and PromptResult as canonical prompt types by [@jlowin](https://github.com/jlowin) in [#2738](https://github.com/PrefectHQ/fastmcp/pull/2738) * Add --reload flag for auto-restart on file changes by [@jlowin](https://github.com/jlowin) in [#2816](https://github.com/PrefectHQ/fastmcp/pull/2816) * Add FileSystemProvider for filesystem-based component discovery by [@jlowin](https://github.com/jlowin) in [#2823](https://github.com/PrefectHQ/fastmcp/pull/2823) * Add standalone decorators and eliminate fastmcp.fs module by [@jlowin](https://github.com/jlowin) in [#2832](https://github.com/PrefectHQ/fastmcp/pull/2832) * Add authorization checks to components and servers by [@jlowin](https://github.com/jlowin) in [#2855](https://github.com/PrefectHQ/fastmcp/pull/2855) * Decorators return functions instead of component objects by [@jlowin](https://github.com/jlowin) in [#2856](https://github.com/PrefectHQ/fastmcp/pull/2856) * Add transform system for modifying components in provider chains by [@jlowin](https://github.com/jlowin) in [#2836](https://github.com/PrefectHQ/fastmcp/pull/2836) * Add OpenTelemetry tracing support by [@chrisguidry](https://github.com/chrisguidry) in [#2869](https://github.com/PrefectHQ/fastmcp/pull/2869) * Add component versioning and VersionFilter transform by [@jlowin](https://github.com/jlowin) in [#2894](https://github.com/PrefectHQ/fastmcp/pull/2894) * Add version discovery and calling a certain version for components by [@jlowin](https://github.com/jlowin) in [#2897](https://github.com/PrefectHQ/fastmcp/pull/2897) * Refactor visibility to mark-based enabled system by [@jlowin](https://github.com/jlowin) in [#2912](https://github.com/PrefectHQ/fastmcp/pull/2912) * Add session-specific visibility control via Context by [@jlowin](https://github.com/jlowin) in [#2917](https://github.com/PrefectHQ/fastmcp/pull/2917) * Add Skills Provider for exposing agent skills as MCP resources by [@jlowin](https://github.com/jlowin) in [#2944](https://github.com/PrefectHQ/fastmcp/pull/2944) ### Enhancements 🔧 * Convert mounted servers to MountedProvider by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) * Simplify .key as computed property by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) * Refactor MountedProvider into FastMCPProvider + TransformingProvider by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) * Enable background task support for custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2657](https://github.com/PrefectHQ/fastmcp/pull/2657) * Use CreateTaskResult for background task creation by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) * Refactor provider execution: components own their execution by [@jlowin](https://github.com/jlowin) in [#2663](https://github.com/PrefectHQ/fastmcp/pull/2663) * Add supports_tasks() method to replace string mode checks by [@jlowin](https://github.com/jlowin) in [#2664](https://github.com/PrefectHQ/fastmcp/pull/2664) * Replace type: ignore[attr-defined] with isinstance assertions in tests by [@jlowin](https://github.com/jlowin) in [#2665](https://github.com/PrefectHQ/fastmcp/pull/2665) * Add poll_interval to TaskConfig by [@jlowin](https://github.com/jlowin) in [#2666](https://github.com/PrefectHQ/fastmcp/pull/2666) * Refactor task module: rename protocol.py to requests.py and reduce redundancy by [@jlowin](https://github.com/jlowin) in [#2667](https://github.com/PrefectHQ/fastmcp/pull/2667) * Refactor FastMCPProxy into ProxyProvider by [@jlowin](https://github.com/jlowin) in [#2669](https://github.com/PrefectHQ/fastmcp/pull/2669) * Move OpenAPI to providers/openapi submodule by [@jlowin](https://github.com/jlowin) in [#2672](https://github.com/PrefectHQ/fastmcp/pull/2672) * Use ergonomic provider initialization pattern by [@jlowin](https://github.com/jlowin) in [#2675](https://github.com/PrefectHQ/fastmcp/pull/2675) * Fix ty 0.0.5 type errors by [@jlowin](https://github.com/jlowin) in [#2676](https://github.com/PrefectHQ/fastmcp/pull/2676) * Remove execution methods from Provider base class by [@jlowin](https://github.com/jlowin) in [#2681](https://github.com/PrefectHQ/fastmcp/pull/2681) * Add type-prefixed keys for globally unique component identification by [@jlowin](https://github.com/jlowin) in [#2704](https://github.com/PrefectHQ/fastmcp/pull/2704) * Skip parallel MCP config test on Windows by [@jlowin](https://github.com/jlowin) in [#2711](https://github.com/PrefectHQ/fastmcp/pull/2711) * Consolidate notification system with unified API by [@jlowin](https://github.com/jlowin) in [#2710](https://github.com/PrefectHQ/fastmcp/pull/2710) * Skip test_multi_client on Windows by [@jlowin](https://github.com/jlowin) in [#2714](https://github.com/PrefectHQ/fastmcp/pull/2714) * Parallelize provider operations by [@jlowin](https://github.com/jlowin) in [#2716](https://github.com/PrefectHQ/fastmcp/pull/2716) * Consolidate get_* and _list_* methods into single API by [@jlowin](https://github.com/jlowin) in [#2719](https://github.com/PrefectHQ/fastmcp/pull/2719) * Consolidate execution method chains into single public API by [@jlowin](https://github.com/jlowin) in [#2728](https://github.com/PrefectHQ/fastmcp/pull/2728) * Add documentation check to required PR workflow by [@jlowin](https://github.com/jlowin) in [#2730](https://github.com/PrefectHQ/fastmcp/pull/2730) * Parallelize list_* calls in Provider.get_tasks() by [@jlowin](https://github.com/jlowin) in [#2731](https://github.com/PrefectHQ/fastmcp/pull/2731) * Consistent decorator-based MCP handler registration by [@jlowin](https://github.com/jlowin) in [#2732](https://github.com/PrefectHQ/fastmcp/pull/2732) * Make ToolResult a BaseModel for serialization support by [@jlowin](https://github.com/jlowin) in [#2736](https://github.com/PrefectHQ/fastmcp/pull/2736) * Align prompt handler with resource pattern by [@jlowin](https://github.com/jlowin) in [#2740](https://github.com/PrefectHQ/fastmcp/pull/2740) * Update classes to inherit from FastMCPBaseModel instead of BaseModel by [@jlowin](https://github.com/jlowin) in [#2739](https://github.com/PrefectHQ/fastmcp/pull/2739) * Convert provider tests to use direct server calls by [@jlowin](https://github.com/jlowin) in [#2748](https://github.com/PrefectHQ/fastmcp/pull/2748) * Add explicit task_meta parameter to FastMCP.call_tool() by [@jlowin](https://github.com/jlowin) in [#2749](https://github.com/PrefectHQ/fastmcp/pull/2749) * Add task_meta parameter to read_resource() for explicit task control by [@jlowin](https://github.com/jlowin) in [#2750](https://github.com/PrefectHQ/fastmcp/pull/2750) * Add task_meta to prompts and centralize fn_key enrichment by [@jlowin](https://github.com/jlowin) in [#2751](https://github.com/PrefectHQ/fastmcp/pull/2751) * Remove unused include_tags/exclude_tags settings by [@jlowin](https://github.com/jlowin) in [#2756](https://github.com/PrefectHQ/fastmcp/pull/2756) * Parallelize provider access when executing components by [@jlowin](https://github.com/jlowin) in [#2744](https://github.com/PrefectHQ/fastmcp/pull/2744) * Add tests for OAuth generator cleanup and use aclosing by [@jlowin](https://github.com/jlowin) in [#2759](https://github.com/PrefectHQ/fastmcp/pull/2759) * Deprecate tool_serializer parameter by [@jlowin](https://github.com/jlowin) in [#2753](https://github.com/PrefectHQ/fastmcp/pull/2753) * Feature/supabase custom auth route by [@EloiZalczer](https://github.com/EloiZalczer) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) * Add regression tests for caching with mounted server prefixes by [@jlowin](https://github.com/jlowin) in [#2762](https://github.com/PrefectHQ/fastmcp/pull/2762) * Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2766](https://github.com/PrefectHQ/fastmcp/pull/2766) * Make FASTMCP_SHOW_SERVER_BANNER apply to all server startup methods by [@jlowin](https://github.com/jlowin) in [#2771](https://github.com/PrefectHQ/fastmcp/pull/2771) * Add MCP tool annotations to smart_home example by [@triepod-ai](https://github.com/triepod-ai) in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) * Cherry-pick debug logging for OAuth token expiry to main by [@jlowin](https://github.com/jlowin) in [#2797](https://github.com/PrefectHQ/fastmcp/pull/2797) * Turn off negative CLI flags by default by [@jlowin](https://github.com/jlowin) in [#2801](https://github.com/PrefectHQ/fastmcp/pull/2801) * Configure ty to fail on warnings by [@jlowin](https://github.com/jlowin) in [#2804](https://github.com/PrefectHQ/fastmcp/pull/2804) * Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2814](https://github.com/PrefectHQ/fastmcp/pull/2814) * Add v3.0 feature tracking document by [@jlowin](https://github.com/jlowin) in [#2822](https://github.com/PrefectHQ/fastmcp/pull/2822) * Remove deprecated WSTransport by [@jlowin](https://github.com/jlowin) in [#2826](https://github.com/PrefectHQ/fastmcp/pull/2826) * Add composable lifespans by [@jlowin](https://github.com/jlowin) in [#2828](https://github.com/PrefectHQ/fastmcp/pull/2828) * Replace FastMCP.as_proxy() with create_proxy() function by [@jlowin](https://github.com/jlowin) in [#2829](https://github.com/PrefectHQ/fastmcp/pull/2829) * Add docs-broken-links command and fix docstring markdown parsing by [@jlowin](https://github.com/jlowin) in [#2830](https://github.com/PrefectHQ/fastmcp/pull/2830) * Add PingMiddleware for keepalive connections by [@jlowin](https://github.com/jlowin) in [#2838](https://github.com/PrefectHQ/fastmcp/pull/2838) * Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2840](https://github.com/PrefectHQ/fastmcp/pull/2840) * Add agent skills for testing and code review by [@jlowin](https://github.com/jlowin) in [#2846](https://github.com/PrefectHQ/fastmcp/pull/2846) * Add loq pre-commit hook for file size enforcement by [@jlowin](https://github.com/jlowin) in [#2847](https://github.com/PrefectHQ/fastmcp/pull/2847) * Add transport property to Context by [@jlowin](https://github.com/jlowin) in [#2850](https://github.com/PrefectHQ/fastmcp/pull/2850) * Add loq file size limits and clean up type ignores by [@jlowin](https://github.com/jlowin) in [#2859](https://github.com/PrefectHQ/fastmcp/pull/2859) * Run sync tools/resources/prompts in threadpool automatically by [@jlowin](https://github.com/jlowin) in [#2865](https://github.com/PrefectHQ/fastmcp/pull/2865) * Add timeout parameter for tool foreground execution by [@jlowin](https://github.com/jlowin) in [#2872](https://github.com/PrefectHQ/fastmcp/pull/2872) * Adopt OpenTelemetry MCP semantic conventions by [@chrisguidry](https://github.com/chrisguidry) in [#2886](https://github.com/PrefectHQ/fastmcp/pull/2886) * Add client_secret_post authentication to IntrospectionTokenVerifier by [@shulkx](https://github.com/shulkx) in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) * Add enable_rich_logging setting to disable rich formatting by [@strawgate](https://github.com/strawgate) in [#2893](https://github.com/PrefectHQ/fastmcp/pull/2893) * Rename _fastmcp metadata namespace to fastmcp and make non-optional by [@jlowin](https://github.com/jlowin) in [#2895](https://github.com/PrefectHQ/fastmcp/pull/2895) * Refactor FastMCP to inherit from Provider by [@jlowin](https://github.com/jlowin) in [#2901](https://github.com/PrefectHQ/fastmcp/pull/2901) * Swap public/private method naming in Provider by [@jlowin](https://github.com/jlowin) in [#2902](https://github.com/PrefectHQ/fastmcp/pull/2902) * Add MCP-compliant pagination support by [@jlowin](https://github.com/jlowin) in [#2903](https://github.com/PrefectHQ/fastmcp/pull/2903) * Support VersionSpec in enable/disable for range-based filtering by [@jlowin](https://github.com/jlowin) in [#2914](https://github.com/PrefectHQ/fastmcp/pull/2914) * Remove sync notification infrastructure by [@jlowin](https://github.com/jlowin) in [#2915](https://github.com/PrefectHQ/fastmcp/pull/2915) * Immutable transform wrapping for providers by [@jlowin](https://github.com/jlowin) in [#2913](https://github.com/PrefectHQ/fastmcp/pull/2913) * Unify discovery API: deduplicate at protocol layer only by [@jlowin](https://github.com/jlowin) in [#2919](https://github.com/PrefectHQ/fastmcp/pull/2919) * Split transports.py into modular structure by [@jlowin](https://github.com/jlowin) in [#2921](https://github.com/PrefectHQ/fastmcp/pull/2921) * Move session visibility logic to enabled.py by [@jlowin](https://github.com/jlowin) in [#2924](https://github.com/PrefectHQ/fastmcp/pull/2924) * Refactor Client class into mixins and add timeout utilities by [@jlowin](https://github.com/jlowin) in [#2933](https://github.com/PrefectHQ/fastmcp/pull/2933) * Refactor OAuthProxy into focused modules by [@jlowin](https://github.com/jlowin) in [#2935](https://github.com/PrefectHQ/fastmcp/pull/2935) * Refactor LocalProvider into mixin modules by [@jlowin](https://github.com/jlowin) in [#2936](https://github.com/PrefectHQ/fastmcp/pull/2936) * Refactor server.py into mixins by [@jlowin](https://github.com/jlowin) in [#2939](https://github.com/PrefectHQ/fastmcp/pull/2939) * Consolidate test fixtures and refactor large test files by [@jlowin](https://github.com/jlowin) in [#2941](https://github.com/PrefectHQ/fastmcp/pull/2941) * Refactor transform list methods to pure function pattern by [@jlowin](https://github.com/jlowin) in [#2942](https://github.com/PrefectHQ/fastmcp/pull/2942) * Add ResourcesAsTools transform by [@jlowin](https://github.com/jlowin) in [#2943](https://github.com/PrefectHQ/fastmcp/pull/2943) * Add PromptsAsTools transform by [@jlowin](https://github.com/jlowin) in [#2946](https://github.com/PrefectHQ/fastmcp/pull/2946) * Add client utilities for downloading skills by [@jlowin](https://github.com/jlowin) in [#2948](https://github.com/PrefectHQ/fastmcp/pull/2948) * Rename Enabled transform to Visibility by [@jlowin](https://github.com/jlowin) in [#2950](https://github.com/PrefectHQ/fastmcp/pull/2950) ### Fixes 🐞 * Let FastMCPError propagate from dependencies by [@chrisguidry](https://github.com/chrisguidry) in [#2646](https://github.com/PrefectHQ/fastmcp/pull/2646) * Fix task execution for tools with custom names by [@chrisguidry](https://github.com/chrisguidry) in [#2645](https://github.com/PrefectHQ/fastmcp/pull/2645) * fix: check the cause of the tool error by [@rjolaverria](https://github.com/rjolaverria) in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) * Bump pydocket to 0.16.3 for task cancellation support by [@chrisguidry](https://github.com/chrisguidry) in [#2683](https://github.com/PrefectHQ/fastmcp/pull/2683) * Fix uvicorn 0.39+ test timeouts and FastMCPError propagation by [@jlowin](https://github.com/jlowin) in [#2699](https://github.com/PrefectHQ/fastmcp/pull/2699) * Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) * Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) * Fix Provider.get_tasks() to include custom component subclasses by [@jlowin](https://github.com/jlowin) in [#2729](https://github.com/PrefectHQ/fastmcp/pull/2729) * Fix Proxy provider to return all resource contents by [@jlowin](https://github.com/jlowin) in [#2742](https://github.com/PrefectHQ/fastmcp/pull/2742) * Fix prompt return type documentation by [@jlowin](https://github.com/jlowin) in [#2741](https://github.com/PrefectHQ/fastmcp/pull/2741) * fix: Client OAuth async_auth_flow() method causing MCP-SDK self.context.lock error. by [@lgndluke](https://github.com/lgndluke) in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) * Fix rate limit detection during teardown phase by [@jlowin](https://github.com/jlowin) in [#2757](https://github.com/PrefectHQ/fastmcp/pull/2757) * fix: set pytest-asyncio default fixture loop scope to function by [@jlowin](https://github.com/jlowin) in [#2758](https://github.com/PrefectHQ/fastmcp/pull/2758) * Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2764](https://github.com/PrefectHQ/fastmcp/pull/2764) * [BugFix] Fix `openapi_version` Check So 3.1 Is Included by [@deeleeramone](https://github.com/deeleeramone) in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) * Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2773](https://github.com/PrefectHQ/fastmcp/pull/2773) * Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) * Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2784](https://github.com/PrefectHQ/fastmcp/pull/2784) * Fix OAuth token storage TTL calculation by [@jlowin](https://github.com/jlowin) in [#2796](https://github.com/PrefectHQ/fastmcp/pull/2796) * Use consistent refresh_ttl for JTI mapping store by [@jlowin](https://github.com/jlowin) in [#2799](https://github.com/PrefectHQ/fastmcp/pull/2799) * Return 401 for invalid_grant token errors per MCP spec by [@jlowin](https://github.com/jlowin) in [#2800](https://github.com/PrefectHQ/fastmcp/pull/2800) * Fix client hanging on HTTP 4xx/5xx errors by [@jlowin](https://github.com/jlowin) in [#2803](https://github.com/PrefectHQ/fastmcp/pull/2803) * Fix unawaited coroutine warning and treat as test error by [@jlowin](https://github.com/jlowin) in [#2806](https://github.com/PrefectHQ/fastmcp/pull/2806) * Fix keep_alive passthrough in StdioMCPServer.to_transport() by [@jlowin](https://github.com/jlowin) in [#2791](https://github.com/PrefectHQ/fastmcp/pull/2791) * Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2808](https://github.com/PrefectHQ/fastmcp/pull/2808) * Prefix Redis keys with docket name for ACL isolation by [@chrisguidry](https://github.com/chrisguidry) in [#2811](https://github.com/PrefectHQ/fastmcp/pull/2811) * fix smart_home example: HueAttributes schema and deprecated prefix by [@zzstoatzz](https://github.com/zzstoatzz) in [#2818](https://github.com/PrefectHQ/fastmcp/pull/2818) * Fix redirect URI validation docs to match implementation by [@jlowin](https://github.com/jlowin) in [#2824](https://github.com/PrefectHQ/fastmcp/pull/2824) * Fix timeout not propagating to proxy clients in multi-server MCPConfig by [@jlowin](https://github.com/jlowin) in [#2809](https://github.com/PrefectHQ/fastmcp/pull/2809) * Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2844](https://github.com/PrefectHQ/fastmcp/pull/2844) * Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2849](https://github.com/PrefectHQ/fastmcp/pull/2849) * Fix decorator error messages to link to correct doc pages by [@jlowin](https://github.com/jlowin) in [#2858](https://github.com/PrefectHQ/fastmcp/pull/2858) * Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2875](https://github.com/PrefectHQ/fastmcp/pull/2875) * Bump the uv group across 1 directory with 2 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2890](https://github.com/PrefectHQ/fastmcp/pull/2890) ### Breaking Changes 🛫 * Add VisibilityFilter for hierarchical enable/disable by [@jlowin](https://github.com/jlowin) in [#2708](https://github.com/PrefectHQ/fastmcp/pull/2708) * Remove automatic environment variable loading from auth providers by [@jlowin](https://github.com/jlowin) in [#2752](https://github.com/PrefectHQ/fastmcp/pull/2752) * Make pydocket optional and unify DI systems by [@jlowin](https://github.com/jlowin) in [#2835](https://github.com/PrefectHQ/fastmcp/pull/2835) * Add session-scoped state persistence by [@jlowin](https://github.com/jlowin) in [#2873](https://github.com/PrefectHQ/fastmcp/pull/2873) ### Docs 📚 * Undocumented `McpError` exceptions by [@ivanbelenky](https://github.com/ivanbelenky) in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) * docs(server): add http to transport options in run() method docstring by [@Ashif4354](https://github.com/Ashif4354) in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) * Add v3 breaking changes notice to README by [@jlowin](https://github.com/jlowin) in [#2712](https://github.com/PrefectHQ/fastmcp/pull/2712) * Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2725](https://github.com/PrefectHQ/fastmcp/pull/2725) * Reorganize docs around provider architecture by [@jlowin](https://github.com/jlowin) in [#2723](https://github.com/PrefectHQ/fastmcp/pull/2723) * Fix documentation to use 'meta' instead of '_meta' for MCP spec field by [@jlowin](https://github.com/jlowin) in [#2735](https://github.com/PrefectHQ/fastmcp/pull/2735) * Enhance documentation on tool transformation by [@shea-parkes](https://github.com/shea-parkes) in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) * Add FastMCP 4.0 preview to documentation by [@jlowin](https://github.com/jlowin) in [#2831](https://github.com/PrefectHQ/fastmcp/pull/2831) * Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2852](https://github.com/PrefectHQ/fastmcp/pull/2852) * Add missing 3.0.0 version badges and document tasks extra by [@jlowin](https://github.com/jlowin) in [#2866](https://github.com/PrefectHQ/fastmcp/pull/2866) * Fix custom provider docs to show correct interface by [@jlowin](https://github.com/jlowin) in [#2920](https://github.com/PrefectHQ/fastmcp/pull/2920) * Update v3 features that were missed in PRs by [@jlowin](https://github.com/jlowin) in [#2947](https://github.com/PrefectHQ/fastmcp/pull/2947) * Restructure documentation for FastMCP 3.0 by [@jlowin](https://github.com/jlowin) in [#2951](https://github.com/PrefectHQ/fastmcp/pull/2951) * Fix broken documentation links by [@jlowin](https://github.com/jlowin) in [#2952](https://github.com/PrefectHQ/fastmcp/pull/2952) * Clarify installation for FastMCP 3.0 beta by [@jlowin](https://github.com/jlowin) in [#2953](https://github.com/PrefectHQ/fastmcp/pull/2953) ### Dependencies 📦 * Bump peter-evans/create-pull-request from 7 to 8 by [@dependabot](https://github.com/dependabot)\[bot\] in [#2623](https://github.com/PrefectHQ/fastmcp/pull/2623) * Bump ty to 0.0.7+ by [@jlowin](https://github.com/jlowin) in [#2737](https://github.com/PrefectHQ/fastmcp/pull/2737) * Bump the uv group across 1 directory with 4 updates by [@dependabot](https://github.com/dependabot)\[bot\] in [#2891](https://github.com/PrefectHQ/fastmcp/pull/2891) ## New Contributors * [@ivanbelenky](https://github.com/ivanbelenky) made their first contribution in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) * [@rjolaverria](https://github.com/rjolaverria) made their first contribution in [#2674](https://github.com/PrefectHQ/fastmcp/pull/2674) * [@mgoldsborough](https://github.com/mgoldsborough) made their first contribution in [#2701](https://github.com/PrefectHQ/fastmcp/pull/2701) * [@Ashif4354](https://github.com/Ashif4354) made their first contribution in [#2707](https://github.com/PrefectHQ/fastmcp/pull/2707) * [@majiayu000](https://github.com/majiayu000) made their first contribution in [#2720](https://github.com/PrefectHQ/fastmcp/pull/2720) * [@lgndluke](https://github.com/lgndluke) made their first contribution in [#2644](https://github.com/PrefectHQ/fastmcp/pull/2644) * [@EloiZalczer](https://github.com/EloiZalczer) made their first contribution in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) * [@deeleeramone](https://github.com/deeleeramone) made their first contribution in [#2768](https://github.com/PrefectHQ/fastmcp/pull/2768) * [@shea-parkes](https://github.com/shea-parkes) made their first contribution in [#2781](https://github.com/PrefectHQ/fastmcp/pull/2781) * [@triepod-ai](https://github.com/triepod-ai) made their first contribution in [#2777](https://github.com/PrefectHQ/fastmcp/pull/2777) * [@bhbs](https://github.com/bhbs) made their first contribution in [#2776](https://github.com/PrefectHQ/fastmcp/pull/2776) * [@shulkx](https://github.com/shulkx) made their first contribution in [#2884](https://github.com/PrefectHQ/fastmcp/pull/2884) **Full Changelog**: [v2.14.1...v3.0.0b1](https://github.com/PrefectHQ/fastmcp/compare/v2.14.1...v3.0.0b1) **[v2.14.5: Sealed Docket](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.5)** Fixes a memory leak in the memory:// docket broker where cancelled tasks accumulated instead of being cleaned up. Bumps pydocket to ≥0.17.2. ## What's Changed ### Enhancements 🔧 * Bump pydocket to 0.17.2 (memory leak fix) by [@chrisguidry](https://github.com/chrisguidry) in [#2992](https://github.com/PrefectHQ/fastmcp/pull/2992) **Full Changelog**: [v2.14.4...v2.14.5](https://github.com/PrefectHQ/fastmcp/compare/v2.14.4...v2.14.5) **[v2.14.4: Package Deal](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.4)** Fixes a fresh install bug where the packaging library was missing as a direct dependency, plus backports from 3.x for $ref dereferencing in tool schemas and a task capabilities location fix. ## What's Changed ### Enhancements 🔧 * Add release notes for v2.14.2 and v2.14.3 by [@jlowin](https://github.com/jlowin) in [#2851](https://github.com/PrefectHQ/fastmcp/pull/2851) ### Fixes 🐞 * Backport: Dereference $ref in tool schemas for MCP client compatibility by [@jlowin](https://github.com/jlowin) in [#2861](https://github.com/PrefectHQ/fastmcp/pull/2861) * Fix task capabilities location (issue #2870) by [@jlowin](https://github.com/jlowin) in [#2874](https://github.com/PrefectHQ/fastmcp/pull/2874) * Add missing packaging dependency by [@jlowin](https://github.com/jlowin) in [#2989](https://github.com/PrefectHQ/fastmcp/pull/2989) **Full Changelog**: [v2.14.3...v2.14.4](https://github.com/PrefectHQ/fastmcp/compare/v2.14.3...v2.14.4) **[v2.14.3: Time After Timeout](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.3)** Sometimes five seconds just isn't enough. This release fixes an HTTP transport bug that was cutting connections short, along with OAuth and Redis fixes, better ASGI support, and CLI update notifications so you never miss a beat. ## What's Changed ### Enhancements 🔧 * Add debug logging for OAuth token expiry diagnostics by [@jlowin](https://github.com/jlowin) in [#2789](https://github.com/PrefectHQ/fastmcp/pull/2789) * Add CLI update notifications by [@jlowin](https://github.com/jlowin) in [#2839](https://github.com/PrefectHQ/fastmcp/pull/2839) * Use pip instead of uv pip in upgrade instructions by [@jlowin](https://github.com/jlowin) in [#2841](https://github.com/PrefectHQ/fastmcp/pull/2841) ### Fixes 🐞 * Backport OAuth token storage TTL fix to release/2.x by [@jlowin](https://github.com/jlowin) in [#2798](https://github.com/PrefectHQ/fastmcp/pull/2798) * Prefix Redis keys with docket name for ACL isolation (2.x backport) by [@chrisguidry](https://github.com/chrisguidry) in [#2812](https://github.com/PrefectHQ/fastmcp/pull/2812) * Fix ContextVar propagation for ASGI-mounted servers with tasks by [@chrisguidry](https://github.com/chrisguidry) in [#2843](https://github.com/PrefectHQ/fastmcp/pull/2843) * Fix HTTP transport timeout defaulting to 5 seconds by [@jlowin](https://github.com/jlowin) in [#2848](https://github.com/PrefectHQ/fastmcp/pull/2848) **Full Changelog**: [v2.14.2...v2.14.3](https://github.com/PrefectHQ/fastmcp/compare/v2.14.2...v2.14.3) **[v2.14.2: Port Authority](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.2)** FastMCP 2.14.2 brings a wave of community contributions safely into the 2.x line. A variety of important fixes backported from 3.0 work improve OpenAPI 3.1 compatibility, MCP spec compliance for output schemas and elicitation, and correct a subtle base_url fallback issue. The CLI now gently reminds you that FastMCP 3.0 is on the horizon. ## What's Changed ### Enhancements 🔧 * Pin MCP under 2.x by [@jlowin](https://github.com/jlowin) in [#2709](https://github.com/PrefectHQ/fastmcp/pull/2709) * Add auth_route parameter to SupabaseProvider by [@EloiZalczer](https://github.com/EloiZalczer) in [#2760](https://github.com/PrefectHQ/fastmcp/pull/2760) * Update CLI banner with FastMCP 3.0 notice by [@jlowin](https://github.com/jlowin) in [#2765](https://github.com/PrefectHQ/fastmcp/pull/2765) ### Fixes 🐞 * Let FastMCPError propagate unchanged from managers by [@jlowin](https://github.com/jlowin) in [#2697](https://github.com/PrefectHQ/fastmcp/pull/2697) * Fix test cleanup for uvicorn 0.39+ context isolation by [@jlowin](https://github.com/jlowin) in [#2696](https://github.com/PrefectHQ/fastmcp/pull/2696) * Bump pydocket to 0.16.3 to fix worker cleanup race condition by [@chrisguidry](https://github.com/chrisguidry) in [#2700](https://github.com/PrefectHQ/fastmcp/pull/2700) * Fix Prefect website URL in docs footer by [@mgoldsborough](https://github.com/mgoldsborough) in [#2705](https://github.com/PrefectHQ/fastmcp/pull/2705) * Fix: resolve root-level $ref in outputSchema for MCP spec compliance by [@majiayu000](https://github.com/majiayu000) in [#2727](https://github.com/PrefectHQ/fastmcp/pull/2727) * Fix OAuth Proxy resource parameter validation by [@jlowin](https://github.com/jlowin) in [#2763](https://github.com/PrefectHQ/fastmcp/pull/2763) * Fix openapi_version check to include 3.1 by [@deeleeramone](https://github.com/deeleeramone) in [#2769](https://github.com/PrefectHQ/fastmcp/pull/2769) * Fix titled enum elicitation schema to comply with MCP spec by [@jlowin](https://github.com/jlowin) in [#2774](https://github.com/PrefectHQ/fastmcp/pull/2774) * Fix base_url fallback when url is not set by [@bhbs](https://github.com/bhbs) in [#2782](https://github.com/PrefectHQ/fastmcp/pull/2782) * Lazy import DiskStore to avoid sqlite3 dependency on import by [@jlowin](https://github.com/jlowin) in [#2785](https://github.com/PrefectHQ/fastmcp/pull/2785) ### Docs 📚 * Add v3 breaking changes notice to README and docs by [@jlowin](https://github.com/jlowin) in [#2713](https://github.com/PrefectHQ/fastmcp/pull/2713) * Add changelog entries for v2.13.1 through v2.14.1 by [@jlowin](https://github.com/jlowin) in [#2724](https://github.com/PrefectHQ/fastmcp/pull/2724) * conference to 2.x branch by [@aaazzam](https://github.com/aaazzam) in [#2787](https://github.com/PrefectHQ/fastmcp/pull/2787) **Full Changelog**: [v2.14.1...v2.14.2](https://github.com/PrefectHQ/fastmcp/compare/v2.14.1...v2.14.2) **[v2.14.1: 'Tis a Gift to Be Sample](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.1)** FastMCP 2.14.1 introduces sampling with tools (SEP-1577), enabling servers to pass tools to `ctx.sample()` for agentic workflows where the LLM can automatically execute tool calls in a loop. The new `ctx.sample_step()` method provides single LLM calls that return `SampleStep` objects for custom control flow, while `result_type` enables structured outputs via validated Pydantic models. 🤖 **AnthropicSamplingHandler** joins the existing OpenAI handler, providing multi-provider sampling support out of the box. ⚡ **OpenAISamplingHandler promoted** from experimental status—sampling handlers are now production-ready with a unified API. ## What's Changed ### New Features 🎉 * Sampling with tools by [@jlowin](https://github.com/jlowin) in [#2538](https://github.com/PrefectHQ/fastmcp/pull/2538) * Add AnthropicSamplingHandler by [@jlowin](https://github.com/jlowin) in [#2677](https://github.com/PrefectHQ/fastmcp/pull/2677) ### Enhancements 🔧 * Add Python 3.13 to ubuntu CI by [@jlowin](https://github.com/jlowin) in [#2648](https://github.com/PrefectHQ/fastmcp/pull/2648) * Remove legacy task initialization workaround by [@jlowin](https://github.com/jlowin) in [#2649](https://github.com/PrefectHQ/fastmcp/pull/2649) * Consolidate session state reset logic by [@jlowin](https://github.com/jlowin) in [#2651](https://github.com/PrefectHQ/fastmcp/pull/2651) * Unify SamplingHandler; promote OpenAI from experimental by [@jlowin](https://github.com/jlowin) in [#2656](https://github.com/PrefectHQ/fastmcp/pull/2656) * Add `tool_names` parameter to mount() for name customization by [@jlowin](https://github.com/jlowin) in [#2660](https://github.com/PrefectHQ/fastmcp/pull/2660) * Use streamable HTTP client API from MCP SDK by [@jlowin](https://github.com/jlowin) in [#2678](https://github.com/PrefectHQ/fastmcp/pull/2678) * Deprecate `exclude_args` in favor of Depends() by [@jlowin](https://github.com/jlowin) in [#2693](https://github.com/PrefectHQ/fastmcp/pull/2693) ### Fixes 🐞 * Fix prompt tasks to return mcp.types.PromptMessage by [@jlowin](https://github.com/jlowin) in [#2650](https://github.com/PrefectHQ/fastmcp/pull/2650) * Fix Windows test warnings by [@jlowin](https://github.com/jlowin) in [#2653](https://github.com/PrefectHQ/fastmcp/pull/2653) * Cleanup cancelled connection startup by [@jlowin](https://github.com/jlowin) in [#2679](https://github.com/PrefectHQ/fastmcp/pull/2679) * Fix tool choice bug in sampling examples by [@shawnthapa](https://github.com/shawnthapa) in [#2686](https://github.com/PrefectHQ/fastmcp/pull/2686) ### Docs 📚 * Simplify Docket tip wording by [@chrisguidry](https://github.com/chrisguidry) in [#2662](https://github.com/PrefectHQ/fastmcp/pull/2662) ### Other Changes 🦾 * Bump pydocket to ≥0.15.5 by [@jlowin](https://github.com/jlowin) in [#2694](https://github.com/PrefectHQ/fastmcp/pull/2694) ## New Contributors * [@shawnthapa](https://github.com/shawnthapa) made their first contribution in [#2686](https://github.com/PrefectHQ/fastmcp/pull/2686) **Full Changelog**: [v2.14.0...v2.14.1](https://github.com/PrefectHQ/fastmcp/compare/v2.14.0...v2.14.1) **[v2.14.0: Task and You Shall Receive](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.14.0)** FastMCP 2.14 begins adopting the MCP 2025-11-25 specification, introducing protocol-native background tasks (SEP-1686) that enable long-running operations to report progress without blocking clients. The experimental OpenAPI parser graduates to standard, the `OpenAISamplingHandler` is promoted from experimental, and deprecated APIs accumulated across the 2.x series are removed. ⏳ **Background Tasks** let you add `task=True` to any async tool decorator to run operations in the background with progress tracking. Powered by [Docket](https://github.com/chrisguidry/docket), an enterprise task scheduler handling millions of concurrent tasks daily—in-memory backends work out-of-the-box, and Redis URLs enable persistence and horizontal scaling. 🔧 **OpenAPI Parser Promoted** from experimental to standard with improved performance through single-pass schema processing and cleaner abstractions. 📋 **MCP 2025-11-25 Specification Support** including SSE polling and event resumability (SEP-1699), multi-select enum elicitation schemas (SEP-1330), default values for elicitation (SEP-1034), and tool name validation at registration time (SEP-986). ## Breaking Changes - Docket is always enabled; task execution is forbidden through proxies - Task protocol enabled by default - Removed deprecated settings, imports, and methods accumulated across 2.x series ## What's Changed ### New Features 🎉 * OpenAPI parser is now the default by [@jlowin](https://github.com/jlowin) in [#2583](https://github.com/PrefectHQ/fastmcp/pull/2583) * Implement SEP-1686: Background Tasks by [@jlowin](https://github.com/jlowin) in [#2550](https://github.com/PrefectHQ/fastmcp/pull/2550) ### Enhancements 🔧 * Expose InitializeResult in middleware by [@jlowin](https://github.com/jlowin) in [#2562](https://github.com/PrefectHQ/fastmcp/pull/2562) * Update MCP SDK auth compatibility by [@jlowin](https://github.com/jlowin) in [#2574](https://github.com/PrefectHQ/fastmcp/pull/2574) * Validate tool names at registration (SEP-986) by [@jlowin](https://github.com/jlowin) in [#2588](https://github.com/PrefectHQ/fastmcp/pull/2588) * Support SEP-1034 and SEP-1330 for elicitation by [@jlowin](https://github.com/jlowin) in [#2595](https://github.com/PrefectHQ/fastmcp/pull/2595) * Implement SSE polling (SEP-1699) by [@jlowin](https://github.com/jlowin) in [#2612](https://github.com/PrefectHQ/fastmcp/pull/2612) * Expose session ID callback by [@jlowin](https://github.com/jlowin) in [#2628](https://github.com/PrefectHQ/fastmcp/pull/2628) ### Fixes 🐞 * Fix OAuth metadata discovery by [@jlowin](https://github.com/jlowin) in [#2565](https://github.com/PrefectHQ/fastmcp/pull/2565) * Fix fastapi.cli package structure by [@jlowin](https://github.com/jlowin) in [#2570](https://github.com/PrefectHQ/fastmcp/pull/2570) * Correct OAuth error codes by [@jlowin](https://github.com/jlowin) in [#2578](https://github.com/PrefectHQ/fastmcp/pull/2578) * Prevent function signature modification by [@jlowin](https://github.com/jlowin) in [#2590](https://github.com/PrefectHQ/fastmcp/pull/2590) * Fix proxy client kwargs by [@jlowin](https://github.com/jlowin) in [#2605](https://github.com/PrefectHQ/fastmcp/pull/2605) * Fix nested server routing by [@jlowin](https://github.com/jlowin) in [#2618](https://github.com/PrefectHQ/fastmcp/pull/2618) * Use access token expiry fallback by [@jlowin](https://github.com/jlowin) in [#2635](https://github.com/PrefectHQ/fastmcp/pull/2635) * Handle transport cleanup exceptions by [@jlowin](https://github.com/jlowin) in [#2642](https://github.com/PrefectHQ/fastmcp/pull/2642) ### Docs 📚 * Add OCI and Supabase integration docs by [@jlowin](https://github.com/jlowin) in [#2580](https://github.com/PrefectHQ/fastmcp/pull/2580) * Add v2.14.0 upgrade guide by [@jlowin](https://github.com/jlowin) in [#2598](https://github.com/PrefectHQ/fastmcp/pull/2598) * Rewrite background tasks documentation by [@jlowin](https://github.com/jlowin) in [#2620](https://github.com/PrefectHQ/fastmcp/pull/2620) * Document read-only tool patterns by [@jlowin](https://github.com/jlowin) in [#2632](https://github.com/PrefectHQ/fastmcp/pull/2632) ## New Contributors 11 total contributors including 7 first-time participants. **Full Changelog**: [v2.13.3...v2.14.0](https://github.com/PrefectHQ/fastmcp/compare/v2.13.3...v2.14.0) **[v2.13.3: Pin-ish Line](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.3)** FastMCP 2.13.3 pins `mcp<1.23` as a precautionary measure. MCP SDK 1.23 introduced changes related to the November 25, 2025 MCP protocol update that break certain FastMCP patches and workarounds, particularly around OAuth implementation details. FastMCP 2.14 introduces proper support for the updated protocol and requires `mcp>=1.23`. ## What's Changed ### Fixes 🐞 * Pin MCP SDK below 1.23 by [@jlowin](https://github.com/jlowin) in [#2545](https://github.com/PrefectHQ/fastmcp/pull/2545) **Full Changelog**: [v2.13.2...v2.13.3](https://github.com/PrefectHQ/fastmcp/compare/v2.13.2...v2.13.3) **[v2.13.2: Refreshing Changes](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.2)** FastMCP 2.13.2 polishes the authentication stack with improvements to token refresh, scope handling, and multi-instance deployments. Discord was added as a built-in OAuth provider, Azure and Google token handling became more reliable, and proxy classes now properly forward icons and titles. ## What's Changed ### New Features 🎉 * Add Discord OAuth provider by [@jlowin](https://github.com/jlowin) in [#2480](https://github.com/PrefectHQ/fastmcp/pull/2480) ### Enhancements 🔧 * Descope Provider updates for new well-known URLs by [@anvibanga](https://github.com/anvibanga) in [#2465](https://github.com/PrefectHQ/fastmcp/pull/2465) * Scalekit provider improvements by [@jlowin](https://github.com/jlowin) in [#2472](https://github.com/PrefectHQ/fastmcp/pull/2472) * Add CSP customization for consent screens by [@jlowin](https://github.com/jlowin) in [#2488](https://github.com/PrefectHQ/fastmcp/pull/2488) * Add icon support to proxy classes by [@jlowin](https://github.com/jlowin) in [#2495](https://github.com/PrefectHQ/fastmcp/pull/2495) ### Fixes 🐞 * Google Provider now defaults to refresh token support by [@jlowin](https://github.com/jlowin) in [#2468](https://github.com/PrefectHQ/fastmcp/pull/2468) * Fix Azure OAuth token refresh with unprefixed scopes by [@jlowin](https://github.com/jlowin) in [#2475](https://github.com/PrefectHQ/fastmcp/pull/2475) * Prevent `$defs` mutation during tool transforms by [@jlowin](https://github.com/jlowin) in [#2482](https://github.com/PrefectHQ/fastmcp/pull/2482) * Fix OAuth proxy refresh token storage for multi-instance deployments by [@jlowin](https://github.com/jlowin) in [#2490](https://github.com/PrefectHQ/fastmcp/pull/2490) * Fix stale token issue after OAuth refresh by [@jlowin](https://github.com/jlowin) in [#2498](https://github.com/PrefectHQ/fastmcp/pull/2498) * Fix Azure provider OIDC scope handling by [@jlowin](https://github.com/jlowin) in [#2505](https://github.com/PrefectHQ/fastmcp/pull/2505) ## New Contributors 7 new contributors made their first FastMCP contributions in this release. **Full Changelog**: [v2.13.1...v2.13.2](https://github.com/PrefectHQ/fastmcp/compare/v2.13.1...v2.13.2) **[v2.13.1: Heavy Meta](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.1)** FastMCP 2.13.1 introduces meta parameter support for `ToolResult`, enabling tools to return supplementary metadata alongside results. This supports emerging use cases like OpenAI's Apps SDK. The release also brings improved OAuth functionality with custom token verifiers including a new DebugTokenVerifier, and adds OCI and Supabase authentication providers. 🏷️ **Meta parameters for ToolResult** enable tools to return supplementary metadata alongside results, supporting patterns like OpenAI's Apps SDK integration. 🔐 **Custom token verifiers** with DebugTokenVerifier for development, plus Azure Government support through a `base_authority` parameter and Supabase authentication algorithm configuration. 🔒 **Security fixes** address CVE-2025-61920 through authlib updates and validate Cursor deeplink URLs using safer Windows APIs. ## What's Changed ### New Features 🎉 * Add meta parameter support for ToolResult by [@jlowin](https://github.com/jlowin) in [#2350](https://github.com/PrefectHQ/fastmcp/pull/2350) * Add OCI authentication provider by [@jlowin](https://github.com/jlowin) in [#2365](https://github.com/PrefectHQ/fastmcp/pull/2365) * Add Supabase authentication provider by [@jlowin](https://github.com/jlowin) in [#2378](https://github.com/PrefectHQ/fastmcp/pull/2378) ### Enhancements 🔧 * Add custom token verifier support to OIDCProxy by [@jlowin](https://github.com/jlowin) in [#2355](https://github.com/PrefectHQ/fastmcp/pull/2355) * Add DebugTokenVerifier for development by [@jlowin](https://github.com/jlowin) in [#2362](https://github.com/PrefectHQ/fastmcp/pull/2362) * Add Azure Government support via base_authority parameter by [@jlowin](https://github.com/jlowin) in [#2385](https://github.com/PrefectHQ/fastmcp/pull/2385) * Add Supabase authentication algorithm configuration by [@jlowin](https://github.com/jlowin) in [#2392](https://github.com/PrefectHQ/fastmcp/pull/2392) ### Fixes 🐞 * Security: Update authlib for CVE-2025-61920 by [@jlowin](https://github.com/jlowin) in [#2398](https://github.com/PrefectHQ/fastmcp/pull/2398) * Validate Cursor deeplink URLs using safer Windows APIs by [@jlowin](https://github.com/jlowin) in [#2405](https://github.com/PrefectHQ/fastmcp/pull/2405) * Exclude MCP SDK 1.21.1 due to integration test failures by [@jlowin](https://github.com/jlowin) in [#2422](https://github.com/PrefectHQ/fastmcp/pull/2422) ## New Contributors 18 new contributors joined in this release across 70+ pull requests. **Full Changelog**: [v2.13.0...v2.13.1](https://github.com/PrefectHQ/fastmcp/compare/v2.13.0...v2.13.1) **[v2.13.0: Cache Me If You Can](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.13.0)** FastMCP 2.13 "Cache Me If You Can" represents a fundamental maturation of the framework. After months of community feedback on authentication and state management, this release delivers the infrastructure FastMCP needs to handle production workloads: persistent storage, response caching, and pragmatic OAuth improvements that reflect real-world deployment challenges. 💾 **Pluggable storage backends** bring persistent state to FastMCP servers. Built on [py-key-value-aio](https://github.com/strawgate/py-key-value), a new library from FastMCP maintainer Bill Easton ([@strawgate](https://github.com/strawgate)), the storage layer provides encrypted disk storage by default, platform-aware token management, and a simple key-value interface for application state. We're excited to bring this elegantly designed library into the FastMCP ecosystem - it's both powerful and remarkably easy to use, including wrappers to add encryption, TTLs, caching, and more to backends ranging from Elasticsearch, Redis, DynamoDB, filesystem, in-memory, and more! OAuth providers now automatically persist tokens across restarts, and developers can store arbitrary state without reaching for external databases. This foundation enables long-running sessions, cached credentials, and stateful applications built on MCP. 🔐 **OAuth maturity** brings months of production learnings into the framework. The new consent screen prevents confused deputy and authorization bypass attacks discovered in earlier versions while providing a clean UX with customizable branding. The OAuth proxy now issues its own tokens with automatic key derivation from client secrets, and RFC 7662 token introspection support enables enterprise auth flows. Path prefix mounting enables OAuth-protected servers to integrate into existing web applications under custom paths like `/api`, and MCP 1.17+ compliance with RFC 9728 ensures protocol compatibility. Combined with improved error handling and platform-aware token storage, OAuth is now production-ready and security-hardened for serious applications. FastMCP now supports out-of-the-box authentication with: - **[WorkOS](https://gofastmcp.com/integrations/workos)** and **[AuthKit](https://gofastmcp.com/integrations/authkit)** - **[GitHub](https://gofastmcp.com/integrations/github)** - **[Google](https://gofastmcp.com/integrations/google)** - **[Azure](https://gofastmcp.com/integrations/azure)** (Entra ID) - **[AWS Cognito](https://gofastmcp.com/integrations/aws-cognito)** - **[Auth0](https://gofastmcp.com/integrations/auth0)** - **[Descope](https://gofastmcp.com/integrations/descope)** - **[Scalekit](https://gofastmcp.com/integrations/scalekit)** - **[JWTs](https://gofastmcp.com/servers/auth/token-verification#jwt-token-verification)** - **[RFC 7662 token introspection](https://gofastmcp.com/servers/auth/token-verification#token-introspection-protocol)** ⚡ **Response Caching Middleware** dramatically improves performance for expensive operations. Cache tool and resource responses with configurable TTLs, reducing redundant API calls and speeding up repeated queries. 🔄 **Server lifespans** provide proper initialization and cleanup hooks that run once per server instance instead of per client session. This fixes a long-standing source of confusion in the MCP SDK and enables proper resource management for database connections, background tasks, and other server-level state. Note: this is a breaking behavioral change if you were using the `lifespan` parameter. ✨ **Developer experience improvements** include Pydantic input validation for better type safety, icon support for richer UX, RFC 6570 query parameters for resource templates, improved Context API methods (list_resources, list_prompts, get_prompt), and async file/directory resources. This release includes contributions from **20** new contributors and represents the largest feature set in a while. Thank you to everyone who tested preview builds and filed issues - your feedback shaped these improvements! **Full Changelog**: [v2.12.5...v2.13.0](https://github.com/PrefectHQ/fastmcp/compare/v2.12.5...v2.13.0) **[v2.12.5: Safety Pin](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.5)** FastMCP 2.12.5 is a point release that pins the MCP SDK version below 1.17, which introduced a change affecting FastMCP users with auth providers mounted as part of a larger application. This ensures the `.well-known` payload appears in the expected location when using FastMCP authentication providers with composite applications. ## What's Changed ### Fixes 🐞 * Pin MCP SDK version below 1.17 by [@jlowin](https://github.com/jlowin) in [a1b2c3d](https://github.com/PrefectHQ/fastmcp/commit/dab2b316ddc3883b7896a86da21cacb68da01e5c) **Full Changelog**: [v2.12.4...v2.12.5](https://github.com/PrefectHQ/fastmcp/compare/v2.12.4...v2.12.5) **[v2.12.4: OIDC What You Did There](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.4)** FastMCP 2.12.4 adds comprehensive OIDC support and expands authentication options with AWS Cognito and Descope providers. The release also includes improvements to logging middleware, URL handling for nested resources, persistent OAuth client registration storage, and various fixes to the experimental OpenAPI parser. ## What's Changed ### New Features 🎉 * feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) ### Enhancements 🔧 * feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) * Refactor Logging and Structured Logging Middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/PrefectHQ/fastmcp/pull/1805) * Update pull_request_template.md by [@jlowin](https://github.com/jlowin) in [#1824](https://github.com/PrefectHQ/fastmcp/pull/1824) * chore: Set redirect_path default in function by [@ruhulio](https://github.com/ruhulio) in [#1833](https://github.com/PrefectHQ/fastmcp/pull/1833) * feat: Set instructions in code by [@attiks](https://github.com/attiks) in [#1838](https://github.com/PrefectHQ/fastmcp/pull/1838) * Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/PrefectHQ/fastmcp/pull/1779) * chore: Cleanup Auth0 redirect_path initialization by [@ruhulio](https://github.com/ruhulio) in [#1842](https://github.com/PrefectHQ/fastmcp/pull/1842) * feat: Add support for Descope Authentication by [@anvibanga](https://github.com/anvibanga) in [#1853](https://github.com/PrefectHQ/fastmcp/pull/1853) * Update descope version badges by [@jlowin](https://github.com/jlowin) in [#1870](https://github.com/PrefectHQ/fastmcp/pull/1870) * Update welcome images by [@jlowin](https://github.com/jlowin) in [#1884](https://github.com/PrefectHQ/fastmcp/pull/1884) * Fix rounded edges of image by [@jlowin](https://github.com/jlowin) in [#1886](https://github.com/PrefectHQ/fastmcp/pull/1886) * optimize test suite by [@zzstoatzz](https://github.com/zzstoatzz) in [#1893](https://github.com/PrefectHQ/fastmcp/pull/1893) * Enhancement: client completions support context_arguments by [@isijoe](https://github.com/isijoe) in [#1906](https://github.com/PrefectHQ/fastmcp/pull/1906) * Update Descope icon by [@anvibanga](https://github.com/anvibanga) in [#1912](https://github.com/PrefectHQ/fastmcp/pull/1912) * Add AWS Cognito OAuth Provider for Enterprise Authentication by [@stephaneberle9](https://github.com/stephaneberle9) in [#1873](https://github.com/PrefectHQ/fastmcp/pull/1873) * Fix typos discovered by codespell by [@cclauss](https://github.com/cclauss) in [#1922](https://github.com/PrefectHQ/fastmcp/pull/1922) * Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/PrefectHQ/fastmcp/pull/1791) ### Fixes 🐞 * Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) * Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/PrefectHQ/fastmcp/pull/1823) * Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/PrefectHQ/fastmcp/pull/1820) * fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/PrefectHQ/fastmcp/pull/1828) * fix: Increase default 3s timeout on Pytest by [@dacamposol](https://github.com/dacamposol) in [#1866](https://github.com/PrefectHQ/fastmcp/pull/1866) * fix: Improve URL handling in OIDCConfiguration by [@ruhulio](https://github.com/ruhulio) in [#1850](https://github.com/PrefectHQ/fastmcp/pull/1850) * fix: correct typing for on_read_resource middleware method by [@strawgate](https://github.com/strawgate) in [#1858](https://github.com/PrefectHQ/fastmcp/pull/1858) * feat(experimental/openapi): replace $ref in additionalProperties; add tests by [@jlowin](https://github.com/jlowin) in [#1735](https://github.com/PrefectHQ/fastmcp/pull/1735) * Honor client supplied scopes during registration by [@dmikusa](https://github.com/dmikusa) in [#1860](https://github.com/PrefectHQ/fastmcp/pull/1860) * Fix: FastAPI list parameter parsing in experimental OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#1834](https://github.com/PrefectHQ/fastmcp/pull/1834) * Add log level support for stdio and HTTP transports by [@jlowin](https://github.com/jlowin) in [#1840](https://github.com/PrefectHQ/fastmcp/pull/1840) * Fix OAuth pre-flight check to accept HTTP 200 responses by [@jlowin](https://github.com/jlowin) in [#1874](https://github.com/PrefectHQ/fastmcp/pull/1874) * Fix: Preserve OpenAPI parameter descriptions in experimental parser by [@shlomo666](https://github.com/shlomo666) in [#1877](https://github.com/PrefectHQ/fastmcp/pull/1877) * Add persistent storage for OAuth client registrations by [@jlowin](https://github.com/jlowin) in [#1879](https://github.com/PrefectHQ/fastmcp/pull/1879) * docs: update release dates based on github releases by [@lodu](https://github.com/lodu) in [#1890](https://github.com/PrefectHQ/fastmcp/pull/1890) * Small updates to Sampling types by [@strawgate](https://github.com/strawgate) in [#1882](https://github.com/PrefectHQ/fastmcp/pull/1882) * remove lockfile smart_home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#1892](https://github.com/PrefectHQ/fastmcp/pull/1892) * Fix: Remove JSON schema title metadata while preserving parameters named 'title' by [@jlowin](https://github.com/jlowin) in [#1872](https://github.com/PrefectHQ/fastmcp/pull/1872) * Fix: get_resource_url nested URL handling by [@raphael-linx](https://github.com/raphael-linx) in [#1914](https://github.com/PrefectHQ/fastmcp/pull/1914) * Clean up code for creating the resource url by [@jlowin](https://github.com/jlowin) in [#1916](https://github.com/PrefectHQ/fastmcp/pull/1916) * Fix route count logging in OpenAPI server by [@zzstoatzz](https://github.com/zzstoatzz) in [#1928](https://github.com/PrefectHQ/fastmcp/pull/1928) ### Docs 📚 * docs: make Gemini CLI integration discoverable by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1827](https://github.com/PrefectHQ/fastmcp/pull/1827) * docs: update NEW tags for AI assistant integrations by [@jackwotherspoon](https://github.com/jackwotherspoon) in [#1829](https://github.com/PrefectHQ/fastmcp/pull/1829) * Update wordmark by [@jlowin](https://github.com/jlowin) in [#1832](https://github.com/PrefectHQ/fastmcp/pull/1832) * docs: improve OAuth and OIDC Proxy documentation by [@jlowin](https://github.com/jlowin) in [#1880](https://github.com/PrefectHQ/fastmcp/pull/1880) * Update readme + welcome docs by [@jlowin](https://github.com/jlowin) in [#1883](https://github.com/PrefectHQ/fastmcp/pull/1883) * Update dark mode image in README by [@jlowin](https://github.com/jlowin) in [#1885](https://github.com/PrefectHQ/fastmcp/pull/1885) ## New Contributors * [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) * [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) * [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) * [@attiks](https://github.com/attiks) made their first contribution in [#1838](https://github.com/PrefectHQ/fastmcp/pull/1838) * [@anvibanga](https://github.com/anvibanga) made their first contribution in [#1853](https://github.com/PrefectHQ/fastmcp/pull/1853) * [@shlomo666](https://github.com/shlomo666) made their first contribution in [#1877](https://github.com/PrefectHQ/fastmcp/pull/1877) * [@lodu](https://github.com/lodu) made their first contribution in [#1890](https://github.com/PrefectHQ/fastmcp/pull/1890) * [@isijoe](https://github.com/isijoe) made their first contribution in [#1906](https://github.com/PrefectHQ/fastmcp/pull/1906) * [@raphael-linx](https://github.com/raphael-linx) made their first contribution in [#1914](https://github.com/PrefectHQ/fastmcp/pull/1914) * [@stephaneberle9](https://github.com/stephaneberle9) made their first contribution in [#1873](https://github.com/PrefectHQ/fastmcp/pull/1873) * [@cclauss](https://github.com/cclauss) made their first contribution in [#1922](https://github.com/PrefectHQ/fastmcp/pull/1922) **Full Changelog**: [v2.12.3...v2.12.4](https://github.com/PrefectHQ/fastmcp/compare/v2.12.3...v2.12.4) **[v2.12.3: Double Time](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.3)** FastMCP 2.12.3 focuses on performance and developer experience improvements based on community feedback. This release includes optimized auth provider imports that reduce server startup time, enhanced OIDC authentication flows with proper token management, and several reliability fixes for OAuth proxy configurations. The addition of automatic inline snapshot creation significantly improves the testing experience for contributors. ## What's Changed ### New Features 🎉 * feat: Support setting MCP log level via transport configuration by [@jlowin](https://github.com/jlowin) in [#1756](https://github.com/PrefectHQ/fastmcp/pull/1756) ### Enhancements 🔧 * Add client-side auth support for mcp install cursor command by [@jlowin](https://github.com/jlowin) in [#1747](https://github.com/PrefectHQ/fastmcp/pull/1747) * Automatically Create inline Snapshots by [@strawgate](https://github.com/strawgate) in [#1779](https://github.com/PrefectHQ/fastmcp/pull/1779) * Use lowercase namespace for fastmcp logger by [@jlowin](https://github.com/jlowin) in [#1791](https://github.com/PrefectHQ/fastmcp/pull/1791) ### Fixes 🐞 * fix: correct merge mistake during auth0 refactor by [@strawgate](https://github.com/strawgate) in [#1742](https://github.com/PrefectHQ/fastmcp/pull/1742) * Remove extraneous union import by [@jlowin](https://github.com/jlowin) in [#1823](https://github.com/PrefectHQ/fastmcp/pull/1823) * Delay import of Provider classes until FastMCP Server Creation by [@strawgate](https://github.com/strawgate) in [#1820](https://github.com/PrefectHQ/fastmcp/pull/1820) * fix: refactor OIDC configuration provider for proper token management by [@strawgate](https://github.com/strawgate) in [#1751](https://github.com/PrefectHQ/fastmcp/pull/1751) * Fix smart_home example imports by [@strawgate](https://github.com/strawgate) in [#1753](https://github.com/PrefectHQ/fastmcp/pull/1753) * fix: correct oauth proxy initialization of client by [@strawgate](https://github.com/strawgate) in [#1759](https://github.com/PrefectHQ/fastmcp/pull/1759) * Fix: return empty string when prompts have no arguments by [@jlowin](https://github.com/jlowin) in [#1766](https://github.com/PrefectHQ/fastmcp/pull/1766) * Fix async server callbacks by [@strawgate](https://github.com/strawgate) in [#1774](https://github.com/PrefectHQ/fastmcp/pull/1774) * Fix error when retrieving Completion API errors by [@strawgate](https://github.com/strawgate) in [#1785](https://github.com/PrefectHQ/fastmcp/pull/1785) * fix: correct documentation link in deprecation warning by [@strawgate](https://github.com/strawgate) in [#1828](https://github.com/PrefectHQ/fastmcp/pull/1828) ### Docs 📚 * Add migration docs for 2.12 by [@jlowin](https://github.com/jlowin) in [#1745](https://github.com/PrefectHQ/fastmcp/pull/1745) * Update docs for default sampling implementation to mention OpenAI API Key by [@strawgate](https://github.com/strawgate) in [#1763](https://github.com/PrefectHQ/fastmcp/pull/1763) * Add tip about sampling prompts and user_context to sampling documentation by [@jlowin](https://github.com/jlowin) in [#1764](https://github.com/PrefectHQ/fastmcp/pull/1764) * Update quickstart.mdx by [@radi-dev](https://github.com/radi-dev) in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) ### Other Changes 🦾 * Replace Marvin with Claude Code in CI by [@jlowin](https://github.com/jlowin) in [#1800](https://github.com/PrefectHQ/fastmcp/pull/1800) * Refactor logging and structured logging middleware by [@strawgate](https://github.com/strawgate) in [#1805](https://github.com/PrefectHQ/fastmcp/pull/1805) * feat: Move the Starlette context middleware to the front by [@akkuman](https://github.com/akkuman) in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) * feat: Add support for OIDC configuration by [@ruhulio](https://github.com/ruhulio) in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) ## New Contributors * [@radi-dev](https://github.com/radi-dev) made their first contribution in [#1821](https://github.com/PrefectHQ/fastmcp/pull/1821) * [@akkuman](https://github.com/akkuman) made their first contribution in [#1812](https://github.com/PrefectHQ/fastmcp/pull/1812) * [@ruhulio](https://github.com/ruhulio) made their first contribution in [#1817](https://github.com/PrefectHQ/fastmcp/pull/1817) **Full Changelog**: [v2.12.2...v2.12.3](https://github.com/PrefectHQ/fastmcp/compare/v2.12.2...v2.12.3) **[v2.12.2: Perchance to Stream](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.2)** This is a hotfix for a bug where the `streamable-http` transport was not recognized as a valid option in `fastmcp.json` configuration files, despite being supported by the CLI. This resulted in a parsing error when the CLI arguments were merged against the configuration spec. ## What's Changed ### Fixes 🐞 * Fix streamable-http transport validation in fastmcp.json config by [@jlowin](https://github.com/jlowin) in [#1739](https://github.com/PrefectHQ/fastmcp/pull/1739) **Full Changelog**: [v2.12.1...v2.12.2](https://github.com/PrefectHQ/fastmcp/compare/v2.12.1...v2.12.2) **[v2.12.1: OAuth to Joy](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.1)** FastMCP 2.12.1 strengthens the OAuth proxy implementation based on extensive community testing and feedback. This release improves client storage reliability, adds PKCE forwarding for enhanced security, introduces configurable token endpoint authentication methods, and expands scope handling—all addressing real-world integration challenges discovered since 2.12.0. The enhanced test suite with mock providers ensures these improvements are robust and maintainable. ## Breaking Changes - **OAuth Proxy**: Users of built-in IDP integrations should note that `resource_server_url` has been renamed to `base_url` for clarity and consistency ## What's Changed ### Enhancements 🔧 * Make openai dependency optional by [@jlowin](https://github.com/jlowin) in [#1701](https://github.com/PrefectHQ/fastmcp/pull/1701) * Remove orphaned OAuth proxy code by [@jlowin](https://github.com/jlowin) in [#1722](https://github.com/PrefectHQ/fastmcp/pull/1722) * Expose valid scopes from OAuthProxy metadata by [@dmikusa](https://github.com/dmikusa) in [#1717](https://github.com/PrefectHQ/fastmcp/pull/1717) * OAuth proxy PKCE forwarding by [@jlowin](https://github.com/jlowin) in [#1733](https://github.com/PrefectHQ/fastmcp/pull/1733) * Add token_endpoint_auth_method parameter to OAuthProxy by [@jlowin](https://github.com/jlowin) in [#1736](https://github.com/PrefectHQ/fastmcp/pull/1736) * Clean up and enhance OAuth proxy tests with mock provider by [@jlowin](https://github.com/jlowin) in [#1738](https://github.com/PrefectHQ/fastmcp/pull/1738) ### Fixes 🐞 * refactor: replace auth provider registry with ImportString by [@jlowin](https://github.com/jlowin) in [#1710](https://github.com/PrefectHQ/fastmcp/pull/1710) * Fix OAuth resource URL handling and WWW-Authenticate header by [@jlowin](https://github.com/jlowin) in [#1706](https://github.com/PrefectHQ/fastmcp/pull/1706) * Fix OAuth proxy client storage and add retry logic by [@jlowin](https://github.com/jlowin) in [#1732](https://github.com/PrefectHQ/fastmcp/pull/1732) ### Docs 📚 * Fix documentation: use StreamableHttpTransport for headers in testing by [@jlowin](https://github.com/jlowin) in [#1702](https://github.com/PrefectHQ/fastmcp/pull/1702) * docs: add performance warnings for mounted servers and proxies by [@strawgate](https://github.com/strawgate) in [#1669](https://github.com/PrefectHQ/fastmcp/pull/1669) * Update documentation around scopes for google by [@jlowin](https://github.com/jlowin) in [#1703](https://github.com/PrefectHQ/fastmcp/pull/1703) * Add deployment information to quickstart by [@seanpwlms](https://github.com/seanpwlms) in [#1433](https://github.com/PrefectHQ/fastmcp/pull/1433) * Update quickstart by [@jlowin](https://github.com/jlowin) in [#1728](https://github.com/PrefectHQ/fastmcp/pull/1728) * Add development docs for FastMCP by [@jlowin](https://github.com/jlowin) in [#1719](https://github.com/PrefectHQ/fastmcp/pull/1719) ### Other Changes 🦾 * Set generics without bounds to default=Any by [@strawgate](https://github.com/strawgate) in [#1648](https://github.com/PrefectHQ/fastmcp/pull/1648) ## New Contributors * [@dmikusa](https://github.com/dmikusa) made their first contribution in [#1717](https://github.com/PrefectHQ/fastmcp/pull/1717) * [@seanpwlms](https://github.com/seanpwlms) made their first contribution in [#1433](https://github.com/PrefectHQ/fastmcp/pull/1433) **Full Changelog**: [v2.12.0...v2.12.1](https://github.com/PrefectHQ/fastmcp/compare/v2.12.0...v2.12.1) **[v2.12.0: Auth to the Races](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.12.0)** FastMCP 2.12 represents one of our most significant releases to date, both in scope and community involvement. After extensive testing and iteration with the community, we're shipping major improvements to authentication, configuration, and MCP feature adoption. 🔐 **OAuth Proxy for Broader Provider Support** addresses a fundamental challenge: while MCP requires Dynamic Client Registration (DCR), many popular OAuth providers don't support it. The new OAuth proxy bridges this gap, enabling FastMCP servers to authenticate with providers like GitHub, Google, WorkOS, and Azure through minimal configuration. These native integrations ship today, with more providers planned based on community needs. 📋 **Declarative JSON Configuration** introduces a standardized, portable way to describe and deploy MCP servers. The `fastmcp.json` configuration file becomes the single source of truth for dependencies, transport settings, entrypoints, and server metadata. This foundation sets the stage for future capabilities like transformations and remote sources, moving toward a world where MCP servers are as portable and shareable as container images. 🧠 **Sampling API Fallback** tackles the chicken-and-egg problem limiting adoption of advanced MCP features. Sampling—where servers request LLM completions from clients—is powerful but underutilized due to limited client support. FastMCP now lets server authors define fallback handlers that generate sampling completions server-side when clients don't support the feature, encouraging adoption while maintaining compatibility. This release took longer than usual to ship, and for good reason: the community's aggressive testing and feedback on the authentication system helped us reach a level of stability we're confident in. There's certainly more work ahead, but these foundations position FastMCP to handle increasingly complex use cases while remaining approachable for developers. Thank you to our new contributors and everyone who tested preview builds. Your feedback directly shaped these features. ## What's Changed ### New Features 🎉 * Add OAuth proxy that allows authentication with social IDPs without DCR support by [@jlowin](https://github.com/jlowin) in [#1434](https://github.com/PrefectHQ/fastmcp/pull/1434) * feat: introduce declarative JSON configuration system by [@jlowin](https://github.com/jlowin) in [#1517](https://github.com/PrefectHQ/fastmcp/pull/1517) * ✨ Fallback to a Completions API when Sampling is not available by [@strawgate](https://github.com/strawgate) in [#1145](https://github.com/PrefectHQ/fastmcp/pull/1145) * Implement typed source system for FastMCP declarative configuration by [@jlowin](https://github.com/jlowin) in [#1607](https://github.com/PrefectHQ/fastmcp/pull/1607) ### Enhancements 🔧 * Support importing custom_route endpoints when mounting servers by [@jlowin](https://github.com/jlowin) in [#1470](https://github.com/PrefectHQ/fastmcp/pull/1470) * Remove unnecessary asserts by [@jlowin](https://github.com/jlowin) in [#1484](https://github.com/PrefectHQ/fastmcp/pull/1484) * Add Claude issue triage by [@jlowin](https://github.com/jlowin) in [#1510](https://github.com/PrefectHQ/fastmcp/pull/1510) * Inline dedupe prompt by [@jlowin](https://github.com/jlowin) in [#1512](https://github.com/PrefectHQ/fastmcp/pull/1512) * Improve stdio and mcp_config clean-up by [@strawgate](https://github.com/strawgate) in [#1444](https://github.com/PrefectHQ/fastmcp/pull/1444) * involve kwargs to pass parameters on creating RichHandler for logging customization. by [@itaru2622](https://github.com/itaru2622) in [#1504](https://github.com/PrefectHQ/fastmcp/pull/1504) * Move SDK docs generation to post-merge workflow by [@jlowin](https://github.com/jlowin) in [#1513](https://github.com/PrefectHQ/fastmcp/pull/1513) * Improve label triage guidance by [@jlowin](https://github.com/jlowin) in [#1516](https://github.com/PrefectHQ/fastmcp/pull/1516) * Add code review guidelines for agents by [@jlowin](https://github.com/jlowin) in [#1520](https://github.com/PrefectHQ/fastmcp/pull/1520) * Remove trailing slash in unit tests by [@jlowin](https://github.com/jlowin) in [#1535](https://github.com/PrefectHQ/fastmcp/pull/1535) * Update OAuth callback UI branding by [@jlowin](https://github.com/jlowin) in [#1536](https://github.com/PrefectHQ/fastmcp/pull/1536) * Fix Marvin workflow to support development tools by [@jlowin](https://github.com/jlowin) in [#1537](https://github.com/PrefectHQ/fastmcp/pull/1537) * Add mounted_components_raise_on_load_error setting for debugging by [@jlowin](https://github.com/jlowin) in [#1534](https://github.com/PrefectHQ/fastmcp/pull/1534) * feat: Add --workspace flag to fastmcp install cursor by [@jlowin](https://github.com/jlowin) in [#1522](https://github.com/PrefectHQ/fastmcp/pull/1522) * switch from `pyright` to `ty` by [@zzstoatzz](https://github.com/zzstoatzz) in [#1545](https://github.com/PrefectHQ/fastmcp/pull/1545) * feat: trigger Marvin workflow on PR body content by [@jlowin](https://github.com/jlowin) in [#1549](https://github.com/PrefectHQ/fastmcp/pull/1549) * Add WorkOS and Azure OAuth providers by [@jlowin](https://github.com/jlowin) in [#1550](https://github.com/PrefectHQ/fastmcp/pull/1550) * Adjust timeout for slow MCP Server shutdown test by [@strawgate](https://github.com/strawgate) in [#1561](https://github.com/PrefectHQ/fastmcp/pull/1561) * Update banner by [@jlowin](https://github.com/jlowin) in [#1567](https://github.com/PrefectHQ/fastmcp/pull/1567) * Added import of AuthProxy to auth __init__ by [@KaliszS](https://github.com/KaliszS) in [#1568](https://github.com/PrefectHQ/fastmcp/pull/1568) * Add configurable redirect URI validation for OAuth providers by [@jlowin](https://github.com/jlowin) in [#1582](https://github.com/PrefectHQ/fastmcp/pull/1582) * Remove invalid-argument-type ignore and fix type errors by [@jlowin](https://github.com/jlowin) in [#1588](https://github.com/PrefectHQ/fastmcp/pull/1588) * Remove generate-schema from public CLI by [@jlowin](https://github.com/jlowin) in [#1591](https://github.com/PrefectHQ/fastmcp/pull/1591) * Skip flaky windows test / mulit-client garbage collection by [@jlowin](https://github.com/jlowin) in [#1592](https://github.com/PrefectHQ/fastmcp/pull/1592) * Add setting to disable logging configuration by [@isra17](https://github.com/isra17) in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) * Improve debug logging for nested Servers / Clients by [@strawgate](https://github.com/strawgate) in [#1604](https://github.com/PrefectHQ/fastmcp/pull/1604) * Add GitHub pull request template by [@strawgate](https://github.com/strawgate) in [#1581](https://github.com/PrefectHQ/fastmcp/pull/1581) * chore: Automate docs and schema updates via PRs by [@jlowin](https://github.com/jlowin) in [#1611](https://github.com/PrefectHQ/fastmcp/pull/1611) * Experiment with haiku for limited workflows by [@jlowin](https://github.com/jlowin) in [#1613](https://github.com/PrefectHQ/fastmcp/pull/1613) * feat: Improve GitHub workflow automation for schema and SDK docs by [@jlowin](https://github.com/jlowin) in [#1615](https://github.com/PrefectHQ/fastmcp/pull/1615) * Consolidate server loading logic into FileSystemSource by [@jlowin](https://github.com/jlowin) in [#1614](https://github.com/PrefectHQ/fastmcp/pull/1614) * Prevent Haiku Marvin from commenting when there are no duplicates by [@jlowin](https://github.com/jlowin) in [#1622](https://github.com/PrefectHQ/fastmcp/pull/1622) * chore: Add clarifying note to automated PR bodies by [@jlowin](https://github.com/jlowin) in [#1623](https://github.com/PrefectHQ/fastmcp/pull/1623) * feat: introduce inline snapshots by [@strawgate](https://github.com/strawgate) in [#1605](https://github.com/PrefectHQ/fastmcp/pull/1605) * Improve fastmcp.json environment configuration and project-based deployments by [@jlowin](https://github.com/jlowin) in [#1631](https://github.com/PrefectHQ/fastmcp/pull/1631) * fix: allow passing query params in OAuthProxy upstream authorization url by [@danb27](https://github.com/danb27) in [#1630](https://github.com/PrefectHQ/fastmcp/pull/1630) * Support multiple --with-editable flags in CLI commands by [@jlowin](https://github.com/jlowin) in [#1634](https://github.com/PrefectHQ/fastmcp/pull/1634) * feat: support comma separated oauth scopes by [@jlowin](https://github.com/jlowin) in [#1642](https://github.com/PrefectHQ/fastmcp/pull/1642) * Add allowed_client_redirect_uris to OAuth provider subclasses by [@jlowin](https://github.com/jlowin) in [#1662](https://github.com/PrefectHQ/fastmcp/pull/1662) * Consolidate CLI config parsing and prevent infinite loops by [@jlowin](https://github.com/jlowin) in [#1660](https://github.com/PrefectHQ/fastmcp/pull/1660) * Internal refactor: mcp server config by [@jlowin](https://github.com/jlowin) in [#1672](https://github.com/PrefectHQ/fastmcp/pull/1672) * Refactor Environment to support multiple runtime types by [@jlowin](https://github.com/jlowin) in [#1673](https://github.com/PrefectHQ/fastmcp/pull/1673) * Add type field to Environment base class by [@jlowin](https://github.com/jlowin) in [#1676](https://github.com/PrefectHQ/fastmcp/pull/1676) ### Fixes 🐞 * Fix breaking change: restore output_schema=False compatibility by [@jlowin](https://github.com/jlowin) in [#1482](https://github.com/PrefectHQ/fastmcp/pull/1482) * Fix #1506: Update tool filtering documentation from _meta to meta by [@maybenotconnor](https://github.com/maybenotconnor) in [#1511](https://github.com/PrefectHQ/fastmcp/pull/1511) * Fix pytest warnings by [@jlowin](https://github.com/jlowin) in [#1559](https://github.com/PrefectHQ/fastmcp/pull/1559) * nest schemas under assets by [@jlowin](https://github.com/jlowin) in [#1593](https://github.com/PrefectHQ/fastmcp/pull/1593) * Skip flaky windows test by [@jlowin](https://github.com/jlowin) in [#1596](https://github.com/PrefectHQ/fastmcp/pull/1596) * ACTUALLY move schemas to fastmcp.json by [@jlowin](https://github.com/jlowin) in [#1597](https://github.com/PrefectHQ/fastmcp/pull/1597) * Fix and centralize CLI path resolution by [@jlowin](https://github.com/jlowin) in [#1590](https://github.com/PrefectHQ/fastmcp/pull/1590) * Remove client info modifications by [@jlowin](https://github.com/jlowin) in [#1620](https://github.com/PrefectHQ/fastmcp/pull/1620) * Fix $defs being discarded in input schema of transformed tool by [@pldesch-chift](https://github.com/pldesch-chift) in [#1578](https://github.com/PrefectHQ/fastmcp/pull/1578) * Fix enum elicitation to use inline schemas for MCP compatibility by [@jlowin](https://github.com/jlowin) in [#1632](https://github.com/PrefectHQ/fastmcp/pull/1632) * Reuse session for `StdioTransport` in `Client.new` by [@strawgate](https://github.com/strawgate) in [#1635](https://github.com/PrefectHQ/fastmcp/pull/1635) * Feat: Configurable LoggingMiddleware payload serialization by [@vl-kp](https://github.com/vl-kp) in [#1636](https://github.com/PrefectHQ/fastmcp/pull/1636) * Fix OAuth redirect URI validation for DCR compatibility by [@jlowin](https://github.com/jlowin) in [#1661](https://github.com/PrefectHQ/fastmcp/pull/1661) * Add default scope handling in OAuth proxy by [@romanusyk](https://github.com/romanusyk) in [#1667](https://github.com/PrefectHQ/fastmcp/pull/1667) * Fix OAuth token expiry handling by [@jlowin](https://github.com/jlowin) in [#1671](https://github.com/PrefectHQ/fastmcp/pull/1671) * Add resource_server_url parameter to OAuth proxy providers by [@jlowin](https://github.com/jlowin) in [#1682](https://github.com/PrefectHQ/fastmcp/pull/1682) ### Breaking Changes 🛫 * Enhance inspect command with structured output and format options by [@jlowin](https://github.com/jlowin) in [#1481](https://github.com/PrefectHQ/fastmcp/pull/1481) ### Docs 📚 * Update changelog by [@jlowin](https://github.com/jlowin) in [#1453](https://github.com/PrefectHQ/fastmcp/pull/1453) * Update banner by [@jlowin](https://github.com/jlowin) in [#1472](https://github.com/PrefectHQ/fastmcp/pull/1472) * Update logo files by [@jlowin](https://github.com/jlowin) in [#1473](https://github.com/PrefectHQ/fastmcp/pull/1473) * Update deployment docs by [@jlowin](https://github.com/jlowin) in [#1486](https://github.com/PrefectHQ/fastmcp/pull/1486) * Update FastMCP Cloud screenshot by [@jlowin](https://github.com/jlowin) in [#1487](https://github.com/PrefectHQ/fastmcp/pull/1487) * Update authentication note in docs by [@jlowin](https://github.com/jlowin) in [#1488](https://github.com/PrefectHQ/fastmcp/pull/1488) * chore: Update installation.mdx version snippet by [@thomas-te](https://github.com/thomas-te) in [#1496](https://github.com/PrefectHQ/fastmcp/pull/1496) * Update fastmcp cloud server requirements by [@jlowin](https://github.com/jlowin) in [#1497](https://github.com/PrefectHQ/fastmcp/pull/1497) * Fix oauth pyright type checking by [@strawgate](https://github.com/strawgate) in [#1498](https://github.com/PrefectHQ/fastmcp/pull/1498) * docs: Fix type annotation in return value documentation by [@MaikelVeen](https://github.com/MaikelVeen) in [#1499](https://github.com/PrefectHQ/fastmcp/pull/1499) * Fix PromptMessage usage in docs example by [@jlowin](https://github.com/jlowin) in [#1515](https://github.com/PrefectHQ/fastmcp/pull/1515) * Create CODE_OF_CONDUCT.md by [@jlowin](https://github.com/jlowin) in [#1523](https://github.com/PrefectHQ/fastmcp/pull/1523) * Fixed wrong import path in new docs page by [@KaliszS](https://github.com/KaliszS) in [#1538](https://github.com/PrefectHQ/fastmcp/pull/1538) * Document symmetric key JWT verification support by [@jlowin](https://github.com/jlowin) in [#1586](https://github.com/PrefectHQ/fastmcp/pull/1586) * Update fastmcp.json schema path by [@jlowin](https://github.com/jlowin) in [#1595](https://github.com/PrefectHQ/fastmcp/pull/1595) ### Dependencies 📦 * Bump actions/create-github-app-token from 1 to 2 by [@dependabot](https://github.com/dependabot)[bot] in [#1436](https://github.com/PrefectHQ/fastmcp/pull/1436) * Bump astral-sh/setup-uv from 4 to 6 by [@dependabot](https://github.com/dependabot)[bot] in [#1532](https://github.com/PrefectHQ/fastmcp/pull/1532) * Bump actions/checkout from 4 to 5 by [@dependabot](https://github.com/dependabot)[bot] in [#1533](https://github.com/PrefectHQ/fastmcp/pull/1533) ### Other Changes 🦾 * Add dedupe workflow by [@jlowin](https://github.com/jlowin) in [#1454](https://github.com/PrefectHQ/fastmcp/pull/1454) * Update AGENTS.md by [@jlowin](https://github.com/jlowin) in [#1471](https://github.com/PrefectHQ/fastmcp/pull/1471) * Give Marvin the power of the Internet by [@strawgate](https://github.com/strawgate) in [#1475](https://github.com/PrefectHQ/fastmcp/pull/1475) * Update `just` error message for static checks by [@jlowin](https://github.com/jlowin) in [#1483](https://github.com/PrefectHQ/fastmcp/pull/1483) * Remove labeler by [@jlowin](https://github.com/jlowin) in [#1509](https://github.com/PrefectHQ/fastmcp/pull/1509) * update aproto server to handle rich links by [@zzstoatzz](https://github.com/zzstoatzz) in [#1556](https://github.com/PrefectHQ/fastmcp/pull/1556) * fix: enable triage bot for fork PRs using pull_request_target by [@jlowin](https://github.com/jlowin) in [#1557](https://github.com/PrefectHQ/fastmcp/pull/1557) ## New Contributors * [@thomas-te](https://github.com/thomas-te) made their first contribution in [#1496](https://github.com/PrefectHQ/fastmcp/pull/1496) * [@maybenotconnor](https://github.com/maybenotconnor) made their first contribution in [#1511](https://github.com/PrefectHQ/fastmcp/pull/1511) * [@MaikelVeen](https://github.com/MaikelVeen) made their first contribution in [#1499](https://github.com/PrefectHQ/fastmcp/pull/1499) * [@KaliszS](https://github.com/KaliszS) made their first contribution in [#1538](https://github.com/PrefectHQ/fastmcp/pull/1538) * [@isra17](https://github.com/isra17) made their first contribution in [#1575](https://github.com/PrefectHQ/fastmcp/pull/1575) * [@marvin-context-protocol](https://github.com/marvin-context-protocol)[bot] made their first contribution in [#1616](https://github.com/PrefectHQ/fastmcp/pull/1616) * [@pldesch-chift](https://github.com/pldesch-chift) made their first contribution in [#1578](https://github.com/PrefectHQ/fastmcp/pull/1578) * [@vl-kp](https://github.com/vl-kp) made their first contribution in [#1636](https://github.com/PrefectHQ/fastmcp/pull/1636) * [@romanusyk](https://github.com/romanusyk) made their first contribution in [#1667](https://github.com/PrefectHQ/fastmcp/pull/1667) **Full Changelog**: [v2.11.3...v2.12.0](https://github.com/PrefectHQ/fastmcp/compare/v2.11.3...v2.12.0) **[v2.11.3: API-tite for Change](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.3)** This release includes significant enhancements to the experimental OpenAPI parser and fixes a significant bug that led schemas not to be included in input/output schemas if they were transitive dependencies (e.g. A → B → C implies A depends on C). For users naively transforming large OpenAPI specs into MCP servers, this may result in ballooning payload sizes and necessitate curation. ## What's Changed ### Enhancements 🔧 * Improve redirect handling to address 307's by [@jlowin](https://github.com/jlowin) in [#1387](https://github.com/PrefectHQ/fastmcp/pull/1387) * Ensure resource + template names are properly prefixed when importing/mounting by [@jlowin](https://github.com/jlowin) in [#1423](https://github.com/PrefectHQ/fastmcp/pull/1423) * fixes #1398: Add JWT claims to AccessToken by [@panargirakis](https://github.com/panargirakis) in [#1399](https://github.com/PrefectHQ/fastmcp/pull/1399) * Enable Protected Resource Metadata to provide resource_name and resou… by [@yannj-fr](https://github.com/yannj-fr) in [#1371](https://github.com/PrefectHQ/fastmcp/pull/1371) * Pin mcp SDK under 2.0 to avoid breaking changes by [@jlowin](https://github.com/jlowin) in [#1428](https://github.com/PrefectHQ/fastmcp/pull/1428) * Clean up complexity from PR #1426 by [@jlowin](https://github.com/jlowin) in [#1435](https://github.com/PrefectHQ/fastmcp/pull/1435) * Optimize OpenAPI payload size by 46% by [@jlowin](https://github.com/jlowin) in [#1452](https://github.com/PrefectHQ/fastmcp/pull/1452) * Update static checks by [@jlowin](https://github.com/jlowin) in [#1448](https://github.com/PrefectHQ/fastmcp/pull/1448) ### Fixes 🐞 * Fix client-side logging bug #1394 by [@chi2liu](https://github.com/chi2liu) in [#1397](https://github.com/PrefectHQ/fastmcp/pull/1397) * fix: Fix httpx_client_factory type annotation to match MCP SDK (#1402) by [@chi2liu](https://github.com/chi2liu) in [#1405](https://github.com/PrefectHQ/fastmcp/pull/1405) * Fix OpenAPI allOf handling at requestBody top level (#1378) by [@chi2liu](https://github.com/chi2liu) in [#1425](https://github.com/PrefectHQ/fastmcp/pull/1425) * Fix OpenAPI transitive references and performance (#1372) by [@jlowin](https://github.com/jlowin) in [#1426](https://github.com/PrefectHQ/fastmcp/pull/1426) * fix(type): lifespan is partially unknown by [@ykun9](https://github.com/ykun9) in [#1389](https://github.com/PrefectHQ/fastmcp/pull/1389) * Ensure transformed tools generate structured content by [@jlowin](https://github.com/jlowin) in [#1443](https://github.com/PrefectHQ/fastmcp/pull/1443) ### Docs 📚 * docs(client/logging): reflect corrected default log level mapping by [@jlowin](https://github.com/jlowin) in [#1403](https://github.com/PrefectHQ/fastmcp/pull/1403) * Add documentation for get_access_token() dependency function by [@jlowin](https://github.com/jlowin) in [#1446](https://github.com/PrefectHQ/fastmcp/pull/1446) ### Other Changes 🦾 * Add comprehensive tests for utilities.components module by [@chi2liu](https://github.com/chi2liu) in [#1395](https://github.com/PrefectHQ/fastmcp/pull/1395) * Consolidate agent instructions into AGENTS.md by [@jlowin](https://github.com/jlowin) in [#1404](https://github.com/PrefectHQ/fastmcp/pull/1404) * Fix performance test threshold to prevent flaky failures by [@jlowin](https://github.com/jlowin) in [#1406](https://github.com/PrefectHQ/fastmcp/pull/1406) * Update agents.md; add github instructions by [@jlowin](https://github.com/jlowin) in [#1410](https://github.com/PrefectHQ/fastmcp/pull/1410) * Add Marvin assistant by [@jlowin](https://github.com/jlowin) in [#1412](https://github.com/PrefectHQ/fastmcp/pull/1412) * Marvin: fix deprecated variable names by [@jlowin](https://github.com/jlowin) in [#1417](https://github.com/PrefectHQ/fastmcp/pull/1417) * Simplify action setup and add github tools for Marvin by [@jlowin](https://github.com/jlowin) in [#1419](https://github.com/PrefectHQ/fastmcp/pull/1419) * Update marvin workflow name by [@jlowin](https://github.com/jlowin) in [#1421](https://github.com/PrefectHQ/fastmcp/pull/1421) * Improve GitHub templates by [@jlowin](https://github.com/jlowin) in [#1422](https://github.com/PrefectHQ/fastmcp/pull/1422) ## New Contributors * [@panargirakis](https://github.com/panargirakis) made their first contribution in [#1399](https://github.com/PrefectHQ/fastmcp/pull/1399) * [@ykun9](https://github.com/ykun9) made their first contribution in [#1389](https://github.com/PrefectHQ/fastmcp/pull/1389) * [@yannj-fr](https://github.com/yannj-fr) made their first contribution in [#1371](https://github.com/PrefectHQ/fastmcp/pull/1371) **Full Changelog**: [v2.11.2...v2.11.3](https://github.com/PrefectHQ/fastmcp/compare/v2.11.2...v2.11.3) ## [v2.11.2: Satis-factory](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.2) ## What's Changed ### Enhancements 🔧 * Support factory functions in fastmcp run by [@jlowin](https://github.com/jlowin) in [#1384](https://github.com/PrefectHQ/fastmcp/pull/1384) * Add async support to client_factory in FastMCPProxy (#1286) by [@bianning](https://github.com/bianning) in [#1375](https://github.com/PrefectHQ/fastmcp/pull/1375) ### Fixes 🐞 * Fix server_version field in inspect manifest by [@jlowin](https://github.com/jlowin) in [#1383](https://github.com/PrefectHQ/fastmcp/pull/1383) * Fix Settings field with both default and default_factory by [@jlowin](https://github.com/jlowin) in [#1380](https://github.com/PrefectHQ/fastmcp/pull/1380) ### Other Changes 🦾 * Remove unused arg by [@jlowin](https://github.com/jlowin) in [#1382](https://github.com/PrefectHQ/fastmcp/pull/1382) * Add remote auth provider tests by [@jlowin](https://github.com/jlowin) in [#1351](https://github.com/PrefectHQ/fastmcp/pull/1351) ## New Contributors * [@bianning](https://github.com/bianning) made their first contribution in [#1375](https://github.com/PrefectHQ/fastmcp/pull/1375) **Full Changelog**: [v2.11.1...v2.11.2](https://github.com/PrefectHQ/fastmcp/compare/v2.11.1...v2.11.2) ## [v2.11.1: You're Better Auth Now](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.1) ## What's Changed ### New Features 🎉 * Introduce `RemoteAuthProvider` for cleaner external identity provider integration, update docs by [@jlowin](https://github.com/jlowin) in [#1346](https://github.com/PrefectHQ/fastmcp/pull/1346) ### Enhancements 🔧 * perf: optimize string operations in OpenAPI parameter processing by [@chi2liu](https://github.com/chi2liu) in [#1342](https://github.com/PrefectHQ/fastmcp/pull/1342) ### Fixes 🐞 * Fix method-bound FunctionTool schemas by [@strawgate](https://github.com/strawgate) in [#1360](https://github.com/PrefectHQ/fastmcp/pull/1360) * Manually set `_key` after `model_copy()` to enable prefixing Transformed Tools by [@strawgate](https://github.com/strawgate) in [#1357](https://github.com/PrefectHQ/fastmcp/pull/1357) ### Docs 📚 * Docs updates by [@jlowin](https://github.com/jlowin) in [#1336](https://github.com/PrefectHQ/fastmcp/pull/1336) * Add 2.11 to changelog by [@jlowin](https://github.com/jlowin) in [#1337](https://github.com/PrefectHQ/fastmcp/pull/1337) * Update AuthKit vocab by [@jlowin](https://github.com/jlowin) in [#1338](https://github.com/PrefectHQ/fastmcp/pull/1338) * Fix typo in decorating-methods.mdx by [@Ozzuke](https://github.com/Ozzuke) in [#1344](https://github.com/PrefectHQ/fastmcp/pull/1344) ## New Contributors * [@Ozzuke](https://github.com/Ozzuke) made their first contribution in [#1344](https://github.com/PrefectHQ/fastmcp/pull/1344) **Full Changelog**: [v2.11.0...v2.11.1](https://github.com/PrefectHQ/fastmcp/compare/v2.11.0...v2.11.1) ## [v2.11.0: Auth to a Good Start](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.11.0) FastMCP 2.11 doubles down on what developers need most: speed and simplicity. This massive release delivers significant performance improvements and a dramatically better developer experience. 🔐 **Enterprise-Ready Authentication** brings comprehensive OAuth 2.1 support with WorkOS's AuthKit integration. The new AuthProvider interface leverages MCP's support for separate resource and authorization servers, handling API keys and remote authentication with Dynamic Client Registration. AuthKit integration means you can plug into existing enterprise identity systems without rebuilding your auth stack, setting the stage for plug-and-play auth that doesn't require users to become security experts overnight. ⚡ The **Experimental OpenAPI Parser** delivers dramatic performance improvements through single-pass schema processing and optimized memory usage. OpenAPI integrations are now significantly faster, with cleaner, more maintainable code. _(Note: the experimental parser is disabled by default, set `FASTMCPEXPERIMENTALENABLENEWOPENAPIPARSER=1` to enable it. A message will be shown to all users on the legacy parser encouraging them to try the new one before it becomes the default.)_ 🧠 **Context State Management** finally gives you persistent state across tool calls with a simple dict interface, while enhanced meta support lets you expose rich component metadata to clients. Combined with improved type annotations, string-based argument descriptions, and UV transport support, this release makes FastMCP feel more intuitive than ever. This release represents a TON of community contributions and sets the foundation for even more ambitious features ahead. ## What's Changed ### New Features 🎉 * Introduce experimental OpenAPI parser with improved performance and maintainability by [@jlowin](https://github.com/jlowin) in [#1209](https://github.com/PrefectHQ/fastmcp/pull/1209) * Add state dict to Context (#1118) by [@mukulmurthy](https://github.com/mukulmurthy) in [#1160](https://github.com/PrefectHQ/fastmcp/pull/1160) * Expose FastMCP tags to clients via component `meta` dict by [@jlowin](https://github.com/jlowin) in [#1281](https://github.com/PrefectHQ/fastmcp/pull/1281) * Add _fastmcp meta namespace by [@jlowin](https://github.com/jlowin) in [#1290](https://github.com/PrefectHQ/fastmcp/pull/1290) * Add TokenVerifier protocol support alongside existing OAuthProvider authentication by [@jlowin](https://github.com/jlowin) in [#1297](https://github.com/PrefectHQ/fastmcp/pull/1297) * Add comprehensive OAuth 2.1 authentication system with WorkOS integration by [@jlowin](https://github.com/jlowin) in [#1327](https://github.com/PrefectHQ/fastmcp/pull/1327) ### Enhancements 🔧 * [🐶] Transform MCP Server Tools by [@strawgate](https://github.com/strawgate) in [#1132](https://github.com/PrefectHQ/fastmcp/pull/1132) * Add --python, --project, and --with-requirements options to CLI commands by [@jlowin](https://github.com/jlowin) in [#1190](https://github.com/PrefectHQ/fastmcp/pull/1190) * Support `fastmcp run mcp.json` by [@strawgate](https://github.com/strawgate) in [#1138](https://github.com/PrefectHQ/fastmcp/pull/1138) * Support from __future__ import annotations by [@jlowin](https://github.com/jlowin) in [#1199](https://github.com/PrefectHQ/fastmcp/pull/1199) * Optimize OpenAPI parser performance with single-pass schema processing by [@jlowin](https://github.com/jlowin) in [#1214](https://github.com/PrefectHQ/fastmcp/pull/1214) * Log tool name on transform validation error by [@strawgate](https://github.com/strawgate) in [#1238](https://github.com/PrefectHQ/fastmcp/pull/1238) * Refactor `get_http_request` and `context.session_id` by [@hopeful0](https://github.com/hopeful0) in [#1242](https://github.com/PrefectHQ/fastmcp/pull/1242) * Support creating tool argument descriptions from string annotations by [@jlowin](https://github.com/jlowin) in [#1255](https://github.com/PrefectHQ/fastmcp/pull/1255) * feat: Add Annotations support for resources and resource templates by [@chughtapan](https://github.com/chughtapan) in [#1260](https://github.com/PrefectHQ/fastmcp/pull/1260) * Add UV Transport by [@strawgate](https://github.com/strawgate) in [#1270](https://github.com/PrefectHQ/fastmcp/pull/1270) * Improve OpenAPI-to-JSONSchema conversion utilities by [@jlowin](https://github.com/jlowin) in [#1283](https://github.com/PrefectHQ/fastmcp/pull/1283) * Ensure proxy components forward meta dicts by [@jlowin](https://github.com/jlowin) in [#1282](https://github.com/PrefectHQ/fastmcp/pull/1282) * fix: server argument passing in CLI run command by [@chughtapan](https://github.com/chughtapan) in [#1293](https://github.com/PrefectHQ/fastmcp/pull/1293) * Add meta support to tool transformation utilities by [@jlowin](https://github.com/jlowin) in [#1295](https://github.com/PrefectHQ/fastmcp/pull/1295) * feat: Allow Resource Metadata URL as field in OAuthProvider by [@dacamposol](https://github.com/dacamposol) in [#1287](https://github.com/PrefectHQ/fastmcp/pull/1287) * Use a simple overwrite instead of a merge for meta by [@jlowin](https://github.com/jlowin) in [#1296](https://github.com/PrefectHQ/fastmcp/pull/1296) * Remove unused TimedCache by [@strawgate](https://github.com/strawgate) in [#1303](https://github.com/PrefectHQ/fastmcp/pull/1303) * refactor: standardize logging usage across OpenAPI utilities by [@chi2liu](https://github.com/chi2liu) in [#1322](https://github.com/PrefectHQ/fastmcp/pull/1322) * perf: optimize OpenAPI parsing by reducing dict copy operations by [@chi2liu](https://github.com/chi2liu) in [#1321](https://github.com/PrefectHQ/fastmcp/pull/1321) * Structured client-side logging by [@cjermain](https://github.com/cjermain) in [#1326](https://github.com/PrefectHQ/fastmcp/pull/1326) ### Fixes 🐞 * fix: preserve def reference when referenced in allOf / oneOf / anyOf by [@algirdasci](https://github.com/algirdasci) in [#1208](https://github.com/PrefectHQ/fastmcp/pull/1208) * fix: add type hint to custom_route decorator by [@zzstoatzz](https://github.com/zzstoatzz) in [#1210](https://github.com/PrefectHQ/fastmcp/pull/1210) * chore: typo by [@richardkmichael](https://github.com/richardkmichael) in [#1216](https://github.com/PrefectHQ/fastmcp/pull/1216) * fix: handle non-string $ref values in experimental OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#1217](https://github.com/PrefectHQ/fastmcp/pull/1217) * Skip repeated type conversion and validation in proxy client elicitation handler by [@chughtapan](https://github.com/chughtapan) in [#1222](https://github.com/PrefectHQ/fastmcp/pull/1222) * Ensure default fields are not marked nullable by [@jlowin](https://github.com/jlowin) in [#1224](https://github.com/PrefectHQ/fastmcp/pull/1224) * Fix stateful proxy client mixing in multi-proxies sessions by [@hopeful0](https://github.com/hopeful0) in [#1245](https://github.com/PrefectHQ/fastmcp/pull/1245) * Fix invalid async context manager usage in proxy documentation by [@zzstoatzz](https://github.com/zzstoatzz) in [#1246](https://github.com/PrefectHQ/fastmcp/pull/1246) * fix: experimental FastMCPOpenAPI server lost headers in request when __init__(client with headers) by [@itaru2622](https://github.com/itaru2622) in [#1254](https://github.com/PrefectHQ/fastmcp/pull/1254) * Fix typing, add tests for tool call middleware by [@jlowin](https://github.com/jlowin) in [#1269](https://github.com/PrefectHQ/fastmcp/pull/1269) * Fix: prune hidden parameter defs by [@muhammadkhalid-03](https://github.com/muhammadkhalid-03) in [#1257](https://github.com/PrefectHQ/fastmcp/pull/1257) * Fix nullable field handling in OpenAPI to JSON Schema conversion by [@jlowin](https://github.com/jlowin) in [#1279](https://github.com/PrefectHQ/fastmcp/pull/1279) * Ensure fastmcp run supports v1 servers by [@jlowin](https://github.com/jlowin) in [#1332](https://github.com/PrefectHQ/fastmcp/pull/1332) ### Breaking Changes 🛫 * Change server flag to --name by [@jlowin](https://github.com/jlowin) in [#1248](https://github.com/PrefectHQ/fastmcp/pull/1248) ### Docs 📚 * Remove unused import from FastAPI integration documentation by [@mariotaddeucci](https://github.com/mariotaddeucci) in [#1194](https://github.com/PrefectHQ/fastmcp/pull/1194) * Update fastapi docs by [@jlowin](https://github.com/jlowin) in [#1198](https://github.com/PrefectHQ/fastmcp/pull/1198) * Add docs for context state management by [@jlowin](https://github.com/jlowin) in [#1227](https://github.com/PrefectHQ/fastmcp/pull/1227) * Permit.io integration docs by [@orweis](https://github.com/orweis) in [#1226](https://github.com/PrefectHQ/fastmcp/pull/1226) * Update docs to reflect sync tools by [@jlowin](https://github.com/jlowin) in [#1234](https://github.com/PrefectHQ/fastmcp/pull/1234) * Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1235](https://github.com/PrefectHQ/fastmcp/pull/1235) * Update SDK docs by [@jlowin](https://github.com/jlowin) in [#1236](https://github.com/PrefectHQ/fastmcp/pull/1236) * Update --name flag documentation for Cursor/Claude by [@adam-conway](https://github.com/adam-conway) in [#1239](https://github.com/PrefectHQ/fastmcp/pull/1239) * Add annotations docs by [@jlowin](https://github.com/jlowin) in [#1268](https://github.com/PrefectHQ/fastmcp/pull/1268) * Update openapi/fastapi URLs README.md by [@jbn](https://github.com/jbn) in [#1278](https://github.com/PrefectHQ/fastmcp/pull/1278) * Add 2.11 version badge for state management by [@jlowin](https://github.com/jlowin) in [#1289](https://github.com/PrefectHQ/fastmcp/pull/1289) * Add meta parameter support to tools, resources, templates, and prompts decorators by [@jlowin](https://github.com/jlowin) in [#1294](https://github.com/PrefectHQ/fastmcp/pull/1294) * docs: update get_state and set_state references by [@Maxi91f](https://github.com/Maxi91f) in [#1306](https://github.com/PrefectHQ/fastmcp/pull/1306) * Add unit tests and docs for denying tool calls with middleware by [@jlowin](https://github.com/jlowin) in [#1333](https://github.com/PrefectHQ/fastmcp/pull/1333) * Remove reference to stacked decorators by [@jlowin](https://github.com/jlowin) in [#1334](https://github.com/PrefectHQ/fastmcp/pull/1334) * Eunomia authorization server can run embedded within the MCP server by [@tommitt](https://github.com/tommitt) in [#1317](https://github.com/PrefectHQ/fastmcp/pull/1317) ### Other Changes 🦾 * Update README.md by [@jlowin](https://github.com/jlowin) in [#1230](https://github.com/PrefectHQ/fastmcp/pull/1230) * Logcapture addition to test_server file by [@Sourav-Tripathy](https://github.com/Sourav-Tripathy) in [#1229](https://github.com/PrefectHQ/fastmcp/pull/1229) * Add tests for headers with both legacy and experimental openapi parser by [@jlowin](https://github.com/jlowin) in [#1259](https://github.com/PrefectHQ/fastmcp/pull/1259) * Small clean-up from MCP Tool Transform PR by [@strawgate](https://github.com/strawgate) in [#1267](https://github.com/PrefectHQ/fastmcp/pull/1267) * Add test for proxy tags visibility by [@jlowin](https://github.com/jlowin) in [#1302](https://github.com/PrefectHQ/fastmcp/pull/1302) * Add unit test for sampling with image messages by [@jlowin](https://github.com/jlowin) in [#1329](https://github.com/PrefectHQ/fastmcp/pull/1329) * Remove redundant resource_metadata_url assignment by [@jlowin](https://github.com/jlowin) in [#1328](https://github.com/PrefectHQ/fastmcp/pull/1328) * Update bug.yml by [@jlowin](https://github.com/jlowin) in [#1331](https://github.com/PrefectHQ/fastmcp/pull/1331) * Ensure validation errors are raised when masked by [@jlowin](https://github.com/jlowin) in [#1330](https://github.com/PrefectHQ/fastmcp/pull/1330) ## New Contributors * [@mariotaddeucci](https://github.com/mariotaddeucci) made their first contribution in [#1194](https://github.com/PrefectHQ/fastmcp/pull/1194) * [@algirdasci](https://github.com/algirdasci) made their first contribution in [#1208](https://github.com/PrefectHQ/fastmcp/pull/1208) * [@chughtapan](https://github.com/chughtapan) made their first contribution in [#1222](https://github.com/PrefectHQ/fastmcp/pull/1222) * [@mukulmurthy](https://github.com/mukulmurthy) made their first contribution in [#1160](https://github.com/PrefectHQ/fastmcp/pull/1160) * [@orweis](https://github.com/orweis) made their first contribution in [#1226](https://github.com/PrefectHQ/fastmcp/pull/1226) * [@Sourav-Tripathy](https://github.com/Sourav-Tripathy) made their first contribution in [#1229](https://github.com/PrefectHQ/fastmcp/pull/1229) * [@adam-conway](https://github.com/adam-conway) made their first contribution in [#1239](https://github.com/PrefectHQ/fastmcp/pull/1239) * [@muhammadkhalid-03](https://github.com/muhammadkhalid-03) made their first contribution in [#1257](https://github.com/PrefectHQ/fastmcp/pull/1257) * [@jbn](https://github.com/jbn) made their first contribution in [#1278](https://github.com/PrefectHQ/fastmcp/pull/1278) * [@dacamposol](https://github.com/dacamposol) made their first contribution in [#1287](https://github.com/PrefectHQ/fastmcp/pull/1287) * [@chi2liu](https://github.com/chi2liu) made their first contribution in [#1322](https://github.com/PrefectHQ/fastmcp/pull/1322) * [@cjermain](https://github.com/cjermain) made their first contribution in [#1326](https://github.com/PrefectHQ/fastmcp/pull/1326) **Full Changelog**: [v2.10.6...v2.11.0](https://github.com/PrefectHQ/fastmcp/compare/v2.10.6...v2.11.0) ## [v2.10.6: Hymn for the Weekend](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.6) A special Saturday release with many fixes. ## What's Changed ### Enhancements 🔧 * Resolve #1139 -- Implement include_context argument in Context.sample by [@codingjoe](https://github.com/codingjoe) in [#1141](https://github.com/PrefectHQ/fastmcp/pull/1141) * feat(settings): add log level normalization by [@ka2048](https://github.com/ka2048) in [#1171](https://github.com/PrefectHQ/fastmcp/pull/1171) * add server name to mounted server warnings by [@artificial-aidan](https://github.com/artificial-aidan) in [#1147](https://github.com/PrefectHQ/fastmcp/pull/1147) * Add StatefulProxyClient by [@hopeful0](https://github.com/hopeful0) in [#1109](https://github.com/PrefectHQ/fastmcp/pull/1109) ### Fixes 🐞 * Fix OpenAPI empty parameters by [@FabrizioSandri](https://github.com/FabrizioSandri) in [#1128](https://github.com/PrefectHQ/fastmcp/pull/1128) * Fix title field preservation in tool transformations by [@jlowin](https://github.com/jlowin) in [#1131](https://github.com/PrefectHQ/fastmcp/pull/1131) * Fix optional parameter validation in OpenAPI integration by [@jlowin](https://github.com/jlowin) in [#1135](https://github.com/PrefectHQ/fastmcp/pull/1135) * Do not silently exclude the "context" key from JSON body by [@melkamar](https://github.com/melkamar) in [#1153](https://github.com/PrefectHQ/fastmcp/pull/1153) * Fix tool output schema generation to respect Pydantic serialization aliases by [@zzstoatzz](https://github.com/zzstoatzz) in [#1148](https://github.com/PrefectHQ/fastmcp/pull/1148) * fix: _replace_ref_with_defs; ensure ref_path is string by [@itaru2622](https://github.com/itaru2622) in [#1164](https://github.com/PrefectHQ/fastmcp/pull/1164) * Fix nesting when making OpenAPI arrays and objects optional by [@melkamar](https://github.com/melkamar) in [#1178](https://github.com/PrefectHQ/fastmcp/pull/1178) * Fix `mcp-json` output format to include server name by [@jlowin](https://github.com/jlowin) in [#1185](https://github.com/PrefectHQ/fastmcp/pull/1185) * Only configure logging one time by [@jlowin](https://github.com/jlowin) in [#1187](https://github.com/PrefectHQ/fastmcp/pull/1187) ### Docs 📚 * Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1127](https://github.com/PrefectHQ/fastmcp/pull/1127) * Eunomia Authorization with native FastMCP's Middleware by [@tommitt](https://github.com/tommitt) in [#1144](https://github.com/PrefectHQ/fastmcp/pull/1144) * update api ref for new `mdxify` version by [@zzstoatzz](https://github.com/zzstoatzz) in [#1182](https://github.com/PrefectHQ/fastmcp/pull/1182) ### Other Changes 🦾 * Expand empty parameter filtering and add comprehensive tests by [@jlowin](https://github.com/jlowin) in [#1129](https://github.com/PrefectHQ/fastmcp/pull/1129) * Add no-commit-to-branch hook by [@zzstoatzz](https://github.com/zzstoatzz) in [#1149](https://github.com/PrefectHQ/fastmcp/pull/1149) * Update README.md by [@jlowin](https://github.com/jlowin) in [#1165](https://github.com/PrefectHQ/fastmcp/pull/1165) * skip on rate limit by [@zzstoatzz](https://github.com/zzstoatzz) in [#1183](https://github.com/PrefectHQ/fastmcp/pull/1183) * Remove deprecated proxy creation by [@jlowin](https://github.com/jlowin) in [#1186](https://github.com/PrefectHQ/fastmcp/pull/1186) * Separate integration tests from unit tests in CI by [@jlowin](https://github.com/jlowin) in [#1188](https://github.com/PrefectHQ/fastmcp/pull/1188) ## New Contributors * [@FabrizioSandri](https://github.com/FabrizioSandri) made their first contribution in [#1128](https://github.com/PrefectHQ/fastmcp/pull/1128) * [@melkamar](https://github.com/melkamar) made their first contribution in [#1153](https://github.com/PrefectHQ/fastmcp/pull/1153) * [@codingjoe](https://github.com/codingjoe) made their first contribution in [#1141](https://github.com/PrefectHQ/fastmcp/pull/1141) * [@itaru2622](https://github.com/itaru2622) made their first contribution in [#1164](https://github.com/PrefectHQ/fastmcp/pull/1164) * [@ka2048](https://github.com/ka2048) made their first contribution in [#1171](https://github.com/PrefectHQ/fastmcp/pull/1171) * [@artificial-aidan](https://github.com/artificial-aidan) made their first contribution in [#1147](https://github.com/PrefectHQ/fastmcp/pull/1147) **Full Changelog**: [v2.10.5...v2.10.6](https://github.com/PrefectHQ/fastmcp/compare/v2.10.5...v2.10.6) ## [v2.10.5: Middle Management](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.5) A maintenance release focused on OpenAPI refinements and middleware fixes, plus console improvements. ## What's Changed ### Enhancements 🔧 * Fix Claude Code CLI detection for npm global installations by [@jlowin](https://github.com/jlowin) in [#1106](https://github.com/PrefectHQ/fastmcp/pull/1106) * Fix OpenAPI parameter name collisions with location suffixing by [@jlowin](https://github.com/jlowin) in [#1107](https://github.com/PrefectHQ/fastmcp/pull/1107) * Add mirrored component support for proxy servers by [@jlowin](https://github.com/jlowin) in [#1105](https://github.com/PrefectHQ/fastmcp/pull/1105) ### Fixes 🐞 * Fix OpenAPI deepObject style parameter encoding by [@jlowin](https://github.com/jlowin) in [#1122](https://github.com/PrefectHQ/fastmcp/pull/1122) * xfail when github token is not set ('' or None) by [@jlowin](https://github.com/jlowin) in [#1123](https://github.com/PrefectHQ/fastmcp/pull/1123) * fix: replace oneOf with anyOf in OpenAPI output schemas by [@MagnusS0](https://github.com/MagnusS0) in [#1119](https://github.com/PrefectHQ/fastmcp/pull/1119) * Fix middleware list result types by [@jlowin](https://github.com/jlowin) in [#1125](https://github.com/PrefectHQ/fastmcp/pull/1125) * Improve console width for logo by [@jlowin](https://github.com/jlowin) in [#1126](https://github.com/PrefectHQ/fastmcp/pull/1126) ### Docs 📚 * Improve transport + integration docs by [@jlowin](https://github.com/jlowin) in [#1103](https://github.com/PrefectHQ/fastmcp/pull/1103) * Update proxy.mdx by [@coldfire-x](https://github.com/coldfire-x) in [#1108](https://github.com/PrefectHQ/fastmcp/pull/1108) ### Other Changes 🦾 * Update github remote server tests with secret by [@jlowin](https://github.com/jlowin) in [#1112](https://github.com/PrefectHQ/fastmcp/pull/1112) ## New Contributors * [@coldfire-x](https://github.com/coldfire-x) made their first contribution in [#1108](https://github.com/PrefectHQ/fastmcp/pull/1108) * [@MagnusS0](https://github.com/MagnusS0) made their first contribution in [#1119](https://github.com/PrefectHQ/fastmcp/pull/1119) **Full Changelog**: [v2.10.4...v2.10.5](https://github.com/PrefectHQ/fastmcp/compare/v2.10.4...v2.10.5) ## [v2.10.4: Transport-ation](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.4) A quick fix to ensure the CLI accepts "streamable-http" as a valid transport option. ## What's Changed ### Fixes 🐞 * Ensure the CLI accepts "streamable-http" as a valid transport by [@jlowin](https://github.com/jlowin) in [#1099](https://github.com/PrefectHQ/fastmcp/pull/1099) **Full Changelog**: [v2.10.3...v2.10.4](https://github.com/PrefectHQ/fastmcp/compare/v2.10.3...v2.10.4) ## [v2.10.3: CLI Me a River](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.3) A major CLI overhaul featuring a complete refactor from typer to cyclopts, new IDE integrations, and comprehensive OpenAPI improvements. ## What's Changed ### New Features 🎉 * Refactor CLI from typer to cyclopts and add comprehensive tests by [@jlowin](https://github.com/jlowin) in [#1062](https://github.com/PrefectHQ/fastmcp/pull/1062) * Add output schema support for OpenAPI tools by [@jlowin](https://github.com/jlowin) in [#1073](https://github.com/PrefectHQ/fastmcp/pull/1073) ### Enhancements 🔧 * Add Cursor support via CLI integration by [@jlowin](https://github.com/jlowin) in [#1052](https://github.com/PrefectHQ/fastmcp/pull/1052) * Add Claude Code install integration by [@jlowin](https://github.com/jlowin) in [#1053](https://github.com/PrefectHQ/fastmcp/pull/1053) * Generate MCP JSON config output from CLI as new `fastmcp install` command by [@jlowin](https://github.com/jlowin) in [#1056](https://github.com/PrefectHQ/fastmcp/pull/1056) * Use isawaitable instead of iscoroutine by [@jlowin](https://github.com/jlowin) in [#1059](https://github.com/PrefectHQ/fastmcp/pull/1059) * feat: Add `--path` Option to CLI for HTTP/SSE Route by [@davidbk-legit](https://github.com/davidbk-legit) in [#1087](https://github.com/PrefectHQ/fastmcp/pull/1087) * Fix concurrent proxy client operations with session isolation by [@jlowin](https://github.com/jlowin) in [#1083](https://github.com/PrefectHQ/fastmcp/pull/1083) ### Fixes 🐞 * Refactor Client context management to avoid concurrency issue by [@hopeful0](https://github.com/hopeful0) in [#1054](https://github.com/PrefectHQ/fastmcp/pull/1054) * Keep json schema $defs on transform by [@strawgate](https://github.com/strawgate) in [#1066](https://github.com/PrefectHQ/fastmcp/pull/1066) * Ensure fastmcp version copy is plaintext by [@jlowin](https://github.com/jlowin) in [#1071](https://github.com/PrefectHQ/fastmcp/pull/1071) * Fix single-element list unwrapping in tool content by [@jlowin](https://github.com/jlowin) in [#1074](https://github.com/PrefectHQ/fastmcp/pull/1074) * Fix max recursion error when pruning OpenAPI definitions by [@dimitribarbot](https://github.com/dimitribarbot) in [#1092](https://github.com/PrefectHQ/fastmcp/pull/1092) * Fix OpenAPI tool name registration when modified by mcp_component_fn by [@jlowin](https://github.com/jlowin) in [#1096](https://github.com/PrefectHQ/fastmcp/pull/1096) ### Docs 📚 * Docs: add example of more concise way to use bearer auth by [@neilconway](https://github.com/neilconway) in [#1055](https://github.com/PrefectHQ/fastmcp/pull/1055) * Update favicon by [@jlowin](https://github.com/jlowin) in [#1058](https://github.com/PrefectHQ/fastmcp/pull/1058) * Update environment note by [@jlowin](https://github.com/jlowin) in [#1075](https://github.com/PrefectHQ/fastmcp/pull/1075) * Add fastmcp version --copy documentation by [@jlowin](https://github.com/jlowin) in [#1076](https://github.com/PrefectHQ/fastmcp/pull/1076) ### Other Changes 🦾 * Remove asserts and add documentation following #1054 by [@jlowin](https://github.com/jlowin) in [#1057](https://github.com/PrefectHQ/fastmcp/pull/1057) * Add --copy flag for fastmcp version by [@jlowin](https://github.com/jlowin) in [#1063](https://github.com/PrefectHQ/fastmcp/pull/1063) * Fix docstring format for fastmcp.client.Client by [@neilconway](https://github.com/neilconway) in [#1094](https://github.com/PrefectHQ/fastmcp/pull/1094) ## New Contributors * [@neilconway](https://github.com/neilconway) made their first contribution in [#1055](https://github.com/PrefectHQ/fastmcp/pull/1055) * [@davidbk-legit](https://github.com/davidbk-legit) made their first contribution in [#1087](https://github.com/PrefectHQ/fastmcp/pull/1087) * [@dimitribarbot](https://github.com/dimitribarbot) made their first contribution in [#1092](https://github.com/PrefectHQ/fastmcp/pull/1092) **Full Changelog**: [v2.10.2...v2.10.3](https://github.com/PrefectHQ/fastmcp/compare/v2.10.2...v2.10.3) ## [v2.10.2: Forward March](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.2) The headline feature of this release is the ability to "forward" advanced MCP interactions like logging, progress, and elicitation through proxy servers. If the remote server requests an elicitation, the proxy client will pass that request to the new, "ultimate" client. ## What's Changed ### New Features 🎉 * Proxy support advanced MCP features by [@hopeful0](https://github.com/hopeful0) in [#1022](https://github.com/PrefectHQ/fastmcp/pull/1022) ### Enhancements 🔧 * Re-add splash screen by [@jlowin](https://github.com/jlowin) in [#1027](https://github.com/PrefectHQ/fastmcp/pull/1027) * Reduce banner padding by [@jlowin](https://github.com/jlowin) in [#1030](https://github.com/PrefectHQ/fastmcp/pull/1030) * Allow per-server timeouts in MCPConfig by [@cegersdoerfer](https://github.com/cegersdoerfer) in [#1031](https://github.com/PrefectHQ/fastmcp/pull/1031) * Support 'scp' claim for OAuth scopes in BearerAuthProvider by [@jlowin](https://github.com/jlowin) in [#1033](https://github.com/PrefectHQ/fastmcp/pull/1033) * Add path expansion to image/audio/file by [@jlowin](https://github.com/jlowin) in [#1038](https://github.com/PrefectHQ/fastmcp/pull/1038) * Ensure multi-client configurations use new ProxyClient by [@jlowin](https://github.com/jlowin) in [#1045](https://github.com/PrefectHQ/fastmcp/pull/1045) ### Fixes 🐞 * Expose stateless_http kwarg for mcp.run() by [@jlowin](https://github.com/jlowin) in [#1018](https://github.com/PrefectHQ/fastmcp/pull/1018) * Avoid propagating logs by [@jlowin](https://github.com/jlowin) in [#1042](https://github.com/PrefectHQ/fastmcp/pull/1042) ### Docs 📚 * Clean up docs by [@jlowin](https://github.com/jlowin) in [#1028](https://github.com/PrefectHQ/fastmcp/pull/1028) * Docs: clarify server URL paths for ChatGPT integration by [@thap2331](https://github.com/thap2331) in [#1017](https://github.com/PrefectHQ/fastmcp/pull/1017) ### Other Changes 🦾 * Split giant openapi test file into smaller files by [@jlowin](https://github.com/jlowin) in [#1034](https://github.com/PrefectHQ/fastmcp/pull/1034) * Add comprehensive OpenAPI 3.0 vs 3.1 compatibility tests by [@jlowin](https://github.com/jlowin) in [#1035](https://github.com/PrefectHQ/fastmcp/pull/1035) * Update banner and use console.log by [@jlowin](https://github.com/jlowin) in [#1041](https://github.com/PrefectHQ/fastmcp/pull/1041) ## New Contributors * [@cegersdoerfer](https://github.com/cegersdoerfer) made their first contribution in [#1031](https://github.com/PrefectHQ/fastmcp/pull/1031) * [@hopeful0](https://github.com/hopeful0) made their first contribution in [#1022](https://github.com/PrefectHQ/fastmcp/pull/1022) * [@thap2331](https://github.com/thap2331) made their first contribution in [#1017](https://github.com/PrefectHQ/fastmcp/pull/1017) **Full Changelog**: [v2.10.1...v2.10.2](https://github.com/PrefectHQ/fastmcp/compare/v2.10.1...v2.10.2) ## [v2.10.1: Revert to Sender](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.1) A quick patch to revert the CLI banner that was added in v2.10.0. ## What's Changed ### Docs 📚 * Update changelog.mdx by [@jlowin](https://github.com/jlowin) in [#1009](https://github.com/PrefectHQ/fastmcp/pull/1009) * Revert "Add CLI banner" by [@jlowin](https://github.com/jlowin) in [#1011](https://github.com/PrefectHQ/fastmcp/pull/1011) **Full Changelog**: [v2.10.0...v2.10.1](https://github.com/PrefectHQ/fastmcp/compare/v2.10.0...v2.10.1) ## [v2.10.0: Great Spec-tations](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.10.0) FastMCP 2.10 brings full compliance with the 6/18/2025 MCP spec update, introducing elicitation support for dynamic server-client communication and output schemas for structured tool responses. Please note that due to these changes, this release also includes a breaking change to the return signature of `client.call_tool()`. ### Elicitation Support Elicitation allows MCP servers to request additional information from clients during tool execution, enabling more interactive and dynamic server behavior. This opens up new possibilities for tools that need user input or confirmation during execution. ### Output Schemas Tools can now define structured output schemas, ensuring that responses conform to expected formats and making tool integration more predictable and type-safe. ## What's Changed ### New Features 🎉 * MCP 6/18/25: Add output schema to tools by [@jlowin](https://github.com/jlowin) in [#901](https://github.com/PrefectHQ/fastmcp/pull/901) * MCP 6/18/25: Elicitation support by [@jlowin](https://github.com/jlowin) in [#889](https://github.com/PrefectHQ/fastmcp/pull/889) ### Enhancements 🔧 * Update types + tests for SDK changes by [@jlowin](https://github.com/jlowin) in [#888](https://github.com/PrefectHQ/fastmcp/pull/888) * MCP 6/18/25: Update auth primitives by [@jlowin](https://github.com/jlowin) in [#966](https://github.com/PrefectHQ/fastmcp/pull/966) * Add OpenAPI extensions support to HTTPRoute by [@maddymanu](https://github.com/maddymanu) in [#977](https://github.com/PrefectHQ/fastmcp/pull/977) * Add title field support to FastMCP components by [@jlowin](https://github.com/jlowin) in [#982](https://github.com/PrefectHQ/fastmcp/pull/982) * Support implicit Elicitation acceptance by [@jlowin](https://github.com/jlowin) in [#983](https://github.com/PrefectHQ/fastmcp/pull/983) * Support 'no response' elicitation requests by [@jlowin](https://github.com/jlowin) in [#992](https://github.com/PrefectHQ/fastmcp/pull/992) * Add Support for Configurable Algorithms by [@sstene1](https://github.com/sstene1) in [#997](https://github.com/PrefectHQ/fastmcp/pull/997) ### Fixes 🐞 * Improve stdio error handling to raise connection failures immediately by [@jlowin](https://github.com/jlowin) in [#984](https://github.com/PrefectHQ/fastmcp/pull/984) * Fix type hints for FunctionResource:fn by [@CfirTsabari](https://github.com/CfirTsabari) in [#986](https://github.com/PrefectHQ/fastmcp/pull/986) * Update link to OpenAI MCP example by [@mossbanay](https://github.com/mossbanay) in [#985](https://github.com/PrefectHQ/fastmcp/pull/985) * Fix output schema generation edge case by [@jlowin](https://github.com/jlowin) in [#995](https://github.com/PrefectHQ/fastmcp/pull/995) * Refactor array parameter formatting to reduce code duplication by [@jlowin](https://github.com/jlowin) in [#1007](https://github.com/PrefectHQ/fastmcp/pull/1007) * Fix OpenAPI array parameter explode handling by [@jlowin](https://github.com/jlowin) in [#1008](https://github.com/PrefectHQ/fastmcp/pull/1008) ### Breaking Changes 🛫 * MCP 6/18/25: Upgrade to mcp 1.10 by [@jlowin](https://github.com/jlowin) in [#887](https://github.com/PrefectHQ/fastmcp/pull/887) ### Docs 📚 * Update middleware imports and documentation by [@jlowin](https://github.com/jlowin) in [#999](https://github.com/PrefectHQ/fastmcp/pull/999) * Update OpenAI docs by [@jlowin](https://github.com/jlowin) in [#1001](https://github.com/PrefectHQ/fastmcp/pull/1001) * Add CLI banner by [@jlowin](https://github.com/jlowin) in [#1005](https://github.com/PrefectHQ/fastmcp/pull/1005) ### Examples & Contrib 💡 * Component Manager by [@gorocode](https://github.com/gorocode) in [#976](https://github.com/PrefectHQ/fastmcp/pull/976) ### Other Changes 🦾 * Minor auth improvements by [@jlowin](https://github.com/jlowin) in [#967](https://github.com/PrefectHQ/fastmcp/pull/967) * Add .ccignore for copychat by [@jlowin](https://github.com/jlowin) in [#1000](https://github.com/PrefectHQ/fastmcp/pull/1000) ## New Contributors * [@maddymanu](https://github.com/maddymanu) made their first contribution in [#977](https://github.com/PrefectHQ/fastmcp/pull/977) * [@github0hello](https://github.com/github0hello) made their first contribution in [#979](https://github.com/PrefectHQ/fastmcp/pull/979) * [@tommitt](https://github.com/tommitt) made their first contribution in [#975](https://github.com/PrefectHQ/fastmcp/pull/975) * [@CfirTsabari](https://github.com/CfirTsabari) made their first contribution in [#986](https://github.com/PrefectHQ/fastmcp/pull/986) * [@mossbanay](https://github.com/mossbanay) made their first contribution in [#985](https://github.com/PrefectHQ/fastmcp/pull/985) * [@sstene1](https://github.com/sstene1) made their first contribution in [#997](https://github.com/PrefectHQ/fastmcp/pull/997) **Full Changelog**: [v2.9.2...v2.10.0](https://github.com/PrefectHQ/fastmcp/compare/v2.9.2...v2.10.0) ## [v2.9.2: Safety Pin](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.2) This is a patch release to pin `mcp` below 1.10, which includes changes related to the 6/18/2025 MCP spec update and could potentially break functionality for some FastMCP users. ## What's Changed ### Docs 📚 * Fix version badge for messages by [@jlowin](https://github.com/jlowin) in [#960](https://github.com/PrefectHQ/fastmcp/pull/960) ### Dependencies 📦 * Pin mcp dependency by [@jlowin](https://github.com/jlowin) in [#962](https://github.com/PrefectHQ/fastmcp/pull/962) **Full Changelog**: [v2.9.1...v2.9.2](https://github.com/PrefectHQ/fastmcp/compare/v2.9.1...v2.9.2) ## [v2.9.1: Call Me Maybe](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.1) FastMCP 2.9.1 introduces automatic MCP list change notifications, allowing servers to notify clients when tools, resources, or prompts are dynamically updated. This enables more responsive and adaptive MCP integrations. ## What's Changed ### New Features 🎉 * Add automatic MCP list change notifications and client message handling by [@jlowin](https://github.com/jlowin) in [#939](https://github.com/PrefectHQ/fastmcp/pull/939) ### Enhancements 🔧 * Add debug logging to bearer token authentication by [@jlowin](https://github.com/jlowin) in [#952](https://github.com/PrefectHQ/fastmcp/pull/952) ### Fixes 🐞 * Fix duplicate error logging in exception handlers by [@jlowin](https://github.com/jlowin) in [#938](https://github.com/PrefectHQ/fastmcp/pull/938) * Fix parameter location enum handling in OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#953](https://github.com/PrefectHQ/fastmcp/pull/953) * Fix external schema reference handling in OpenAPI parser by [@jlowin](https://github.com/jlowin) in [#954](https://github.com/PrefectHQ/fastmcp/pull/954) ### Docs 📚 * Update changelog for 2.9 release by [@jlowin](https://github.com/jlowin) in [#929](https://github.com/PrefectHQ/fastmcp/pull/929) * Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#935](https://github.com/PrefectHQ/fastmcp/pull/935) * Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#947](https://github.com/PrefectHQ/fastmcp/pull/947) * Regenerate API references by [@zzstoatzz](https://github.com/zzstoatzz) in [#949](https://github.com/PrefectHQ/fastmcp/pull/949) ### Examples & Contrib 💡 * Add `create_thread` tool to bsky MCP server by [@zzstoatzz](https://github.com/zzstoatzz) in [#927](https://github.com/PrefectHQ/fastmcp/pull/927) * Update `mount_example.py` to work with current fastmcp API by [@rajephon](https://github.com/rajephon) in [#957](https://github.com/PrefectHQ/fastmcp/pull/957) ## New Contributors * [@rajephon](https://github.com/rajephon) made their first contribution in [#957](https://github.com/PrefectHQ/fastmcp/pull/957) **Full Changelog**: [v2.9.0...v2.9.1](https://github.com/PrefectHQ/fastmcp/compare/v2.9.0...v2.9.1) ## [v2.9.0: Stuck in the Middleware With You](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.9.0) FastMCP 2.9 introduces two important features that push beyond the basic MCP protocol: MCP Middleware and server-side type conversion. ### MCP Middleware MCP middleware lets you intercept and modify requests and responses at the protocol level, giving you powerful capabilities for logging, authentication, validation, and more. This is particularly useful for building production-ready MCP servers that need sophisticated request handling. ### Server-side Type Conversion This release also introduces server-side type conversion for prompt arguments, ensuring that data is properly formatted before being passed to your functions. This reduces the burden on individual tools and prompts to handle type validation and conversion. ## What's Changed ### New Features 🎉 * Add File utility for binary data by [@gorocode](https://github.com/gorocode) in [#843](https://github.com/PrefectHQ/fastmcp/pull/843) * Consolidate prefix logic into FastMCP methods by [@jlowin](https://github.com/jlowin) in [#861](https://github.com/PrefectHQ/fastmcp/pull/861) * Add MCP Middleware by [@jlowin](https://github.com/jlowin) in [#870](https://github.com/PrefectHQ/fastmcp/pull/870) * Implement server-side type conversion for prompt arguments by [@jlowin](https://github.com/jlowin) in [#908](https://github.com/PrefectHQ/fastmcp/pull/908) ### Enhancements 🔧 * Fix tool description indentation issue by [@zfflxx](https://github.com/zfflxx) in [#845](https://github.com/PrefectHQ/fastmcp/pull/845) * Add version parameter to FastMCP constructor by [@mkyutani](https://github.com/mkyutani) in [#842](https://github.com/PrefectHQ/fastmcp/pull/842) * Update version to not be positional by [@jlowin](https://github.com/jlowin) in [#848](https://github.com/PrefectHQ/fastmcp/pull/848) * Add key to component by [@jlowin](https://github.com/jlowin) in [#869](https://github.com/PrefectHQ/fastmcp/pull/869) * Add session_id property to Context for data sharing by [@jlowin](https://github.com/jlowin) in [#881](https://github.com/PrefectHQ/fastmcp/pull/881) * Fix CORS documentation example by [@jlowin](https://github.com/jlowin) in [#895](https://github.com/PrefectHQ/fastmcp/pull/895) ### Fixes 🐞 * "report_progress missing passing related_request_id causes notifications not working" by [@alexsee](https://github.com/alexsee) in [#838](https://github.com/PrefectHQ/fastmcp/pull/838) * Fix JWT issuer validation to support string values per RFC 7519 by [@jlowin](https://github.com/jlowin) in [#892](https://github.com/PrefectHQ/fastmcp/pull/892) * Fix BearerAuthProvider audience type annotations by [@jlowin](https://github.com/jlowin) in [#894](https://github.com/PrefectHQ/fastmcp/pull/894) ### Docs 📚 * Add CLAUDE.md development guidelines by [@jlowin](https://github.com/jlowin) in [#880](https://github.com/PrefectHQ/fastmcp/pull/880) * Update context docs for session_id property by [@jlowin](https://github.com/jlowin) in [#882](https://github.com/PrefectHQ/fastmcp/pull/882) * Add API reference by [@zzstoatzz](https://github.com/zzstoatzz) in [#893](https://github.com/PrefectHQ/fastmcp/pull/893) * Fix API ref rendering by [@zzstoatzz](https://github.com/zzstoatzz) in [#900](https://github.com/PrefectHQ/fastmcp/pull/900) * Simplify docs nav by [@jlowin](https://github.com/jlowin) in [#902](https://github.com/PrefectHQ/fastmcp/pull/902) * Add fastmcp inspect command by [@jlowin](https://github.com/jlowin) in [#904](https://github.com/PrefectHQ/fastmcp/pull/904) * Update client docs by [@jlowin](https://github.com/jlowin) in [#912](https://github.com/PrefectHQ/fastmcp/pull/912) * Update docs nav by [@jlowin](https://github.com/jlowin) in [#913](https://github.com/PrefectHQ/fastmcp/pull/913) * Update integration documentation for Claude Desktop, ChatGPT, and Claude Code by [@jlowin](https://github.com/jlowin) in [#915](https://github.com/PrefectHQ/fastmcp/pull/915) * Add http as an alias for streamable http by [@jlowin](https://github.com/jlowin) in [#917](https://github.com/PrefectHQ/fastmcp/pull/917) * Clean up parameter documentation by [@jlowin](https://github.com/jlowin) in [#918](https://github.com/PrefectHQ/fastmcp/pull/918) * Add middleware examples for timing, logging, rate limiting, and error handling by [@jlowin](https://github.com/jlowin) in [#919](https://github.com/PrefectHQ/fastmcp/pull/919) * ControlFlow → FastMCP rename by [@jlowin](https://github.com/jlowin) in [#922](https://github.com/PrefectHQ/fastmcp/pull/922) ### Examples & Contrib 💡 * Add contrib.mcp_mixin support for annotations by [@rsp2k](https://github.com/rsp2k) in [#860](https://github.com/PrefectHQ/fastmcp/pull/860) * Add ATProto (Bluesky) MCP Server Example by [@zzstoatzz](https://github.com/zzstoatzz) in [#916](https://github.com/PrefectHQ/fastmcp/pull/916) * Fix path in atproto example pyproject by [@zzstoatzz](https://github.com/zzstoatzz) in [#920](https://github.com/PrefectHQ/fastmcp/pull/920) * Remove uv source in example by [@zzstoatzz](https://github.com/zzstoatzz) in [#921](https://github.com/PrefectHQ/fastmcp/pull/921) ## New Contributors * [@alexsee](https://github.com/alexsee) made their first contribution in [#838](https://github.com/PrefectHQ/fastmcp/pull/838) * [@zfflxx](https://github.com/zfflxx) made their first contribution in [#845](https://github.com/PrefectHQ/fastmcp/pull/845) * [@mkyutani](https://github.com/mkyutani) made their first contribution in [#842](https://github.com/PrefectHQ/fastmcp/pull/842) * [@gorocode](https://github.com/gorocode) made their first contribution in [#843](https://github.com/PrefectHQ/fastmcp/pull/843) * [@rsp2k](https://github.com/rsp2k) made their first contribution in [#860](https://github.com/PrefectHQ/fastmcp/pull/860) * [@owtaylor](https://github.com/owtaylor) made their first contribution in [#897](https://github.com/PrefectHQ/fastmcp/pull/897) * [@Jason-CKY](https://github.com/Jason-CKY) made their first contribution in [#906](https://github.com/PrefectHQ/fastmcp/pull/906) **Full Changelog**: [v2.8.1...v2.9.0](https://github.com/PrefectHQ/fastmcp/compare/v2.8.1...v2.9.0) ## [v2.8.1: Sound Judgement](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.8.1) 2.8.1 introduces audio support, as well as minor fixes and updates for deprecated features. ### Audio Support This release adds support for audio content in MCP tools and resources, expanding FastMCP's multimedia capabilities beyond text and images. ## What's Changed ### New Features 🎉 * Add audio support by [@jlowin](https://github.com/jlowin) in [#833](https://github.com/PrefectHQ/fastmcp/pull/833) ### Enhancements 🔧 * Add flag for disabling deprecation warnings by [@jlowin](https://github.com/jlowin) in [#802](https://github.com/PrefectHQ/fastmcp/pull/802) * Add examples to Tool Arg Param transformation by [@strawgate](https://github.com/strawgate) in [#806](https://github.com/PrefectHQ/fastmcp/pull/806) ### Fixes 🐞 * Restore .settings access as deprecated by [@jlowin](https://github.com/jlowin) in [#800](https://github.com/PrefectHQ/fastmcp/pull/800) * Ensure handling of false http kwargs correctly; removed unused kwarg by [@jlowin](https://github.com/jlowin) in [#804](https://github.com/PrefectHQ/fastmcp/pull/804) * Bump mcp 1.9.4 by [@jlowin](https://github.com/jlowin) in [#835](https://github.com/PrefectHQ/fastmcp/pull/835) ### Docs 📚 * Update changelog for 2.8.0 by [@jlowin](https://github.com/jlowin) in [#794](https://github.com/PrefectHQ/fastmcp/pull/794) * Update welcome docs by [@jlowin](https://github.com/jlowin) in [#808](https://github.com/PrefectHQ/fastmcp/pull/808) * Update headers in docs by [@jlowin](https://github.com/jlowin) in [#809](https://github.com/PrefectHQ/fastmcp/pull/809) * Add MCP group to tutorials by [@jlowin](https://github.com/jlowin) in [#810](https://github.com/PrefectHQ/fastmcp/pull/810) * Add Community section to documentation by [@zzstoatzz](https://github.com/zzstoatzz) in [#819](https://github.com/PrefectHQ/fastmcp/pull/819) * Add 2.8 update by [@jlowin](https://github.com/jlowin) in [#821](https://github.com/PrefectHQ/fastmcp/pull/821) * Embed YouTube videos in community showcase by [@zzstoatzz](https://github.com/zzstoatzz) in [#820](https://github.com/PrefectHQ/fastmcp/pull/820) ### Other Changes 🦾 * Ensure http args are passed through by [@jlowin](https://github.com/jlowin) in [#803](https://github.com/PrefectHQ/fastmcp/pull/803) * Fix install link in readme by [@jlowin](https://github.com/jlowin) in [#836](https://github.com/PrefectHQ/fastmcp/pull/836) **Full Changelog**: [v2.8.0...v2.8.1](https://github.com/PrefectHQ/fastmcp/compare/v2.8.0...v2.8.1) ## [v2.8.0: Transform and Roll Out](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.8.0) FastMCP 2.8.0 introduces powerful new ways to customize and control your MCP servers! ### Tool Transformation The highlight of this release is first-class [**Tool Transformation**](/patterns/tool-transformation), a new feature that lets you create enhanced variations of existing tools. You can now easily rename arguments, hide parameters, modify descriptions, and even wrap tools with custom validation or post-processing logic—all without rewriting the original code. This makes it easier than ever to adapt generic tools for specific LLM use cases or to simplify complex APIs. Huge thanks to [@strawgate](https://github.com/strawgate) for partnering on this, starting with [#591](https://github.com/PrefectHQ/fastmcp/discussions/591) and [#599](https://github.com/PrefectHQ/fastmcp/pull/599) and continuing offline. ### Component Control This release also gives you more granular control over which components are exposed to clients. With new [**tag-based filtering**](/servers/server#tag-based-filtering), you can selectively enable or disable tools, resources, and prompts based on tags, perfect for managing different environments or user permissions. Complementing this, every component now supports being [programmatically enabled or disabled](/servers/tools#disabling-tools), offering dynamic control over your server's capabilities. ### Tools-by-Default Finally, to improve compatibility with a wider range of LLM clients, this release changes the default behavior for OpenAPI integration: all API endpoints are now converted to `Tools` by default. This is a **breaking change** but pragmatically necessitated by the fact that the majority of MCP clients available today are, sadly, only compatible with MCP tools. Therefore, this change significantly simplifies the out-of-the-box experience and ensures your entire API is immediately accessible to any tool-using agent. ## What's Changed ### New Features 🎉 * First-class tool transformation by [@jlowin](https://github.com/jlowin) in [#745](https://github.com/PrefectHQ/fastmcp/pull/745) * Support enable/disable for all FastMCP components (tools, prompts, resources, templates) by [@jlowin](https://github.com/jlowin) in [#781](https://github.com/PrefectHQ/fastmcp/pull/781) * Add support for tag-based component filtering by [@jlowin](https://github.com/jlowin) in [#748](https://github.com/PrefectHQ/fastmcp/pull/748) * Allow tag assignments for OpenAPI by [@jlowin](https://github.com/jlowin) in [#791](https://github.com/PrefectHQ/fastmcp/pull/791) ### Enhancements 🔧 * Create common base class for components by [@jlowin](https://github.com/jlowin) in [#776](https://github.com/PrefectHQ/fastmcp/pull/776) * Move components to own file; add resource by [@jlowin](https://github.com/jlowin) in [#777](https://github.com/PrefectHQ/fastmcp/pull/777) * Update FastMCP component with __eq__ and __repr__ by [@jlowin](https://github.com/jlowin) in [#779](https://github.com/PrefectHQ/fastmcp/pull/779) * Remove open-ended and server-specific settings by [@jlowin](https://github.com/jlowin) in [#750](https://github.com/PrefectHQ/fastmcp/pull/750) ### Fixes 🐞 * Ensure client is only initialized once by [@jlowin](https://github.com/jlowin) in [#758](https://github.com/PrefectHQ/fastmcp/pull/758) * Fix field validator for resource by [@jlowin](https://github.com/jlowin) in [#778](https://github.com/PrefectHQ/fastmcp/pull/778) * Ensure proxies can overwrite remote tools without falling back to the remote by [@jlowin](https://github.com/jlowin) in [#782](https://github.com/PrefectHQ/fastmcp/pull/782) ### Breaking Changes 🛫 * Treat all openapi routes as tools by [@jlowin](https://github.com/jlowin) in [#788](https://github.com/PrefectHQ/fastmcp/pull/788) * Fix issue with global OpenAPI tags by [@jlowin](https://github.com/jlowin) in [#792](https://github.com/PrefectHQ/fastmcp/pull/792) ### Docs 📚 * Minor docs updates by [@jlowin](https://github.com/jlowin) in [#755](https://github.com/PrefectHQ/fastmcp/pull/755) * Add 2.7 update by [@jlowin](https://github.com/jlowin) in [#756](https://github.com/PrefectHQ/fastmcp/pull/756) * Reduce 2.7 image size by [@jlowin](https://github.com/jlowin) in [#757](https://github.com/PrefectHQ/fastmcp/pull/757) * Update updates.mdx by [@jlowin](https://github.com/jlowin) in [#765](https://github.com/PrefectHQ/fastmcp/pull/765) * Hide docs sidebar scrollbar by default by [@jlowin](https://github.com/jlowin) in [#766](https://github.com/PrefectHQ/fastmcp/pull/766) * Add "stop vibe testing" to tutorials by [@jlowin](https://github.com/jlowin) in [#767](https://github.com/PrefectHQ/fastmcp/pull/767) * Add docs links by [@jlowin](https://github.com/jlowin) in [#768](https://github.com/PrefectHQ/fastmcp/pull/768) * Fix: updated variable name under Gemini remote client by [@yrangana](https://github.com/yrangana) in [#769](https://github.com/PrefectHQ/fastmcp/pull/769) * Revert "Hide docs sidebar scrollbar by default" by [@jlowin](https://github.com/jlowin) in [#770](https://github.com/PrefectHQ/fastmcp/pull/770) * Add updates by [@jlowin](https://github.com/jlowin) in [#773](https://github.com/PrefectHQ/fastmcp/pull/773) * Add tutorials by [@jlowin](https://github.com/jlowin) in [#783](https://github.com/PrefectHQ/fastmcp/pull/783) * Update LLM-friendly docs by [@jlowin](https://github.com/jlowin) in [#784](https://github.com/PrefectHQ/fastmcp/pull/784) * Update oauth.mdx by [@JeremyCraigMartinez](https://github.com/JeremyCraigMartinez) in [#787](https://github.com/PrefectHQ/fastmcp/pull/787) * Add changelog by [@jlowin](https://github.com/jlowin) in [#789](https://github.com/PrefectHQ/fastmcp/pull/789) * Add tutorials by [@jlowin](https://github.com/jlowin) in [#790](https://github.com/PrefectHQ/fastmcp/pull/790) * Add docs for tag-based filtering by [@jlowin](https://github.com/jlowin) in [#793](https://github.com/PrefectHQ/fastmcp/pull/793) ### Other Changes 🦾 * Create dependabot.yml by [@jlowin](https://github.com/jlowin) in [#759](https://github.com/PrefectHQ/fastmcp/pull/759) * Bump astral-sh/setup-uv from 3 to 6 by [@dependabot](https://github.com/dependabot) in [#760](https://github.com/PrefectHQ/fastmcp/pull/760) * Add dependencies section to release by [@jlowin](https://github.com/jlowin) in [#761](https://github.com/PrefectHQ/fastmcp/pull/761) * Remove extra imports for MCPConfig by [@Maanas-Verma](https://github.com/Maanas-Verma) in [#763](https://github.com/PrefectHQ/fastmcp/pull/763) * Split out enhancements in release notes by [@jlowin](https://github.com/jlowin) in [#764](https://github.com/PrefectHQ/fastmcp/pull/764) ## New Contributors * [@dependabot](https://github.com/dependabot) made their first contribution in [#760](https://github.com/PrefectHQ/fastmcp/pull/760) * [@Maanas-Verma](https://github.com/Maanas-Verma) made their first contribution in [#763](https://github.com/PrefectHQ/fastmcp/pull/763) * [@JeremyCraigMartinez](https://github.com/JeremyCraigMartinez) made their first contribution in [#787](https://github.com/PrefectHQ/fastmcp/pull/787) **Full Changelog**: [v2.7.1...v2.8.0](https://github.com/PrefectHQ/fastmcp/compare/v2.7.1...v2.8.0) ## [v2.7.1: The Bearer Necessities](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.7.1) This release primarily contains a fix for parsing string tokens that are provided to FastMCP clients. ### New Features 🎉 * Respect cache setting, set default to 1 second by [@jlowin](https://github.com/jlowin) in [#747](https://github.com/PrefectHQ/fastmcp/pull/747) ### Fixes 🐞 * Ensure event store is properly typed by [@jlowin](https://github.com/jlowin) in [#753](https://github.com/PrefectHQ/fastmcp/pull/753) * Fix passing token string to client auth & add auth to MCPConfig clients by [@jlowin](https://github.com/jlowin) in [#754](https://github.com/PrefectHQ/fastmcp/pull/754) ### Docs 📚 * Docs : fix client to mcp\_client in Gemini example by [@yrangana](https://github.com/yrangana) in [#734](https://github.com/PrefectHQ/fastmcp/pull/734) * update add tool docstring by [@strawgate](https://github.com/strawgate) in [#739](https://github.com/PrefectHQ/fastmcp/pull/739) * Fix contrib link by [@richardkmichael](https://github.com/richardkmichael) in [#749](https://github.com/PrefectHQ/fastmcp/pull/749) ### Other Changes 🦾 * Switch Pydantic defaults to kwargs by [@strawgate](https://github.com/strawgate) in [#731](https://github.com/PrefectHQ/fastmcp/pull/731) * Fix Typo in CLI module by [@wfclark5](https://github.com/wfclark5) in [#737](https://github.com/PrefectHQ/fastmcp/pull/737) * chore: fix prompt docstring by [@danb27](https://github.com/danb27) in [#752](https://github.com/PrefectHQ/fastmcp/pull/752) * Add accept to excluded headers by [@jlowin](https://github.com/jlowin) in [#751](https://github.com/PrefectHQ/fastmcp/pull/751) ### New Contributors * [@wfclark5](https://github.com/wfclark5) made their first contribution in [#737](https://github.com/PrefectHQ/fastmcp/pull/737) * [@richardkmichael](https://github.com/richardkmichael) made their first contribution in [#749](https://github.com/PrefectHQ/fastmcp/pull/749) * [@danb27](https://github.com/danb27) made their first contribution in [#752](https://github.com/PrefectHQ/fastmcp/pull/752) **Full Changelog**: [v2.7.0...v2.7.1](https://github.com/PrefectHQ/fastmcp/compare/v2.7.0...v2.7.1) ## [v2.7.0: Pare Programming](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.7.0) This is primarily a housekeeping release to remove or deprecate cruft that's accumulated since v1. Primarily, this release refactors FastMCP's internals in preparation for features planned in the next few major releases. However please note that as a result, this release has some minor breaking changes (which is why it's 2.7, not 2.6.2, in accordance with repo guidelines) though not to the core user-facing APIs. ### Breaking Changes 🛫 * decorators return the objects they create, not the decorated function * websockets is an optional dependency * methods on the server for automatically converting functions into tools/resources/prompts have been deprecated in favor of using the decorators directly ### New Features 🎉 * allow passing flags to servers by [@zzstoatzz](https://github.com/zzstoatzz) in [#690](https://github.com/PrefectHQ/fastmcp/pull/690) * replace $ref pointing to `#/components/schemas/` with `#/$defs/` by [@phateffect](https://github.com/phateffect) in [#697](https://github.com/PrefectHQ/fastmcp/pull/697) * Split Tool into Tool and FunctionTool by [@jlowin](https://github.com/jlowin) in [#700](https://github.com/PrefectHQ/fastmcp/pull/700) * Use strict basemodel for Prompt; relax from\_function deprecation by [@jlowin](https://github.com/jlowin) in [#701](https://github.com/PrefectHQ/fastmcp/pull/701) * Formalize resource/functionresource replationship by [@jlowin](https://github.com/jlowin) in [#702](https://github.com/PrefectHQ/fastmcp/pull/702) * Formalize template/functiontemplate split by [@jlowin](https://github.com/jlowin) in [#703](https://github.com/PrefectHQ/fastmcp/pull/703) * Support flexible @tool decorator call patterns by [@jlowin](https://github.com/jlowin) in [#706](https://github.com/PrefectHQ/fastmcp/pull/706) * Ensure deprecation warnings have stacklevel=2 by [@jlowin](https://github.com/jlowin) in [#710](https://github.com/PrefectHQ/fastmcp/pull/710) * Allow naked prompt decorator by [@jlowin](https://github.com/jlowin) in [#711](https://github.com/PrefectHQ/fastmcp/pull/711) ### Fixes 🐞 * Updates / Fixes for Tool Content Conversion by [@strawgate](https://github.com/strawgate) in [#642](https://github.com/PrefectHQ/fastmcp/pull/642) * Fix pr labeler permissions by [@jlowin](https://github.com/jlowin) in [#708](https://github.com/PrefectHQ/fastmcp/pull/708) * remove -n auto by [@jlowin](https://github.com/jlowin) in [#709](https://github.com/PrefectHQ/fastmcp/pull/709) * Fix links in README.md by [@alainivars](https://github.com/alainivars) in [#723](https://github.com/PrefectHQ/fastmcp/pull/723) Happily, this release DOES permit the use of "naked" decorators to align with Pythonic practice: ```python @mcp.tool def my_tool(): ... ``` **Full Changelog**: [v2.6.2...v2.7.0](https://github.com/PrefectHQ/fastmcp/compare/v2.6.2...v2.7.0) ## [v2.6.1: Blast Auth (second ignition)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.6.1) This is a patch release to restore py.typed in #686. ### Docs 📚 * Update readme by [@jlowin](https://github.com/jlowin) in [#679](https://github.com/PrefectHQ/fastmcp/pull/679) * Add gemini tutorial by [@jlowin](https://github.com/jlowin) in [#680](https://github.com/PrefectHQ/fastmcp/pull/680) * Fix : fix path error to CLI Documentation by [@yrangana](https://github.com/yrangana) in [#684](https://github.com/PrefectHQ/fastmcp/pull/684) * Update auth docs by [@jlowin](https://github.com/jlowin) in [#687](https://github.com/PrefectHQ/fastmcp/pull/687) ### Other Changes 🦾 * Remove deprecation notice by [@jlowin](https://github.com/jlowin) in [#677](https://github.com/PrefectHQ/fastmcp/pull/677) * Delete server.py by [@jlowin](https://github.com/jlowin) in [#681](https://github.com/PrefectHQ/fastmcp/pull/681) * Restore py.typed by [@jlowin](https://github.com/jlowin) in [#686](https://github.com/PrefectHQ/fastmcp/pull/686) ### New Contributors * [@yrangana](https://github.com/yrangana) made their first contribution in [#684](https://github.com/PrefectHQ/fastmcp/pull/684) **Full Changelog**: [v2.6.0...v2.6.1](https://github.com/PrefectHQ/fastmcp/compare/v2.6.0...v2.6.1) ## [v2.6.0: Blast Auth](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.6.0) ### New Features 🎉 * Introduce MCP client oauth flow by [@jlowin](https://github.com/jlowin) in [#478](https://github.com/PrefectHQ/fastmcp/pull/478) * Support providing tools at init by [@jlowin](https://github.com/jlowin) in [#647](https://github.com/PrefectHQ/fastmcp/pull/647) * Simplify code for running servers in processes during tests by [@jlowin](https://github.com/jlowin) in [#649](https://github.com/PrefectHQ/fastmcp/pull/649) * Add basic bearer auth for server and client by [@jlowin](https://github.com/jlowin) in [#650](https://github.com/PrefectHQ/fastmcp/pull/650) * Support configuring bearer auth from env vars by [@jlowin](https://github.com/jlowin) in [#652](https://github.com/PrefectHQ/fastmcp/pull/652) * feat(tool): add support for excluding arguments from tool definition by [@deepak-stratforge](https://github.com/deepak-stratforge) in [#626](https://github.com/PrefectHQ/fastmcp/pull/626) * Add docs for server + client auth by [@jlowin](https://github.com/jlowin) in [#655](https://github.com/PrefectHQ/fastmcp/pull/655) ### Fixes 🐞 * fix: Support concurrency in FastMcpProxy (and Client) by [@Sillocan](https://github.com/Sillocan) in [#635](https://github.com/PrefectHQ/fastmcp/pull/635) * Ensure Client.close() cleans up client context appropriately by [@jlowin](https://github.com/jlowin) in [#643](https://github.com/PrefectHQ/fastmcp/pull/643) * Update client.mdx: ClientError namespace by [@mjkaye](https://github.com/mjkaye) in [#657](https://github.com/PrefectHQ/fastmcp/pull/657) ### Docs 📚 * Make FastMCPTransport support simulated Streamable HTTP Transport (didn't work) by [@jlowin](https://github.com/jlowin) in [#645](https://github.com/PrefectHQ/fastmcp/pull/645) * Document exclude\_args by [@jlowin](https://github.com/jlowin) in [#653](https://github.com/PrefectHQ/fastmcp/pull/653) * Update welcome by [@jlowin](https://github.com/jlowin) in [#673](https://github.com/PrefectHQ/fastmcp/pull/673) * Add Anthropic + Claude desktop integration guides by [@jlowin](https://github.com/jlowin) in [#674](https://github.com/PrefectHQ/fastmcp/pull/674) * Minor docs design updates by [@jlowin](https://github.com/jlowin) in [#676](https://github.com/PrefectHQ/fastmcp/pull/676) ### Other Changes 🦾 * Update test typing by [@jlowin](https://github.com/jlowin) in [#646](https://github.com/PrefectHQ/fastmcp/pull/646) * Add OpenAI integration docs by [@jlowin](https://github.com/jlowin) in [#660](https://github.com/PrefectHQ/fastmcp/pull/660) ### New Contributors * [@Sillocan](https://github.com/Sillocan) made their first contribution in [#635](https://github.com/PrefectHQ/fastmcp/pull/635) * [@deepak-stratforge](https://github.com/deepak-stratforge) made their first contribution in [#626](https://github.com/PrefectHQ/fastmcp/pull/626) * [@mjkaye](https://github.com/mjkaye) made their first contribution in [#657](https://github.com/PrefectHQ/fastmcp/pull/657) **Full Changelog**: [v2.5.2...v2.6.0](https://github.com/PrefectHQ/fastmcp/compare/v2.5.2...v2.6.0) ## [v2.5.2: Stayin' Alive](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.2) ### New Features 🎉 * Add graceful error handling for unreachable mounted servers by [@davenpi](https://github.com/davenpi) in [#605](https://github.com/PrefectHQ/fastmcp/pull/605) * Improve type inference from client transport by [@jlowin](https://github.com/jlowin) in [#623](https://github.com/PrefectHQ/fastmcp/pull/623) * Add keep\_alive param to reuse subprocess by [@jlowin](https://github.com/jlowin) in [#624](https://github.com/PrefectHQ/fastmcp/pull/624) ### Fixes 🐞 * Fix handling tools without descriptions by [@jlowin](https://github.com/jlowin) in [#610](https://github.com/PrefectHQ/fastmcp/pull/610) * Don't print env vars to console when format is wrong by [@jlowin](https://github.com/jlowin) in [#615](https://github.com/PrefectHQ/fastmcp/pull/615) * Ensure behavior-affecting headers are excluded when forwarding proxies/openapi by [@jlowin](https://github.com/jlowin) in [#620](https://github.com/PrefectHQ/fastmcp/pull/620) ### Docs 📚 * Add notes about uv and claude desktop by [@jlowin](https://github.com/jlowin) in [#597](https://github.com/PrefectHQ/fastmcp/pull/597) ### Other Changes 🦾 * add init\_timeout for mcp client by [@jfouret](https://github.com/jfouret) in [#607](https://github.com/PrefectHQ/fastmcp/pull/607) * Add init\_timeout for mcp client (incl settings) by [@jlowin](https://github.com/jlowin) in [#609](https://github.com/PrefectHQ/fastmcp/pull/609) * Support for uppercase letters at the log level by [@ksawaray](https://github.com/ksawaray) in [#625](https://github.com/PrefectHQ/fastmcp/pull/625) ### New Contributors * [@jfouret](https://github.com/jfouret) made their first contribution in [#607](https://github.com/PrefectHQ/fastmcp/pull/607) * [@ksawaray](https://github.com/ksawaray) made their first contribution in [#625](https://github.com/PrefectHQ/fastmcp/pull/625) **Full Changelog**: [v2.5.1...v2.5.2](https://github.com/PrefectHQ/fastmcp/compare/v2.5.1...v2.5.2) ## [v2.5.1: Route Awakening (Part 2)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.1) ### Fixes 🐞 * Ensure content-length is always stripped from client headers by [@jlowin](https://github.com/jlowin) in [#589](https://github.com/PrefectHQ/fastmcp/pull/589) ### Docs 📚 * Fix redundant section of docs by [@jlowin](https://github.com/jlowin) in [#583](https://github.com/PrefectHQ/fastmcp/pull/583) **Full Changelog**: [v2.5.0...v2.5.1](https://github.com/PrefectHQ/fastmcp/compare/v2.5.0...v2.5.1) ## [v2.5.0: Route Awakening](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.5.0) This release introduces completely new tools for generating and customizing MCP servers from OpenAPI specs and FastAPI apps, including popular requests like mechanisms for determining what routes map to what MCP components; renaming routes; and customizing the generated MCP components. ### New Features 🎉 * Add FastMCP 1.0 server support for in-memory Client / Testing by [@jlowin](https://github.com/jlowin) in [#539](https://github.com/PrefectHQ/fastmcp/pull/539) * Minor addition: add transport to stdio server in mcpconfig, with default by [@jlowin](https://github.com/jlowin) in [#555](https://github.com/PrefectHQ/fastmcp/pull/555) * Raise an error if a Client is created with no servers in config by [@jlowin](https://github.com/jlowin) in [#554](https://github.com/PrefectHQ/fastmcp/pull/554) * Expose model preferences in `Context.sample` for flexible model selection. by [@davenpi](https://github.com/davenpi) in [#542](https://github.com/PrefectHQ/fastmcp/pull/542) * Ensure custom routes are respected by [@jlowin](https://github.com/jlowin) in [#558](https://github.com/PrefectHQ/fastmcp/pull/558) * Add client method to send cancellation notifications by [@davenpi](https://github.com/davenpi) in [#563](https://github.com/PrefectHQ/fastmcp/pull/563) * Enhance route map logic for include/exclude OpenAPI routes by [@jlowin](https://github.com/jlowin) in [#564](https://github.com/PrefectHQ/fastmcp/pull/564) * Add tag-based route maps by [@jlowin](https://github.com/jlowin) in [#565](https://github.com/PrefectHQ/fastmcp/pull/565) * Add advanced control of openAPI route creation by [@jlowin](https://github.com/jlowin) in [#566](https://github.com/PrefectHQ/fastmcp/pull/566) * Make error masking configurable by [@jlowin](https://github.com/jlowin) in [#550](https://github.com/PrefectHQ/fastmcp/pull/550) * Ensure client headers are passed through to remote servers by [@jlowin](https://github.com/jlowin) in [#575](https://github.com/PrefectHQ/fastmcp/pull/575) * Use lowercase name for headers when comparing by [@jlowin](https://github.com/jlowin) in [#576](https://github.com/PrefectHQ/fastmcp/pull/576) * Permit more flexible name generation for OpenAPI servers by [@jlowin](https://github.com/jlowin) in [#578](https://github.com/PrefectHQ/fastmcp/pull/578) * Ensure that tools/templates/prompts are compatible with callable objects by [@jlowin](https://github.com/jlowin) in [#579](https://github.com/PrefectHQ/fastmcp/pull/579) ### Docs 📚 * Add version badge for prefix formats by [@jlowin](https://github.com/jlowin) in [#537](https://github.com/PrefectHQ/fastmcp/pull/537) * Add versioning note to docs by [@jlowin](https://github.com/jlowin) in [#551](https://github.com/PrefectHQ/fastmcp/pull/551) * Bump 2.3.6 references to 2.4.0 by [@jlowin](https://github.com/jlowin) in [#567](https://github.com/PrefectHQ/fastmcp/pull/567) **Full Changelog**: [v2.4.0...v2.5.0](https://github.com/PrefectHQ/fastmcp/compare/v2.4.0...v2.5.0) ## [v2.4.0: Config and Conquer](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.4.0) **Note**: this release includes a backwards-incompatible change to how resources are prefixed when mounted in composed servers. However, it is only backwards-incompatible if users were running tests or manually loading resources by prefixed key; LLMs should not have any issue discovering the new route. ### New Features 🎉 * Allow \* Methods and all routes as tools shortcuts by [@jlowin](https://github.com/jlowin) in [#520](https://github.com/PrefectHQ/fastmcp/pull/520) * Improved support for config dicts by [@jlowin](https://github.com/jlowin) in [#522](https://github.com/PrefectHQ/fastmcp/pull/522) * Support creating clients from MCP config dicts, including multi-server clients by [@jlowin](https://github.com/jlowin) in [#527](https://github.com/PrefectHQ/fastmcp/pull/527) * Make resource prefix format configurable by [@jlowin](https://github.com/jlowin) in [#534](https://github.com/PrefectHQ/fastmcp/pull/534) ### Fixes 🐞 * Avoid hanging on initializing server session by [@jlowin](https://github.com/jlowin) in [#523](https://github.com/PrefectHQ/fastmcp/pull/523) ### Breaking Changes 🛫 * Remove customizable separators; improve resource separator by [@jlowin](https://github.com/jlowin) in [#526](https://github.com/PrefectHQ/fastmcp/pull/526) ### Docs 📚 * Improve client documentation by [@jlowin](https://github.com/jlowin) in [#517](https://github.com/PrefectHQ/fastmcp/pull/517) ### Other Changes 🦾 * Ensure openapi path params are handled properly by [@jlowin](https://github.com/jlowin) in [#519](https://github.com/PrefectHQ/fastmcp/pull/519) * better error when missing lifespan by [@zzstoatzz](https://github.com/zzstoatzz) in [#521](https://github.com/PrefectHQ/fastmcp/pull/521) **Full Changelog**: [v2.3.5...v2.4.0](https://github.com/PrefectHQ/fastmcp/compare/v2.3.5...v2.4.0) ## [v2.3.5: Making Progress](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.5) ### New Features 🎉 * support messages in progress notifications by [@rickygenhealth](https://github.com/rickygenhealth) in [#471](https://github.com/PrefectHQ/fastmcp/pull/471) * feat: Add middleware option in server.run by [@Maxi91f](https://github.com/Maxi91f) in [#475](https://github.com/PrefectHQ/fastmcp/pull/475) * Add lifespan property to app by [@jlowin](https://github.com/jlowin) in [#483](https://github.com/PrefectHQ/fastmcp/pull/483) * Update `fastmcp run` to work with remote servers by [@jlowin](https://github.com/jlowin) in [#491](https://github.com/PrefectHQ/fastmcp/pull/491) * Add FastMCP.as\_proxy() by [@jlowin](https://github.com/jlowin) in [#490](https://github.com/PrefectHQ/fastmcp/pull/490) * Infer sse transport from urls containing /sse by [@jlowin](https://github.com/jlowin) in [#512](https://github.com/PrefectHQ/fastmcp/pull/512) * Add progress handler to client by [@jlowin](https://github.com/jlowin) in [#513](https://github.com/PrefectHQ/fastmcp/pull/513) * Store the initialize result on the client by [@jlowin](https://github.com/jlowin) in [#509](https://github.com/PrefectHQ/fastmcp/pull/509) ### Fixes 🐞 * Remove patch and use upstream SSEServerTransport by [@jlowin](https://github.com/jlowin) in [#425](https://github.com/PrefectHQ/fastmcp/pull/425) ### Docs 📚 * Update transport docs by [@jlowin](https://github.com/jlowin) in [#458](https://github.com/PrefectHQ/fastmcp/pull/458) * update proxy docs + example by [@zzstoatzz](https://github.com/zzstoatzz) in [#460](https://github.com/PrefectHQ/fastmcp/pull/460) * doc(asgi): Change custom route example to PlainTextResponse by [@mcw0933](https://github.com/mcw0933) in [#477](https://github.com/PrefectHQ/fastmcp/pull/477) * Store FastMCP instance on app.state.fastmcp\_server by [@jlowin](https://github.com/jlowin) in [#489](https://github.com/PrefectHQ/fastmcp/pull/489) * Improve AGENTS.md overview by [@jlowin](https://github.com/jlowin) in [#492](https://github.com/PrefectHQ/fastmcp/pull/492) * Update release numbers for anticipated version by [@jlowin](https://github.com/jlowin) in [#516](https://github.com/PrefectHQ/fastmcp/pull/516) ### Other Changes 🦾 * run tests on all PRs by [@jlowin](https://github.com/jlowin) in [#468](https://github.com/PrefectHQ/fastmcp/pull/468) * add null check by [@zzstoatzz](https://github.com/zzstoatzz) in [#473](https://github.com/PrefectHQ/fastmcp/pull/473) * strict typing for `server.py` by [@zzstoatzz](https://github.com/zzstoatzz) in [#476](https://github.com/PrefectHQ/fastmcp/pull/476) * Doc(quickstart): Fix import statements by [@mai-nakagawa](https://github.com/mai-nakagawa) in [#479](https://github.com/PrefectHQ/fastmcp/pull/479) * Add labeler by [@jlowin](https://github.com/jlowin) in [#484](https://github.com/PrefectHQ/fastmcp/pull/484) * Fix flaky timeout test by increasing timeout (#474) by [@davenpi](https://github.com/davenpi) in [#486](https://github.com/PrefectHQ/fastmcp/pull/486) * Skipping `test_permission_error` if runner is root. by [@ZiadAmerr](https://github.com/ZiadAmerr) in [#502](https://github.com/PrefectHQ/fastmcp/pull/502) * allow passing full uvicorn config by [@zzstoatzz](https://github.com/zzstoatzz) in [#504](https://github.com/PrefectHQ/fastmcp/pull/504) * Skip timeout tests on windows by [@jlowin](https://github.com/jlowin) in [#514](https://github.com/PrefectHQ/fastmcp/pull/514) ### New Contributors * [@rickygenhealth](https://github.com/rickygenhealth) made their first contribution in [#471](https://github.com/PrefectHQ/fastmcp/pull/471) * [@Maxi91f](https://github.com/Maxi91f) made their first contribution in [#475](https://github.com/PrefectHQ/fastmcp/pull/475) * [@mcw0933](https://github.com/mcw0933) made their first contribution in [#477](https://github.com/PrefectHQ/fastmcp/pull/477) * [@mai-nakagawa](https://github.com/mai-nakagawa) made their first contribution in [#479](https://github.com/PrefectHQ/fastmcp/pull/479) * [@ZiadAmerr](https://github.com/ZiadAmerr) made their first contribution in [#502](https://github.com/PrefectHQ/fastmcp/pull/502) **Full Changelog**: [v2.3.4...v2.3.5](https://github.com/PrefectHQ/fastmcp/compare/v2.3.4...v2.3.5) ## [v2.3.4: Error Today, Gone Tomorrow](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.4) ### New Features 🎉 * logging stack trace for easier debugging by [@jbkoh](https://github.com/jbkoh) in [#413](https://github.com/PrefectHQ/fastmcp/pull/413) * add missing StreamableHttpTransport in client exports by [@yihuang](https://github.com/yihuang) in [#408](https://github.com/PrefectHQ/fastmcp/pull/408) * Improve error handling for tools and resources by [@jlowin](https://github.com/jlowin) in [#434](https://github.com/PrefectHQ/fastmcp/pull/434) * feat: add support for removing tools from server by [@davenpi](https://github.com/davenpi) in [#437](https://github.com/PrefectHQ/fastmcp/pull/437) * Prune titles from JSONSchemas by [@jlowin](https://github.com/jlowin) in [#449](https://github.com/PrefectHQ/fastmcp/pull/449) * Declare toolsChanged capability for stdio server. by [@davenpi](https://github.com/davenpi) in [#450](https://github.com/PrefectHQ/fastmcp/pull/450) * Improve handling of exceptiongroups when raised in clients by [@jlowin](https://github.com/jlowin) in [#452](https://github.com/PrefectHQ/fastmcp/pull/452) * Add timeout support to client by [@jlowin](https://github.com/jlowin) in [#455](https://github.com/PrefectHQ/fastmcp/pull/455) ### Fixes 🐞 * Pin to mcp 1.8.1 to resolve callback deadlocks with SHTTP by [@jlowin](https://github.com/jlowin) in [#427](https://github.com/PrefectHQ/fastmcp/pull/427) * Add reprs for OpenAPI objects by [@jlowin](https://github.com/jlowin) in [#447](https://github.com/PrefectHQ/fastmcp/pull/447) * Ensure openapi defs for structured objects are loaded properly by [@jlowin](https://github.com/jlowin) in [#448](https://github.com/PrefectHQ/fastmcp/pull/448) * Ensure tests run against correct python version by [@jlowin](https://github.com/jlowin) in [#454](https://github.com/PrefectHQ/fastmcp/pull/454) * Ensure result is only returned if a new key was found by [@jlowin](https://github.com/jlowin) in [#456](https://github.com/PrefectHQ/fastmcp/pull/456) ### Docs 📚 * Add documentation for tool removal by [@jlowin](https://github.com/jlowin) in [#440](https://github.com/PrefectHQ/fastmcp/pull/440) ### Other Changes 🦾 * Deprecate passing settings to the FastMCP instance by [@jlowin](https://github.com/jlowin) in [#424](https://github.com/PrefectHQ/fastmcp/pull/424) * Add path prefix to test by [@jlowin](https://github.com/jlowin) in [#432](https://github.com/PrefectHQ/fastmcp/pull/432) ### New Contributors * [@jbkoh](https://github.com/jbkoh) made their first contribution in [#413](https://github.com/PrefectHQ/fastmcp/pull/413) * [@davenpi](https://github.com/davenpi) made their first contribution in [#437](https://github.com/PrefectHQ/fastmcp/pull/437) **Full Changelog**: [v2.3.3...v2.3.4](https://github.com/PrefectHQ/fastmcp/compare/v2.3.3...v2.3.4) ## [v2.3.3: SSE you later](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.3) This is a hotfix for a bug introduced in 2.3.2 that broke SSE servers ### Fixes 🐞 * Fix bug that sets message path and sse path to same value by [@jlowin](https://github.com/jlowin) in [#405](https://github.com/PrefectHQ/fastmcp/pull/405) ### Docs 📚 * Update composition docs by [@jlowin](https://github.com/jlowin) in [#403](https://github.com/PrefectHQ/fastmcp/pull/403) ### Other Changes 🦾 * Add test for no prefix when importing by [@jlowin](https://github.com/jlowin) in [#404](https://github.com/PrefectHQ/fastmcp/pull/404) **Full Changelog**: [v2.3.2...v2.3.3](https://github.com/PrefectHQ/fastmcp/compare/v2.3.2...v2.3.3) ## [v2.3.2: Stuck in the Middleware With You](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.2) ### New Features 🎉 * Allow users to pass middleware to starlette app constructors by [@jlowin](https://github.com/jlowin) in [#398](https://github.com/PrefectHQ/fastmcp/pull/398) * Deprecate transport-specific methods on FastMCP server by [@jlowin](https://github.com/jlowin) in [#401](https://github.com/PrefectHQ/fastmcp/pull/401) ### Docs 📚 * Update CLI docs by [@jlowin](https://github.com/jlowin) in [#402](https://github.com/PrefectHQ/fastmcp/pull/402) ### Other Changes 🦾 * Adding 23 tests for CLI by [@didier-durand](https://github.com/didier-durand) in [#394](https://github.com/PrefectHQ/fastmcp/pull/394) **Full Changelog**: [v2.3.1...v2.3.2](https://github.com/PrefectHQ/fastmcp/compare/v2.3.1...v2.3.2) ## [v2.3.1: For Good-nests Sake](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.1) This release primarily patches a long-standing bug with nested ASGI SSE servers. ### Fixes 🐞 * Fix tool result serialization when the tool returns a list by [@strawgate](https://github.com/strawgate) in [#379](https://github.com/PrefectHQ/fastmcp/pull/379) * Ensure FastMCP handles nested SSE and SHTTP apps properly in ASGI frameworks by [@jlowin](https://github.com/jlowin) in [#390](https://github.com/PrefectHQ/fastmcp/pull/390) ### Docs 📚 * Update transport docs by [@jlowin](https://github.com/jlowin) in [#377](https://github.com/PrefectHQ/fastmcp/pull/377) * Add llms.txt to docs by [@jlowin](https://github.com/jlowin) in [#384](https://github.com/PrefectHQ/fastmcp/pull/384) * Fixing various text typos by [@didier-durand](https://github.com/didier-durand) in [#385](https://github.com/PrefectHQ/fastmcp/pull/385) ### Other Changes 🦾 * Adding a few tests to Image type by [@didier-durand](https://github.com/didier-durand) in [#387](https://github.com/PrefectHQ/fastmcp/pull/387) * Adding tests for TimedCache by [@didier-durand](https://github.com/didier-durand) in [#388](https://github.com/PrefectHQ/fastmcp/pull/388) ### New Contributors * [@didier-durand](https://github.com/didier-durand) made their first contribution in [#385](https://github.com/PrefectHQ/fastmcp/pull/385) **Full Changelog**: [v2.3.0...v2.3.1](https://github.com/PrefectHQ/fastmcp/compare/v2.3.0...v2.3.1) ## [v2.3.0: Stream Me Up, Scotty](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.3.0) ### New Features 🎉 * Add streaming support for HTTP transport by [@jlowin](https://github.com/jlowin) in [#365](https://github.com/PrefectHQ/fastmcp/pull/365) * Support streaming HTTP transport in clients by [@jlowin](https://github.com/jlowin) in [#366](https://github.com/PrefectHQ/fastmcp/pull/366) * Add streaming support to CLI by [@jlowin](https://github.com/jlowin) in [#367](https://github.com/PrefectHQ/fastmcp/pull/367) ### Fixes 🐞 * Fix streaming transport initialization by [@jlowin](https://github.com/jlowin) in [#368](https://github.com/PrefectHQ/fastmcp/pull/368) ### Docs 📚 * Update transport documentation for streaming support by [@jlowin](https://github.com/jlowin) in [#369](https://github.com/PrefectHQ/fastmcp/pull/369) **Full Changelog**: [v2.2.10...v2.3.0](https://github.com/PrefectHQ/fastmcp/compare/v2.2.10...v2.3.0) ## [v2.2.10: That's JSON Bourne](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.10) ### Fixes 🐞 * Disable automatic JSON parsing of tool args by [@jlowin](https://github.com/jlowin) in [#341](https://github.com/PrefectHQ/fastmcp/pull/341) * Fix prompt test by [@jlowin](https://github.com/jlowin) in [#342](https://github.com/PrefectHQ/fastmcp/pull/342) ### Other Changes 🦾 * Update docs.json by [@jlowin](https://github.com/jlowin) in [#338](https://github.com/PrefectHQ/fastmcp/pull/338) * Add test coverage + tests on 4 examples by [@alainivars](https://github.com/alainivars) in [#306](https://github.com/PrefectHQ/fastmcp/pull/306) ### New Contributors * [@alainivars](https://github.com/alainivars) made their first contribution in [#306](https://github.com/PrefectHQ/fastmcp/pull/306) **Full Changelog**: [v2.2.9...v2.2.10](https://github.com/PrefectHQ/fastmcp/compare/v2.2.9...v2.2.10) ## [v2.2.9: Str-ing the Pot (Hotfix)](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.9) This release is a hotfix for the issue detailed in #330 ### Fixes 🐞 * Prevent invalid resource URIs by [@jlowin](https://github.com/jlowin) in [#336](https://github.com/PrefectHQ/fastmcp/pull/336) * Coerce numbers to str by [@jlowin](https://github.com/jlowin) in [#337](https://github.com/PrefectHQ/fastmcp/pull/337) ### Docs 📚 * Add client badge by [@jlowin](https://github.com/jlowin) in [#327](https://github.com/PrefectHQ/fastmcp/pull/327) * Update bug.yml by [@jlowin](https://github.com/jlowin) in [#328](https://github.com/PrefectHQ/fastmcp/pull/328) ### Other Changes 🦾 * Update quickstart.mdx example to include import by [@discdiver](https://github.com/discdiver) in [#329](https://github.com/PrefectHQ/fastmcp/pull/329) ### New Contributors * [@discdiver](https://github.com/discdiver) made their first contribution in [#329](https://github.com/PrefectHQ/fastmcp/pull/329) **Full Changelog**: [v2.2.8...v2.2.9](https://github.com/PrefectHQ/fastmcp/compare/v2.2.8...v2.2.9) ## [v2.2.8: Parse and Recreation](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.8) ### New Features 🎉 * Replace custom parsing with TypeAdapter by [@jlowin](https://github.com/jlowin) in [#314](https://github.com/PrefectHQ/fastmcp/pull/314) * Handle \*args/\*\*kwargs appropriately for various components by [@jlowin](https://github.com/jlowin) in [#317](https://github.com/PrefectHQ/fastmcp/pull/317) * Add timeout-graceful-shutdown as a default config for SSE app by [@jlowin](https://github.com/jlowin) in [#323](https://github.com/PrefectHQ/fastmcp/pull/323) * Ensure prompts return descriptions by [@jlowin](https://github.com/jlowin) in [#325](https://github.com/PrefectHQ/fastmcp/pull/325) ### Fixes 🐞 * Ensure that tool serialization has a graceful fallback by [@jlowin](https://github.com/jlowin) in [#310](https://github.com/PrefectHQ/fastmcp/pull/310) ### Docs 📚 * Update docs for clarity by [@jlowin](https://github.com/jlowin) in [#312](https://github.com/PrefectHQ/fastmcp/pull/312) ### Other Changes 🦾 * Remove is\_async attribute by [@jlowin](https://github.com/jlowin) in [#315](https://github.com/PrefectHQ/fastmcp/pull/315) * Dry out retrieving context kwarg by [@jlowin](https://github.com/jlowin) in [#316](https://github.com/PrefectHQ/fastmcp/pull/316) **Full Changelog**: [v2.2.7...v2.2.8](https://github.com/PrefectHQ/fastmcp/compare/v2.2.7...v2.2.8) ## [v2.2.7: You Auth to Know Better](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.7) ### New Features 🎉 * use pydantic\_core.to\_json by [@jlowin](https://github.com/jlowin) in [#290](https://github.com/PrefectHQ/fastmcp/pull/290) * Ensure openapi descriptions are included in tool details by [@jlowin](https://github.com/jlowin) in [#293](https://github.com/PrefectHQ/fastmcp/pull/293) * Bump mcp to 1.7.1 by [@jlowin](https://github.com/jlowin) in [#298](https://github.com/PrefectHQ/fastmcp/pull/298) * Add support for tool annotations by [@jlowin](https://github.com/jlowin) in [#299](https://github.com/PrefectHQ/fastmcp/pull/299) * Add auth support by [@jlowin](https://github.com/jlowin) in [#300](https://github.com/PrefectHQ/fastmcp/pull/300) * Add low-level methods to client by [@jlowin](https://github.com/jlowin) in [#301](https://github.com/PrefectHQ/fastmcp/pull/301) * Add method for retrieving current starlette request to FastMCP context by [@jlowin](https://github.com/jlowin) in [#302](https://github.com/PrefectHQ/fastmcp/pull/302) * get\_starlette\_request → get\_http\_request by [@jlowin](https://github.com/jlowin) in [#303](https://github.com/PrefectHQ/fastmcp/pull/303) * Support custom Serializer for Tools by [@strawgate](https://github.com/strawgate) in [#308](https://github.com/PrefectHQ/fastmcp/pull/308) * Support proxy mount by [@jlowin](https://github.com/jlowin) in [#309](https://github.com/PrefectHQ/fastmcp/pull/309) ### Other Changes 🦾 * Improve context injection type checks by [@jlowin](https://github.com/jlowin) in [#291](https://github.com/PrefectHQ/fastmcp/pull/291) * add readme to smarthome example by [@zzstoatzz](https://github.com/zzstoatzz) in [#294](https://github.com/PrefectHQ/fastmcp/pull/294) **Full Changelog**: [v2.2.6...v2.2.7](https://github.com/PrefectHQ/fastmcp/compare/v2.2.6...v2.2.7) ## [v2.2.6: The REST is History](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.6) ### New Features 🎉 * Added feature : Load MCP server using config by [@sandipan1](https://github.com/sandipan1) in [#260](https://github.com/PrefectHQ/fastmcp/pull/260) * small typing fixes by [@zzstoatzz](https://github.com/zzstoatzz) in [#237](https://github.com/PrefectHQ/fastmcp/pull/237) * Expose configurable timeout for OpenAPI by [@jlowin](https://github.com/jlowin) in [#279](https://github.com/PrefectHQ/fastmcp/pull/279) * Lower websockets pin for compatibility by [@jlowin](https://github.com/jlowin) in [#286](https://github.com/PrefectHQ/fastmcp/pull/286) * Improve OpenAPI param handling by [@jlowin](https://github.com/jlowin) in [#287](https://github.com/PrefectHQ/fastmcp/pull/287) ### Fixes 🐞 * Ensure openapi tool responses are properly converted by [@jlowin](https://github.com/jlowin) in [#283](https://github.com/PrefectHQ/fastmcp/pull/283) * Fix OpenAPI examples by [@jlowin](https://github.com/jlowin) in [#285](https://github.com/PrefectHQ/fastmcp/pull/285) * Fix client docs for advanced features, add tests for logging by [@jlowin](https://github.com/jlowin) in [#284](https://github.com/PrefectHQ/fastmcp/pull/284) ### Other Changes 🦾 * add testing doc by [@jlowin](https://github.com/jlowin) in [#264](https://github.com/PrefectHQ/fastmcp/pull/264) * #267 Fix openapi template resource to support multiple path parameters by [@jeger-at](https://github.com/jeger-at) in [#278](https://github.com/PrefectHQ/fastmcp/pull/278) ### New Contributors * [@sandipan1](https://github.com/sandipan1) made their first contribution in [#260](https://github.com/PrefectHQ/fastmcp/pull/260) * [@jeger-at](https://github.com/jeger-at) made their first contribution in [#278](https://github.com/PrefectHQ/fastmcp/pull/278) **Full Changelog**: [v2.2.5...v2.2.6](https://github.com/PrefectHQ/fastmcp/compare/v2.2.5...v2.2.6) ## [v2.2.5: Context Switching](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.5) ### New Features 🎉 * Add tests for tool return types; improve serialization behavior by [@jlowin](https://github.com/jlowin) in [#262](https://github.com/PrefectHQ/fastmcp/pull/262) * Support context injection in resources, templates, and prompts (like tools) by [@jlowin](https://github.com/jlowin) in [#263](https://github.com/PrefectHQ/fastmcp/pull/263) ### Docs 📚 * Update wildcards to 2.2.4 by [@jlowin](https://github.com/jlowin) in [#257](https://github.com/PrefectHQ/fastmcp/pull/257) * Update note in templates docs by [@jlowin](https://github.com/jlowin) in [#258](https://github.com/PrefectHQ/fastmcp/pull/258) * Significant documentation and test expansion for tool input types by [@jlowin](https://github.com/jlowin) in [#261](https://github.com/PrefectHQ/fastmcp/pull/261) **Full Changelog**: [v2.2.4...v2.2.5](https://github.com/PrefectHQ/fastmcp/compare/v2.2.4...v2.2.5) ## [v2.2.4: The Wild Side, Actually](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.4) The wildcard URI templates exposed in v2.2.3 were blocked by a server-level check which is removed in this release. ### New Features 🎉 * Allow customization of inspector proxy port, ui port, and version by [@jlowin](https://github.com/jlowin) in [#253](https://github.com/PrefectHQ/fastmcp/pull/253) ### Fixes 🐞 * fix: unintended type convert by [@cutekibry](https://github.com/cutekibry) in [#252](https://github.com/PrefectHQ/fastmcp/pull/252) * Ensure openapi resources return valid responses by [@jlowin](https://github.com/jlowin) in [#254](https://github.com/PrefectHQ/fastmcp/pull/254) * Ensure servers expose template wildcards by [@jlowin](https://github.com/jlowin) in [#256](https://github.com/PrefectHQ/fastmcp/pull/256) ### Docs 📚 * Update README.md Grammar error by [@TechWithTy](https://github.com/TechWithTy) in [#249](https://github.com/PrefectHQ/fastmcp/pull/249) ### Other Changes 🦾 * Add resource template tests by [@jlowin](https://github.com/jlowin) in [#255](https://github.com/PrefectHQ/fastmcp/pull/255) ### New Contributors * [@TechWithTy](https://github.com/TechWithTy) made their first contribution in [#249](https://github.com/PrefectHQ/fastmcp/pull/249) * [@cutekibry](https://github.com/cutekibry) made their first contribution in [#252](https://github.com/PrefectHQ/fastmcp/pull/252) **Full Changelog**: [v2.2.3...v2.2.4](https://github.com/PrefectHQ/fastmcp/compare/v2.2.3...v2.2.4) ## [v2.2.3: The Wild Side](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.3) ### New Features 🎉 * Add wildcard params for resource templates by [@jlowin](https://github.com/jlowin) in [#246](https://github.com/PrefectHQ/fastmcp/pull/246) ### Docs 📚 * Indicate that Image class is for returns by [@jlowin](https://github.com/jlowin) in [#242](https://github.com/PrefectHQ/fastmcp/pull/242) * Update mermaid diagram by [@jlowin](https://github.com/jlowin) in [#243](https://github.com/PrefectHQ/fastmcp/pull/243) ### Other Changes 🦾 * update version badges by [@jlowin](https://github.com/jlowin) in [#248](https://github.com/PrefectHQ/fastmcp/pull/248) **Full Changelog**: [v2.2.2...v2.2.3](https://github.com/PrefectHQ/fastmcp/compare/v2.2.2...v2.2.3) ## [v2.2.2: Prompt and Circumstance](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.2) ### New Features 🎉 * Add prompt support by [@jlowin](https://github.com/jlowin) in [#235](https://github.com/PrefectHQ/fastmcp/pull/235) ### Fixes 🐞 * Ensure that resource templates are properly exposed by [@jlowin](https://github.com/jlowin) in [#238](https://github.com/PrefectHQ/fastmcp/pull/238) ### Docs 📚 * Update docs for prompts by [@jlowin](https://github.com/jlowin) in [#236](https://github.com/PrefectHQ/fastmcp/pull/236) ### Other Changes 🦾 * Add prompt tests by [@jlowin](https://github.com/jlowin) in [#239](https://github.com/PrefectHQ/fastmcp/pull/239) **Full Changelog**: [v2.2.1...v2.2.2](https://github.com/PrefectHQ/fastmcp/compare/v2.2.1...v2.2.2) ## [v2.2.1: Template for Success](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.1) ### New Features 🎉 * Add resource templates by [@jlowin](https://github.com/jlowin) in [#230](https://github.com/PrefectHQ/fastmcp/pull/230) ### Fixes 🐞 * Ensure that resource templates are properly exposed by [@jlowin](https://github.com/jlowin) in [#231](https://github.com/PrefectHQ/fastmcp/pull/231) ### Docs 📚 * Update docs for resource templates by [@jlowin](https://github.com/jlowin) in [#232](https://github.com/PrefectHQ/fastmcp/pull/232) ### Other Changes 🦾 * Add resource template tests by [@jlowin](https://github.com/jlowin) in [#233](https://github.com/PrefectHQ/fastmcp/pull/233) **Full Changelog**: [v2.2.0...v2.2.1](https://github.com/PrefectHQ/fastmcp/compare/v2.2.0...v2.2.1) ## [v2.2.0: Compose Yourself](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.2.0) ### New Features 🎉 * Add support for mounting FastMCP servers by [@jlowin](https://github.com/jlowin) in [#175](https://github.com/PrefectHQ/fastmcp/pull/175) * Add support for duplicate behavior == ignore by [@jlowin](https://github.com/jlowin) in [#169](https://github.com/PrefectHQ/fastmcp/pull/169) ### Breaking Changes 🛫 * Refactor MCP composition by [@jlowin](https://github.com/jlowin) in [#176](https://github.com/PrefectHQ/fastmcp/pull/176) ### Docs 📚 * Improve integration documentation by [@jlowin](https://github.com/jlowin) in [#184](https://github.com/PrefectHQ/fastmcp/pull/184) * Improve documentation by [@jlowin](https://github.com/jlowin) in [#185](https://github.com/PrefectHQ/fastmcp/pull/185) ### Other Changes 🦾 * Add transport kwargs for mcp.run() and fastmcp run by [@jlowin](https://github.com/jlowin) in [#161](https://github.com/PrefectHQ/fastmcp/pull/161) * Allow resource templates to have optional / excluded arguments by [@jlowin](https://github.com/jlowin) in [#164](https://github.com/PrefectHQ/fastmcp/pull/164) * Update resources.mdx by [@jlowin](https://github.com/jlowin) in [#165](https://github.com/PrefectHQ/fastmcp/pull/165) ### New Contributors * [@kongqi404](https://github.com/kongqi404) made their first contribution in [#181](https://github.com/PrefectHQ/fastmcp/pull/181) **Full Changelog**: [v2.1.2...v2.2.0](https://github.com/PrefectHQ/fastmcp/compare/v2.1.2...v2.2.0) ## [v2.1.2: Copy That, Good Buddy](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.2) The main improvement in this release is a fix that allows FastAPI / OpenAPI-generated servers to be mounted as sub-servers. ### Fixes 🐞 * Ensure objects are copied properly and test mounting fastapi by [@jlowin](https://github.com/jlowin) in [#153](https://github.com/PrefectHQ/fastmcp/pull/153) ### Docs 📚 * Fix broken links in docs by [@jlowin](https://github.com/jlowin) in [#154](https://github.com/PrefectHQ/fastmcp/pull/154) ### Other Changes 🦾 * Update README.md by [@jlowin](https://github.com/jlowin) in [#149](https://github.com/PrefectHQ/fastmcp/pull/149) * Only apply log config to FastMCP loggers by [@jlowin](https://github.com/jlowin) in [#155](https://github.com/PrefectHQ/fastmcp/pull/155) * Update pyproject.toml by [@jlowin](https://github.com/jlowin) in [#156](https://github.com/PrefectHQ/fastmcp/pull/156) **Full Changelog**: [v2.1.1...v2.1.2](https://github.com/PrefectHQ/fastmcp/compare/v2.1.1...v2.1.2) ## [v2.1.1: Doc Holiday](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.1) FastMCP's docs are now available at gofastmcp.com. ### Docs 📚 * Add docs by [@jlowin](https://github.com/jlowin) in [#136](https://github.com/PrefectHQ/fastmcp/pull/136) * Add docs link to readme by [@jlowin](https://github.com/jlowin) in [#137](https://github.com/PrefectHQ/fastmcp/pull/137) * Minor docs updates by [@jlowin](https://github.com/jlowin) in [#138](https://github.com/PrefectHQ/fastmcp/pull/138) ### Fixes 🐞 * fix branch name in example by [@zzstoatzz](https://github.com/zzstoatzz) in [#140](https://github.com/PrefectHQ/fastmcp/pull/140) ### Other Changes 🦾 * smart home example by [@zzstoatzz](https://github.com/zzstoatzz) in [#115](https://github.com/PrefectHQ/fastmcp/pull/115) * Remove mac os tests by [@jlowin](https://github.com/jlowin) in [#142](https://github.com/PrefectHQ/fastmcp/pull/142) * Expand support for various method interactions by [@jlowin](https://github.com/jlowin) in [#143](https://github.com/PrefectHQ/fastmcp/pull/143) * Update docs and add\_resource\_fn by [@jlowin](https://github.com/jlowin) in [#144](https://github.com/PrefectHQ/fastmcp/pull/144) * Update description by [@jlowin](https://github.com/jlowin) in [#145](https://github.com/PrefectHQ/fastmcp/pull/145) * Support openapi 3.0 and 3.1 by [@jlowin](https://github.com/jlowin) in [#147](https://github.com/PrefectHQ/fastmcp/pull/147) **Full Changelog**: [v2.1.0...v2.1.1](https://github.com/PrefectHQ/fastmcp/compare/v2.1.0...v2.1.1) ## [v2.1.0: Tag, You're It](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.1.0) The primary motivation for this release is the fix in #128 for Claude desktop compatibility, but the primary new feature of this release is per-object tags. Currently these are for bookkeeping only but will become useful in future releases. ### New Features 🎉 * Add tags for all core MCP objects by [@jlowin](https://github.com/jlowin) in [#121](https://github.com/PrefectHQ/fastmcp/pull/121) * Ensure that openapi tags are transferred to MCP objects by [@jlowin](https://github.com/jlowin) in [#124](https://github.com/PrefectHQ/fastmcp/pull/124) ### Fixes 🐞 * Change default mounted tool separator from / to \_ by [@jlowin](https://github.com/jlowin) in [#128](https://github.com/PrefectHQ/fastmcp/pull/128) * Enter mounted app lifespans by [@jlowin](https://github.com/jlowin) in [#129](https://github.com/PrefectHQ/fastmcp/pull/129) * Fix CLI that called mcp instead of fastmcp by [@jlowin](https://github.com/jlowin) in [#128](https://github.com/PrefectHQ/fastmcp/pull/128) ### Breaking Changes 🛫 * Changed configuration for duplicate resources/tools/prompts by [@jlowin](https://github.com/jlowin) in [#121](https://github.com/PrefectHQ/fastmcp/pull/121) * Improve client return types by [@jlowin](https://github.com/jlowin) in [#123](https://github.com/PrefectHQ/fastmcp/pull/123) ### Other Changes 🦾 * Add tests for tags in server decorators by [@jlowin](https://github.com/jlowin) in [#122](https://github.com/PrefectHQ/fastmcp/pull/122) * Clean up server tests by [@jlowin](https://github.com/jlowin) in [#125](https://github.com/PrefectHQ/fastmcp/pull/125) **Full Changelog**: [v2.0.0...v2.1.0](https://github.com/PrefectHQ/fastmcp/compare/v2.0.0...v2.1.0) ## [v2.0.0: Second to None](https://github.com/PrefectHQ/fastmcp/releases/tag/v2.0.0) ### New Features 🎉 * Support mounting FastMCP instances as sub-MCPs by [@jlowin](https://github.com/jlowin) in [#99](https://github.com/PrefectHQ/fastmcp/pull/99) * Add in-memory client for calling FastMCP servers (and tests) by [@jlowin](https://github.com/jlowin) in [#100](https://github.com/PrefectHQ/fastmcp/pull/100) * Add MCP proxy server by [@jlowin](https://github.com/jlowin) in [#105](https://github.com/PrefectHQ/fastmcp/pull/105) * Update FastMCP for upstream changes by [@jlowin](https://github.com/jlowin) in [#107](https://github.com/PrefectHQ/fastmcp/pull/107) * Generate FastMCP servers from OpenAPI specs and FastAPI by [@jlowin](https://github.com/jlowin) in [#110](https://github.com/PrefectHQ/fastmcp/pull/110) * Reorganize all client / transports by [@jlowin](https://github.com/jlowin) in [#111](https://github.com/PrefectHQ/fastmcp/pull/111) * Add sampling and roots by [@jlowin](https://github.com/jlowin) in [#117](https://github.com/PrefectHQ/fastmcp/pull/117) ### Fixes 🐞 * Fix bug with tools that return lists by [@jlowin](https://github.com/jlowin) in [#116](https://github.com/PrefectHQ/fastmcp/pull/116) ### Other Changes 🦾 * Add back FastMCP CLI by [@jlowin](https://github.com/jlowin) in [#108](https://github.com/PrefectHQ/fastmcp/pull/108) * Update Readme for v2 by [@jlowin](https://github.com/jlowin) in [#112](https://github.com/PrefectHQ/fastmcp/pull/112) * fix deprecation warnings by [@zzstoatzz](https://github.com/zzstoatzz) in [#113](https://github.com/PrefectHQ/fastmcp/pull/113) * Readme by [@jlowin](https://github.com/jlowin) in [#118](https://github.com/PrefectHQ/fastmcp/pull/118) * FastMCP 2.0 by [@jlowin](https://github.com/jlowin) in [#119](https://github.com/PrefectHQ/fastmcp/pull/119) **Full Changelog**: [v1.0...v2.0.0](https://github.com/PrefectHQ/fastmcp/compare/v1.0...v2.0.0) ## [v1.0: It's Official](https://github.com/PrefectHQ/fastmcp/releases/tag/v1.0) This release commemorates FastMCP 1.0, which is included in the official Model Context Protocol SDK: ```python from mcp.server.fastmcp import FastMCP ``` To the best of my knowledge, v1 is identical to the upstream version included with `mcp`. ### Docs 📚 * Update readme to redirect to the official SDK by [@jlowin](https://github.com/jlowin) in [#79](https://github.com/PrefectHQ/fastmcp/pull/79) ### Other Changes 🦾 * fix: use Mount instead of Route for SSE message handling by [@samihamine](https://github.com/samihamine) in [#77](https://github.com/PrefectHQ/fastmcp/pull/77) ### New Contributors * [@samihamine](https://github.com/samihamine) made their first contribution in [#77](https://github.com/PrefectHQ/fastmcp/pull/77) **Full Changelog**: [v0.4.1...v1.0](https://github.com/PrefectHQ/fastmcp/compare/v0.4.1...v1.0) ## [v0.4.1: String Theory](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.4.1) ### Fixes 🐞 * fix: handle strings containing numbers correctly by [@sd2k](https://github.com/sd2k) in [#63](https://github.com/PrefectHQ/fastmcp/pull/63) ### Docs 📚 * patch: Update pyproject.toml license by [@leonkozlowski](https://github.com/leonkozlowski) in [#67](https://github.com/PrefectHQ/fastmcp/pull/67) ### Other Changes 🦾 * Avoid new try\_eval\_type unavailable with older pydantic by [@jurasofish](https://github.com/jurasofish) in [#57](https://github.com/PrefectHQ/fastmcp/pull/57) * Decorator typing by [@jurasofish](https://github.com/jurasofish) in [#56](https://github.com/PrefectHQ/fastmcp/pull/56) ### New Contributors * [@leonkozlowski](https://github.com/leonkozlowski) made their first contribution in [#67](https://github.com/PrefectHQ/fastmcp/pull/67) **Full Changelog**: [v0.4.0...v0.4.1](https://github.com/PrefectHQ/fastmcp/compare/v0.4.0...v0.4.1) ## [v0.4.0: Nice to MIT You](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.4.0) This is a relatively small release in terms of features, but the version is bumped to 0.4 to reflect that the code is being relicensed from Apache 2.0 to MIT. This is to facilitate FastMCP's inclusion in the official MCP SDK. ### New Features 🎉 * Add pyright + tests by [@jlowin](https://github.com/jlowin) in [#52](https://github.com/PrefectHQ/fastmcp/pull/52) * add pgvector memory example by [@zzstoatzz](https://github.com/zzstoatzz) in [#49](https://github.com/PrefectHQ/fastmcp/pull/49) ### Fixes 🐞 * fix: use stderr for logging by [@sd2k](https://github.com/sd2k) in [#51](https://github.com/PrefectHQ/fastmcp/pull/51) ### Docs 📚 * Update ai-labeler.yml by [@jlowin](https://github.com/jlowin) in [#48](https://github.com/PrefectHQ/fastmcp/pull/48) * Relicense from Apache 2.0 to MIT by [@jlowin](https://github.com/jlowin) in [#54](https://github.com/PrefectHQ/fastmcp/pull/54) ### Other Changes 🦾 * fix warning and flake by [@zzstoatzz](https://github.com/zzstoatzz) in [#47](https://github.com/PrefectHQ/fastmcp/pull/47) ### New Contributors * [@sd2k](https://github.com/sd2k) made their first contribution in [#51](https://github.com/PrefectHQ/fastmcp/pull/51) **Full Changelog**: [v0.3.5...v0.4.0](https://github.com/PrefectHQ/fastmcp/compare/v0.3.5...v0.4.0) ## [v0.3.5: Windows of Opportunity](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.5) This release is highlighted by the ability to handle complex JSON objects as MCP inputs and improved Windows compatibility. ### New Features 🎉 * Set up multiple os tests by [@jlowin](https://github.com/jlowin) in [#44](https://github.com/PrefectHQ/fastmcp/pull/44) * Changes to accommodate windows users. by [@justjoehere](https://github.com/justjoehere) in [#42](https://github.com/PrefectHQ/fastmcp/pull/42) * Handle complex inputs by [@jurasofish](https://github.com/jurasofish) in [#31](https://github.com/PrefectHQ/fastmcp/pull/31) ### Docs 📚 * Make AI labeler more conservative by [@jlowin](https://github.com/jlowin) in [#46](https://github.com/PrefectHQ/fastmcp/pull/46) ### Other Changes 🦾 * Additional Windows Fixes for Dev running and for importing modules in a server by [@justjoehere](https://github.com/justjoehere) in [#43](https://github.com/PrefectHQ/fastmcp/pull/43) ### New Contributors * [@justjoehere](https://github.com/justjoehere) made their first contribution in [#42](https://github.com/PrefectHQ/fastmcp/pull/42) * [@jurasofish](https://github.com/jurasofish) made their first contribution in [#31](https://github.com/PrefectHQ/fastmcp/pull/31) **Full Changelog**: [v0.3.4...v0.3.5](https://github.com/PrefectHQ/fastmcp/compare/v0.3.4...v0.3.5) ## [v0.3.4: URL's Well That Ends Well](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.4) ### Fixes 🐞 * Handle missing config file when installing by [@jlowin](https://github.com/jlowin) in [#37](https://github.com/PrefectHQ/fastmcp/pull/37) * Remove BaseURL reference and use AnyURL by [@jlowin](https://github.com/jlowin) in [#40](https://github.com/PrefectHQ/fastmcp/pull/40) **Full Changelog**: [v0.3.3...v0.3.4](https://github.com/PrefectHQ/fastmcp/compare/v0.3.3...v0.3.4) ## [v0.3.3: Dependence Day](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.3) ### New Features 🎉 * Surge example by [@zzstoatzz](https://github.com/zzstoatzz) in [#29](https://github.com/PrefectHQ/fastmcp/pull/29) * Support Python dependencies in Server by [@jlowin](https://github.com/jlowin) in [#34](https://github.com/PrefectHQ/fastmcp/pull/34) ### Docs 📚 * add `Contributing` section to README by [@zzstoatzz](https://github.com/zzstoatzz) in [#32](https://github.com/PrefectHQ/fastmcp/pull/32) **Full Changelog**: [v0.3.2...v0.3.3](https://github.com/PrefectHQ/fastmcp/compare/v0.3.2...v0.3.3) ## [v0.3.2: Green with ENVy](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.2) ### New Features 🎉 * Support env vars when installing by [@jlowin](https://github.com/jlowin) in [#27](https://github.com/PrefectHQ/fastmcp/pull/27) ### Docs 📚 * Remove top level env var by [@jlowin](https://github.com/jlowin) in [#28](https://github.com/PrefectHQ/fastmcp/pull/28) **Full Changelog**: [v0.3.1...v0.3.2](https://github.com/PrefectHQ/fastmcp/compare/v0.3.1...v0.3.2) ## [v0.3.1](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.1) ### New Features 🎉 * Update README.md by [@jlowin](https://github.com/jlowin) in [#23](https://github.com/PrefectHQ/fastmcp/pull/23) * add rich handler and dotenv loading for settings by [@zzstoatzz](https://github.com/zzstoatzz) in [#22](https://github.com/PrefectHQ/fastmcp/pull/22) * print exception when server can't start by [@jlowin](https://github.com/jlowin) in [#25](https://github.com/PrefectHQ/fastmcp/pull/25) ### Docs 📚 * Update README.md by [@jlowin](https://github.com/jlowin) in [#24](https://github.com/PrefectHQ/fastmcp/pull/24) ### Other Changes 🦾 * Remove log by [@jlowin](https://github.com/jlowin) in [#26](https://github.com/PrefectHQ/fastmcp/pull/26) **Full Changelog**: [v0.3.0...v0.3.1](https://github.com/PrefectHQ/fastmcp/compare/v0.3.0...v0.3.1) ## [v0.3.0: Prompt and Circumstance](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.3.0) ### New Features 🎉 * Update README by [@jlowin](https://github.com/jlowin) in [#3](https://github.com/PrefectHQ/fastmcp/pull/3) * Make log levels strings by [@jlowin](https://github.com/jlowin) in [#4](https://github.com/PrefectHQ/fastmcp/pull/4) * Make content method a function by [@jlowin](https://github.com/jlowin) in [#5](https://github.com/PrefectHQ/fastmcp/pull/5) * Add template support by [@jlowin](https://github.com/jlowin) in [#6](https://github.com/PrefectHQ/fastmcp/pull/6) * Refactor resources module by [@jlowin](https://github.com/jlowin) in [#7](https://github.com/PrefectHQ/fastmcp/pull/7) * Clean up cli imports by [@jlowin](https://github.com/jlowin) in [#8](https://github.com/PrefectHQ/fastmcp/pull/8) * Prepare to list templates by [@jlowin](https://github.com/jlowin) in [#11](https://github.com/PrefectHQ/fastmcp/pull/11) * Move image to separate module by [@jlowin](https://github.com/jlowin) in [#9](https://github.com/PrefectHQ/fastmcp/pull/9) * Add support for request context, progress, logging, etc. by [@jlowin](https://github.com/jlowin) in [#12](https://github.com/PrefectHQ/fastmcp/pull/12) * Add context tests and better runtime loads by [@jlowin](https://github.com/jlowin) in [#13](https://github.com/PrefectHQ/fastmcp/pull/13) * Refactor tools + resourcemanager by [@jlowin](https://github.com/jlowin) in [#14](https://github.com/PrefectHQ/fastmcp/pull/14) * func → fn everywhere by [@jlowin](https://github.com/jlowin) in [#15](https://github.com/PrefectHQ/fastmcp/pull/15) * Add support for prompts by [@jlowin](https://github.com/jlowin) in [#16](https://github.com/PrefectHQ/fastmcp/pull/16) * Create LICENSE by [@jlowin](https://github.com/jlowin) in [#18](https://github.com/PrefectHQ/fastmcp/pull/18) * Update cli file spec by [@jlowin](https://github.com/jlowin) in [#19](https://github.com/PrefectHQ/fastmcp/pull/19) * Update readmeUpdate README by [@jlowin](https://github.com/jlowin) in [#20](https://github.com/PrefectHQ/fastmcp/pull/20) * Use hatchling for version by [@jlowin](https://github.com/jlowin) in [#21](https://github.com/PrefectHQ/fastmcp/pull/21) ### Other Changes 🦾 * Add echo server by [@jlowin](https://github.com/jlowin) in [#1](https://github.com/PrefectHQ/fastmcp/pull/1) * Add github workflows by [@jlowin](https://github.com/jlowin) in [#2](https://github.com/PrefectHQ/fastmcp/pull/2) * typing updates by [@zzstoatzz](https://github.com/zzstoatzz) in [#17](https://github.com/PrefectHQ/fastmcp/pull/17) ### New Contributors * [@jlowin](https://github.com/jlowin) made their first contribution in [#1](https://github.com/PrefectHQ/fastmcp/pull/1) * [@zzstoatzz](https://github.com/zzstoatzz) made their first contribution in [#17](https://github.com/PrefectHQ/fastmcp/pull/17) **Full Changelog**: [v0.2.0...v0.3.0](https://github.com/PrefectHQ/fastmcp/compare/v0.2.0...v0.3.0) ## [v0.2.0](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.2.0) **Full Changelog**: [v0.1.0...v0.2.0](https://github.com/PrefectHQ/fastmcp/compare/v0.1.0...v0.2.0) ## [v0.1.0](https://github.com/PrefectHQ/fastmcp/releases/tag/v0.1.0) The very first release of FastMCP! 🎉 **Full Changelog**: [Initial commits](https://github.com/PrefectHQ/fastmcp/commits/v0.1.0) ================================================ FILE: docs/cli/auth.mdx ================================================ --- title: Auth Utilities sidebarTitle: Auth description: Create and validate CIMD documents for OAuth icon: key --- import { VersionBadge } from '/snippets/version-badge.mdx' The `fastmcp auth` commands help with CIMD (Client ID Metadata Document) management — part of MCP's OAuth authentication flow. A CIMD is a JSON document you host at an HTTPS URL to identify your client application to MCP servers. ## Creating a CIMD `fastmcp auth cimd create` generates a CIMD document: ```bash fastmcp auth cimd create \ --name "My App" \ --redirect-uri "http://localhost:*/callback" ``` ```json { "client_id": "https://your-domain.com/oauth/client.json", "client_name": "My App", "redirect_uris": ["http://localhost:*/callback"], "token_endpoint_auth_method": "none" } ``` The generated document includes a placeholder `client_id` — update it to match the URL where you'll host the document before deploying. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Name | `--name` | **Required.** Human-readable client name | | Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (repeatable) | | Client URI | `--client-uri` | Client's home page URL | | Logo URI | `--logo-uri` | Client's logo URL | | Scope | `--scope` | Space-separated list of scopes | | Output | `--output`, `-o` | Save to file (default: stdout) | | Pretty | `--pretty` | Pretty-print JSON (default: true) | ### Example ```bash fastmcp auth cimd create \ --name "My Production App" \ --redirect-uri "http://localhost:*/callback" \ --redirect-uri "https://myapp.example.com/callback" \ --client-uri "https://myapp.example.com" \ --scope "read write" \ --output client.json ``` ## Validating a CIMD `fastmcp auth cimd validate` fetches a hosted CIMD and verifies it conforms to the spec: ```bash fastmcp auth cimd validate https://myapp.example.com/oauth/client.json ``` The validator checks that the URL is valid (HTTPS, non-root path), the document is valid JSON, the `client_id` matches the URL, and no shared-secret auth methods are used. On success: ``` → Fetching https://myapp.example.com/oauth/client.json... ✓ Valid CIMD document Document details: client_id: https://myapp.example.com/oauth/client.json client_name: My App token_endpoint_auth_method: none redirect_uris: • http://localhost:*/callback ``` | Option | Flag | Description | | ------ | ---- | ----------- | | Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) | ================================================ FILE: docs/cli/client.mdx ================================================ --- title: Client Commands sidebarTitle: Client description: List tools, call them, and discover configured servers icon: satellite-dish --- import { VersionBadge } from '/snippets/version-badge.mdx' The CLI can act as an MCP client — connecting to any server (local or remote) to list what it exposes and call its tools directly. This is useful for development, debugging, scripting, and giving shell-capable LLM agents access to MCP servers. ## Listing Tools `fastmcp list` connects to a server and prints its tools as function signatures, showing parameter names, types, and descriptions at a glance: ```bash fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list weather # name-based resolution ``` When you need the full JSON Schema for a tool's inputs or outputs — for understanding nested objects, enum constraints, or complex types — opt in with `--input-schema` or `--output-schema`: ```bash fastmcp list server.py --input-schema ``` ### Resources and Prompts By default, only tools are shown. Add `--resources` or `--prompts` to include those: ```bash fastmcp list server.py --resources --prompts ``` ### Machine-Readable Output The `--json` flag switches to structured JSON with full schemas included. This is the format to use when feeding tool definitions to an LLM or building automation: ```bash fastmcp list server.py --json ``` ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Command | `--command` | Connect via stdio (e.g., `'npx -y @mcp/server'`) | | Transport | `--transport`, `-t` | Force `http` or `sse` for URL targets | | Resources | `--resources` | Include resources in output | | Prompts | `--prompts` | Include prompts in output | | Input Schema | `--input-schema` | Show full input schemas | | Output Schema | `--output-schema` | Show full output schemas | | JSON | `--json` | Structured JSON output | | Timeout | `--timeout` | Connection timeout in seconds | | Auth | `--auth` | `oauth` (default for HTTP), a bearer token, or `none` | ## Calling Tools `fastmcp call` invokes a single tool on a server. Pass arguments as `key=value` pairs — the CLI fetches the tool's schema and coerces your string values to the right types automatically: ```bash fastmcp call server.py greet name=World fastmcp call http://localhost:8000/mcp search query=hello limit=5 ``` Type coercion is schema-driven: `"5"` becomes the integer `5` when the schema expects an integer. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Arrays and objects are parsed as JSON. ### Complex Arguments For tools with nested or structured parameters, `key=value` syntax gets awkward. Pass a single JSON object instead: ```bash fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale"], "metadata": {"color": "blue"}}' ``` Or use `--input-json` to provide a base dictionary, then override individual keys with `key=value` pairs: ```bash fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10 ``` ### Error Handling If you misspell a tool name, the CLI suggests corrections via fuzzy matching. Missing required arguments produce a clear message with the tool's signature as a reminder. Tool execution errors are printed with a non-zero exit code, making the CLI straightforward to use in scripts. ### Structured Output `--json` emits the raw result including content blocks, error status, and structured content: ```bash fastmcp call server.py get_weather city=London --json ``` ### Interactive Elicitation Some tools request additional input during execution through MCP's elicitation mechanism. When this happens, the CLI prompts you in the terminal — showing each field's name, type, and whether it's required. You can type `decline` to skip a question or `cancel` to abort the call entirely. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Command | `--command` | Connect via stdio | | Transport | `--transport`, `-t` | Force `http` or `sse` | | Input JSON | `--input-json` | Base arguments as JSON (merged with `key=value`) | | JSON | `--json` | Raw JSON output | | Timeout | `--timeout` | Connection timeout in seconds | | Auth | `--auth` | `oauth`, a bearer token, or `none` | ## Discovering Configured Servers `fastmcp discover` scans your machine for MCP servers configured in editors and tools. It checks: - **Claude Desktop** — `claude_desktop_config.json` - **Claude Code** — `~/.claude.json` - **Cursor** — `.cursor/mcp.json` (walks up from current directory) - **Gemini CLI** — `~/.gemini/settings.json` - **Goose** — `~/.config/goose/config.yaml` - **Project** — `./mcp.json` in the current directory ```bash fastmcp discover ``` The output groups servers by source, showing each server's name and transport. Filter by source or get machine-readable output: ```bash fastmcp discover --source claude-code fastmcp discover --source cursor --source gemini --json ``` Any server that appears here can be used by name with `list`, `call`, and other commands — so you can go from "I have a server in Claude Code" to querying it without copying URLs or paths. ## LLM Agent Integration For LLM agents that can execute shell commands but don't have native MCP support, the CLI provides a clean bridge. The agent calls `fastmcp list --json` to discover available tools with full schemas, then `fastmcp call --json` to invoke them with structured results. Because the CLI handles connection management, transport selection, and type coercion internally, the agent doesn't need to understand MCP protocol details — it just reads JSON and constructs shell commands. ================================================ FILE: docs/cli/generate-cli.mdx ================================================ --- title: Generate CLI sidebarTitle: Generate CLI description: Scaffold a standalone typed CLI from any MCP server icon: wand-magic-sparkles --- import { VersionBadge } from '/snippets/version-badge.mdx' `fastmcp list` and `fastmcp call` are general-purpose — you always specify the server, the tool name, and the arguments from scratch. `fastmcp generate-cli` goes further: it connects to a server, reads its tool schemas, and writes a standalone Python script where every tool is a proper subcommand with typed flags, help text, and tab completion. The result is a CLI that feels hand-written for that specific server. MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status, and defaults. `generate-cli` maps that into [cyclopts](https://cyclopts.readthedocs.io/) commands, so JSON Schema types become Python type annotations, descriptions become `--help` text, and required parameters become mandatory flags. ## Generating a Script Point the command at any [server target](/cli/overview#server-targets) and it writes a CLI script: ```bash fastmcp generate-cli weather fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` The second positional argument sets the output path (defaults to `cli.py`). If the file already exists, pass `-f` to overwrite: ```bash fastmcp generate-cli weather -f ``` ## What You Get The generated script is a regular Python file — executable, editable, and yours: ``` $ python cli.py call-tool --help Usage: weather-cli call-tool COMMAND Call a tool on the server Commands: get_forecast Get the weather forecast for a city. search_city Search for a city by name. ``` Each tool has typed parameters with help text pulled directly from the server's schema: ``` $ python cli.py call-tool get_forecast --help Usage: weather-cli call-tool get_forecast [OPTIONS] Get the weather forecast for a city. Options: --city [str] City name (required) --days [int] Number of forecast days (default: 3) ``` Beyond tool commands, the script includes generic MCP operations — `list-tools`, `list-resources`, `read-resource`, `list-prompts`, and `get-prompt` — that always reflect the server's current state, even if tools have changed since generation. ## Parameter Handling Parameters are mapped based on their JSON Schema type: **Simple types** (`string`, `integer`, `number`, `boolean`) become typed flags: ```bash python cli.py call-tool get_forecast --city London --days 3 ``` **Arrays of simple types** become repeatable flags: ```bash python cli.py call-tool tag_items --tags python --tags fastapi --tags mcp ``` **Complex types** (objects, nested arrays, unions) accept JSON strings. The `--help` output shows the full schema so you know what structure to pass: ```bash python cli.py call-tool create_user \ --name John \ --metadata '{"role": "admin", "dept": "engineering"}' ``` ## Agent Skill Alongside the CLI script, `generate-cli` writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents every tool's exact invocation syntax, parameter flags, types, and descriptions. An agent can pick up the CLI immediately without running `--help` or experimenting with flag names. To skip skill generation: ```bash fastmcp generate-cli weather --no-skill ``` ## How It Works The generated script is a *client*, not a server — it connects to the server on every invocation rather than bundling it. A `CLIENT_SPEC` variable at the top holds the resolved transport (a URL string or `StdioTransport` with baked-in command and arguments). The most common edit is changing `CLIENT_SPEC` — for example, pointing a script generated from a dev server at production. Beyond that, the helper functions (`_call_tool`, `_print_tool_result`) are thin wrappers around `fastmcp.Client` that are easy to adapt. The script requires `fastmcp` as a dependency. If it lives outside a project that already has FastMCP installed: ```bash uv run --with fastmcp python cli.py call-tool get_forecast --city London ``` ================================================ FILE: docs/cli/inspecting.mdx ================================================ --- title: Inspecting Servers sidebarTitle: Inspecting description: View a server's components and metadata icon: magnifying-glass --- import { VersionBadge } from '/snippets/version-badge.mdx' `fastmcp inspect` loads a server and reports what it contains — its tools, resources, prompts, version, and metadata. The default output is a human-readable summary: ```bash fastmcp inspect server.py ``` ``` Server: MyServer Instructions: A helpful MCP server Version: 1.0.0 Components: Tools: 5 Prompts: 2 Resources: 3 Templates: 1 Environment: FastMCP: 2.0.0 MCP: 1.0.0 Use --format [fastmcp|mcp] for complete JSON output ``` ## JSON Output For programmatic use, two JSON formats are available: **FastMCP format** (`--format fastmcp`) includes everything FastMCP knows about the server — tool tags, enabled status, output schemas, annotations, and custom metadata. Field names use `snake_case`. This is the format for debugging and introspecting FastMCP servers. **MCP protocol format** (`--format mcp`) shows exactly what MCP clients see through the protocol — only standard MCP fields, `camelCase` names, no FastMCP-specific extensions. This is the format for verifying client compatibility and debugging what clients actually receive. ```bash # Full FastMCP metadata to stdout fastmcp inspect server.py --format fastmcp # MCP protocol view saved to file fastmcp inspect server.py --format mcp -o manifest.json ``` ## Options | Option | Flag | Description | | ------ | ---- | ----------- | | Format | `--format`, `-f` | `fastmcp` or `mcp` (required when using `-o`) | | Output File | `--output`, `-o` | Save to file instead of stdout | ## Entrypoints The `inspect` command supports the same local entrypoints as [`fastmcp run`](/cli/running): inferred instances, explicit entrypoints, factory functions, and `fastmcp.json` configs. ```bash fastmcp inspect server.py # inferred instance fastmcp inspect server.py:my_server # explicit entrypoint fastmcp inspect server.py:create_server # factory function fastmcp inspect fastmcp.json # config file ``` `inspect` only works with local files and `fastmcp.json` — it doesn't connect to remote URLs or standard MCP config files. ================================================ FILE: docs/cli/install-mcp.mdx ================================================ --- title: Install MCP Servers sidebarTitle: Install MCPs description: Install MCP servers into Claude, Cursor, Gemini, and other clients icon: download --- import { VersionBadge } from '/snippets/version-badge.mdx' `fastmcp install` registers a server with an MCP client application so the client can launch it automatically. Each MCP client runs servers in its own isolated environment, which means dependencies need to be explicitly declared — you can't rely on whatever happens to be installed locally. ```bash fastmcp install claude-desktop server.py fastmcp install claude-code server.py --with pandas --with matplotlib fastmcp install cursor server.py -e . ``` `uv` must be installed and available in your system PATH. Both Claude Desktop and Cursor run servers in isolated environments managed by `uv`. On macOS, install it globally with Homebrew for Claude Desktop compatibility: `brew install uv`. ## Supported Clients | Client | Install method | | ------ | -------------- | | `claude-code` | Claude Code's built-in MCP management | | `claude-desktop` | Direct config file modification | | `cursor` | Deeplink that opens Cursor for confirmation | | `gemini-cli` | Gemini CLI's built-in MCP management | | `goose` | Deeplink that opens Goose for confirmation (uses `uvx`) | | `mcp-json` | Generates standard MCP JSON config for manual use | | `stdio` | Outputs the shell command to run via stdio | ## Declaring Dependencies Because MCP clients run servers in isolation, you need to tell the install command what your server needs. There are two approaches: **Command-line flags** let you specify dependencies directly: ```bash fastmcp install claude-desktop server.py --with pandas --with "sqlalchemy>=2.0" fastmcp install cursor server.py -e . --with-requirements requirements.txt ``` **`fastmcp.json`** configuration files declare dependencies alongside the server definition. When you install from a config file, dependencies are picked up automatically: ```bash fastmcp install claude-desktop fastmcp.json fastmcp install claude-desktop # auto-detects fastmcp.json in current directory ``` See [Server Configuration](/deployment/server-configuration) for the full config format. ## Options | Option | Flag | Description | | ------ | ---- | ----------- | | Server Name | `--server-name`, `-n` | Custom name for the server | | Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | | Extra Packages | `--with` | Additional packages (repeatable) | | Environment Variables | `--env` | `KEY=VALUE` pairs (repeatable) | | Environment File | `--env-file`, `-f` | Load env vars from a `.env` file | | Python | `--python` | Python version (e.g., `3.11`) | | Project | `--project` | Run within a uv project directory | | Requirements | `--with-requirements` | Install from a requirements file | | Config Path | `--config-path` | Custom path to Claude Desktop config directory (`claude-desktop` only) | ## Examples ```bash # Basic install with auto-detected server instance fastmcp install claude-desktop server.py # Install from fastmcp.json with auto-detection fastmcp install claude-desktop # Explicit entrypoint with dependencies fastmcp install claude-desktop server.py:my_server \ --server-name "My Analysis Server" \ --with pandas # With environment variables fastmcp install claude-code server.py \ --env API_KEY=secret \ --env DEBUG=true # With env file fastmcp install cursor server.py --env-file .env # Specific Python version and requirements file fastmcp install claude-desktop server.py \ --python 3.11 \ --with-requirements requirements.txt # With custom config path (claude-desktop only) fastmcp install claude-desktop server.py \ --config-path "C:\Users\username\AppData\Local\Packages\Claude_xyz\LocalCache\Roaming\Claude" ``` ## Generating MCP JSON The `mcp-json` target generates standard MCP configuration JSON instead of installing into a specific client. This is useful for clients that FastMCP doesn't directly support, for CI/CD environments, or for sharing server configs: ```bash fastmcp install mcp-json server.py ``` The output follows the standard format used by Claude Desktop, Cursor, and other MCP clients: ```json { "server-name": { "command": "uv", "args": ["run", "--with", "fastmcp", "fastmcp", "run", "/path/to/server.py"], "env": { "API_KEY": "value" } } } ``` Use `--copy` to send it to your clipboard instead of stdout. ## Generating Stdio Commands The `stdio` target outputs the shell command an MCP host would use to start your server over stdio: ```bash fastmcp install stdio server.py # Output: uv run --with fastmcp fastmcp run /absolute/path/to/server.py ``` When installing from a `fastmcp.json`, dependencies from the config are included automatically: ```bash fastmcp install stdio fastmcp.json # Output: uv run --with fastmcp --with pillow --with 'qrcode[pil]>=8.0' fastmcp run /path/to/server.py ``` Use `--copy` to copy to clipboard. `fastmcp install` is designed for local server files with stdio transport. For remote servers running over HTTP, use your client's native configuration — FastMCP's value here is simplifying the complex local setup with `uv`, dependencies, and environment variables. ================================================ FILE: docs/cli/overview.mdx ================================================ --- title: CLI sidebarTitle: Overview description: The fastmcp command-line interface icon: terminal --- import { VersionBadge } from '/snippets/version-badge.mdx' The `fastmcp` CLI is installed automatically with FastMCP. It's the primary way to run, test, install, and interact with MCP servers from your terminal. ```bash fastmcp --help ``` ## Commands at a Glance | Command | What it does | | ------- | ------------ | | [`run`](/cli/running) | Run a server (local file, factory function, remote URL, or config file) | | [`dev apps`](/cli/running#previewing-apps) | Launch a browser-based preview UI for Prefab App tools | | [`dev inspector`](/cli/running#development-with-the-inspector) | Launch a server inside the MCP Inspector for interactive testing | | [`install`](/cli/install-mcp) | Install a server into Claude Code, Claude Desktop, Cursor, Gemini CLI, or Goose | | [`inspect`](/cli/inspecting) | Print a server's tools, resources, and prompts as a summary or JSON report | | [`list`](/cli/client) | List a server's tools (and optionally resources and prompts) | | [`call`](/cli/client#calling-tools) | Call a single tool with arguments | | [`discover`](/cli/client#discovering-configured-servers) | Find MCP servers configured in your editors and tools | | [`generate-cli`](/cli/generate-cli) | Scaffold a standalone typed CLI from a server's tool schemas | | [`project prepare`](/cli/running#pre-building-environments) | Pre-install dependencies into a reusable uv project | | [`auth cimd`](/cli/auth) | Create and validate CIMD documents for OAuth | | `version` | Print version info (`--copy` to copy to clipboard) | ## Server Targets Most commands need to know *which server* to talk to. You pass a "server spec" as the first argument, and FastMCP resolves the right transport automatically. **URLs** connect to a running HTTP server: ```bash fastmcp list http://localhost:8000/mcp fastmcp call http://localhost:8000/mcp get_forecast city=London ``` **Python files** are loaded directly — no `mcp.run()` boilerplate needed. FastMCP finds a server instance named `mcp`, `server`, or `app` in the file, or you can specify one explicitly: ```bash fastmcp list server.py fastmcp run server.py:my_custom_server ``` **Config files** work too — both FastMCP's own `fastmcp.json` format and standard MCP config files with an `mcpServers` key: ```bash fastmcp run fastmcp.json fastmcp list mcp-config.json ``` **Stdio commands** connect to any MCP server that speaks over standard I/O. Use `--command` instead of a positional argument: ```bash fastmcp list --command 'npx -y @modelcontextprotocol/server-github' ``` ### Name-Based Resolution If your servers are already configured in an editor or tool, you can refer to them by name. FastMCP scans configs from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose: ```bash fastmcp list weather fastmcp call weather get_forecast city=London ``` When the same name appears in multiple configs, use the `source:name` form to be specific: ```bash fastmcp list claude-code:my-server fastmcp call cursor:weather get_forecast city=London ``` Run [`fastmcp discover`](/cli/client#discovering-configured-servers) to see what's available on your machine. ## Authentication When targeting an HTTP URL, the CLI enables OAuth authentication by default. If the server requires it, you'll be guided through the flow (typically opening a browser). If it doesn't, the setup is a silent no-op. To skip authentication entirely — useful for local development servers — pass `--auth none`: ```bash fastmcp call http://localhost:8000/mcp my_tool --auth none ``` You can also pass a bearer token directly: ```bash fastmcp list http://localhost:8000/mcp --auth "Bearer sk-..." ``` ## Transport Override FastMCP defaults to Streamable HTTP for URL targets. If the server only supports Server-Sent Events (SSE), force the older transport: ```bash fastmcp list http://localhost:8000 --transport sse ``` ================================================ FILE: docs/cli/running.mdx ================================================ --- title: Running Servers sidebarTitle: Running description: Start, develop, and configure servers from the command line icon: play --- import { VersionBadge } from '/snippets/version-badge.mdx' ## Starting a Server `fastmcp run` starts a server. Point it at a Python file, a factory function, a remote URL, or a config file: ```bash fastmcp run server.py fastmcp run server.py:create_server fastmcp run https://example.com/mcp fastmcp run fastmcp.json ``` By default, the server runs over **stdio** — the transport that MCP clients like Claude Desktop expect. To serve over HTTP instead, specify the transport: ```bash fastmcp run server.py --transport http fastmcp run server.py --transport http --host 0.0.0.0 --port 9000 ``` ### Entrypoints FastMCP supports several ways to locate and start your server: **Inferred instance** — FastMCP imports the file and looks for a variable named `mcp`, `server`, or `app`: ```bash fastmcp run server.py ``` **Explicit instance** — point at a specific variable: ```bash fastmcp run server.py:my_server ``` **Factory function** — FastMCP calls the function and uses the returned server. Useful when your server needs async setup or configuration that runs before startup: ```bash fastmcp run server.py:create_server ``` **Remote URL** — starts a local proxy that bridges to a remote server. Handy for local development against a deployed server, or for bridging a remote HTTP server to stdio: ```bash fastmcp run https://example.com/mcp ``` **FastMCP config** — uses a `fastmcp.json` file that declaratively specifies the server, its dependencies, and deployment settings. When you run `fastmcp run` with no arguments, it auto-detects `fastmcp.json` in the current directory: ```bash fastmcp run fastmcp run my-config.fastmcp.json ``` See [Server Configuration](/deployment/server-configuration) for the full `fastmcp.json` format. **MCP config** — runs servers defined in a standard MCP configuration file (any `.json` with an `mcpServers` key): ```bash fastmcp run mcp.json ``` `fastmcp run` completely ignores the `if __name__ == "__main__"` block. Any setup code in that block won't execute. If you need initialization logic to run, use a [factory function](/cli/overview#factory-functions). ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Transport | `--transport`, `-t` | `stdio` (default), `http`, or `sse` | | Host | `--host` | Bind address for HTTP (default: `127.0.0.1`) | | Port | `--port`, `-p` | Bind port for HTTP (default: `8000`) | | Path | `--path` | URL path for HTTP (default: `/mcp/`) | | Log Level | `--log-level`, `-l` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | | No Banner | `--no-banner` | Suppress the startup banner | | Auto-Reload | `--reload` / `--no-reload` | Watch for file changes and restart automatically | | Reload Dirs | `--reload-dir` | Directories to watch (repeatable) | | Skip Env | `--skip-env` | Don't set up a uv environment (use when already in one) | | Python | `--python` | Python version to use (e.g., `3.11`) | | Extra Packages | `--with` | Additional packages to install (repeatable) | | Project | `--project` | Run within a specific uv project directory | | Requirements | `--with-requirements` | Install from a requirements file | ### Dependency Management By default, `fastmcp run` uses your current Python environment directly. When you pass `--python`, `--with`, `--project`, or `--with-requirements`, it switches to running via `uv run` in a subprocess, which handles dependency isolation automatically. The `--skip-env` flag is useful when you're already inside an activated venv, a Docker container with pre-installed dependencies, or a uv-managed project — it prevents uv from trying to set up another environment layer. ## Previewing Apps `fastmcp dev apps` launches a browser-based preview UI for servers with [Prefab App tools](/apps/prefab). It starts your MCP server on one port and a local dev UI on another — giving you a live, interactive picker where you can call app tools and see their rendered output without needing a full MCP host client. ```bash fastmcp dev apps server.py fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 ``` The picker auto-generates a form from each tool's input schema. Submit the form and the result opens in a new tab as a rendered Prefab UI. Auto-reload is on by default — save a file and the MCP server restarts automatically. `fastmcp dev apps` requires `fastmcp[apps]` — install with `pip install "fastmcp[apps]"`. | Option | Flag | Description | | ------ | ---- | ----------- | | MCP Port | `--mcp-port` | Port for the MCP server (default: `8000`) | | Dev Port | `--dev-port` | Port for the dev UI (default: `8080`) | | Auto-Reload | `--reload` / `--no-reload` | Watch for file changes (default: on) | ## Development with the Inspector `fastmcp dev inspector` launches your server inside the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), a browser-based tool for interactively testing MCP servers. Auto-reload is on by default, so your server restarts when you save changes. ```bash fastmcp dev inspector server.py fastmcp dev inspector server.py -e . --with pandas ``` The Inspector always runs your server via `uv run` in a subprocess — it never uses your local environment directly. Specify dependencies with `--with`, `--with-editable`, `--with-requirements`, or through a `fastmcp.json` file. The Inspector connects over **stdio only**. When it launches, you may need to select "STDIO" from the transport dropdown and click connect. To test a server over HTTP, start it separately with `fastmcp run server.py --transport http` and point the Inspector at the URL. | Option | Flag | Description | | ------ | ---- | ----------- | | Editable Package | `--with-editable`, `-e` | Install a directory in editable mode | | Extra Packages | `--with` | Additional packages (repeatable) | | Inspector Version | `--inspector-version` | MCP Inspector version to use | | UI Port | `--ui-port` | Port for the Inspector UI | | Server Port | `--server-port` | Port for the Inspector proxy | | Auto-Reload | `--reload` / `--no-reload` | File watching (default: on) | | Reload Dirs | `--reload-dir` | Directories to watch (repeatable) | | Python | `--python` | Python version | | Project | `--project` | Run within a uv project directory | | Requirements | `--with-requirements` | Install from a requirements file | ## Pre-Building Environments `fastmcp project prepare` creates a persistent uv project from a `fastmcp.json` file, pre-installing all dependencies. This separates environment setup from server execution — install once, run many times. ```bash # Step 1: Build the environment (slow, does dependency resolution) fastmcp project prepare fastmcp.json --output-dir ./env # Step 2: Run using the prepared environment (fast, no install step) fastmcp run fastmcp.json --project ./env ``` The prepared directory contains a `pyproject.toml`, a `.venv` with all packages installed, and a `uv.lock` for reproducibility. This is particularly useful in deployment scenarios where you want deterministic, pre-built environments. ================================================ FILE: docs/clients/auth/bearer.mdx ================================================ --- title: Bearer Token Authentication sidebarTitle: Bearer Auth description: Authenticate your FastMCP client with a Bearer token. icon: key --- import { VersionBadge } from "/snippets/version-badge.mdx" Bearer Token authentication is only relevant for HTTP-based transports. You can configure your FastMCP client to use **bearer authentication** by supplying a valid access token. This is most appropriate for service accounts, long-lived API keys, CI/CD, applications where authentication is managed separately, or other non-interactive authentication methods. A Bearer token is a JSON Web Token (JWT) that is used to authenticate a request. It is most commonly used in the `Authorization` header of an HTTP request, using the `Bearer` scheme: ```http Authorization: Bearer ``` ## Client Usage The most straightforward way to use a pre-existing Bearer token is to provide it as a string to the `auth` parameter of the `fastmcp.Client` or transport instance. FastMCP will automatically format it correctly for the `Authorization` header and bearer scheme. If you're using a string token, do not include the `Bearer` prefix. FastMCP will add it for you. ```python {5} from fastmcp import Client async with Client( "https://your-server.fastmcp.app/mcp", auth="", ) as client: await client.ping() ``` You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`: ```python {6} from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport( "http://your-server.fastmcp.app/mcp", auth="", ) async with Client(transport) as client: await client.ping() ``` ## `BearerAuth` Helper If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface. ```python {6} from fastmcp import Client from fastmcp.client.auth import BearerAuth async with Client( "https://your-server.fastmcp.app/mcp", auth=BearerAuth(token=""), ) as client: await client.ping() ``` ## Custom Headers If the MCP server expects a custom header or token scheme, you can manually set the client's `headers` instead of using the `auth` parameter by setting them on your transport: ```python {5} from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport async with Client( transport=StreamableHttpTransport( "https://your-server.fastmcp.app/mcp", headers={"X-API-Key": ""}, ), ) as client: await client.ping() ``` ================================================ FILE: docs/clients/auth/cimd.mdx ================================================ --- title: CIMD Authentication sidebarTitle: CIMD description: Use Client ID Metadata Documents for verifiable, domain-based client identity. icon: id-badge tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx" CIMD authentication is only relevant for HTTP-based transports and requires a server that advertises CIMD support. With standard OAuth, your client registers dynamically with every server it connects to, receiving a fresh `client_id` each time. This works, but the server has no way to verify *who* your client actually is — any client can claim any name during registration. CIMD (Client ID Metadata Documents) flips this around. You host a small JSON document at an HTTPS URL you control, and that URL becomes your `client_id`. When your client connects to a server, the server fetches your metadata document and can verify your identity through your domain ownership. Users see a verified domain badge in the consent screen instead of an unverified client name. ## Client Usage Pass your CIMD document URL to the `client_metadata_url` parameter of `OAuth`: ```python from fastmcp import Client from fastmcp.client.auth import OAuth async with Client( "https://mcp-server.example.com/mcp", auth=OAuth( client_metadata_url="https://myapp.example.com/oauth/client.json", ), ) as client: await client.ping() ``` When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow. You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. ## Creating a CIMD Document A CIMD document is a JSON file that describes your client. The most important field is `client_id`, which must exactly match the URL where you host the document. Use the FastMCP CLI to generate one: ```bash fastmcp auth cimd create \ --name "My Application" \ --redirect-uri "http://localhost:*/callback" \ --client-id "https://myapp.example.com/oauth/client.json" ``` This produces: ```json { "client_id": "https://myapp.example.com/oauth/client.json", "client_name": "My Application", "redirect_uris": ["http://localhost:*/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code"], "response_types": ["code"] } ``` If you omit `--client-id`, the CLI generates a placeholder value and reminds you to update it before hosting. ### CLI Options The `create` command accepts these flags: | Flag | Description | |------|-------------| | `--name` | Human-readable client name (required) | | `--redirect-uri`, `-r` | Allowed redirect URIs — can be specified multiple times (required) | | `--client-id` | The URL where you'll host this document (sets `client_id` directly) | | `--output`, `-o` | Write to a file instead of stdout | | `--scope` | Space-separated list of scopes the client may request | | `--client-uri` | URL of the client's home page | | `--logo-uri` | URL of the client's logo image | | `--no-pretty` | Output compact JSON | ### Redirect URIs The `redirect_uris` field supports wildcard port matching for localhost. The pattern `http://localhost:*/callback` matches any port, which is useful for development clients that bind to random available ports (which is what FastMCP's `OAuth` helper does by default). ## Hosting Requirements CIMD documents must be hosted at a publicly accessible HTTPS URL with a non-root path: - **HTTPS required** — HTTP URLs are rejected for security - **Non-root path** — The URL must have a path component (e.g., `/oauth/client.json`, not just `/`) - **Public accessibility** — The server must be able to fetch the document over the internet - **Matching `client_id`** — The `client_id` field in the document must exactly match the hosting URL Common hosting options include static file hosting services like GitHub Pages, Cloudflare Pages, Vercel, or S3 — anywhere you can serve a JSON file over HTTPS. ## Validating Your Document Before deploying, verify your hosted document passes validation: ```bash fastmcp auth cimd validate https://myapp.example.com/oauth/client.json ``` The validator fetches the document and checks that: - The URL is valid (HTTPS, non-root path) - The document is well-formed JSON conforming to the CIMD schema - The `client_id` in the document matches the URL it was fetched from ## How It Works When your client connects to a CIMD-enabled server, the flow works like this: Your client sends its `client_metadata_url` as the `client_id` in the OAuth authorization request. The server sees that the `client_id` is an HTTPS URL with a path — the signature of a CIMD client — and skips Dynamic Client Registration. The server fetches your JSON document from the URL, validates that `client_id` matches the URL, and extracts your client metadata (name, redirect URIs, scopes). The standard OAuth flow continues: browser opens for user consent, authorization code exchange, token issuance. The consent screen shows your verified domain. The server caches your CIMD document according to HTTP cache headers, so subsequent requests don't require re-fetching. ## Server Configuration CIMD is a server-side feature that your MCP server must support. FastMCP's OAuth proxy providers (GitHub, Google, Auth0, etc.) support CIMD by default. See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for server-side configuration, including private key JWT authentication and security details. ================================================ FILE: docs/clients/auth/oauth.mdx ================================================ --- title: OAuth Authentication sidebarTitle: OAuth description: Authenticate your FastMCP client via OAuth 2.1. icon: window --- import { VersionBadge } from "/snippets/version-badge.mdx" OAuth authentication is only relevant for HTTP-based transports and requires user interaction via a web browser. When your FastMCP client needs to access an MCP server protected by OAuth 2.1, and the process requires user interaction (like logging in and granting consent), you should use the Authorization Code Flow. FastMCP provides the `fastmcp.client.auth.OAuth` helper to simplify this entire process. This flow is common for user-facing applications where the application acts on behalf of the user. ## Client Usage ### Default Configuration The simplest way to use OAuth is to pass the string `"oauth"` to the `auth` parameter of the `Client` or transport instance. FastMCP will automatically configure the client to use OAuth with default settings: ```python {4} from fastmcp import Client # Uses default OAuth settings async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client: await client.ping() ``` ### `OAuth` Helper To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.1 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface. ```python {2, 4, 6} from fastmcp import Client from fastmcp.client.auth import OAuth oauth = OAuth(scopes=["user"]) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() ``` You don't need to pass `mcp_url` when using `OAuth` with `Client(auth=...)` — the transport provides the server URL automatically. #### `OAuth` Parameters - **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings - **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` - **`client_id`** (`str`, optional): Pre-registered OAuth client ID. When provided, skips Dynamic Client Registration entirely. See [Pre-Registered Clients](#pre-registered-clients) - **`client_secret`** (`str`, optional): OAuth client secret for pre-registered clients. Optional — public clients that rely on PKCE can omit this - **`client_metadata_url`** (`str`, optional): URL-based client identity (CIMD). See [CIMD Authentication](/clients/auth/cimd) for details - **`token_storage`** (`AsyncKeyValue`, optional): Storage backend for persisting OAuth tokens. Defaults to in-memory storage (tokens lost on restart). See [Token Storage](#token-storage) for encrypted storage options - **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration - **`callback_port`** (`int`, optional): Fixed port for OAuth callback server. If not specified, uses a random available port - **`httpx_client_factory`** (`McpHttpClientFactory`, optional): Factory for creating httpx clients ## OAuth Flow The OAuth flow is triggered when you use a FastMCP `Client` configured to use OAuth. The client first checks the configured `token_storage` backend for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client. If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. If a `client_id` is provided, the client uses those pre-registered credentials directly and skips this step entirely. Otherwise, if a `client_metadata_url` is configured and the server supports CIMD, the client uses its metadata URL as its identity. As a fallback, the client performs Dynamic Client Registration (RFC 7591) if the server supports it. A temporary local HTTP server is started on an available port (or the port specified via `callback_port`). This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. The user's default web browser is automatically opened, directing them to the OAuth server's authorization endpoint. The user logs in and grants (or denies) the requested `scopes`. Upon approval, the OAuth server redirects the user's browser to the local callback server with an `authorization_code`. The client captures this code and exchanges it with the OAuth server's token endpoint for an `access_token` (and often a `refresh_token`) using PKCE for security. The obtained tokens are saved to the configured `token_storage` backend for future use, eliminating the need for repeated browser interactions. The access token is automatically included in the `Authorization` header for requests to the MCP server. If the access token expires, the client will automatically use the refresh token to get a new access token. ## Token Storage By default, tokens are stored in memory and lost when your application restarts. For persistent storage, pass an `AsyncKeyValue`-compatible storage backend to the `token_storage` parameter. **Security Consideration**: Use encrypted storage for production. MCP clients can accumulate OAuth credentials for many servers over time, and a compromised token store could expose access to multiple services. ```python from fastmcp import Client from fastmcp.client.auth import OAuth from key_value.aio.stores.disk import DiskStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet import os # Create encrypted disk storage encrypted_storage = FernetEncryptionWrapper( key_value=DiskStore(directory="~/.fastmcp/oauth-tokens"), fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"]) ) oauth = OAuth(token_storage=encrypted_storage) async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client: await client.ping() ``` You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption. When selecting a storage backend, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have constraints that affect production suitability. ## CIMD Authentication Client ID Metadata Documents (CIMD) provide an alternative to Dynamic Client Registration. Instead of registering with each server, your client hosts a static JSON document at an HTTPS URL. That URL becomes your client's identity, and servers can verify who you are through your domain ownership. ```python from fastmcp import Client from fastmcp.client.auth import OAuth async with Client( "https://mcp-server.example.com/mcp", auth=OAuth( client_metadata_url="https://myapp.example.com/oauth/client.json", ), ) as client: await client.ping() ``` See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents. ## Pre-Registered Clients Some OAuth servers don't support Dynamic Client Registration — the MCP spec explicitly makes DCR optional. If your client has been pre-registered with the server (you already have a `client_id` and optionally a `client_secret`), you can provide them directly to skip DCR entirely. ```python from fastmcp import Client from fastmcp.client.auth import OAuth async with Client( "https://mcp-server.example.com/mcp", auth=OAuth( client_id="my-registered-client-id", client_secret="my-client-secret", ), ) as client: await client.ping() ``` Public clients that rely on PKCE for security can omit `client_secret`: ```python oauth = OAuth(client_id="my-public-client-id") ``` When using pre-registered credentials, the client will not attempt Dynamic Client Registration. If the server rejects the credentials, the error is surfaced immediately rather than falling back to DCR. ================================================ FILE: docs/clients/cli.mdx ================================================ --- title: Client CLI sidebarTitle: CLI description: Query and invoke MCP server tools directly from the terminal with fastmcp list and fastmcp call. icon: terminal --- import { VersionBadge } from '/snippets/version-badge.mdx' MCP servers are designed for programmatic consumption by AI assistants and applications. But during development, you often want to poke at a server directly: check what tools it exposes, call one with test arguments, or verify that a deployment is responding correctly. The FastMCP CLI gives you that direct access with two commands, `fastmcp list` and `fastmcp call`, so you can query and invoke any MCP server without writing a single line of Python. These commands are also valuable for LLM-based agents that lack native MCP support. An agent that can execute shell commands can use `fastmcp list --json` to discover available tools and `fastmcp call --json` to invoke them, with structured JSON output designed for programmatic consumption. ## Server Targets Both commands need to know which server to talk to. You provide a "server spec" as the first argument, and FastMCP figures out the transport automatically. You can point at an HTTP URL for a running server, a Python file that defines one, a JSON configuration file that describes one, or a JavaScript file. The CLI resolves the right connection mechanism so you can focus on the query. ```bash fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list mcp-config.json ``` Python files are handled with particular care. Rather than requiring your script to call `mcp.run()` at the bottom, the CLI routes it through `fastmcp run` internally, which means any Python file that defines a FastMCP server object works as a target with no boilerplate. For servers that communicate over stdio (common with Node.js-based MCP servers), use the `--command` flag instead of a positional server spec. The string is shell-split into a command and arguments. ```bash fastmcp list --command 'npx -y @modelcontextprotocol/server-github' ``` ### Name-Based Resolution If your MCP servers are already configured in an editor or tool, you can refer to them by name instead of spelling out URLs or file paths. The CLI scans config files from Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose, and matches the name you provide. ```bash fastmcp list weather fastmcp call weather get_forecast city=London ``` You can also use the `source:name` form to target a specific source directly, which is useful when the same server name appears in multiple configs or when you want to be explicit about which config you mean. ```bash fastmcp list claude-code:my-server fastmcp call cursor:weather get_forecast city=London ``` The available source names are `claude-desktop`, `claude-code`, `cursor`, `gemini`, `goose`, and `project` (for `./mcp.json`). Run `fastmcp discover` to see what's available. ## Discovering Configured Servers `fastmcp discover` scans your local editor and project configurations for MCP server definitions. It checks Claude Desktop, Claude Code (`~/.claude.json`), Cursor workspace configs (walking up from the current directory), Gemini CLI (`~/.gemini/settings.json`), Goose (`~/.config/goose/config.yaml`), and `mcp.json` in the current directory. ```bash fastmcp discover ``` The output groups servers by source, showing each server's name and transport. Use `--source` to filter to specific sources, and `--json` for machine-readable output. ```bash fastmcp discover --source claude-code fastmcp discover --source cursor --source gemini --json ``` Any server that appears here can be used by name (or `source:name`) with `fastmcp list` and `fastmcp call`, which means you can go from "I have a server configured in Claude Code" to querying it without copying any URLs or paths. ## Discovering Tools `fastmcp list` connects to a server and prints every tool it exposes. The default output is compact: each tool appears as a function signature with its parameter names, types, and a description. ```bash fastmcp list http://localhost:8000/mcp ``` The output looks like a Python function signature, making it easy to see at a glance what a tool expects and what it returns. Required parameters appear with just their type annotation, while optional ones show their defaults. When you need the full JSON Schema for a tool's inputs or outputs -- useful for understanding nested object structures or enum constraints -- opt into them with `--input-schema` or `--output-schema`. These print the raw schema beneath each tool signature. ### Beyond Tools MCP servers can expose resources and prompts alongside tools. By default, `fastmcp list` only shows tools because they are the most common interaction point. Add `--resources` or `--prompts` to include those in the output. ```bash fastmcp list server.py --resources --prompts ``` Resources appear with their URIs and descriptions. Prompts appear with their argument names so you can see what parameters they accept. ### Machine-Readable Output The `--json` flag switches from human-friendly text to structured JSON. Each tool includes its name, description, and full input schema (and output schema when present). When combined with `--resources` or `--prompts`, those are included as additional top-level keys. ```bash fastmcp list server.py --json ``` This is the format to use when building automation around MCP servers or feeding tool definitions to an LLM agent that needs to decide which tool to call. ## Calling Tools `fastmcp call` invokes a single tool on a server. You provide the server spec, the tool name, and arguments as `key=value` pairs. The CLI fetches the tool's schema, coerces your string values to the correct types (integers, floats, booleans, arrays, objects), and makes the call. ```bash fastmcp call http://localhost:8000/mcp search query=hello limit=5 ``` Type coercion is driven by the tool's JSON Schema. If a parameter is declared as an integer, the string `"5"` becomes the integer `5`. Booleans accept `true`/`false`, `yes`/`no`, and `1`/`0`. Array and object parameters are parsed as JSON. For tools with complex or deeply nested arguments, the `key=value` syntax gets unwieldy. You can pass a single JSON object as the argument instead, and the CLI treats it as the full input dictionary. ```bash fastmcp call server.py create_item '{"name": "Widget", "tags": ["sale", "new"], "metadata": {"color": "blue"}}' ``` Alternatively, `--input-json` provides the base argument dictionary. Any `key=value` pairs you add alongside it override keys from the JSON, which is useful for templating a complex call and varying one parameter at a time. ### Error Handling The CLI validates your call before sending it. If you misspell a tool name, it uses fuzzy matching to suggest corrections. If you omit a required argument, it tells you which ones are missing and prints the tool's signature as a reminder. When a tool call itself returns an error (the server executed the tool but it failed), the error message is printed and the CLI exits with a non-zero status code, making it straightforward to use in scripts. ### Structured Output Like `fastmcp list`, the `--json` flag on `fastmcp call` emits structured JSON instead of formatted text. The output includes the content blocks, error status, and structured content when the server provides it. Use this when you need to parse tool results programmatically. ```bash fastmcp call server.py get_weather city=London --json ``` ## Authentication When the server target is an HTTP URL, the CLI automatically enables OAuth authentication. If the server requires it, you will be guided through the OAuth flow (typically opening a browser for authorization). If the server has no auth requirements, the OAuth setup is a silent no-op. To explicitly disable authentication -- for example, when connecting to a local development server where OAuth setup would just slow you down -- pass `--auth none`. ```bash fastmcp call http://localhost:8000/mcp my_tool --auth none ``` ## Transport Override FastMCP defaults to Streamable HTTP for URL targets. If you are connecting to a server that only supports Server-Sent Events (SSE), use `--transport sse` to force the older transport. This appends `/sse` to the URL path automatically so the client picks the correct protocol. ```bash fastmcp list http://localhost:8000 --transport sse ``` ## Interactive Elicitation Some MCP tools request additional input from the user during execution through a mechanism called elicitation. When a tool sends an elicitation request, the CLI prints the server's question to the terminal and prompts you to respond. Each field in the elicitation schema is presented with its name and expected type, and required fields are clearly marked. You can type `decline` to skip a question or `cancel` to abort the tool call entirely. This interactive behavior means the CLI works naturally with tools that have multi-step or conversational workflows. ## LLM Agent Integration For LLM agents that can execute shell commands but lack built-in MCP support, the CLI provides a clean integration path. The agent calls `fastmcp list --json` to get a structured description of every available tool, including full input schemas, and then calls `fastmcp call --json` with the chosen tool and arguments. Both commands return well-formed JSON that is straightforward to parse. Because the CLI handles connection management, transport selection, and type coercion internally, the agent does not need to understand MCP protocol details. It just needs to read JSON and construct shell commands. ================================================ FILE: docs/clients/client.mdx ================================================ --- title: The FastMCP Client sidebarTitle: Overview description: Programmatic client for interacting with MCP servers through a well-typed, Pythonic interface. icon: user-robot --- import { VersionBadge } from '/snippets/version-badge.mdx' The `fastmcp.Client` class provides a programmatic interface for interacting with any MCP server. It handles protocol details and connection management automatically, letting you focus on the operations you want to perform. The FastMCP Client is designed for deterministic, controlled interactions rather than autonomous behavior, making it ideal for testing MCP servers during development, building deterministic applications that need reliable MCP interactions, and creating the foundation for agentic or LLM-based clients with structured, type-safe operations. This is a programmatic client that requires explicit function calls and provides direct control over all MCP operations. Use it as a building block for higher-level systems. ## Creating a Client You provide a server source and the client automatically infers the appropriate transport mechanism. ```python import asyncio from fastmcp import Client, FastMCP # In-memory server (ideal for testing) server = FastMCP("TestServer") client = Client(server) # HTTP server client = Client("https://example.com/mcp") # Local Python script client = Client("my_mcp_server.py") async def main(): async with client: # Basic server interaction await client.ping() # List available operations tools = await client.list_tools() resources = await client.list_resources() prompts = await client.list_prompts() # Execute operations result = await client.call_tool("example_tool", {"param": "value"}) print(result) asyncio.run(main()) ``` All client operations require using the `async with` context manager for proper connection lifecycle management. ## Choosing a Transport The client automatically selects a transport based on what you pass to it, but different transports have different characteristics that matter for your use case. **In-memory transport** connects directly to a FastMCP server instance within the same Python process. Use this for testing and development where you want to eliminate subprocess and network complexity. The server shares your process's environment and memory space. ```python from fastmcp import Client, FastMCP server = FastMCP("TestServer") client = Client(server) # In-memory, no network or subprocess ``` **STDIO transport** launches a server as a subprocess and communicates through stdin/stdout pipes. This is the standard mechanism used by desktop clients like Claude Desktop. The subprocess runs in an isolated environment, so you must explicitly pass any environment variables the server needs. ```python from fastmcp import Client # Simple inference from file path client = Client("my_server.py") # With explicit environment configuration client = Client("my_server.py", env={"API_KEY": "secret"}) ``` **HTTP transport** connects to servers running as web services. Use this for production deployments where the server runs independently and manages its own lifecycle. ```python from fastmcp import Client client = Client("https://api.example.com/mcp") ``` See [Transports](/clients/transports) for detailed configuration options including authentication headers, session persistence, and multi-server configurations. ## Configuration-Based Clients Create clients from MCP configuration dictionaries, which can include multiple servers. While there is no official standard for MCP configuration format, FastMCP follows established conventions used by tools like Claude Desktop. ```python config = { "mcpServers": { "weather": { "url": "https://weather-api.example.com/mcp" }, "assistant": { "command": "python", "args": ["./assistant_server.py"] } } } client = Client(config) async with client: # Tools are prefixed with server names weather_data = await client.call_tool("weather_get_forecast", {"city": "London"}) response = await client.call_tool("assistant_answer_question", {"question": "What's the capital of France?"}) # Resources use prefixed URIs icons = await client.read_resource("weather://weather/icons/sunny") ``` ## Connection Lifecycle The client uses context managers for connection management. When you enter the context, the client establishes a connection and performs an MCP initialization handshake with the server. This handshake exchanges capabilities, server metadata, and instructions. ```python from fastmcp import Client, FastMCP mcp = FastMCP(name="MyServer", instructions="Use the greet tool to say hello!") @mcp.tool def greet(name: str) -> str: """Greet a user by name.""" return f"Hello, {name}!" async with Client(mcp) as client: # Initialization already happened automatically print(f"Server: {client.initialize_result.serverInfo.name}") print(f"Instructions: {client.initialize_result.instructions}") print(f"Capabilities: {client.initialize_result.capabilities.tools}") ``` For advanced scenarios where you need precise control over when initialization happens, disable automatic initialization and call `initialize()` manually: ```python from fastmcp import Client client = Client("my_mcp_server.py", auto_initialize=False) async with client: # Connection established, but not initialized yet print(f"Connected: {client.is_connected()}") print(f"Initialized: {client.initialize_result is not None}") # False # Initialize manually with custom timeout result = await client.initialize(timeout=10.0) print(f"Server: {result.serverInfo.name}") # Now ready for operations tools = await client.list_tools() ``` ## Operations FastMCP clients interact with three types of server components. **Tools** are server-side functions that the client can execute with arguments. Call them with `call_tool()` and receive structured results. ```python async with client: tools = await client.list_tools() result = await client.call_tool("multiply", {"a": 5, "b": 3}) print(result.data) # 15 ``` See [Tools](/clients/tools) for detailed documentation including version selection, error handling, and structured output. **Resources** are data sources that the client can read, either static or templated. Access them with `read_resource()` using URIs. ```python async with client: resources = await client.list_resources() content = await client.read_resource("file:///config/settings.json") print(content[0].text) ``` See [Resources](/clients/resources) for detailed documentation including templates and binary content. **Prompts** are reusable message templates that can accept arguments. Retrieve rendered prompts with `get_prompt()`. ```python async with client: prompts = await client.list_prompts() messages = await client.get_prompt("analyze_data", {"data": [1, 2, 3]}) print(messages.messages) ``` See [Prompts](/clients/prompts) for detailed documentation including argument serialization. ## Callback Handlers The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications. ```python from fastmcp import Client from fastmcp.client.logging import LogMessage async def log_handler(message: LogMessage): print(f"Server log: {message.data}") async def progress_handler(progress: float, total: float | None, message: str | None): print(f"Progress: {progress}/{total} - {message}") async def sampling_handler(messages, params, context): # Integrate with your LLM service here return "Generated response" client = Client( "my_mcp_server.py", log_handler=log_handler, progress_handler=progress_handler, sampling_handler=sampling_handler, timeout=30.0 ) ``` Each handler type has its own documentation: - **[Sampling](/clients/sampling)** - Respond to server LLM requests - **[Elicitation](/clients/elicitation)** - Handle server requests for user input - **[Progress](/clients/progress)** - Monitor long-running operations - **[Logging](/clients/logging)** - Handle server log messages - **[Roots](/clients/roots)** - Provide local context to servers The FastMCP Client is designed as a foundational tool. Use it directly for deterministic operations, or build higher-level agentic systems on top of its reliable, type-safe interface. ================================================ FILE: docs/clients/elicitation.mdx ================================================ --- title: User Elicitation sidebarTitle: Elicitation description: Handle server requests for structured user input. icon: message-question --- import { VersionBadge } from "/snippets/version-badge.mdx"; Use this when you need to respond to server requests for user input during tool execution. Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context. ## Handler Template ```python from fastmcp import Client from fastmcp.client.elicitation import ElicitResult, ElicitRequestParams, RequestContext async def elicitation_handler( message: str, response_type: type | None, params: ElicitRequestParams, context: RequestContext ) -> ElicitResult | object: """ Handle server requests for user input. Args: message: The prompt to display to the user response_type: Python dataclass type for the response (None if no data expected) params: Original MCP elicitation parameters including raw JSON schema context: Request context with metadata Returns: - Data directly (implicitly accepts the elicitation) - ElicitResult for explicit control over the action """ # Present the message and collect input user_input = input(f"{message}: ") if not user_input: return ElicitResult(action="decline") # Create response using the provided dataclass type return response_type(value=user_input) client = Client( "my_mcp_server.py", elicitation_handler=elicitation_handler, ) ``` ## How It Works When a server needs user input, it sends an elicitation request with a message prompt and a JSON schema describing the expected response structure. FastMCP automatically converts this schema into a Python dataclass type, making it easy to construct properly typed responses without manually parsing JSON schemas. The handler receives four parameters: The prompt message to display to the user A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing. If the server requests an empty object, this will be `None`. The original MCP elicitation parameters, including the raw JSON schema in `params.requestedSchema` Request context containing metadata about the elicitation request ## Response Actions You can return data directly, which implicitly accepts the elicitation: ```python async def elicitation_handler(message, response_type, params, context): user_input = input(f"{message}: ") return response_type(value=user_input) # Implicit accept ``` Or return an `ElicitResult` for explicit control over the action: ```python from fastmcp.client.elicitation import ElicitResult async def elicitation_handler(message, response_type, params, context): user_input = input(f"{message}: ") if not user_input: return ElicitResult(action="decline") # User declined if user_input == "cancel": return ElicitResult(action="cancel") # Cancel entire operation return ElicitResult( action="accept", content=response_type(value=user_input) ) ``` **Action types:** - **`accept`**: User provided valid input. Include the data in the `content` field. - **`decline`**: User chose not to provide the requested information. Omit `content`. - **`cancel`**: User cancelled the entire operation. Omit `content`. ## Example A file management tool might ask which directory to create: ```python from fastmcp import Client from fastmcp.client.elicitation import ElicitResult async def elicitation_handler(message, response_type, params, context): print(f"Server asks: {message}") user_response = input("Your response: ") if not user_response: return ElicitResult(action="decline") # Use the response_type dataclass to create a properly structured response return response_type(value=user_response) client = Client( "my_mcp_server.py", elicitation_handler=elicitation_handler ) ``` ================================================ FILE: docs/clients/generate-cli.mdx ================================================ --- title: Generate CLI sidebarTitle: Generate CLI description: Turn any MCP server into a standalone, typed command-line tool. icon: wand-magic-sparkles tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' `fastmcp list` and `fastmcp call` let you poke at a server interactively, but they're developer tools — you always have to spell out the server spec, the tool name, and the arguments. `fastmcp generate-cli` takes the next step: it connects to a server, reads its schemas, and writes a standalone Python script where every tool is a proper subcommand with typed flags, help text, and tab completion. The result is a CLI that feels like it was hand-written for that specific server. The key insight is that MCP tool schemas already contain everything a CLI framework needs: parameter names, types, descriptions, required/optional status, and defaults. `generate-cli` maps that schema into [cyclopts](https://cyclopts.readthedocs.io/) commands, so JSON Schema types become Python type annotations, descriptions become `--help` text, and required parameters become mandatory flags. ## Generating a Script Point the command at any server spec — URLs, Python files, discovered server names, MCPConfig JSON — and it writes a CLI script: ```bash fastmcp generate-cli weather fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py ``` The second positional argument sets the output path. When omitted, it defaults to `cli.py`. If either the CLI file or its companion `SKILL.md` already exists, the command refuses to overwrite unless you pass `-f`: ```bash fastmcp generate-cli weather -f fastmcp generate-cli weather my_cli.py -f ``` Name-based resolution works here too, so if you have a server configured in Claude Desktop, Cursor, or any other supported editor, you can reference it by name. Run [`fastmcp discover`](/clients/cli#discovering-configured-servers) to see what's available. ```bash fastmcp generate-cli claude-code:my-server output.py ``` The `--timeout` and `--auth` flags work the same way they do in `fastmcp list` and `fastmcp call`. ## What You Get The generated script is a regular Python file — executable, editable, and yours. Here's what it looks like in practice: ``` $ python cli.py --help Usage: weather-cli COMMAND CLI for weather MCP server Commands: call-tool Call a tool on the server list-tools List available tools. list-resources List available resources. read-resource Read a resource by URI. list-prompts List available prompts. get-prompt Get a prompt by name. Pass arguments as key=value pairs. ``` The `call-tool` subcommand is where the generated code lives. Each tool on the server becomes its own command: ``` $ python cli.py call-tool --help Usage: weather-cli call-tool COMMAND Call a tool on the server Commands: get_forecast Get the weather forecast for a city. search_city Search for a city by name. ``` And each tool has typed parameters with help text pulled directly from the server's schema: ``` $ python cli.py call-tool get_forecast --help Usage: weather-cli call-tool get_forecast [OPTIONS] Get the weather forecast for a city. Options: --city [str] City name (required) --days [int] Number of forecast days (default: 3) ``` Tool names are preserved exactly as the server defines them — underscores stay as underscores, so `call-tool get_forecast` matches what the server expects. ## Agent Skill Alongside the CLI script, `generate-cli` also writes a `SKILL.md` file — a [Claude Code agent skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) that documents the generated CLI. The skill includes every tool's exact invocation syntax, parameter flags with types and descriptions, and the utility commands, so an agent can use the CLI immediately without running `--help` or experimenting with flag names. The skill is written to the same directory as the CLI script. For a weather server, it looks something like: ````markdown --- name: "weather-cli" description: "CLI for the weather MCP server. Call tools, list resources, and get prompts." --- # weather CLI ## Tool Commands ### get_forecast Get the weather forecast for a city. ```bash uv run --with fastmcp python cli.py call-tool get_forecast --city --days ``` | Flag | Type | Required | Description | |------|------|----------|-------------| | `--city` | string | yes | City name | | `--days` | integer | no | Number of forecast days | ```` To skip skill generation, pass `--no-skill`: ```bash fastmcp generate-cli weather --no-skill ``` ## How It Works The generated script is a client, not a server. It doesn't bundle or embed the MCP server — it connects to it on every invocation. For URL-based servers, the server needs to be running. For stdio-based servers, the command specified in `CLIENT_SPEC` must be available on the system's `PATH`. At the top of the generated file, a `CLIENT_SPEC` variable holds the resolved transport: either a URL string or a `StdioTransport` with the command and arguments baked in. Every invocation connects through this spec, so the script works without any external configuration. ### Parameter Handling Parameters are mapped intelligently based on their complexity: **Simple types** (`string`, `integer`, `number`, `boolean`) become typed Python parameters with clean flags: ```bash python cli.py call-tool get_forecast --city London --days 3 ``` **Arrays of simple types** (`array` with `string`/`integer`/`number`/`boolean` items) become `list[T]` parameters that accept multiple flags: ```bash python cli.py call-tool tag_items --tags python --tags fastapi --tags mcp ``` **Complex types** (objects, nested arrays, or unions) accept JSON strings. The tool's `--help` displays the full JSON schema so you know exactly what structure to pass: ```bash python cli.py call-tool create_user \ --name John \ --metadata '{"role": "admin", "dept": "engineering"}' ``` Required parameters are mandatory flags; optional ones default to their schema default or `None`. Empty values are filtered out before calling the server. Beyond tool commands, the script includes generic commands that work regardless of what the server exposes: `list-tools`, `list-resources`, `read-resource`, `list-prompts`, and `get-prompt`. These connect to the server at runtime, so they always reflect the server's current state even if the tools have changed since generation. ## Editing the Output The most common edit is changing `CLIENT_SPEC`. If you generated from a local dev server and want to point at production, just change the string. If you generated from a discovered name and want to pin the transport, replace it with an explicit URL or `StdioTransport`. Beyond that, it's a regular Python file. You can add commands, change the output formatting, integrate it into a larger application, or strip out the parts you don't need. The helper functions (`_call_tool`, `_print_tool_result`) are thin wrappers around `fastmcp.Client` that are easy to adapt. The generated script requires `fastmcp` as a dependency. If the script lives outside a project that already has fastmcp installed, `uv run` is the easiest way to run it without permanent installation: ```bash uv run --with fastmcp python cli.py call-tool get_forecast --city London ``` ================================================ FILE: docs/clients/logging.mdx ================================================ --- title: Server Logging sidebarTitle: Logging description: Receive and handle log messages from MCP servers. icon: receipt --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to capture or process log messages sent by the server. MCP servers can emit log messages to clients. The client handles these through a log handler callback. ## Log Handler Provide a `log_handler` function when creating the client: ```python import logging from fastmcp import Client from fastmcp.client.logging import LogMessage logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() async def log_handler(message: LogMessage): """Forward MCP server logs to Python's logging system.""" msg = message.data.get('msg') extra = message.data.get('extra') level = LOGGING_LEVEL_MAP.get(message.level.upper(), logging.INFO) logger.log(level, msg, extra=extra) client = Client( "my_mcp_server.py", log_handler=log_handler, ) ``` The handler receives a `LogMessage` object: The log level The logger name (may be None) The log payload, containing `msg` and `extra` keys ## Structured Logs The `message.data` attribute is a dictionary containing the log payload. This enables structured logging with rich contextual information. ```python async def detailed_log_handler(message: LogMessage): msg = message.data.get('msg') extra = message.data.get('extra') if message.level == "error": print(f"ERROR: {msg} | Details: {extra}") elif message.level == "warning": print(f"WARNING: {msg} | Details: {extra}") else: print(f"{message.level.upper()}: {msg}") ``` This structure is preserved even when logs are forwarded through a FastMCP proxy, making it useful for debugging multi-server applications. ## Default Behavior If you do not provide a custom `log_handler`, FastMCP's default handler routes server logs to Python's logging system at the appropriate severity level. The MCP levels map as follows: `notice` becomes INFO; `alert` and `emergency` become CRITICAL. ```python client = Client("my_mcp_server.py") async with client: # Server logs are forwarded at proper severity automatically await client.call_tool("some_tool") ``` ================================================ FILE: docs/clients/notifications.mdx ================================================ --- title: Notifications sidebarTitle: Notifications description: Handle server-sent notifications for list changes and other events. icon: envelope --- import { VersionBadge } from "/snippets/version-badge.mdx"; Use this when you need to react to server-side changes like tool list updates or resource modifications. MCP servers can send notifications to inform clients about state changes. The message handler provides a unified way to process these notifications. ## Handling Notifications The simplest approach is a function that receives all messages and filters for the notifications you care about: ```python from fastmcp import Client async def message_handler(message): """Handle MCP notifications from the server.""" if hasattr(message, 'root'): method = message.root.method if method == "notifications/tools/list_changed": print("Tools have changed - refresh tool cache") elif method == "notifications/resources/list_changed": print("Resources have changed") elif method == "notifications/prompts/list_changed": print("Prompts have changed") client = Client( "my_mcp_server.py", message_handler=message_handler, ) ``` ## MessageHandler Class For fine-grained targeting, subclass `MessageHandler` to use specific hooks: ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler import mcp.types class MyMessageHandler(MessageHandler): async def on_tool_list_changed( self, notification: mcp.types.ToolListChangedNotification ) -> None: """Handle tool list changes.""" print("Tool list changed - refreshing available tools") async def on_resource_list_changed( self, notification: mcp.types.ResourceListChangedNotification ) -> None: """Handle resource list changes.""" print("Resource list changed") async def on_prompt_list_changed( self, notification: mcp.types.PromptListChangedNotification ) -> None: """Handle prompt list changes.""" print("Prompt list changed") client = Client( "my_mcp_server.py", message_handler=MyMessageHandler(), ) ``` ### Handler Template ```python from fastmcp.client.messages import MessageHandler import mcp.types class MyMessageHandler(MessageHandler): async def on_message(self, message) -> None: """Called for ALL messages (requests and notifications).""" pass async def on_notification( self, notification: mcp.types.ServerNotification ) -> None: """Called for notifications (fire-and-forget).""" pass async def on_tool_list_changed( self, notification: mcp.types.ToolListChangedNotification ) -> None: """Called when the server's tool list changes.""" pass async def on_resource_list_changed( self, notification: mcp.types.ResourceListChangedNotification ) -> None: """Called when the server's resource list changes.""" pass async def on_prompt_list_changed( self, notification: mcp.types.PromptListChangedNotification ) -> None: """Called when the server's prompt list changes.""" pass async def on_progress( self, notification: mcp.types.ProgressNotification ) -> None: """Called for progress updates during long-running operations.""" pass async def on_logging_message( self, notification: mcp.types.LoggingMessageNotification ) -> None: """Called for log messages from the server.""" pass ``` ## List Change Notifications A practical example of maintaining a tool cache that refreshes when tools change: ```python from fastmcp import Client from fastmcp.client.messages import MessageHandler import mcp.types class ToolCacheHandler(MessageHandler): def __init__(self): self.cached_tools = [] async def on_tool_list_changed( self, notification: mcp.types.ToolListChangedNotification ) -> None: """Clear tool cache when tools change.""" print("Tools changed - clearing cache") self.cached_tools = [] # Force refresh on next access client = Client("server.py", message_handler=ToolCacheHandler()) ``` ## Server Requests While the message handler receives server-initiated requests, you should use dedicated callback parameters for most interactive scenarios: - **Sampling requests**: Use [`sampling_handler`](/clients/sampling) - **Elicitation requests**: Use [`elicitation_handler`](/clients/elicitation) - **Progress updates**: Use [`progress_handler`](/clients/progress) - **Log messages**: Use [`log_handler`](/clients/logging) The message handler is primarily for monitoring and handling notifications rather than responding to requests. ================================================ FILE: docs/clients/progress.mdx ================================================ --- title: Progress Monitoring sidebarTitle: Progress description: Handle progress notifications from long-running server operations. icon: bars-progress --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to track progress of long-running operations. MCP servers can report progress during operations. The client receives these updates through a progress handler. ## Progress Handler Set a handler when creating the client: ```python from fastmcp import Client async def progress_handler( progress: float, total: float | None, message: str | None ) -> None: if total is not None: percentage = (progress / total) * 100 print(f"Progress: {percentage:.1f}% - {message or ''}") else: print(f"Progress: {progress} - {message or ''}") client = Client( "my_mcp_server.py", progress_handler=progress_handler ) ``` The handler receives three parameters: Current progress value Expected total value (may be None if unknown) Optional status message ## Per-Call Handler Override the client-level handler for specific tool calls: ```python async with client: result = await client.call_tool( "long_running_task", {"param": "value"}, progress_handler=my_progress_handler ) ``` ================================================ FILE: docs/clients/prompts.mdx ================================================ --- title: Getting Prompts sidebarTitle: Prompts description: Retrieve rendered message templates with automatic argument serialization. icon: message-lines --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to retrieve server-defined message templates for LLM interactions. Prompts are reusable message templates exposed by MCP servers. They can accept arguments to generate personalized message sequences for LLM interactions. ## Basic Usage Request a rendered prompt with `get_prompt()`: ```python async with client: # Simple prompt without arguments result = await client.get_prompt("welcome_message") # result -> mcp.types.GetPromptResult # Access the generated messages for message in result.messages: print(f"Role: {message.role}") print(f"Content: {message.content}") ``` Pass arguments to customize the prompt: ```python async with client: result = await client.get_prompt("user_greeting", { "name": "Alice", "role": "administrator" }) for message in result.messages: print(f"Generated message: {message.content}") ``` ## Argument Serialization FastMCP automatically serializes complex arguments to JSON strings as required by the MCP specification. You can pass typed objects directly: ```python from dataclasses import dataclass @dataclass class UserData: name: str age: int async with client: result = await client.get_prompt("analyze_user", { "user": UserData(name="Alice", age=30), # Automatically serialized "preferences": {"theme": "dark"}, # Dict serialized "scores": [85, 92, 78], # List serialized "simple_name": "Bob" # Strings unchanged }) ``` The client handles serialization using `pydantic_core.to_json()` for consistent formatting. FastMCP servers automatically deserialize these JSON strings back to the expected types. ## Working with Results The `get_prompt()` method returns a `GetPromptResult` containing a list of messages: ```python async with client: result = await client.get_prompt("conversation_starter", {"topic": "climate"}) for i, message in enumerate(result.messages): print(f"Message {i + 1}:") print(f" Role: {message.role}") print(f" Content: {message.content.text if hasattr(message.content, 'text') else message.content}") ``` Prompts can generate different message types. System messages configure LLM behavior: ```python async with client: result = await client.get_prompt("system_configuration", { "role": "helpful assistant", "expertise": "python programming" }) # Access the returned messages message = result.messages[0] print(f"Prompt: {message.content}") ``` Conversation templates generate multi-turn flows: ```python async with client: result = await client.get_prompt("interview_template", { "candidate_name": "Alice", "position": "Senior Developer" }) # Multiple messages for a conversation flow for message in result.messages: print(f"{message.role}: {message.content}") ``` ## Version Selection When a server exposes multiple versions of a prompt, you can request a specific version: ```python async with client: # Get the highest version (default) result = await client.get_prompt("summarize", {"text": "..."}) # Get a specific version result_v1 = await client.get_prompt("summarize", {"text": "..."}, version="1.0") ``` See [Metadata](/servers/versioning#version-discovery) for how to discover available versions. ## Multi-Server Clients When using multi-server clients, prompts are accessible directly without prefixing: ```python async with client: # Multi-server client result1 = await client.get_prompt("weather_prompt", {"city": "London"}) result2 = await client.get_prompt("assistant_prompt", {"query": "help"}) ``` ## Raw Protocol Access For complete control, use `get_prompt_mcp()` which returns the full MCP protocol object: ```python async with client: result = await client.get_prompt_mcp("example_prompt", {"arg": "value"}) # result -> mcp.types.GetPromptResult ``` ================================================ FILE: docs/clients/resources.mdx ================================================ --- title: Reading Resources sidebarTitle: Resources description: Access static and templated data sources from MCP servers. icon: folder-open --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to read data from server-exposed resources like configuration files, generated content, or external data sources. Resources are data sources exposed by MCP servers. They can be static files with fixed content, or dynamic templates that generate content based on parameters in the URI. ## Reading Resources Read a resource using its URI: ```python async with client: content = await client.read_resource("file:///path/to/README.md") # content -> list[TextResourceContents | BlobResourceContents] # Access text content if hasattr(content[0], 'text'): print(content[0].text) # Access binary content if hasattr(content[0], 'blob'): print(f"Binary data: {len(content[0].blob)} bytes") ``` Resource templates generate content based on URI parameters. The template defines a pattern like `weather://{{city}}/current`, and you fill in the parameters when reading: ```python async with client: # Read from a resource template weather_content = await client.read_resource("weather://london/current") print(weather_content[0].text) ``` ## Content Types Resources return different content types depending on what they expose. Text resources include configuration files, JSON data, and other human-readable content: ```python async with client: content = await client.read_resource("resource://config/settings.json") for item in content: if hasattr(item, 'text'): print(f"Text content: {item.text}") print(f"MIME type: {item.mimeType}") ``` Binary resources include images, PDFs, and other non-text data: ```python async with client: content = await client.read_resource("resource://images/logo.png") for item in content: if hasattr(item, 'blob'): print(f"Binary content: {len(item.blob)} bytes") print(f"MIME type: {item.mimeType}") # Save to file with open("downloaded_logo.png", "wb") as f: f.write(item.blob) ``` ## Multi-Server Clients When using multi-server clients, resource URIs are prefixed with the server name: ```python async with client: # Multi-server client weather_icons = await client.read_resource("weather://weather/icons/sunny") templates = await client.read_resource("resource://assistant/templates/list") ``` ## Version Selection When a server exposes multiple versions of a resource, you can request a specific version: ```python async with client: # Read the highest version (default) content = await client.read_resource("data://config") # Read a specific version content_v1 = await client.read_resource("data://config", version="1.0") ``` See [Metadata](/servers/versioning#version-discovery) for how to discover available versions. ## Raw Protocol Access For complete control, use `read_resource_mcp()` which returns the full MCP protocol object: ```python async with client: result = await client.read_resource_mcp("resource://example") # result -> mcp.types.ReadResourceResult ``` ================================================ FILE: docs/clients/roots.mdx ================================================ --- title: Client Roots sidebarTitle: Roots description: Provide local context and resource boundaries to MCP servers. icon: folder-tree --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to tell servers what local resources the client has access to. Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses. ## Static Roots Provide a list of roots when creating the client: ```python from fastmcp import Client client = Client( "my_mcp_server.py", roots=["/path/to/root1", "/path/to/root2"] ) ``` ## Dynamic Roots Use a callback to compute roots dynamically when the server requests them: ```python from fastmcp import Client from fastmcp.client.roots import RequestContext async def roots_callback(context: RequestContext) -> list[str]: print(f"Server requested roots (Request ID: {context.request_id})") return ["/path/to/root1", "/path/to/root2"] client = Client( "my_mcp_server.py", roots=roots_callback ) ``` ================================================ FILE: docs/clients/sampling.mdx ================================================ --- title: LLM Sampling sidebarTitle: Sampling description: Handle server-initiated LLM completion requests. icon: robot --- import { VersionBadge } from "/snippets/version-badge.mdx"; Use this when you need to respond to server requests for LLM completions. MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made. ## Handler Template ```python from fastmcp import Client from fastmcp.client.sampling import SamplingMessage, SamplingParams, RequestContext async def sampling_handler( messages: list[SamplingMessage], params: SamplingParams, context: RequestContext ) -> str: """ Handle server requests for LLM completions. Args: messages: Conversation messages to send to the LLM params: Sampling parameters (temperature, max_tokens, etc.) context: Request context with metadata Returns: Generated text response from your LLM """ # Extract message content conversation = [] for message in messages: content = message.content.text if hasattr(message.content, 'text') else str(message.content) conversation.append(f"{message.role}: {content}") # Use the system prompt if provided system_prompt = params.systemPrompt or "You are a helpful assistant." # Integrate with your LLM service here return "Generated response based on the messages" client = Client( "my_mcp_server.py", sampling_handler=sampling_handler, ) ``` ## Handler Parameters The role of the message The content of the message. TextContent has a `.text` attribute. Optional system prompt the server wants to use Server preferences for model selection (hints, cost/speed/intelligence priorities) Sampling temperature Maximum tokens to generate Stop sequences for sampling Tools the LLM can use during sampling Tool usage behavior (`auto`, `required`, or `none`) ## Built-in Handlers FastMCP provides built-in handlers for OpenAI, Anthropic, and Google Gemini APIs that support the full sampling API including tool use. ### OpenAI Handler ```python from fastmcp import Client from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler client = Client( "my_mcp_server.py", sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"), ) ``` For OpenAI-compatible APIs (like local models): ```python from openai import AsyncOpenAI client = Client( "my_mcp_server.py", sampling_handler=OpenAISamplingHandler( default_model="llama-3.1-70b", client=AsyncOpenAI(base_url="http://localhost:8000/v1"), ), ) ``` Install the OpenAI handler with `pip install fastmcp[openai]`. ### Anthropic Handler ```python from fastmcp import Client from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler client = Client( "my_mcp_server.py", sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"), ) ``` Install the Anthropic handler with `pip install fastmcp[anthropic]`. ### Google Gemini Handler ```python from fastmcp import Client from fastmcp.client.sampling.handlers.google_genai import GoogleGenAISamplingHandler client = Client( "my_mcp_server.py", sampling_handler=GoogleGenAISamplingHandler(default_model="gemini-2.0-flash"), ) ``` Install the Google Gemini handler with `pip install fastmcp[gemini]`. ## Sampling Capabilities When you provide a `sampling_handler`, FastMCP automatically advertises full sampling capabilities to the server, including tool support. To disable tool support for simpler handlers: ```python from mcp.types import SamplingCapability client = Client( "my_mcp_server.py", sampling_handler=basic_handler, sampling_capabilities=SamplingCapability(), # No tool support ) ``` ## Tool Execution Tool execution happens on the server side. The client's role is to pass tools to the LLM and return the LLM's response (which may include tool use requests). The server then executes the tools and may send follow-up sampling requests with tool results. To implement a custom sampling handler, see the [handler source code](https://github.com/PrefectHQ/fastmcp/tree/main/src/fastmcp/client/sampling/handlers) as a reference. ================================================ FILE: docs/clients/tasks.mdx ================================================ --- title: Background Tasks sidebarTitle: Tasks description: Execute operations asynchronously and track their progress. icon: clock tag: "NEW" --- import { VersionBadge } from "/snippets/version-badge.mdx" Use this when you need to run long operations asynchronously while doing other work. The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results. ## Requesting Background Execution Pass `task=True` to run an operation as a background task: ```python from fastmcp import Client async with Client(server) as client: # Start a background task task = await client.call_tool("slow_computation", {"duration": 10}, task=True) print(f"Task started: {task.task_id}") # Do other work while it runs... # Get the result when ready result = await task.result() ``` This works with tools, resources, and prompts: ```python tool_task = await client.call_tool("my_tool", args, task=True) resource_task = await client.read_resource("file://large.txt", task=True) prompt_task = await client.get_prompt("my_prompt", args, task=True) ``` ## Task API All task types share a common interface. ### Getting Results Call `await task.result()` or simply `await task` to block until the task completes: ```python task = await client.call_tool("analyze", {"text": "hello"}, task=True) # Wait for result (blocking) result = await task.result() # or: result = await task ``` ### Checking Status Check the current status without blocking: ```python status = await task.status() print(f"{status.status}: {status.statusMessage}") # status.status is "working", "completed", "failed", or "cancelled" ``` ### Waiting with Control Use `task.wait()` for more control over waiting: ```python # Wait up to 30 seconds for completion status = await task.wait(timeout=30.0) # Wait for a specific state status = await task.wait(state="completed", timeout=30.0) ``` ### Cancellation Cancel a running task: ```python await task.cancel() ``` ## Status Updates Register callbacks to receive real-time status updates as the server reports progress: ```python def on_status_change(status): print(f"Task {status.taskId}: {status.status} - {status.statusMessage}") task.on_status_change(on_status_change) # Async callbacks work too async def on_status_async(status): await log_status(status) task.on_status_change(on_status_async) ``` ### Handler Template ```python from fastmcp import Client def status_handler(status): """ Handle task status updates. Args: status: Task status object with: - taskId: Unique task identifier - status: "working", "completed", "failed", or "cancelled" - statusMessage: Optional progress message from server """ if status.status == "working": print(f"Progress: {status.statusMessage}") elif status.status == "completed": print("Task completed") elif status.status == "failed": print(f"Task failed: {status.statusMessage}") task.on_status_change(status_handler) ``` ## Graceful Degradation You can always pass `task=True` regardless of whether the server supports background tasks. Per the MCP specification, servers without task support execute the operation immediately and return the result inline. ```python task = await client.call_tool("my_tool", args, task=True) if task.returned_immediately: print("Server executed immediately (no background support)") else: print("Running in background") # Either way, this works result = await task.result() ``` This lets you write task-aware client code without worrying about server capabilities. ## Example ```python import asyncio from fastmcp import Client async def main(): async with Client(server) as client: # Start background task task = await client.call_tool( "slow_computation", {"duration": 10}, task=True, ) # Subscribe to updates def on_update(status): print(f"Progress: {status.statusMessage}") task.on_status_change(on_update) # Do other work while task runs print("Doing other work...") await asyncio.sleep(2) # Wait for completion and get result result = await task.result() print(f"Result: {result.content}") asyncio.run(main()) ``` See [Server Background Tasks](/servers/tasks) for how to enable background task support on the server side. ================================================ FILE: docs/clients/tools.mdx ================================================ --- title: Calling Tools sidebarTitle: Tools description: Execute server-side tools and handle structured results. icon: wrench --- import { VersionBadge } from '/snippets/version-badge.mdx' Use this when you need to execute server-side functions and process their results. Tools are executable functions exposed by MCP servers. The client's `call_tool()` method executes a tool by name with arguments and returns structured results. ## Basic Execution ```python async with client: result = await client.call_tool("add", {"a": 5, "b": 3}) # result -> CallToolResult with structured and unstructured data # Access structured data (automatically deserialized) print(result.data) # 8 # Access traditional content blocks print(result.content[0].text) # "8" ``` Arguments are passed as a dictionary. For multi-server clients, tool names are automatically prefixed with the server name (e.g., `weather_get_forecast` for a tool named `get_forecast` on the `weather` server). ## Execution Options The `call_tool()` method supports timeout control and progress monitoring: ```python async with client: # With timeout (aborts if execution takes longer than 2 seconds) result = await client.call_tool( "long_running_task", {"param": "value"}, timeout=2.0 ) # With progress handler result = await client.call_tool( "long_running_task", {"param": "value"}, progress_handler=my_progress_handler ) ``` ## Structured Results Tool execution returns a `CallToolResult` object. The `.data` property provides fully hydrated Python objects including complex types like datetimes and UUIDs, reconstructed from the server's output schema. ```python from datetime import datetime from uuid import UUID async with client: result = await client.call_tool("get_weather", {"city": "London"}) # FastMCP reconstructs complete Python objects weather = result.data print(f"Temperature: {weather.temperature}C at {weather.timestamp}") # Complex types are properly deserialized assert isinstance(weather.timestamp, datetime) assert isinstance(weather.station_id, UUID) # Raw structured JSON is also available print(f"Raw JSON: {result.structured_content}") ``` Fully hydrated Python objects with complex type support (datetimes, UUIDs, custom classes). FastMCP exclusive. Standard MCP content blocks (`TextContent`, `ImageContent`, `AudioContent`, etc.). Standard MCP structured JSON data as sent by the server. Boolean indicating if the tool execution failed. For tools without output schemas or when deserialization fails, `.data` will be `None`. Fall back to content blocks in that case: ```python async with client: result = await client.call_tool("legacy_tool", {"param": "value"}) if result.data is not None: print(f"Structured: {result.data}") else: for content in result.content: if hasattr(content, 'text'): print(f"Text result: {content.text}") ``` FastMCP servers automatically wrap primitive results (like `int`, `str`, `bool`) in a `{"result": value}` structure. FastMCP clients automatically unwrap this, so you get the original value in `.data`. ## Error Handling By default, `call_tool()` raises a `ToolError` if the tool execution fails: ```python from fastmcp.exceptions import ToolError async with client: try: result = await client.call_tool("potentially_failing_tool", {"param": "value"}) print("Tool succeeded:", result.data) except ToolError as e: print(f"Tool failed: {e}") ``` To handle errors manually instead of catching exceptions, disable automatic error raising: ```python async with client: result = await client.call_tool( "potentially_failing_tool", {"param": "value"}, raise_on_error=False ) if result.is_error: print(f"Tool failed: {result.content[0].text}") else: print(f"Tool succeeded: {result.data}") ``` ## Sending Metadata The `meta` parameter sends ancillary information alongside tool calls for observability, debugging, or client identification: ```python async with client: result = await client.call_tool( name="send_email", arguments={ "to": "user@example.com", "subject": "Hello", "body": "Welcome!" }, meta={ "trace_id": "abc-123", "request_source": "mobile_app" } ) ``` See [Client Metadata](/servers/context#client-metadata) to learn how servers access this data. ## Raw Protocol Access For complete control, use `call_tool_mcp()` which returns the raw MCP protocol object: ```python async with client: result = await client.call_tool_mcp("my_tool", {"param": "value"}) # result -> mcp.types.CallToolResult if result.isError: print(f"Tool failed: {result.content}") else: print(f"Tool succeeded: {result.content}") # Note: No automatic deserialization with call_tool_mcp() ``` ================================================ FILE: docs/clients/transports.mdx ================================================ --- title: Client Transports sidebarTitle: Transports description: Configure how clients connect to and communicate with MCP servers. icon: link --- import { VersionBadge } from "/snippets/version-badge.mdx" Transports handle the underlying connection between your client and MCP servers. While the client can automatically select a transport based on what you pass to it, instantiating transports explicitly gives you full control over configuration. ## STDIO Transport STDIO transport communicates with MCP servers through subprocess pipes. When using STDIO, your client launches and manages the server process, controlling its lifecycle and environment. STDIO servers run in isolated environments by default. They do not inherit your shell's environment variables. You must explicitly pass any configuration the server needs. ```python from fastmcp import Client from fastmcp.client.transports import StdioTransport transport = StdioTransport( command="python", args=["my_server.py", "--verbose"], env={"API_KEY": "secret", "LOG_LEVEL": "DEBUG"}, cwd="/path/to/server" ) client = Client(transport) ``` For convenience, the client can infer STDIO transport from file paths, though this limits configuration options: ```python from fastmcp import Client client = Client("my_server.py") # Limited - no configuration options ``` ### Environment Variables Since STDIO servers do not inherit your environment, you need strategies for passing configuration. **Selective forwarding** passes only the variables your server needs: ```python import os from fastmcp.client.transports import StdioTransport required_vars = ["API_KEY", "DATABASE_URL", "REDIS_HOST"] env = {var: os.environ[var] for var in required_vars if var in os.environ} transport = StdioTransport(command="python", args=["server.py"], env=env) client = Client(transport) ``` **Loading from .env files** keeps configuration separate from code: ```python from dotenv import dotenv_values from fastmcp.client.transports import StdioTransport env = dotenv_values(".env") transport = StdioTransport(command="python", args=["server.py"], env=env) client = Client(transport) ``` ### Session Persistence STDIO transports maintain sessions across multiple client contexts by default (`keep_alive=True`). This reuses the same subprocess for multiple connections, improving performance. ```python from fastmcp.client.transports import StdioTransport transport = StdioTransport(command="python", args=["server.py"]) client = Client(transport) async def efficient_multiple_operations(): async with client: await client.ping() async with client: # Reuses the same subprocess await client.call_tool("process_data", {"file": "data.csv"}) ``` For complete isolation between connections, disable session persistence: ```python transport = StdioTransport(command="python", args=["server.py"], keep_alive=False) ``` ## HTTP Transport HTTP transport connects to MCP servers running as web services. This is the recommended transport for production deployments. ```python from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport( url="https://api.example.com/mcp", headers={ "Authorization": "Bearer your-token-here", "X-Custom-Header": "value" } ) client = Client(transport) ``` FastMCP also provides authentication helpers: ```python from fastmcp import Client from fastmcp.client.auth import BearerAuth client = Client( "https://api.example.com/mcp", auth=BearerAuth("your-token-here") ) ``` ### SSL Verification By default, HTTPS connections verify the server's SSL certificate. You can customize this behavior with the `verify` parameter, which accepts the same values as [httpx](https://www.python-httpx.org/advanced/ssl/): ```python from fastmcp import Client # Disable SSL verification (e.g., for self-signed certs in development) client = Client("https://dev-server.internal/mcp", verify=False) # Use a custom CA bundle client = Client("https://corp-server.internal/mcp", verify="/path/to/ca-bundle.pem") # Use a custom SSL context for full control import ssl ctx = ssl.create_default_context() ctx.load_verify_locations("/path/to/internal-ca.pem") client = Client("https://corp-server.internal/mcp", verify=ctx) ``` The `verify` parameter is also available directly on `StreamableHttpTransport` and `SSETransport`: ```python from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport( url="https://dev-server.internal/mcp", verify=False, ) client = Client(transport) ``` ### SSE Transport Server-Sent Events transport is maintained for backward compatibility. Use Streamable HTTP for new deployments unless you have specific infrastructure requirements. ```python from fastmcp.client.transports import SSETransport transport = SSETransport( url="https://api.example.com/sse", headers={"Authorization": "Bearer token"} ) client = Client(transport) ``` ## In-Memory Transport In-memory transport connects directly to a FastMCP server instance within the same Python process. This eliminates both subprocess management and network overhead, making it ideal for testing. ```python from fastmcp import FastMCP, Client import os mcp = FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: prefix = os.environ.get("GREETING_PREFIX", "Hello") return f"{prefix}, {name}!" client = Client(mcp) async with client: result = await client.call_tool("greet", {"name": "World"}) ``` Unlike STDIO transports, in-memory servers share the same memory space and environment variables as your client code. ## Multi-Server Configuration Connect to multiple servers defined in a configuration dictionary: ```python from fastmcp import Client config = { "mcpServers": { "weather": { "url": "https://weather.example.com/mcp", "transport": "http" }, "assistant": { "command": "python", "args": ["./assistant.py"], "env": {"LOG_LEVEL": "INFO"} } } } client = Client(config) async with client: # Tools are namespaced by server weather = await client.call_tool("weather_get_forecast", {"city": "NYC"}) answer = await client.call_tool("assistant_ask", {"question": "What?"}) ``` ### Tool Transformations FastMCP supports tool transformations within the configuration. You can change names, descriptions, tags, and arguments for tools from a server. ```python config = { "mcpServers": { "weather": { "url": "https://weather.example.com/mcp", "transport": "http", "tools": { "weather_get_forecast": { "name": "miami_weather", "description": "Get the weather for Miami", "arguments": { "city": { "default": "Miami", "hide": True, } } } } } } } ``` To filter tools by tag, use `include_tags` or `exclude_tags` at the server level: ```python config = { "mcpServers": { "weather": { "url": "https://weather.example.com/mcp", "include_tags": ["forecast"] # Only tools with this tag } } } ``` ================================================ FILE: docs/community/README.md ================================================ # Community Section This directory contains community-contributed content and showcases for FastMCP. ## Structure - `showcase.mdx` - Main community showcase page featuring high-quality projects and examples ## Adding Content To add new community content: 1. Create a new MDX file in this directory 2. Update `docs.json` to include it in the navigation 3. Follow the existing format for consistency ## Guidelines Community content should: - Demonstrate best practices - Provide educational value - Include proper documentation - Be maintained and up-to-date ================================================ FILE: docs/community/showcase.mdx ================================================ --- title: 'Community Showcase' description: 'High-quality projects and examples from the FastMCP community' icon: 'users' --- import { YouTubeEmbed } from '/snippets/youtube-embed.mdx' ## Join the Community Connect with other FastMCP developers, share your projects, and discuss ideas. ## Featured Projects Discover exemplary MCP servers and implementations created by our community. These projects demonstrate best practices and innovative uses of FastMCP. ### Learning Resources A comprehensive educational example demonstrating FastMCP best practices with professional dual-transport server implementation, interactive test client, and detailed documentation. #### Video Tutorials **Build Remote MCP Servers w/ Python & FastMCP** - Claude Integrations Tutorial by Greg + Code **FastMCP — the best way to build an MCP server with Python** - Tutorial by ZazenCodes **Speedrun a MCP server for Claude Desktop (fastmcp)** - Tutorial by Nate from Prefect ### Community Examples Have you built something interesting with FastMCP? We'd love to feature high-quality examples here! Start a [discussion on GitHub](https://github.com/PrefectHQ/fastmcp/discussions) to share your project. ## Contributing To get your project featured: 1. Ensure your project demonstrates best practices 2. Include comprehensive documentation 3. Add clear usage examples 4. Open a discussion in our [GitHub Discussions](https://github.com/PrefectHQ/fastmcp/discussions) We review submissions regularly and feature projects that provide value to the FastMCP community. ## Further Reading - [Contrib Modules](/patterns/contrib) - Community-contributed modules that are distributed with FastMCP itself ================================================ FILE: docs/css/banner.css ================================================ /* Banner styling -- improve readability with better contrast */ #banner { background: #f1f5f9 !important; color: #1e293b !important; font-size: 0.95rem !important; font-weight: 600 !important; padding-top: 12px !important; padding-bottom: 12px !important; overflow: hidden !important; } #banner::before { content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient( 90deg, rgba(6, 182, 212, 0.25) 0%, rgba(6, 182, 212, 0.05) 25%, rgba(6, 182, 212, 0.35) 50%, rgba(6, 182, 212, 0.08) 75%, rgba(6, 182, 212, 0.28) 100% ); background-size: 300% 100%; animation: colorWave 14s ease-in-out infinite alternate; pointer-events: none; } .dark #banner { background: #475569 !important; color: #f1f5f9 !important; } .dark #banner::before { background: linear-gradient( 90deg, rgba(247, 37, 133, 0.35) 0%, rgba(247, 37, 133, 0.08) 25%, rgba(247, 37, 133, 0.45) 50%, rgba(247, 37, 133, 0.12) 75%, rgba(247, 37, 133, 0.38) 100% ); background-size: 300% 100%; } @keyframes colorWave { 0% { background-position: 0% 0%; } 100% { background-position: 100% 0%; } } #banner * { color: #1e293b !important; margin: 0 !important; } .dark #banner * { color: #f1f5f9 !important; } @media (max-width: 767px) { #banner { font-size: 0.8rem !important; padding-top: 8px !important; padding-bottom: 8px !important; } } ================================================ FILE: docs/css/python-sdk.css ================================================ a:has(svg.icon) { border: none !important; } ================================================ FILE: docs/css/style.css ================================================ html:not([data-page-mode="wide"]) #content-area { max-width: 44rem !important; } img.nav-logo { max-width: 200px; } /* Code highlighting -- target only inline code elements, not code blocks */ p code:not(pre code), table code:not(pre code), .prose code:not(pre code), li code:not(pre code), h1 code:not(pre code), h2 code:not(pre code), h3 code:not(pre code), h4 code:not(pre code), h5 code:not(pre code), h6 code:not(pre code) { color: #f72585 !important; background-color: rgba(247, 37, 133, 0.09); } /* V2 banner - inside content-container, breaks out of padding with negative margins */ #v2-banner { display: block; background: linear-gradient(135deg, #4cc9f0 0%, #2d00f7 100%); color: white; text-align: center; padding: 10px 16px; font-size: 0.875rem; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin: -2rem -2rem 1.5rem -2rem; width: calc(100% + 4rem); border-radius: 8px 8px 0 0; } #v2-banner a { color: white; text-decoration: underline; font-weight: 700; } #v2-banner a:hover { opacity: 0.9; } @media (min-width: 1024px) { #v2-banner { margin: -3rem -4rem 1.5rem -4rem; width: calc(100% + 8rem); } } .dark #v2-banner { background: linear-gradient(135deg, #2d00f7 0%, #4cc9f0 100%); } ================================================ FILE: docs/css/version-badge.css ================================================ /* Version badge -- display a badge with the current version of the documentation */ .version-badge { --color-text: #ff5400 !important; --color-bg: #fef2f2 !important; color: #ff5400 !important; background: #fef2f2 !important; border: 1px solid rgba(220, 38, 38, 0.3) !important; transition: box-shadow 0.2s, transform 0.15s; } .version-badge:hover { box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1); transform: translateY(-1px) scale(1.03); } .dark .version-badge { --color-text: #f1f5f9 !important; --color-bg: #334155 !important; color: #f1f5f9 !important; background: #334155 !important; border: 1px solid #64748b !important; } ================================================ FILE: docs/deployment/http.mdx ================================================ --- title: HTTP Deployment sidebarTitle: HTTP Deployment description: Deploy your FastMCP server over HTTP for remote access icon: server --- import { VersionBadge } from "/snippets/version-badge.mdx"; STDIO transport is perfect for local development and desktop applications. But to unlock the full potential of MCP—centralized services, multi-client access, and network availability—you need remote HTTP deployment. This guide walks you through deploying your FastMCP server as a remote MCP service that's accessible via a URL. Once deployed, your MCP server will be available over the network, allowing multiple clients to connect simultaneously and enabling integration with cloud-based LLM applications. This guide focuses specifically on remote MCP deployment, not local STDIO servers. ## Choosing Your Approach FastMCP provides two ways to deploy your server as an HTTP service. Understanding the trade-offs helps you choose the right approach for your needs. The **direct HTTP server** approach is simpler and perfect for getting started quickly. You modify your server's `run()` method to use HTTP transport, and FastMCP handles all the web server configuration. This approach works well for standalone deployments where you want your MCP server to be the only service running on a port. The **ASGI application** approach gives you more control and flexibility. Instead of running the server directly, you create an ASGI application that can be served by Uvicorn. This approach is better when you need advanced server features like multiple workers, custom middleware, or when you're integrating with existing web applications. ### Direct HTTP Server The simplest way to get your MCP server online is to use the built-in `run()` method with HTTP transport. This approach handles all the server configuration for you and is ideal when you want a standalone MCP server without additional complexity. ```python server.py from fastmcp import FastMCP mcp = FastMCP("My Server") @mcp.tool def process_data(input: str) -> str: """Process data on the server""" return f"Processed: {input}" if __name__ == "__main__": mcp.run(transport="http", host="0.0.0.0", port=8000) ``` Run your server with a simple Python command: ```bash python server.py ``` Your server is now accessible at `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). This approach is ideal when you want to get online quickly with minimal configuration. It's perfect for internal tools, development environments, or simple deployments where you don't need advanced server features. The built-in server handles all the HTTP details, letting you focus on your MCP implementation. ### ASGI Application For production deployments, you'll often want more control over how your server runs. FastMCP can create a standard ASGI application that works with any ASGI server like Uvicorn, Gunicorn, or Hypercorn. This approach is particularly useful when you need to configure advanced server options, run multiple workers, or integrate with existing infrastructure. ```python app.py from fastmcp import FastMCP mcp = FastMCP("My Server") @mcp.tool def process_data(input: str) -> str: """Process data on the server""" return f"Processed: {input}" # Create ASGI application app = mcp.http_app() ``` Run with any ASGI server - here's an example with Uvicorn: ```bash uvicorn app:app --host 0.0.0.0 --port 8000 ``` Your server is accessible at the same URL: `http://localhost:8000/mcp` (or use your server's actual IP address for remote access). The ASGI approach shines in production environments where you need reliability and performance. You can run multiple worker processes to handle concurrent requests, add custom middleware for logging or monitoring, integrate with existing deployment pipelines, or mount your MCP server as part of a larger application. ## Configuring Your Server ### Custom Path By default, your MCP server is accessible at `/mcp/` on your domain. You can customize this path to fit your URL structure or avoid conflicts with existing endpoints. This is particularly useful when integrating MCP into an existing application or following specific API conventions. ```python # Option 1: With mcp.run() mcp.run(transport="http", host="0.0.0.0", port=8000, path="/api/mcp/") # Option 2: With ASGI app app = mcp.http_app(path="/api/mcp/") ``` Now your server is accessible at `http://localhost:8000/api/mcp/`. ### Authentication Authentication is **highly recommended** for remote MCP servers. Some LLM clients require authentication for remote servers and will refuse to connect without it. FastMCP supports multiple authentication methods to secure your remote server. See the [Authentication Overview](/servers/auth/authentication) for complete configuration options including Bearer tokens, JWT, and OAuth. If you're mounting an authenticated server under a path prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) below for important routing considerations. ### Health Checks Health check endpoints are essential for monitoring your deployed server and ensuring it's responding correctly. FastMCP allows you to add custom routes alongside your MCP endpoints, making it easy to implement health checks that work with both deployment approaches. ```python from starlette.responses import JSONResponse @mcp.custom_route("/health", methods=["GET"]) async def health_check(request): return JSONResponse({"status": "healthy", "service": "mcp-server"}) ``` This health endpoint will be available at `http://localhost:8000/health` and can be used by load balancers, monitoring systems, or deployment platforms to verify your server is running. ### Custom Middleware Add custom Starlette middleware to your FastMCP ASGI apps: ```python from fastmcp import FastMCP from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware # Create your FastMCP server mcp = FastMCP("MyServer") # Define middleware middleware = [ Middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) ] # Create ASGI app with middleware http_app = mcp.http_app(middleware=middleware) ``` ### CORS for Browser-Based Clients Most MCP clients, including those that you access through a browser like ChatGPT or Claude, don't need CORS configuration. Only enable CORS if you're working with an MCP client that connects directly from a browser, such as debugging tools or inspectors. CORS (Cross-Origin Resource Sharing) is needed when JavaScript running in a web browser connects directly to your MCP server. This is different from using an LLM through a browser—in that case, the browser connects to the LLM service, and the LLM service connects to your MCP server (no CORS needed). Browser-based MCP clients that need CORS include: - **MCP Inspector** - Browser-based debugging tool for testing MCP servers - **Custom browser-based MCP clients** - If you're building a web app that directly connects to MCP servers For these scenarios, add CORS middleware with the specific headers required for MCP protocol: ```python from fastmcp import FastMCP from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware mcp = FastMCP("MyServer") # Configure CORS for browser-based clients middleware = [ Middleware( CORSMiddleware, allow_origins=["*"], # Allow all origins; use specific origins for security allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=[ "mcp-protocol-version", "mcp-session-id", "Authorization", "Content-Type", ], expose_headers=["mcp-session-id"], ) ] app = mcp.http_app(middleware=middleware) ``` **Key configuration details:** - **`allow_origins`**: Specify exact origins (e.g., `["http://localhost:3000"]`) rather than `["*"]` for production deployments - **`allow_headers`**: Must include `mcp-protocol-version`, `mcp-session-id`, and `Authorization` (for authenticated servers) - **`expose_headers`**: Must include `mcp-session-id` so JavaScript can read the session ID from responses and send it in subsequent requests Without `expose_headers=["mcp-session-id"]`, browsers will receive the session ID but JavaScript won't be able to access it, causing session management to fail. **Production Security**: Never use `allow_origins=["*"]` in production. Specify the exact origins of your browser-based clients. Using wildcards exposes your server to unauthorized access from any website. ### SSE Polling for Long-Running Operations This feature only applies to the **StreamableHTTP transport** (the default for `http_app()`). It does not apply to the legacy SSE transport (`transport="sse"`). When running tools that take a long time to complete, you may encounter issues with load balancers or proxies terminating connections that stay idle too long. [SEP-1699](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1699) introduces SSE polling to solve this by allowing the server to gracefully close connections and have clients automatically reconnect. To enable SSE polling, configure an `EventStore` when creating your HTTP application: ```python from fastmcp import FastMCP, Context from fastmcp.server.event_store import EventStore mcp = FastMCP("My Server") @mcp.tool async def long_running_task(ctx: Context) -> str: """A task that takes several minutes to complete.""" for i in range(100): await ctx.report_progress(i, 100) # Periodically close the connection to avoid load balancer timeouts # Client will automatically reconnect and resume receiving progress if i % 30 == 0 and i > 0: await ctx.close_sse_stream() await do_expensive_work() return "Done!" # Configure with EventStore for resumability event_store = EventStore() app = mcp.http_app( event_store=event_store, retry_interval=2000, # Client reconnects after 2 seconds ) ``` **How it works:** 1. When `event_store` is configured, the server stores all events (progress updates, results) with unique IDs 2. Calling `ctx.close_sse_stream()` gracefully closes the HTTP connection 3. The client automatically reconnects with a `Last-Event-ID` header 4. The server replays any events the client missed during the disconnection The `retry_interval` parameter (in milliseconds) controls how long clients wait before reconnecting. Choose a value that balances responsiveness with server load. `close_sse_stream()` is a no-op if called without an `EventStore` configured, so you can safely include it in tools that may run in different deployment configurations. #### Custom Storage Backends By default, `EventStore` uses in-memory storage. For production deployments with multiple server instances, you can provide a custom storage backend using the `key_value` package: ```python from fastmcp.server.event_store import EventStore from key_value.aio.stores.redis import RedisStore # Use Redis for distributed deployments redis_store = RedisStore(url="redis://localhost:6379") event_store = EventStore( storage=redis_store, max_events_per_stream=100, # Keep last 100 events per stream ttl=3600, # Events expire after 1 hour ) app = mcp.http_app(event_store=event_store) ``` ## Integration with Web Frameworks If you already have a web application running, you can add MCP capabilities by mounting a FastMCP server as a sub-application. This allows you to expose MCP tools alongside your existing API endpoints, sharing the same domain and infrastructure. The MCP server becomes just another route in your application, making it easy to manage and deploy. ### Mounting in Starlette Mount your FastMCP server in a Starlette application: ```python from fastmcp import FastMCP from starlette.applications import Starlette from starlette.routing import Mount # Create your FastMCP server mcp = FastMCP("MyServer") @mcp.tool def analyze(data: str) -> dict: return {"result": f"Analyzed: {data}"} # Create the ASGI app mcp_app = mcp.http_app(path='/mcp') # Create a Starlette app and mount the MCP server app = Starlette( routes=[ Mount("/mcp-server", app=mcp_app), # Add other routes as needed ], lifespan=mcp_app.lifespan, ) ``` The MCP endpoint will be available at `/mcp-server/mcp/` of the resulting Starlette app. For Streamable HTTP transport, you **must** pass the lifespan context from the FastMCP app to the resulting Starlette app, as nested lifespans are not recognized. Otherwise, the FastMCP server's session manager will not be properly initialized. #### Nested Mounts You can create complex routing structures by nesting mounts: ```python from fastmcp import FastMCP from starlette.applications import Starlette from starlette.routing import Mount # Create your FastMCP server mcp = FastMCP("MyServer") # Create the ASGI app mcp_app = mcp.http_app(path='/mcp') # Create nested application structure inner_app = Starlette(routes=[Mount("/inner", app=mcp_app)]) app = Starlette( routes=[Mount("/outer", app=inner_app)], lifespan=mcp_app.lifespan, ) ``` In this setup, the MCP server is accessible at the `/outer/inner/mcp/` path. ### FastAPI Integration For FastAPI-specific integration patterns including both mounting MCP servers into FastAPI apps and generating MCP servers from FastAPI apps, see the [FastAPI Integration guide](/integrations/fastapi). Here's a quick example showing how to add MCP to an existing FastAPI application: ```python from fastapi import FastAPI from fastmcp import FastMCP # Create your MCP server mcp = FastMCP("API Tools") @mcp.tool def query_database(query: str) -> dict: """Run a database query""" return {"result": "data"} # Create the MCP ASGI app with path="/" since we'll mount at /mcp mcp_app = mcp.http_app(path="/") # Create FastAPI app with MCP lifespan (required for session management) api = FastAPI(lifespan=mcp_app.lifespan) @api.get("/api/status") def status(): return {"status": "ok"} # Mount MCP at /mcp api.mount("/mcp", mcp_app) # Run with: uvicorn app:api --host 0.0.0.0 --port 8000 ``` Your existing API remains at `http://localhost:8000/api` while MCP is available at `http://localhost:8000/mcp`. Just like with Starlette, you **must** pass the lifespan from the MCP app to FastAPI. Without this, the session manager won't initialize properly and requests will fail. ## Mounting Authenticated Servers This section only applies if you're **mounting an OAuth-protected FastMCP server under a path prefix** (like `/api`) inside another application using `Mount()`. If you're deploying your FastMCP server at root level without any `Mount()` prefix, the well-known routes are automatically included in `mcp.http_app()` and you don't need to do anything special. OAuth specifications (RFC 8414 and RFC 9728) require discovery metadata to be accessible at well-known paths under the root level of your domain. When you mount an OAuth-protected FastMCP server under a path prefix like `/api`, this creates a routing challenge: your operational OAuth endpoints move under the prefix, but discovery endpoints must remain at the root. **Common Mistakes to Avoid:** 1. **Forgetting to mount `.well-known` routes at root** - FastMCP cannot do this automatically when your server is mounted under a path prefix. You must explicitly mount well-known routes at the root level. 2. **Including mount prefix in both base_url AND mcp_path** - The mount prefix (like `/api`) should only be in `base_url`, not in `mcp_path`. Otherwise you'll get double paths. ✅ **Correct:** ```python base_url = "http://localhost:8000/api" mcp_path = "/mcp" # Result: /api/mcp ``` ❌ **Wrong:** ```python base_url = "http://localhost:8000/api" mcp_path = "/api/mcp" # Result: /api/api/mcp (double prefix!) ``` Follow the configuration instructions below to set up mounting correctly. **CORS Middleware Conflicts:** If you're integrating FastMCP into an existing application with its own CORS middleware, be aware that layering CORS middleware can cause conflicts (such as 404 errors on `.well-known` routes or OPTIONS requests). FastMCP and the MCP SDK already handle CORS for OAuth routes. If you need CORS on your own application routes, consider using the sub-app pattern: mount FastMCP and your routes as separate apps, each with their own middleware, rather than adding application-wide CORS middleware. ### Route Types OAuth-protected MCP servers expose two categories of routes: **Operational routes** handle the OAuth flow and MCP protocol: - `/authorize` - OAuth authorization endpoint - `/token` - Token exchange endpoint - `/auth/callback` - OAuth callback handler - `/mcp` - MCP protocol endpoint **Discovery routes** provide metadata for OAuth clients: - `/.well-known/oauth-authorization-server` - Authorization server metadata - `/.well-known/oauth-protected-resource/*` - Protected resource metadata When you mount your MCP app under a prefix, operational routes move with it, but discovery routes must stay at root level for RFC compliance. ### Configuration Parameters Three parameters control where routes are located and how they combine: **`base_url`** tells clients where to find operational endpoints. This includes any Starlette `Mount()` path prefix (e.g., `/api`): ```python base_url="http://localhost:8000/api" # Includes mount prefix ``` **`mcp_path`** is the internal FastMCP endpoint path, which gets appended to `base_url`: ```python mcp_path="/mcp" # Internal MCP path, NOT the mount prefix ``` **`issuer_url`** (optional) controls the authorization server identity for OAuth discovery. Defaults to `base_url`. ```python # Usually not needed - just set base_url and it works issuer_url="http://localhost:8000" # Only if you want root-level discovery ``` When `issuer_url` has a path (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. **Key Invariant:** `base_url + mcp_path = actual externally-accessible MCP URL` Example: - `base_url`: `http://localhost:8000/api` (mount prefix `/api`) - `mcp_path`: `/mcp` (internal path) - Result: `http://localhost:8000/api/mcp` (final MCP endpoint) Note that the mount prefix (`/api` from `Mount("/api", ...)`) goes in `base_url`, while `mcp_path` is just the internal MCP route. Don't include the mount prefix in both places or you'll get `/api/api/mcp`. ### Mounting Strategy When mounting an OAuth-protected server under a path prefix, declare your URLs upfront to make the relationships clear: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount # Define the routing structure ROOT_URL = "http://localhost:8000" MOUNT_PREFIX = "/api" MCP_PATH = "/mcp" ``` Create the auth provider with `base_url`: ```python auth = GitHubProvider( client_id="your-client-id", client_secret="your-client-secret", base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # Operational endpoints under prefix # issuer_url defaults to base_url - path-aware discovery works automatically ) ``` Create the MCP app, which generates operational routes at the specified path: ```python mcp = FastMCP("Protected Server", auth=auth) mcp_app = mcp.http_app(path=MCP_PATH) ``` Retrieve the discovery routes from the auth provider. The `mcp_path` argument should match the path used when creating the MCP app: ```python well_known_routes = auth.get_well_known_routes(mcp_path=MCP_PATH) ``` Finally, mount everything in the Starlette app with discovery routes at root and the MCP app under the prefix: ```python app = Starlette( routes=[ *well_known_routes, # Discovery routes at root level Mount(MOUNT_PREFIX, app=mcp_app), # Operational routes under prefix ], lifespan=mcp_app.lifespan, ) ``` This configuration produces the following URL structure: - MCP endpoint: `http://localhost:8000/api/mcp` - OAuth authorization: `http://localhost:8000/api/authorize` - OAuth callback: `http://localhost:8000/api/auth/callback` - Authorization server metadata: `http://localhost:8000/.well-known/oauth-authorization-server/api` - Protected resource metadata: `http://localhost:8000/.well-known/oauth-protected-resource/api/mcp` Both discovery endpoints use path-aware URLs per RFC 8414 and RFC 9728, matching the `base_url` path. ### Complete Example Here's a complete working example showing all the pieces together: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider from starlette.applications import Starlette from starlette.routing import Mount import uvicorn # Define routing structure ROOT_URL = "http://localhost:8000" MOUNT_PREFIX = "/api" MCP_PATH = "/mcp" # Create OAuth provider auth = GitHubProvider( client_id="your-client-id", client_secret="your-client-secret", base_url=f"{ROOT_URL}{MOUNT_PREFIX}", # issuer_url defaults to base_url - path-aware discovery works automatically ) # Create MCP server mcp = FastMCP("Protected Server", auth=auth) @mcp.tool def analyze(data: str) -> dict: return {"result": f"Analyzed: {data}"} # Create MCP app mcp_app = mcp.http_app(path=MCP_PATH) # Get discovery routes for root level well_known_routes = auth.get_well_known_routes(mcp_path=MCP_PATH) # Assemble the application app = Starlette( routes=[ *well_known_routes, Mount(MOUNT_PREFIX, app=mcp_app), ], lifespan=mcp_app.lifespan, ) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) ``` For more details on OAuth authentication, see the [Authentication guide](/servers/auth). ## Production Deployment ### Running with Uvicorn When deploying to production, you'll want to optimize your server for performance and reliability. Uvicorn provides several options to improve your server's capabilities: ```bash # Run with basic configuration uvicorn app:app --host 0.0.0.0 --port 8000 # Run with multiple workers for production (requires stateless mode - see below) uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 ``` ### Horizontal Scaling When deploying FastMCP behind a load balancer or running multiple server instances, you need to understand how the HTTP transport handles sessions and configure your server appropriately. #### Understanding Sessions By default, FastMCP's Streamable HTTP transport maintains server-side sessions. Sessions enable stateful MCP features like [elicitation](/servers/elicitation) and [sampling](/servers/sampling), where the server needs to maintain context across multiple requests from the same client. This works perfectly for single-instance deployments. However, sessions are stored in memory on each server instance, which creates challenges when scaling horizontally. #### Without Stateless Mode When running multiple server instances behind a load balancer (Traefik, nginx, HAProxy, Kubernetes, etc.), requests from the same client may be routed to different instances: 1. Client connects to Instance A → session created on Instance A 2. Next request routes to Instance B → session doesn't exist → **request fails** You might expect sticky sessions (session affinity) to solve this, but they don't work reliably with MCP clients. **Why sticky sessions don't work:** Most MCP clients—including Cursor and Claude Code—use `fetch()` internally and don't properly forward `Set-Cookie` headers. Without cookies, load balancers can't identify which instance should handle subsequent requests. This is a limitation in how these clients implement HTTP, not something you can fix with load balancer configuration. #### Enabling Stateless Mode For horizontally scaled deployments, enable stateless HTTP mode. In stateless mode, each request creates a fresh transport context, eliminating the need for session affinity entirely. **Option 1: Via constructor** ```python from fastmcp import FastMCP mcp = FastMCP("My Server") @mcp.tool def process(data: str) -> str: return f"Processed: {data}" app = mcp.http_app(stateless_http=True) ``` **Option 2: Via `run()`** ```python if __name__ == "__main__": mcp.run(transport="http", stateless_http=True) ``` **Option 3: Via environment variable** ```bash FASTMCP_STATELESS_HTTP=true uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 ``` ### Environment Variables Production deployments should never hardcode sensitive information like API keys or authentication tokens. Instead, use environment variables to configure your server at runtime. This keeps your code secure and makes it easy to deploy the same code to different environments with different configurations. Here's an example using bearer token authentication (though OAuth is recommended for production): ```python import os from fastmcp import FastMCP from fastmcp.server.auth import BearerTokenAuth # Read configuration from environment auth_token = os.environ.get("MCP_AUTH_TOKEN") if auth_token: auth = BearerTokenAuth(token=auth_token) mcp = FastMCP("Production Server", auth=auth) else: mcp = FastMCP("Production Server") app = mcp.http_app() ``` Deploy with your secrets safely stored in environment variables: ```bash MCP_AUTH_TOKEN=secret uvicorn app:app --host 0.0.0.0 --port 8000 ``` ### OAuth Token Security If you're using the [OAuth Proxy](/servers/auth/oauth-proxy), FastMCP issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token boundaries. **Default Behavior (Development Only):** By default, FastMCP automatically manages cryptographic keys: - **Mac/Windows**: Keys are generated and stored in your system keyring, surviving server restarts. Suitable **only** for development and local testing. - **Linux**: Keys are ephemeral (random salt at startup), so tokens are invalidated on restart. This automatic approach is convenient for development but not suitable for production deployments. **For Production:** Production requires explicit key management to ensure tokens survive restarts and can be shared across multiple server instances. This requires the following two things working together: 1. **Explicit JWT signing key** for signing tokens issued to clients 3. **Persistent network-accessible storage** for upstream tokens (wrapped in `FernetEncryptionWrapper` to encrypt sensitive data at rest) **Configuration:** Add two parameters to your auth provider: ```python {8-12} from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore(host="redis.example.com", port=6379), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ), base_url="https://your-server.com" # use HTTPS ) ``` Both parameters are required for production. Without an explicit signing key, keys are signed using a key derived from the client_secret, which will cause invalidation upon rotation of the client secret. Without persistent storage, tokens are local to the server and won't be trusted across hosts. **Wrap your storage backend in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without encryption, tokens are stored in plaintext. For more details on the token architecture and key management, see [OAuth Proxy Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management). ## Reverse Proxy (nginx) In production, you'll typically run your FastMCP server behind a reverse proxy like nginx. A reverse proxy provides TLS termination, domain-based routing, static file serving, and an additional layer of security between the internet and your application. ### Running FastMCP as a Linux Service Before configuring nginx, you need your FastMCP server running as a background service. A systemd unit file ensures your server starts automatically and restarts on failure. Create a file at `/etc/systemd/system/fastmcp.service`: ```ini [Unit] Description=FastMCP Server After=network.target [Service] User=www-data Group=www-data WorkingDirectory=/opt/fastmcp ExecStart=/opt/fastmcp/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 Restart=always RestartSec=5 Environment="PATH=/opt/fastmcp/.venv/bin" [Install] WantedBy=multi-user.target ``` Enable and start the service: ```bash sudo systemctl daemon-reload sudo systemctl enable fastmcp sudo systemctl start fastmcp ``` This assumes your ASGI application is in `/opt/fastmcp/app.py` with a virtual environment at `/opt/fastmcp/.venv`. Adjust paths to match your deployment layout. ### nginx Configuration FastMCP's Streamable HTTP transport uses Server-Sent Events (SSE) for streaming responses. This requires specific nginx settings to prevent buffering from breaking the event stream. Create a site configuration at `/etc/nginx/sites-available/fastmcp`: ```nginx server { listen 80; server_name mcp.example.com; # Redirect HTTP to HTTPS return 301 https://$host$request_uri; } server { listen 443 ssl; server_name mcp.example.com; ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem; location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Required for SSE (Server-Sent Events) streaming proxy_buffering off; proxy_cache off; # Allow long-lived connections for streaming responses proxy_read_timeout 300s; proxy_send_timeout 300s; } } ``` Enable the site and reload nginx: ```bash sudo ln -s /etc/nginx/sites-available/fastmcp /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx ``` Your FastMCP server is now accessible at `https://mcp.example.com/mcp`. **SSE buffering is the most common issue.** If clients connect but never receive streaming responses (progress updates, tool results), verify that `proxy_buffering off` is set. Without it, nginx buffers the entire SSE stream and delivers it only when the connection closes, which breaks real-time communication. ### Key Considerations When deploying FastMCP behind a reverse proxy, keep these points in mind: - **Disable buffering**: SSE requires `proxy_buffering off` so events reach clients immediately. This is the single most important setting. - **Increase timeouts**: The default nginx `proxy_read_timeout` is 60 seconds. Long-running MCP tools will cause the connection to drop. Set timeouts to at least 300 seconds, or higher if your tools run longer. For tools that may exceed any timeout, use [SSE Polling](#sse-polling-for-long-running-operations) to gracefully handle proxy disconnections. - **Use HTTP/1.1**: Set `proxy_http_version 1.1` and `proxy_set_header Connection ''` to enable keep-alive connections between nginx and your server. Clearing the `Connection` header prevents clients from sending `Connection: close` to your upstream, which would break SSE streams. Both settings are required for proper SSE support. - **Forward headers**: Pass `X-Forwarded-For` and `X-Forwarded-Proto` so your FastMCP server can determine the real client IP and protocol. This is important for logging and for OAuth redirect URLs. - **TLS termination**: Let nginx handle TLS certificates (e.g., via Let's Encrypt with Certbot). Your FastMCP server can then run on plain HTTP internally. ### Mounting Under a Path Prefix If you want your MCP server available at a subpath like `https://example.com/api/mcp` instead of at the root domain, adjust the nginx `location` block: ```nginx location /api/ { proxy_pass http://127.0.0.1:8000/; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Required for SSE streaming proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; } ``` Note the trailing `/` on both `location /api/` and `proxy_pass http://127.0.0.1:8000/` — this ensures nginx strips the `/api` prefix before forwarding to your server. If you're using OAuth authentication with a mount prefix, see [Mounting Authenticated Servers](#mounting-authenticated-servers) for additional configuration. ## Testing Your Deployment Once your server is deployed, you'll need to verify it's accessible and functioning correctly. For comprehensive testing strategies including connectivity tests, client testing, and authentication testing, see the [Testing Your Server](/development/tests) guide. ## Hosting Your Server This guide has shown you how to create an HTTP-accessible MCP server, but you'll still need a hosting provider to make it available on the internet. Your FastMCP server can run anywhere that supports Python web applications: - **Cloud VMs** (AWS EC2, Google Compute Engine, Azure VMs) - **Container platforms** (Cloud Run, Container Instances, ECS) - **Platform-as-a-Service** (Railway, Render, Vercel) - **Edge platforms** (Cloudflare Workers) - **Kubernetes clusters** (self-managed or managed) The key requirements are Python 3.10+ support and the ability to expose an HTTP port. Most providers will require you to package your server (requirements.txt, Dockerfile, etc.) according to their deployment format. For managed, zero-configuration deployment, see [Prefect Horizon](/deployment/prefect-horizon). ================================================ FILE: docs/deployment/prefect-horizon.mdx ================================================ --- title: Prefect Horizon sidebarTitle: Prefect Horizon description: The MCP platform from the FastMCP team icon: cloud --- [Prefect Horizon](https://www.prefect.io/horizon) is a platform for deploying and managing MCP servers. Built by the FastMCP team at [Prefect](https://www.prefect.io), Horizon provides managed hosting, authentication, access control, and a registry of MCP capabilities. Horizon includes a **free personal tier for FastMCP users**, making it the fastest way to get a secure, production-ready server URL with built-in OAuth authentication. Horizon is free for personal projects. Enterprise governance features are available for teams deploying to thousands of users. ## The Platform Horizon is organized into four integrated pillars: - **Deploy**: Managed hosting with CI/CD, scaling, monitoring, and rollbacks. Push code and get a live, governed endpoint in 60 seconds. - **Registry**: A central catalog of MCP servers across your organization—first-party, third-party, and curated remix servers composed from multiple sources. - **Gateway**: Role-based access control, authentication, and audit logs. Define what agents can see and do at the tool level. - **Agents**: A permissioned chat interface for interacting with any MCP server or curated combination of servers. This guide focuses on **Horizon Deploy**, the managed hosting layer that gives you the fastest path from a FastMCP server to a production URL. ## Prerequisites To use Horizon, you'll need a [GitHub](https://github.com) account and a GitHub repo containing a FastMCP server. If you don't have one yet, Horizon can create a starter repo for you during onboarding. Your repo can be public or private, but must include at least a Python file containing a FastMCP server instance. To verify your file is compatible with Horizon, run `fastmcp inspect ` to see what Horizon will see when it runs your server. If you have a `requirements.txt` or `pyproject.toml` in the repo, Horizon will automatically detect your server's dependencies and install them. Your file *can* have an `if __name__ == "__main__"` block, but it will be ignored by Horizon. For example, a minimal server file might look like: ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool def hello(name: str) -> str: return f"Hello, {name}!" ``` ## Getting Started There are just three steps to deploying a server to Horizon: ### Step 1: Select a Repository Visit [horizon.prefect.io](https://horizon.prefect.io) and sign in with your GitHub account. Connect your GitHub account to grant Horizon access to your repositories, then select the repo you want to deploy. Horizon repository selection ### Step 2: Configure Your Server Next, you'll configure how Horizon should build and deploy your server. Horizon server configuration The configuration screen lets you specify: - **Server name**: A unique name for your server. This determines your server's URL. - **Description**: A brief description of what your server does. - **Entrypoint**: The Python file containing your FastMCP server (e.g., `main.py`). This field has the same syntax as the `fastmcp run` command—use `main.py:mcp` to specify a specific object in the file. - **Authentication**: When enabled, only authenticated users in your organization can connect. Horizon handles all the OAuth complexity for you. Horizon will automatically detect your server's Python dependencies from either a `requirements.txt` or `pyproject.toml` file. ### Step 3: Deploy and Connect Click **Deploy Server** and Horizon will clone your repository, build your server, and deploy it to a unique URL—typically in under 60 seconds. Horizon deployment view showing live server Once deployed, your server is accessible at a URL like: ``` https://your-server-name.fastmcp.app/mcp ``` Horizon monitors your repo and redeploys automatically whenever you push to `main`. It also builds preview deployments for every PR, so you can test changes before they go live. ## Testing Your Server Horizon provides two ways to verify your server is working before connecting external clients. ### Inspector The Inspector gives you a structured view of everything your server exposes—tools, resources, and prompts. You can click any tool, fill in the inputs, execute it, and see the output. This is useful for systematically validating each capability and debugging specific behaviors. ### ChatMCP For quick end-to-end testing, ChatMCP lets you interact with your server conversationally. It uses a fast model optimized for rapid iteration—you can verify the server works, test tool calls in context, and confirm the overall behavior before sharing it with others. Horizon ChatMCP interface ChatMCP is designed for testing, not as a daily work environment. Once you've confirmed your server works, you can copy connection snippets for Claude Desktop, Cursor, Claude Code, and other MCP clients—or use the FastMCP client library to connect programmatically. ## Horizon Agents Beyond testing individual servers, Horizon lets you create **Agents**—chat interfaces backed by one or more MCP servers. While ChatMCP tests a single server, Agents let you compose capabilities from multiple servers into a unified experience. Horizon Agent configuration To create an agent: 1. Navigate to **Agents** in the sidebar 2. Click **Create Agent** and give it a name and description 3. Add MCP servers to the agent—these can be servers you've deployed to Horizon or external servers in the registry Once configured, you can chat with your agent directly in Horizon: Chatting with a Horizon Agent Agents are useful for creating purpose-built interfaces that combine tools from different servers. For example, you might create an agent that has access to both your company's internal data server and a general-purpose utilities server. ================================================ FILE: docs/deployment/running-server.mdx ================================================ --- title: Running Your Server sidebarTitle: Running Your Server description: Learn how to run your FastMCP server locally for development and testing icon: circle-play --- import { VersionBadge } from '/snippets/version-badge.mdx' FastMCP servers can be run in different ways depending on your needs. This guide focuses on running servers locally for development and testing. For production deployment to a URL, see the [HTTP Deployment](/deployment/http) guide. ## The `run()` Method Every FastMCP server needs to be started to accept connections. The simplest way to run a server is by calling the `run()` method on your FastMCP instance. This method starts the server and blocks until it's stopped, handling all the connection management for you. For maximum compatibility, it's best practice to place the `run()` call within an `if __name__ == "__main__":` block. This ensures the server starts only when the script is executed directly, not when imported as a module. ```python {9-10} my_server.py from fastmcp import FastMCP mcp = FastMCP(name="MyServer") @mcp.tool def hello(name: str) -> str: return f"Hello, {name}!" if __name__ == "__main__": mcp.run() ``` You can now run this MCP server by executing `python my_server.py`. ## Transport Protocols MCP servers communicate with clients through different transport protocols. Think of transports as the "language" your server speaks to communicate with clients. FastMCP supports three main transport protocols, each designed for specific use cases and deployment scenarios. The choice of transport determines how clients connect to your server, what network capabilities are available, and how many clients can connect simultaneously. Understanding these transports helps you choose the right approach for your application. ### STDIO Transport (Default) STDIO (Standard Input/Output) is the default transport for FastMCP servers. When you call `run()` without arguments, your server uses STDIO transport. This transport communicates through standard input and output streams, making it perfect for command-line tools and desktop applications like Claude Desktop. With STDIO transport, the client spawns a new server process for each session and manages its lifecycle. The server reads MCP messages from stdin and writes responses to stdout. This is why STDIO servers don't stay running - they're started on-demand by the client. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool def hello(name: str) -> str: return f"Hello, {name}!" if __name__ == "__main__": mcp.run() # Uses STDIO transport by default ``` STDIO is ideal for: - Local development and testing - Claude Desktop integration - Command-line tools - Single-user applications ### HTTP Transport (Streamable) HTTP transport turns your MCP server into a web service accessible via a URL. This transport uses the Streamable HTTP protocol, which allows clients to connect over the network. Unlike STDIO where each client gets its own process, an HTTP server can handle multiple clients simultaneously. The Streamable HTTP protocol provides full bidirectional communication between client and server, supporting all MCP operations including streaming responses. This makes it the recommended choice for network-based deployments. To use HTTP transport, specify it in the `run()` method along with networking options: ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool def hello(name: str) -> str: return f"Hello, {name}!" if __name__ == "__main__": # Start an HTTP server on port 8000 mcp.run(transport="http", host="127.0.0.1", port=8000) ``` Your server is now accessible at `http://localhost:8000/mcp`. This URL is the MCP endpoint that clients will connect to. HTTP transport enables: - Network accessibility - Multiple concurrent clients - Integration with web infrastructure - Remote deployment capabilities For production HTTP deployment with authentication and advanced configuration, see the [HTTP Deployment](/deployment/http) guide. ### SSE Transport (Legacy) Server-Sent Events (SSE) transport was the original HTTP-based transport for MCP. While still supported for backward compatibility, it has limitations compared to the newer Streamable HTTP transport. SSE only supports server-to-client streaming, making it less efficient for bidirectional communication. ```python if __name__ == "__main__": # SSE transport - use HTTP instead for new projects mcp.run(transport="sse", host="127.0.0.1", port=8000) ``` We recommend using HTTP transport instead of SSE for all new projects. SSE remains available only for compatibility with older clients that haven't upgraded to Streamable HTTP. ### Choosing the Right Transport Each transport serves different needs. STDIO is perfect when you need simple, local execution - it's what Claude Desktop and most command-line tools expect. HTTP transport is essential when you need network access, want to serve multiple clients, or plan to deploy your server remotely. SSE exists only for backward compatibility and shouldn't be used in new projects. Consider your deployment scenario: Are you building a tool for local use? STDIO is your best choice. Need a centralized service that multiple clients can access? HTTP transport is the way to go. ## The FastMCP CLI FastMCP provides a powerful command-line interface for running servers without modifying the source code. The CLI can automatically find and run your server with different transports, manage dependencies, and handle development workflows: ```bash fastmcp run server.py ``` The CLI automatically finds a FastMCP instance in your file (named `mcp`, `server`, or `app`) and runs it with the specified options. This is particularly useful for testing different transports or configurations without changing your code. ### Dependency Management The CLI integrates with `uv` to manage Python environments and dependencies: ```bash # Run with a specific Python version fastmcp run server.py --python 3.11 # Run with additional packages fastmcp run server.py --with pandas --with numpy # Run with dependencies from a requirements file fastmcp run server.py --with-requirements requirements.txt # Combine multiple options fastmcp run server.py --python 3.10 --with httpx --transport http # Run within a specific project directory fastmcp run server.py --project /path/to/project ``` When using `--python`, `--with`, `--project`, or `--with-requirements`, the server runs via `uv run` subprocess instead of using your local environment. ### Passing Arguments to Servers When servers accept command line arguments (using argparse, click, or other libraries), you can pass them after `--`: ```bash fastmcp run config_server.py -- --config config.json fastmcp run database_server.py -- --database-path /tmp/db.sqlite --debug ``` This is useful for servers that need configuration files, database paths, API keys, or other runtime options. For more CLI features including development mode with the MCP Inspector, see the [CLI documentation](/cli/running). ### Auto-Reload for Development During development, you can use the `--reload` flag to automatically restart your server when source files change: ```bash fastmcp run server.py --reload ``` The server watches for changes to Python files in the current directory and restarts automatically when you save changes. This provides a fast feedback loop during development without manually stopping and starting the server. ```bash # Watch specific directories for changes fastmcp run server.py --reload --reload-dir ./src --reload-dir ./lib # Combine with other options fastmcp run server.py --reload --transport http --port 8080 ``` Auto-reload uses stateless mode to enable seamless restarts. For stdio transport, this is fully featured. For HTTP transport, some bidirectional features like elicitation are not available during reload mode. SSE transport does not support auto-reload due to session limitations. Use HTTP transport instead if you need both network access and auto-reload. ### Async Usage FastMCP servers are built on async Python, but the framework provides both synchronous and asynchronous APIs to fit your application's needs. The `run()` method we've been using is actually a synchronous wrapper around the async server implementation. For applications that are already running in an async context, FastMCP provides the `run_async()` method: ```python {10-12} from fastmcp import FastMCP import asyncio mcp = FastMCP(name="MyServer") @mcp.tool def hello(name: str) -> str: return f"Hello, {name}!" async def main(): # Use run_async() in async contexts await mcp.run_async(transport="http", port=8000) if __name__ == "__main__": asyncio.run(main()) ``` The `run()` method cannot be called from inside an async function because it creates its own async event loop internally. If you attempt to call `run()` from inside an async function, you'll get an error about the event loop already running. Always use `run_async()` inside async functions and `run()` in synchronous contexts. Both `run()` and `run_async()` accept the same transport arguments, so all the examples above apply to both methods. ## Custom Routes When using HTTP transport, you might want to add custom web endpoints alongside your MCP server. This is useful for health checks, status pages, or simple APIs. FastMCP lets you add custom routes using the `@custom_route` decorator: ```python from fastmcp import FastMCP from starlette.requests import Request from starlette.responses import PlainTextResponse mcp = FastMCP("MyServer") @mcp.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> PlainTextResponse: return PlainTextResponse("OK") @mcp.tool def process(data: str) -> str: return f"Processed: {data}" if __name__ == "__main__": mcp.run(transport="http") # Health check at http://localhost:8000/health ``` Custom routes are served by the same web server as your MCP endpoint. They're available at the root of your domain while the MCP endpoint is at `/mcp/`. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). ## Alternative Initialization Patterns The `if __name__ == "__main__"` pattern works well for standalone scripts, but some deployment scenarios require different approaches. FastMCP handles these cases automatically. ### CLI-Only Servers When using the FastMCP CLI, you don't need the `if __name__` block at all. The CLI will find your FastMCP instance and run it: ```python # server.py from fastmcp import FastMCP mcp = FastMCP("MyServer") # CLI looks for 'mcp', 'server', or 'app' @mcp.tool def process(data: str) -> str: return f"Processed: {data}" # No if __name__ block needed - CLI will find and run 'mcp' ``` ### ASGI Applications For ASGI deployment (running with Uvicorn or similar), you'll want to create an ASGI application object. This approach is common in production deployments where you need more control over the server configuration: ```python # app.py from fastmcp import FastMCP def create_app(): mcp = FastMCP("MyServer") @mcp.tool def process(data: str) -> str: return f"Processed: {data}" return mcp.http_app() app = create_app() # Uvicorn will use this ``` See the [HTTP Deployment](/deployment/http) guide for more ASGI deployment patterns. ================================================ FILE: docs/deployment/server-configuration.mdx ================================================ --- 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. When you have a `fastmcp.json` file, running your server becomes as simple as: ```bash # Run the server using the configuration fastmcp run fastmcp.json # Or if fastmcp.json exists in the current directory 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. ## 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 "type": "filesystem", // Optional, defaults to "filesystem" "path": "server.py", "entrypoint": "mcp" }, "environment": { // WHAT: Environment setup and dependencies "type": "uv", // Optional, defaults to "uv" "python": ">=3.10", "dependencies": ["pandas", "numpy"] }, "deployment": { // HOW: Runtime configuration "transport": "stdio", "log_level": "INFO" } } ``` 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/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" } } ``` Two schema URLs are available: - **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. ### Source Configuration 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. The server source configuration that determines where your server code lives. 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. 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. **Future Source Types** Future releases will support additional source types: - **Git repositories** (`type: "git"`) for loading server code directly from version control - **Prefect Horizon** (`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, ensuring your server runs with the exact Python version and dependencies it requires. This section creates isolated, reproducible environments across different systems. FastMCP uses an extensible environment system with a base `Environment` class that can be implemented by different environment providers. Currently, FastMCP supports the `UVEnvironment` for Python environment management using `uv`'s powerful dependency resolver. Optional environment configuration. When specified, FastMCP uses the appropriate environment implementation to set up your server's runtime. The environment type identifier that determines which implementation to use. Currently supports `"uv"` for Python environments managed by uv. If omitted, defaults to `"uv"`. When `type` is `"uv"` (or omitted), the environment uses uv to manage Python dependencies: Python version constraint. Examples: - Exact version: `"3.12"` - Minimum version: `">=3.10"` - Version range: `">=3.10,<3.13"` List of pip packages with optional version specifiers (PEP 508 format). ```json "dependencies": ["pandas>=2.0", "requests", "httpx"] ``` Path to a requirements.txt file, resolved relative to the config file location. ```json "requirements": "requirements.txt" ``` Path to a project directory containing pyproject.toml for uv project management. ```json "project": "." ``` List of paths to packages to install in editable/development mode. Useful for local development when you want changes to be reflected immediately. Supports multiple packages for monorepo setups or shared libraries. ```json "editable": ["."] ``` Or with multiple packages: ```json "editable": [".", "../shared-lib", "/path/to/another-package"] ``` **Example:** ```json "environment": { "type": "uv", "python": ">=3.10", "dependencies": ["pandas", "numpy"], "editable": ["."] } ``` Note: When any UVEnvironment field is specified, FastMCP automatically creates an isolated environment using `uv` before running your server. When environment configuration is provided, FastMCP: 1. Detects the environment type (defaults to `"uv"` if not specified) 2. Creates an isolated environment using the appropriate provider 3. Installs the specified dependencies 4. Runs your server in this clean environment This build-time setup ensures your server always has the dependencies it needs, without polluting your system Python or conflicting with other projects. **Future Environment Types** Similar to source types, future releases may support additional environment types for different runtime requirements, such as Docker containers or language-specific environments beyond Python. ### 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. Protocol for client communication: - `"stdio"`: Standard input/output for desktop clients - `"http"`: Network-accessible HTTP server - `"sse"`: Server-sent events Network interface to bind (HTTP transport only): - `"127.0.0.1"`: Local connections only - `"0.0.0.0"`: All network interfaces Port number for HTTP transport. URL path for the MCP endpoint when using HTTP transport. Server logging verbosity. Options: - `"DEBUG"`: Detailed debugging information - `"INFO"`: General informational messages - `"WARNING"`: Warning messages - `"ERROR"`: Error messages only - `"CRITICAL"`: Critical errors only Environment variables to set when running the server. Supports `${VAR_NAME}` syntax for runtime interpolation. ```json "env": { "API_KEY": "secret-key", "DATABASE_URL": "postgres://${DB_USER}@${DB_HOST}/mydb" } ``` Working directory for the server process. Relative paths are resolved from the config file location. Command-line arguments to pass to the server, passed after `--` to the server's argument parser. ```json "args": ["--config", "server-config.json"] ``` #### 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: ```json { "deployment": { "env": { "API_URL": "https://api.${ENVIRONMENT}.example.com", "DATABASE_URL": "postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/myapp", "CACHE_KEY": "myapp_${ENVIRONMENT}_${VERSION}" } } } ``` When the server starts, FastMCP replaces `${ENVIRONMENT}`, `${DB_USER}`, etc. with values from your system's environment variables. If a variable doesn't exist, the placeholder is preserved as-is. **Example**: If your system has `ENVIRONMENT=production` and `DB_HOST=db.example.com`: ```json // Configuration { "deployment": { "env": { "API_URL": "https://api.${ENVIRONMENT}.example.com", "DB_HOST": "${DB_HOST}" } } } // Result at runtime { "API_URL": "https://api.production.example.com", "DB_HOST": "db.example.com" } ``` This feature is particularly useful for: - Deploying the same configuration across development, staging, and production - Keeping sensitive values out of configuration files - 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 ``` ### Pre-building Environments You can use `fastmcp project prepare` to create a persistent uv project with all dependencies pre-installed: ```bash # Create a persistent environment fastmcp project prepare fastmcp.json --output-dir ./env # Use the pre-built environment to run the server fastmcp run fastmcp.json --project ./env ``` This pattern separates environment setup (slow) from server execution (fast), useful for deployment scenarios. ### 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": { "type": "uv", "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: **CLI Command**: ```bash uv run --with pandas --with requests \ fastmcp run server.py \ --transport http \ --port 8000 \ --log-level INFO ``` **Equivalent fastmcp.json**: ```json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] }, "deployment": { "transport": "http", "port": 8000, "log_level": "INFO" } } ``` Now simply run: ```bash fastmcp run # Automatically finds and uses fastmcp.json ``` The configuration file approach provides better documentation, easier sharing, and consistent execution across different environments while maintaining the flexibility to override settings when needed. ================================================ FILE: docs/development/contributing.mdx ================================================ --- title: "Contributing" description: "Development workflow for FastMCP contributors" icon: code-pull-request --- Contributing to FastMCP means joining a community that values clean, maintainable code and thoughtful API design. All contributions are valued - from fixing typos in documentation to implementing major features. ## Design Principles Every contribution should advance these principles: - 🚀 **Fast** — High-level interfaces mean less code and faster development - 🍀 **Simple** — Minimal boilerplate; the obvious way should be the right way - 🐍 **Pythonic** — Feels natural to Python developers; no surprising patterns - 🔍 **Complete** — Everything needed for production: auth, testing, deployment, observability PRs are evaluated against these principles. Code that makes FastMCP slower, harder to reason about, less Pythonic, or less complete will be rejected. ## Issues ### Issue First, Code Second **Every pull request requires a corresponding issue - no exceptions.** This requirement creates a collaborative space where approach, scope, and alignment are established before code is written. Issues serve as design documents where maintainers and contributors discuss implementation strategy, identify potential conflicts with existing patterns, and ensure proposed changes advance FastMCP's vision. **FastMCP is an opinionated framework, not a kitchen sink.** The maintainers have strong beliefs about what FastMCP should and shouldn't do. Just because something takes N lines of code and you want it in fewer lines doesn't mean FastMCP should take on the maintenance burden or endorse that pattern. This is judged at the maintainers' discretion. Use issues to understand scope BEFORE opening PRs. The issue discussion determines whether a feature belongs in core, contrib, or not at all. ### Writing Good Issues FastMCP is an extremely highly-trafficked repository maintained by a very small team. Issues that appear to transfer burden to maintainers without any effort to validate the problem will be closed. Please help the maintainers help you by always providing a minimal reproducible example and clearly describing the problem. **LLM-generated issues will be closed immediately.** Issues that contain paragraphs of unnecessary explanation, verbose problem descriptions, or obvious LLM authorship patterns obfuscate the actual problem and transfer burden to maintainers. Write clear, concise issues that: - State the problem directly - Provide a minimal reproducible example - Skip unnecessary background or context - Take responsibility for clear communication Issues may be labeled "Invalid" simply due to confusion caused by verbosity or not adhering to the guidelines outlined here. ## Pull Requests PRs that deviate from FastMCP's core principles will be rejected regardless of implementation quality. **PRs are NOT for iterating on ideas** - they should only be opened for ideas that already have a bias toward acceptance based on issue discussion. ### Development Environment #### Installation To contribute to FastMCP, you'll need to set up a development environment with all necessary tools and dependencies. ```bash # Clone the repository git clone https://github.com/PrefectHQ/fastmcp.git cd fastmcp # Install all dependencies including dev tools uv sync # Install prek hooks uv run prek install ``` In addition, some development commands require [just](https://github.com/casey/just) to be installed. Prek hooks will run automatically on every commit to catch issues before they reach CI. If you see failures, fix them before committing - never commit broken code expecting to fix it later. ### Development Standards #### Scope Large pull requests create review bottlenecks and quality risks. Unless you're fixing a discrete bug or making an incredibly well-scoped change, keep PRs small and focused. A PR that changes 50 lines across 3 files can be thoroughly reviewed in minutes. A PR that changes 500 lines across 20 files requires hours of careful analysis and often hides subtle issues. Breaking large features into smaller PRs: - Creates better review experiences - Makes git history clear - Simplifies debugging with bisect - Reduces merge conflicts - Gets your code merged faster #### Code Quality FastMCP values clarity over cleverness. Every line you write will be maintained by someone else - possibly years from now, possibly without context about your decisions. **PRs can be rejected for two opposing reasons:** 1. **Insufficient quality** - Code that doesn't meet our standards for clarity, maintainability, or idiomaticity 2. **Overengineering** - Code that is overbearing, unnecessarily complex, or tries to be too clever The focus is on idiomatic, high-quality Python. FastMCP uses patterns like `NotSet` type as an alternative to `None` in certain situations - follow existing patterns. #### Required Practices **Full type annotations** on all functions and methods. They catch bugs before runtime and serve as inline documentation. **Async/await patterns** for all I/O operations. Even if your specific use case doesn't need concurrency, consistency means users can compose features without worrying about blocking operations. **Descriptive names** make code self-documenting. `auth_token` is clear; `tok` requires mental translation. **Specific exception types** make error handling predictable. Catching `ValueError` tells readers exactly what error you expect. Never use bare `except` clauses. #### Anti-Patterns to Avoid **Complex one-liners** are hard to debug and modify. Break operations into clear steps. **Mutable default arguments** cause subtle bugs. Use `None` as the default and create the mutable object inside the function. **Breaking established patterns** confuses readers. If you must deviate, discuss in the issue first. ### Prek Checks ```bash # Runs automatically on commit, or manually: uv run prek run --all-files ``` This runs three critical tools: - **Ruff**: Linting and formatting - **Prettier**: Code formatting - **ty**: Static type checking Pytest runs separately as a distinct workflow step after prek checks pass. CI will reject PRs that fail these checks. Always run them locally first. ### Testing Tests are documentation that shows how features work. Good tests give reviewers confidence and help future maintainers understand intent. ```bash # Run specific test directory uv run pytest tests/server/ -v # Run all tests before submitting PR uv run pytest ``` Every new feature needs tests. See the [Testing Guide](/development/tests) for patterns and requirements. ### Documentation A feature doesn't exist unless it's documented. Note that FastMCP's hosted documentation always tracks the main branch - users who want historical documentation can clone the repo, checkout a specific tag, and host it themselves. ```bash # Preview documentation locally just docs ``` Documentation requirements: - **Explain concepts in prose first** - Code without context is just syntax - **Complete, runnable examples** - Every code block should be copy-pasteable - **Register in docs.json** - Makes pages appear in navigation - **Version badges** - Mark when features were added using `` #### SDK Documentation FastMCP's SDK documentation is auto-generated from the source code docstrings and type annotations. It is automatically updated on every merge to main by a GitHub Actions workflow, so users are *not* responsible for keeping the documentation up to date. However, to generate it proactively, you can use the following command: ```bash just api-ref-all ``` ### Submitting Your PR #### Before Submitting 1. **Run all checks**: `uv run prek run --all-files && uv run pytest` 2. **Keep scope small**: One feature or fix per PR 3. **Write clear description**: Your PR description becomes permanent documentation 4. **Update docs**: Include documentation for API changes #### PR Description Write PR descriptions that explain: - What problem you're solving - Why you chose this approach - Any trade-offs or alternatives considered - Migration path for breaking changes Focus on the "why" - the code shows the "what". Keep it concise but complete. #### What We Look For **Framework Philosophy**: FastMCP is NOT trying to do all things or provide all shortcuts. Features are rejected when they don't align with the framework's vision, even if perfectly implemented. The burden of proof is on the PR to demonstrate value. **Code Quality**: We verify code follows existing patterns. Consistency reduces cognitive load. When every module works similarly, developers understand new code quickly. **Test Coverage**: Not every line needs testing, but every behavior does. Tests document intent and protect against regressions. **Breaking Changes**: May be acceptable in minor versions but must be clearly documented. See the [versioning policy](/development/releases#versioning-policy). ## Special Modules **`contrib`**: Community-maintained patterns and utilities. Original authors maintain their contributions. Not representative of the core framework. **`experimental`**: Maintainer-developed features that may preview future functionality. Can break or be deleted at any time without notice. Pin your FastMCP version when using these features. ================================================ FILE: docs/development/releases.mdx ================================================ --- title: "Releases" description: "FastMCP versioning and release process" icon: "truck-fast" --- FastMCP releases frequently to deliver features quickly in the rapidly evolving MCP ecosystem. We use semantic versioning pragmatically - the Model Context Protocol is young, patterns are still emerging, and waiting for perfect stability would mean missing opportunities to empower developers with better tools. ## Versioning Policy ### Semantic Versioning **Major (x.0.0)**: Complete API redesigns Major versions represent fundamental shifts. FastMCP 2.x is entirely different from 1.x in both implementation and design philosophy. **Minor (2.x.0)**: New features and evolution Unlike traditional semantic versioning, minor versions **may** include [breaking changes](#breaking-changes) when necessary for the ecosystem's evolution. This flexibility is essential in a young ecosystem where perfect backwards compatibility would prevent important improvements. FastMCP always targets the most current MCP Protocol version. Breaking changes in the MCP spec or MCP SDK automatically flow through to FastMCP - we prioritize staying current with the latest features and conventions over maintaining compatibility with older protocol versions. **Patch (2.0.x)**: Bug fixes and refinements Patch versions contain only bug fixes without breaking changes. These are safe updates you can apply with confidence. ### Breaking Changes We permit breaking changes in minor versions because the MCP ecosystem is rapidly evolving. Refusing to break problematic APIs would accumulate design debt that eventually makes the framework unusable. Each breaking change represents a deliberate decision to keep FastMCP aligned with the ecosystem's evolution. When breaking changes occur: - They only happen in minor versions (e.g., 2.3.x to 2.4.0) - Release notes explain what changed and how to migrate - We provide deprecation warnings at least 1 minor version in advance when possible - Changes must substantially benefit users to justify disruption The public API is what's covered by our compatibility guarantees - these are the parts of FastMCP you can rely on to remain stable within a minor version. The public API consists of: - `FastMCP` server class, `Client` class, and FastMCP `Context` - Core MCP components: `Tool`, `Prompt`, `Resource`, `ResourceTemplate`, and transports - Their public methods and documented behaviors Everything else (utilities, private methods, internal modules) may change without notice. This boundary lets us refactor internals and improve implementation details without breaking your code. For production stability, pin to specific versions. The `fastmcp.server.auth` module was introduced in 2.12.0 and is exempted from this policy temporarily, meaning it is *expected* to have breaking changes even on patch versions. This is because auth is a rapidly evolving part of the MCP spec and it would be dangerous to be beholden to old decisions. Please pin your FastMCP version if using authentication in production. We expect this exemption to last through at least the 2.12.x and 2.13.x release series. ### Production Use Pin to exact versions: ``` fastmcp==2.11.0 # Good fastmcp>=2.11.0 # Bad - will install breaking changes ``` ## Creating Releases Our release process is intentionally simple: 1. Create GitHub release with tag `vMAJOR.MINOR.PATCH` (e.g., `v2.11.0`) 2. Generate release notes automatically, and curate or add additional editorial information as needed 3. GitHub releases automatically trigger PyPI deployments This automation lets maintainers focus on code quality rather than release mechanics. ### Release Cadence We follow a feature-driven release cadence rather than a fixed schedule. Minor versions ship approximately every 3-4 weeks when significant functionality is ready. Patch releases ship promptly for: - Critical bug fixes - Security updates (immediate release) - Regression fixes This approach means you get improvements as soon as they're ready rather than waiting for arbitrary release dates. ================================================ FILE: docs/development/tests.mdx ================================================ --- title: "Tests" description: "Testing patterns and requirements for FastMCP" icon: vial --- import { VersionBadge } from "/snippets/version-badge.mdx" Good tests are the foundation of reliable software. In FastMCP, we treat tests as first-class documentation that demonstrates how features work while protecting against regressions. Every new capability needs comprehensive tests that demonstrate correctness. ## FastMCP Tests ### Running Tests ```bash # Run all tests uv run pytest # Run specific test file uv run pytest tests/server/test_auth.py # Run with coverage uv run pytest --cov=fastmcp # Skip integration tests for faster runs uv run pytest -m "not integration" # Skip tests that spawn processes uv run pytest -m "not integration and not client_process" ``` Tests should complete in under 1 second unless marked as integration tests. This speed encourages running them frequently, catching issues early. ### Test Organization Our test organization mirrors the `src/` directory structure, creating a predictable mapping between code and tests. When you're working on `src/fastmcp/server/auth.py`, you'll find its tests in `tests/server/test_auth.py`. In rare cases tests are split further - for example, the OpenAPI tests are so comprehensive they're split across multiple files. ### Test Markers We use pytest markers to categorize tests that require special resources or take longer to run: ```python @pytest.mark.integration async def test_github_api_integration(): """Test GitHub API integration with real service.""" token = os.getenv("FASTMCP_GITHUB_TOKEN") if not token: pytest.skip("FASTMCP_GITHUB_TOKEN not available") # Test against real GitHub API client = GitHubClient(token) repos = await client.list_repos("prefecthq") assert "fastmcp" in [repo.name for repo in repos] @pytest.mark.client_process async def test_stdio_transport(): """Test STDIO transport with separate process.""" # This spawns a subprocess async with Client("python examples/simple_echo.py") as client: result = await client.call_tool("echo", {"message": "test"}) assert result.content[0].text == "test" ``` ## Writing Tests ### Test Requirements Following these practices creates maintainable, debuggable test suites that serve as both documentation and regression protection. #### Single Behavior Per Test Each test should verify exactly one behavior. When it fails, you need to know immediately what broke. A test that checks five things gives you five potential failure points to investigate. A test that checks one thing points directly to the problem. ```python Good: Atomic Test async def test_tool_registration(): """Test that tools are properly registered with the server.""" mcp = FastMCP("test-server") @mcp.tool def add(a: int, b: int) -> int: return a + b tools = mcp.list_tools() assert len(tools) == 1 assert tools[0].name == "add" ``` ```python Bad: Multi-Behavior Test async def test_server_functionality(): """Test multiple server features at once.""" mcp = FastMCP("test-server") # Tool registration @mcp.tool def add(a: int, b: int) -> int: return a + b # Resource creation @mcp.resource("config://app") def get_config(): return {"version": "1.0"} # Authentication setup mcp.auth = BearerTokenProvider({"token": "user"}) # What exactly are we testing? If this fails, what broke? assert mcp.list_tools() assert mcp.list_resources() assert mcp.auth is not None ``` #### Self-Contained Setup Every test must create its own setup. Tests should be runnable in any order, in parallel, or in isolation. When a test fails, you should be able to run just that test to reproduce the issue. ```python Good: Self-Contained async def test_tool_execution_with_error(): """Test that tool errors are properly handled.""" mcp = FastMCP("test-server") @mcp.tool def divide(a: int, b: int) -> float: if b == 0: raise ValueError("Cannot divide by zero") return a / b async with Client(mcp) as client: with pytest.raises(Exception): await client.call_tool("divide", {"a": 10, "b": 0}) ``` ```python Bad: Test Dependencies # Global state that tests depend on test_server = None def test_setup_server(): """Setup for other tests.""" global test_server test_server = FastMCP("shared-server") def test_server_works(): """Test server functionality.""" # Depends on test_setup_server running first assert test_server is not None ``` #### Clear Intent Test names and assertions should make the verified behavior obvious. A developer reading your test should understand what feature it validates and how that feature should behave. ```python async def test_authenticated_tool_requires_valid_token(): """Test that authenticated users can access protected tools.""" mcp = FastMCP("test-server") mcp.auth = BearerTokenProvider({"secret-token": "test-user"}) @mcp.tool def protected_action() -> str: return "success" async with Client(mcp, auth=BearerAuth("secret-token")) as client: result = await client.call_tool("protected_action", {}) assert result.content[0].text == "success" ``` #### Using Fixtures Use fixtures to create reusable data, server configurations, or other resources for your tests. Note that you should **not** open FastMCP clients in your fixtures as it can create hard-to-diagnose issues with event loops. ```python import pytest from fastmcp import FastMCP, Client @pytest.fixture def weather_server(): server = FastMCP("WeatherServer") @server.tool def get_temperature(city: str) -> dict: temps = {"NYC": 72, "LA": 85, "Chicago": 68} return {"city": city, "temp": temps.get(city, 70)} return server async def test_temperature_tool(weather_server): async with Client(weather_server) as client: result = await client.call_tool("get_temperature", {"city": "LA"}) assert result.data == {"city": "LA", "temp": 85} ``` #### Effective Assertions Assertions should be specific and provide context on failure. When a test fails during CI, the assertion message should tell you exactly what went wrong. ```python # Basic assertion - minimal context on failure assert result.status == "success" # Better - explains what was expected assert result.status == "success", f"Expected successful operation, got {result.status}: {result.error}" ``` Try not to have too many assertions in a single test unless you truly need to check various aspects of the same behavior. In general, assertions of different behaviors should be in separate tests. #### Inline Snapshots FastMCP uses `inline-snapshot` for testing complex data structures. On first run of `pytest --inline-snapshot=create` with an empty `snapshot()`, pytest will auto-populate the expected value. To update snapshots after intentional changes, run `pytest --inline-snapshot=fix`. This is particularly useful for testing JSON schemas and API responses. ```python from inline_snapshot import snapshot async def test_tool_schema_generation(): """Test that tool schemas are generated correctly.""" mcp = FastMCP("test-server") @mcp.tool def calculate_tax(amount: float, rate: float = 0.1) -> dict: """Calculate tax on an amount.""" return {"amount": amount, "tax": amount * rate, "total": amount * (1 + rate)} tools = mcp.list_tools() schema = tools[0].inputSchema # First run: snapshot() is empty, gets auto-populated # Subsequent runs: compares against stored snapshot assert schema == snapshot({ "type": "object", "properties": { "amount": {"type": "number"}, "rate": {"type": "number", "default": 0.1} }, "required": ["amount"] }) ``` ### In-Memory Testing FastMCP uses in-memory transport for testing, where servers and clients communicate directly. The majority of functionality can be tested in a deterministic fashion this way. We use more complex setups only when testing transports themselves. The in-memory transport runs the real MCP protocol implementation without network overhead. Instead of deploying your server or managing network connections, you pass your server instance directly to the client. Everything runs in the same Python process - you can set breakpoints anywhere and step through with your debugger. ```python from fastmcp import FastMCP, Client # Create your server server = FastMCP("WeatherServer") @server.tool def get_temperature(city: str) -> dict: """Get current temperature for a city""" temps = {"NYC": 72, "LA": 85, "Chicago": 68} return {"city": city, "temp": temps.get(city, 70)} async def test_weather_operations(): # Pass server directly - no deployment needed async with Client(server) as client: result = await client.call_tool("get_temperature", {"city": "NYC"}) assert result.data == {"city": "NYC", "temp": 72} ``` This pattern makes tests deterministic and fast - typically completing in milliseconds rather than seconds. ### Mocking External Dependencies FastMCP servers are standard Python objects, so you can mock external dependencies using your preferred approach: ```python from unittest.mock import AsyncMock async def test_database_tool(): server = FastMCP("DataServer") # Mock the database mock_db = AsyncMock() mock_db.fetch_users.return_value = [ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"} ] @server.tool async def list_users() -> list: return await mock_db.fetch_users() async with Client(server) as client: result = await client.call_tool("list_users", {}) assert len(result.data) == 2 assert result.data[0]["name"] == "Alice" mock_db.fetch_users.assert_called_once() ``` ### Testing Network Transports While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers (preferred), and separate subprocess servers (for special cases). #### In-Process Network Testing (Preferred) For most network transport tests, use `run_server_async` as an async context manager. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support: ```python import pytest from fastmcp import FastMCP, Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.utilities.tests import run_server_async def create_test_server() -> FastMCP: """Create a test server instance.""" server = FastMCP("TestServer") @server.tool def greet(name: str) -> str: return f"Hello, {name}!" return server @pytest.fixture async def http_server() -> str: """Start server in-process for testing.""" server = create_test_server() async with run_server_async(server) as url: yield url async def test_http_transport(http_server: str): """Test actual HTTP transport behavior.""" async with Client( transport=StreamableHttpTransport(http_server) ) as client: result = await client.ping() assert result is True greeting = await client.call_tool("greet", {"name": "World"}) assert greeting.data == "Hello, World!" ``` The `run_server_async` context manager automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages. #### Subprocess Testing (Special Cases) For tests that require complete process isolation (like STDIO transport or testing subprocess behavior), use `run_server_in_process`: ```python import pytest from fastmcp.utilities.tests import run_server_in_process from fastmcp import FastMCP, Client from fastmcp.client.transports import StreamableHttpTransport def run_server(host: str, port: int) -> None: """Function to run in subprocess.""" server = FastMCP("TestServer") @server.tool def greet(name: str) -> str: return f"Hello, {name}!" server.run(host=host, port=port) @pytest.fixture async def http_server(): """Fixture that runs server in subprocess.""" with run_server_in_process(run_server, transport="http") as url: yield f"{url}/mcp" async def test_http_transport(http_server: str): """Test actual HTTP transport behavior.""" async with Client( transport=StreamableHttpTransport(http_server) ) as client: result = await client.ping() assert result is True ``` The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI. ### Documentation Testing Documentation requires the same validation as code. The `just docs` command launches a local Mintlify server that renders your documentation exactly as users will see it: ```bash # Start local documentation server with hot reload just docs # Or run Mintlify directly mintlify dev ``` The local server watches for changes and automatically refreshes. This preview catches formatting issues and helps you see documentation as users will experience it. ================================================ FILE: docs/development/v3-notes/auth-provider-env-vars.mdx ================================================ --- title: Auth Provider Environment Variables --- ## Decision: Remove automatic environment variable loading from auth providers You can still use environment variables for configuration - you just read them yourself with `os.environ` instead of relying on FastMCP's automatic loading. **Status:** Implemented in v3.0.0 ### Background Auth providers in v2.x used `pydantic-settings` to automatically load configuration from environment variables with a `FASTMCP_SERVER_AUTH__` prefix. For example, `GitHubProvider` would read from: - `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID` - `FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET` - `FASTMCP_SERVER_AUTH_GITHUB_BASE_URL` - etc. This was implemented via a `*ProviderSettings(BaseSettings)` class in each provider, combined with a `NotSet` sentinel pattern to distinguish between "not provided" and `None`. ### Why remove it 1. **Maintenance burden**: Every new provider needed to implement the settings class, validators, and the `NotSet` merging logic. This was ~50-100 lines of boilerplate per provider. 2. **Documentation complexity**: Each provider needed documentation explaining both the parameter and the corresponding environment variable. This doubled the surface area to document and maintain. 3. **Contributor friction**: New contributors adding providers had to understand and replicate this pattern, which was a source of inconsistency and bugs. 4. **Marginal user value**: Python developers are comfortable with `os.environ["VAR"]` or `os.environ.get("VAR", default)`. The automatic loading saved a single line of code per parameter while adding significant complexity. 5. **Implicit behavior**: Magic environment variable loading makes it harder to understand where values come from. Explicit `os.environ` calls are more traceable. ### Migration path The migration is trivial - users add explicit environment variable reads: ```python # Before (v2.x) auth = GitHubProvider() # Relied on env vars # After (v3.0) import os auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url=os.environ["MY_BASE_URL"], ) ``` Users can also use `os.environ.get()` with defaults, or any other configuration library they prefer (dotenv, dynaconf, etc.). ### Backwards compatibility We chose not to provide backwards compatibility because: 1. This is a major version bump (v3.0), which is the appropriate time for breaking changes 2. The migration is straightforward (add `os.environ` calls) 3. Maintaining compatibility would require keeping all the boilerplate we're trying to remove 4. The pattern was likely not heavily used - most production deployments pass secrets explicitly rather than relying on magic prefixes ### What was removed - `*ProviderSettings(BaseSettings)` classes from all auth providers - `NotSet` sentinel usage in provider constructors - `pydantic-settings` dependency for auth providers - Environment variable documentation from provider docs - Related test cases for env var loading ### Result Provider constructors are now simple and explicit. Required parameters are actually required (Python raises `TypeError` if missing), and optional parameters have clear defaults. The code is more readable and easier to maintain. ================================================ FILE: docs/development/v3-notes/v3-features.mdx ================================================ --- title: v3.0 Feature Tracking --- This document tracks major features in FastMCP v3.0 for release notes preparation. ## 3.0.0rc1 ### SamplingTool Conversion Helpers Server tools (FunctionTool and TransformedTool) can now be passed directly to sampling methods via `SamplingTool.from_callable_tool()` ([#3062](https://github.com/PrefectHQ/fastmcp/pull/3062)). Previously, tools defined with `@mcp.tool` had to be recreated as functions for use in `ctx.sample()`. Now `ctx.sample()` and `ctx.sample_step()` accept these tool instances directly. ```python @mcp.tool def search(query: str) -> str: """Search the web.""" return do_search(query) # Use tool directly in sampling result = await ctx.sample( "Research Python frameworks", tools=[search] # FunctionTool works directly! ) ``` ### Google GenAI Sampling Handler FastMCP now includes a sampling handler for Google's Gemini models ([#2977](https://github.com/jlowin/fastmcp/pull/2977)). This enables MCP clients to use Google's GenAI models with the sampling protocol, including full tool calling support. ```python from fastmcp import Client from fastmcp.client.sampling.handlers import GoogleGenaiSamplingHandler from google.genai import Client as GoogleGenaiClient # Initialize the handler handler = GoogleGenaiSamplingHandler( default_model="gemini-2.0-flash-exp", client=GoogleGenaiClient(), # Optional - creates one if not provided ) # Use with MCP sampling (handler is configured at Client construction) async with Client("http://server/mcp", sampling_handler=handler) as client: result = await client.sample( messages=[...], tools=[...], ) ``` Key features: - Converts MCP tool schemas to Google's function calling format - Supports all Google GenAI models that implement function calling - Handles nullable types, nested objects, and arrays in tool schemas - Properly maps tool choices (`auto`, `required`, `none`) to Google's configuration - Preserves model preferences from MCP sampling parameters The handler joins the existing Anthropic and OpenAI handlers, providing a consistent interface for model-agnostic sampling across providers. ### Concurrent Tool Execution in Sampling When an LLM returns multiple tool calls in a single sampling response, they can now be executed concurrently ([#3022](https://github.com/PrefectHQ/fastmcp/pull/3022)). Default behavior remains sequential; opt in with `tool_concurrency`. Tools can declare `sequential=True` to force sequential execution even when concurrency is enabled. ```python result = await context.sample( messages="Fetch weather for NYC and LA", tools=[fetch_weather], tool_concurrency=0, # Unlimited parallel execution ) ``` ### OpenAPI `validate_output` Option `OpenAPIProvider` and `FastMCP.from_openapi()` now accept `validate_output=False` to skip output schema validation ([#3134](https://github.com/PrefectHQ/fastmcp/pull/3134)). Useful when backends don't conform to their own OpenAPI response schemas — structured JSON still flows through, only the strict schema checking is disabled. ```python mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, validate_output=False, ) ``` ### Auth Token Injection and Azure OBO Dependencies New dependency injection for accessing the authenticated user's token directly in tool parameters ([#2918](https://github.com/PrefectHQ/fastmcp/pull/2918)). Works with any auth provider. ```python from fastmcp.server.dependencies import CurrentAccessToken, TokenClaim from fastmcp.server.auth import AccessToken @mcp.tool() async def my_tool( token: AccessToken = CurrentAccessToken, user_id: str = TokenClaim("oid"), ): ... ``` For Azure/Entra, the new `fastmcp[azure]` extra adds `EntraOBOToken`, which handles the On-Behalf-Of token exchange declaratively: ```python from fastmcp.server.auth.providers.azure import EntraOBOToken @mcp.tool() async def get_emails( graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), ): # graph_token is ready — OBO exchange happened automatically ... ``` ### `generate-cli` Agent Skill Generation `fastmcp generate-cli` now produces a `SKILL.md` alongside the CLI script ([#3115](https://github.com/PrefectHQ/fastmcp/pull/3115)) — a Claude Code agent skill with pre-computed invocation syntax for every tool. Agents reading the skill can call tools immediately without running `--help`. On by default; pass `--no-skill` to opt out. ### Background Task Notification Queue Background tasks now use a distributed Redis notification queue for reliable delivery ([#2906](https://github.com/PrefectHQ/fastmcp/pull/2906)). Elicitation switches from polling to BLPOP (single blocking call instead of ~7,200 round-trips/hour), and notification delivery retries up to 3x with TTL-based expiration. ### Async Auth Checks Auth check functions can now be `async`, enabling authorization decisions that depend on asynchronous operations like reading server state via `Context.get_state` or calling external services ([#3150](https://github.com/PrefectHQ/fastmcp/issues/3150)). Sync and async checks can be freely mixed. Previously, passing an async function as an auth check would silently pass (coroutine objects are truthy). ### Optional `$ref` Dereferencing in Schemas Schema `$ref` dereferencing — which inlines all `$defs` for compatibility with MCP clients that don't handle `$ref` — is now controlled by the `dereference_schemas` constructor kwarg ([#3141](https://github.com/PrefectHQ/fastmcp/issues/3141)). Default is `True` (dereference on) because the non-compliant clients are popular and the failure mode is silent breakage that server authors can't diagnose. Opt out when you know your clients handle `$ref` and want smaller schemas: ```python mcp = FastMCP("my-server", dereference_schemas=False) ``` Dereferencing is implemented as middleware (`DereferenceRefsMiddleware`) that runs at serve-time, so schemas are stored with `$ref` intact and only inlined when sent to clients. ### Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed Sixteen deprecated keyword arguments have been removed from `FastMCP.__init__`. Passing any of them now raises `TypeError` with a migration hint. Environment variables (e.g., `FASTMCP_HOST`) continue to work — only the constructor kwargs moved. **Transport/server settings** (`host`, `port`, `log_level`, `debug`, `sse_path`, `message_path`, `streamable_http_path`, `json_response`, `stateless_http`): Pass to `run()`, `run_http_async()`, or `http_app()` as appropriate, or set via environment variables. ```python # Before mcp = FastMCP("server", host="0.0.0.0", port=8080) mcp.run() # After mcp = FastMCP("server") mcp.run(transport="http", host="0.0.0.0", port=8080) ``` **Duplicate handling** (`on_duplicate_tools`, `on_duplicate_resources`, `on_duplicate_prompts`): Use the unified `on_duplicate=` parameter. **Tag filtering** (`include_tags`, `exclude_tags`): Use `server.enable(tags=..., only=True)` and `server.disable(tags=...)` after construction. **Tool serializer** (`tool_serializer`): Return `ToolResult` from tools instead. **Tool transformations** (`tool_transformations`): Use `server.add_transform(ToolTransform(...))` after construction. The `_deprecated_settings` attribute and `.settings` property are also removed. `ExperimentalSettings` has been deleted (dead code). ### Breaking: `ui=` Renamed to `app=` The MCP Apps decorator parameter has been renamed from `ui=ToolUI(...)` / `ui=ResourceUI(...)` to `app=AppConfig(...)` ([#3117](https://github.com/PrefectHQ/fastmcp/pull/3117)). `ToolUI` and `ResourceUI` are consolidated into a single `AppConfig` class. Wire format is unchanged. See the MCP Apps section under beta2 for full details. ## 3.0.0beta2 ### CLI: `fastmcp list` and `fastmcp call` New client-side CLI commands for querying and invoking tools on any MCP server — remote URLs, local Python files, MCPConfig JSON, or arbitrary stdio commands. Especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands. ```bash # Discover tools on a server fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list --command 'npx -y @modelcontextprotocol/server-github' # Call a tool fastmcp call server.py greet name=World fastmcp call http://localhost:8000/mcp search query=hello limit=5 fastmcp call server.py create_item '{"name": "Widget", "tags": ["a", "b"]}' ``` Key features: - Tool arguments are auto-coerced using the tool's JSON schema (`limit=5` → int) - Single JSON objects work as positional args alongside `key=value` and `--input-json` - `--input-schema` / `--output-schema` for full JSON schemas, `--json` for machine-readable output - `--transport sse` for SSE servers, `--command` for stdio servers - Auto OAuth for HTTP targets (no-ops if server doesn't require auth) - Fuzzy tool name matching suggests alternatives on typos - Interactive terminal elicitation for tools that request user input mid-execution Documentation: [CLI Querying](/cli/client) ### CLI: `fastmcp discover` and name-based resolution `fastmcp discover` scans editor configs (Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose) and project-level `mcp.json` files for MCP server definitions. Discovered servers can be referenced by name — or `source:name` for precision — in `fastmcp list` and `fastmcp call`. ```bash # See all configured servers fastmcp discover # Use a server by name fastmcp list weather fastmcp call weather get_forecast city=London # Target a specific source with source:name fastmcp list claude-code:my-server fastmcp call cursor:weather get_forecast city=London # Filter discovery to specific sources fastmcp discover --source claude-code --source cursor ``` Documentation: [CLI Querying](/cli/client) ### CLI: Expanded Reload File Watching The `--reload` flag now watches a comprehensive set of file types, making it suitable for MCP apps with frontend bundles ([#3028](https://github.com/PrefectHQ/fastmcp/pull/3028)). Previously limited to `.py` files, it now watches JavaScript, TypeScript, HTML, CSS, config files, and media assets. ### CLI: fastmcp install stdio The new `fastmcp install stdio` command generates full `uv run` commands for running FastMCP servers over stdio ([#3032](https://github.com/PrefectHQ/fastmcp/pull/3032)). ```bash # Generate command for a server fastmcp install stdio server.py # Outputs: # uv run --directory /path/to/project fastmcp run server.py ``` The command automatically detects the project directory and generates the appropriate `uv run` invocation, making it easy to integrate FastMCP servers with MCP clients. ### CIMD (Client ID Metadata Documents) CIMD provides an alternative to Dynamic Client Registration for OAuth-authenticated MCP servers. Instead of registering with each server dynamically, clients host a static JSON document at an HTTPS URL. That URL becomes the client's `client_id`, and servers verify identity through domain ownership. **Client usage:** ```python from fastmcp import Client from fastmcp.client.auth import OAuth async with Client( "https://mcp-server.example.com/mcp", auth=OAuth( client_metadata_url="https://myapp.example.com/oauth/client.json", ), ) as client: await client.ping() ``` The `OAuth` helper now supports deferred binding — `mcp_url` is optional when using `OAuth` with `Client(auth=...)`, since the transport provides the server URL automatically. **CLI tools for document management:** ```bash # Generate a CIMD document fastmcp auth cimd create --name "My App" \ --redirect-uri "http://localhost:*/callback" \ --client-id "https://myapp.example.com/oauth/client.json" \ --output client.json # Validate a hosted document fastmcp auth cimd validate https://myapp.example.com/oauth/client.json ``` **Server-side support:** CIMD is enabled by default on `OAuthProxy` and its provider subclasses (GitHub, Google, etc.). The server-side implementation includes SSRF-hardened document fetching with DNS pinning, dual redirect URI validation (both CIMD document patterns and proxy patterns must match), HTTP cache-aware revalidation, and `private_key_jwt` assertion validation for clients that need stronger authentication than public client auth. Key details: - CIMD URLs must be HTTPS with a non-root path - `token_endpoint_auth_method` limited to `none` or `private_key_jwt` (no shared secrets) - `redirect_uris` in CIMD documents support wildcard port patterns (`http://localhost:*/callback`) - Servers fetch and cache documents with standard HTTP caching (ETag, Last-Modified, Cache-Control) - CIMD is a protocol-level feature — any auth provider implementing the spec can support it Documentation: [CIMD Authentication](/clients/auth/cimd), [OAuth Proxy CIMD config](/servers/auth/oauth-proxy#cimd-support) ### Pre-Registered OAuth Clients The `OAuth` client helper now accepts `client_id` and `client_secret` parameters for servers where the client is already registered ([#3086](https://github.com/PrefectHQ/fastmcp/pull/3086)). This bypasses Dynamic Client Registration entirely — useful when DCR is disabled, or when the server has pre-provisioned credentials for your application. ```python from fastmcp import Client from fastmcp.client.auth import OAuth async with Client( "https://mcp-server.example.com/mcp", auth=OAuth( client_id="my-registered-app", client_secret="my-secret", scopes=["read", "write"], ), ) as client: await client.ping() ``` The static credentials are injected before the OAuth flow begins, so the client never attempts DCR. If the server rejects the credentials, the error surfaces immediately rather than retrying with fresh registration (which can't help for fixed credentials). Public clients can omit `client_secret`. Documentation: [Pre-Registered Clients](/clients/auth/oauth#pre-registered-clients) ### CLI: `fastmcp generate-cli` `fastmcp generate-cli` connects to any MCP server, reads its tool schemas, and writes a standalone Python CLI script where every tool becomes a typed subcommand with flags, help text, and tab completion ([#3065](https://github.com/PrefectHQ/fastmcp/pull/3065)). The insight is that MCP tool schemas already contain everything a CLI framework needs — parameter names, types, descriptions, required/optional status — so the generator maps JSON Schema directly into [cyclopts](https://cyclopts.readthedocs.io/) commands. ```bash # Generate from any server spec fastmcp generate-cli weather fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py my_weather_cli.py # Use the generated script python my_weather_cli.py call-tool get_forecast --city London --days 3 python my_weather_cli.py list-tools python my_weather_cli.py read-resource docs://readme ``` The generated script embeds the resolved transport (URL or stdio command), so it's self-contained — users don't need to know about MCP or FastMCP to use it. Supports `-f` to overwrite existing files, and name-based resolution via `fastmcp discover`. Documentation: [Generate CLI](/cli/generate-cli) ### CLI: Goose Integration New `fastmcp install goose` command that generates a `goose://extension?...` deeplink URL and opens it, prompting Goose to install the server as a STDIO extension ([#3040](https://github.com/PrefectHQ/fastmcp/pull/3040)). Goose requires `uvx` rather than `uv run`, so the command builds the appropriate invocation automatically. ```bash fastmcp install goose server.py fastmcp install goose server.py --with pandas --python 3.11 ``` Also adds a full integration guide at [Goose Integration](/integrations/goose). ### ResponseLimitingMiddleware New middleware for controlling tool response sizes, preventing large outputs from overwhelming LLM context windows ([#3072](https://github.com/PrefectHQ/fastmcp/pull/3072)). Text responses are truncated at UTF-8 character boundaries; structured responses (tools with `output_schema`) raise `ToolError` since truncation would corrupt the schema. ```python from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware # Limit all tool responses to 500KB mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) # Limit only specific tools, raise errors instead of truncating mcp.add_middleware(ResponseLimitingMiddleware( max_size=100_000, tools=["search", "fetch_data"], raise_on_unstructured=True, )) ``` Key features: - Configurable size limit (default 1MB) - Tool-specific filtering via `tools` parameter - Size metadata added to result's `meta` field for monitoring - Configurable `raise_on_structured` and `raise_on_unstructured` behavior Documentation: [Middleware](/servers/middleware) ### Background Task Context (SEP-1686) `Context` now works transparently in background tasks running in Docket workers ([#2905](https://github.com/PrefectHQ/fastmcp/pull/2905)). Previously, tools running as background tasks couldn't use `ctx.elicit()` because there was no active request context. Now, when a tool executes in a Docket worker, `Context` detects this via its `task_id` and routes elicitation through Redis-based coordination: the task sets its status to `input_required`, sends a `notifications/tasks/updated` notification with elicitation metadata, and waits for the client to respond via `tasks/sendInput`. ```python @mcp.tool(task=True) async def interactive_task(ctx: Context) -> str: # Works transparently in both foreground and background task modes result = await ctx.elicit("Please provide additional input", str) if isinstance(result, AcceptedElicitation): return f"You provided: {result.data}" else: return "Elicitation was declined or cancelled" ``` `ctx.is_background_task` and `ctx.task_id` are available for tools that need to branch on execution mode. ### `require_auth` Removed The `require_auth` authorization check introduced in beta1 has been removed in favor of scope-based authorization via `require_scopes` ([#3103](https://github.com/PrefectHQ/fastmcp/pull/3103)). Since configuring an `AuthProvider` already rejects unauthenticated requests at the transport level, `require_auth` was redundant — `require_scopes` provides the same guarantee with better granularity. The beta1 Component Authorization section has been updated to reflect this. ### MCP Apps (SDK Compatibility) Support for [MCP Apps](https://modelcontextprotocol.io/specification/2025-06-18/server/apps) — the spec extension that lets MCP servers deliver interactive UIs via sandboxed iframes. Extension negotiation, typed UI metadata on tools and resources, and the `ui://` resource scheme. No component DSL, renderer, or `FastMCPApp` class yet — those are future phases. **Breaking change from beta 2:** The `ui=` parameter on `@mcp.tool()` and `@mcp.resource()` has been renamed to `app=`, and the `ToolUI`/`ResourceUI` classes have been consolidated into a single `AppConfig` class. This follows the established `task=True`/`TaskConfig` pattern. The wire format (`meta["ui"]`, `_meta.ui`) is unchanged. **Registering tools with app metadata:** ```python from fastmcp import FastMCP from fastmcp.server.apps import AppConfig, ResourceCSP, ResourcePermissions mcp = FastMCP("My Server") # Register the HTML bundle as a ui:// resource with CSP @mcp.resource( "ui://my-app/view.html", app=AppConfig( csp=ResourceCSP(resource_domains=["https://unpkg.com"]), permissions=ResourcePermissions(clipboard_write={}), ), ) def app_html() -> str: from pathlib import Path return Path("./dist/index.html").read_text() # Tool with UI — clients render an iframe alongside the result @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html")) async def list_users() -> list[dict]: return [{"id": "1", "name": "Alice"}] # App-only tool — visible to the UI but hidden from the model @mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html", visibility=["app"])) async def delete_user(id: str) -> dict: return {"deleted": True} ``` The `app=` parameter accepts `True` (enable with defaults), an `AppConfig` instance, or a raw dict for forward compatibility. It merges into `meta["ui"]` — alongside any other metadata you set. **`ui://` resources** automatically get the correct MIME type (`text/html;profile=mcp-app`) unless you override it explicitly. **Extension negotiation**: The server advertises `io.modelcontextprotocol/ui` in `capabilities.extensions`. UI metadata (`_meta.ui`) always flows through to clients — the MCP Apps spec assigns visibility enforcement to the host, not the server. Tools can check whether the connected client supports a given extension at runtime via `ctx.client_supports_extension()`: ```python from fastmcp import Context from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID @mcp.tool(app=AppConfig(resource_uri="ui://dashboard")) async def dashboard(ctx: Context) -> dict: data = compute_dashboard() if ctx.client_supports_extension(UI_EXTENSION_ID): return data return {"summary": format_text(data)} ``` **Key details:** - `AppConfig` fields: `resource_uri`, `visibility`, `csp`, `permissions`, `domain`, `prefers_border` (all optional). On resources, `resource_uri` and `visibility` are validated as not-applicable and will raise `ValueError` if set. - `csp` accepts a `ResourceCSP` model with structured domain lists: `connect_domains`, `resource_domains`, `frame_domains`, `base_uri_domains` - `permissions` accepts a `ResourcePermissions` model: `camera`, `microphone`, `geolocation`, `clipboard_write` (each set to `{}` to request) - `AppConfig` uses `extra="allow"` for forward compatibility with future spec additions - Models use Pydantic aliases for wire format (`resourceUri`, `prefersBorder`, `connectDomains`, `clipboardWrite`) - Resource metadata (including CSP/permissions) is propagated to `resources/read` response content items so hosts can read it when rendering the iframe - `ctx.client_supports_extension(id)` is a general-purpose method — works for any extension, not just MCP Apps - `structuredContent` in tool results already works via `ToolResult` — MCP Apps clients use this to pass data into the iframe - The server does not strip `_meta.ui` for non-UI clients; per the spec, visibility enforcement is the host's responsibility **Future phases** will add a component DSL for building UIs declaratively, an in-repo renderer, and a `FastMCPApp` class. Implementation: `src/fastmcp/server/apps.py` (models and constants), with integration points in `server.py` (decorator parameters), `low_level.py` (extension advertisement), and `context.py` (`client_supports_extension` method). --- ## 3.0.0beta1 ### Provider-Based Architecture v3.0 introduces a provider-based component system that replaces v2's static-only registration ([#2622](https://github.com/PrefectHQ/fastmcp/pull/2622)). Providers dynamically source tools, resources, templates, and prompts at runtime. **Core abstraction** (`src/fastmcp/server/providers/base.py`): ```python class Provider: async def list_tools(self) -> Sequence[Tool]: ... async def get_tool(self, name: str) -> Tool | None: ... async def list_resources(self) -> Sequence[Resource]: ... async def get_resource(self, uri: str) -> Resource | None: ... async def list_resource_templates(self) -> Sequence[ResourceTemplate]: ... async def get_resource_template(self, uri: str) -> ResourceTemplate | None: ... async def list_prompts(self) -> Sequence[Prompt]: ... async def get_prompt(self, name: str) -> Prompt | None: ... ``` Providers support: - **Lifecycle management**: `async def lifespan()` for setup/teardown - **Visibility control**: `enable()` / `disable()` with name, version, tags, components, and allowlist mode - **Transform stacking**: `provider.add_transform(Namespace(...))`, `provider.add_transform(ToolTransform(...))` ### LocalProvider `LocalProvider` (`src/fastmcp/server/providers/local_provider.py`) manages components registered via decorators. Can be used standalone and attached to multiple servers: ```python from fastmcp.server.providers import LocalProvider provider = LocalProvider() @provider.tool def greet(name: str) -> str: return f"Hello, {name}!" # Attach to multiple servers server1 = FastMCP("Server1", providers=[provider]) server2 = FastMCP("Server2", providers=[provider]) ``` ### ProxyProvider `ProxyProvider` (`src/fastmcp/server/providers/proxy.py`) proxies components from remote MCP servers via a client factory. Used by `create_proxy()` and `FastMCP.mount()` for remote server integration. ```python from fastmcp.server import create_proxy # Create proxy to remote server server = create_proxy("http://remote-server/mcp") ``` ### OpenAPIProvider `OpenAPIProvider` (`src/fastmcp/server/providers/openapi/provider.py`) creates MCP components from OpenAPI specifications. Routes map HTTP operations to tools, resources, or templates based on configurable rules. ```python from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("API Server", providers=[provider]) ``` Features: - Automatic route-to-component mapping (GET → resource, POST/PUT/DELETE → tool) - Custom route mappings via `route_maps` or `route_map_fn` - Component customization via `mcp_component_fn` - Name collision detection and handling ### FastMCPProvider `FastMCPProvider` (`src/fastmcp/server/providers/fastmcp_provider.py`) wraps a FastMCP server to enable mounting one server onto another. Components delegate execution through the wrapped server's middleware chain. ```python from fastmcp import FastMCP from fastmcp.server.providers import FastMCPProvider from fastmcp.server.transforms import Namespace main = FastMCP("Main") sub = FastMCP("Sub") @sub.tool def greet(name: str) -> str: return f"Hello, {name}!" # Mount with namespace provider = FastMCPProvider(sub) provider.add_transform(Namespace("sub")) main.add_provider(provider) # Tool accessible as "sub_greet" ``` ### Transforms Transforms modify components (tools, resources, prompts) as they flow from providers to clients ([#2836](https://github.com/PrefectHQ/fastmcp/pull/2836)). They use a middleware pattern where each transform receives a `call_next` callable to continue the chain. **Built-in transforms** (`src/fastmcp/server/transforms/`): - `Namespace` - adds prefixes to names (`tool` → `api_tool`) and path segments to URIs (`data://x` → `data://api/x`) - `ToolTransform` - modifies tool schemas (rename, description, tags, argument transforms) - `Visibility` - sets visibility state on components by key or tag (backs `enable()`/`disable()` API) - `VersionFilter` - filters components by version range (`version_gte`, `version_lt`) - `ResourcesAsTools` - exposes resources as tools for tool-only clients - `PromptsAsTools` - exposes prompts as tools for tool-only clients ```python from fastmcp.server.transforms import Namespace, ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = SomeProvider() provider.add_transform(Namespace("api")) provider.add_transform(ToolTransform({ "api_verbose_tool_name": ToolTransformConfig(name="short") })) # Stacking composes transformations # "foo" → "api_foo" (namespace) → "short" (rename) ``` **Custom transforms** subclass `Transform` and override needed methods: ```python from collections.abc import Sequence from fastmcp.server.transforms import Transform, ListToolsNext, GetToolNext from fastmcp.tools import Tool class TagFilter(Transform): def __init__(self, required_tags: set[str]): self.required_tags = required_tags async def list_tools(self, call_next: ListToolsNext) -> Sequence[Tool]: tools = await call_next() # Get tools from downstream return [t for t in tools if t.tags & self.required_tags] async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: tool = await call_next(name) return tool if tool and tool.tags & self.required_tags else None ``` Transforms apply at two levels: - **Provider-level**: `provider.add_transform()` - affects only that provider's components - **Server-level**: `server.add_transform()` - affects all components from all providers Documentation: `docs/servers/transforms/transforms.mdx`, `docs/servers/visibility.mdx` ### ResourcesAsTools and PromptsAsTools These transforms expose resources and prompts as tools for clients that only support the tools protocol. Each transform generates two tools that provide listing and access functionality. **ResourcesAsTools** generates `list_resources` and `read_resource` tools: ```python from fastmcp import FastMCP from fastmcp.server.transforms import ResourcesAsTools mcp = FastMCP("Server") @mcp.resource("data://config") def get_config() -> dict: return {"setting": "value"} mcp.add_transform(ResourcesAsTools(mcp)) # Now has list_resources and read_resource tools ``` The `list_resources` tool returns JSON with resource metadata. The `read_resource` tool accepts a URI and returns the resource content, preserving both text and binary data through base64 encoding. **PromptsAsTools** generates `list_prompts` and `get_prompt` tools: ```python from fastmcp import FastMCP from fastmcp.server.transforms import PromptsAsTools mcp = FastMCP("Server") @mcp.prompt def analyze_code(code: str, language: str = "python") -> str: return f"Analyze this {language} code:\n{code}" mcp.add_transform(PromptsAsTools(mcp)) # Now has list_prompts and get_prompt tools ``` The `list_prompts` tool returns JSON with prompt metadata including argument information. The `get_prompt` tool accepts a prompt name and optional arguments dict, returning the rendered prompt as a messages array. Non-text content (like embedded resources) is preserved as structured JSON. Both transforms: - Capture a provider reference at construction for deferred querying - Route through `FastMCP.read_resource()` / `FastMCP.render_prompt()` when the provider is FastMCP, ensuring middleware chains execute - Fall back to direct provider methods for plain providers - Return JSON for easy parsing by tool-only clients Documentation: `docs/servers/transforms/resources-as-tools.mdx`, `docs/servers/transforms/prompts-as-tools.mdx` --- ### Session-Scoped State v3.0 changes context state from request-scoped to session-scoped. State now persists across multiple tool calls within the same MCP session. ```python @mcp.tool async def increment_counter(ctx: Context) -> int: count = await ctx.get_state("counter") or 0 await ctx.set_state("counter", count + 1) return count + 1 ``` State is automatically keyed by session ID, ensuring isolation between different clients. The implementation uses [pykeyvalue](https://github.com/strawgate/py-key-value) for pluggable storage backends: ```python from key_value.aio.stores.redis import RedisStore # Use Redis for distributed deployments mcp = FastMCP("server", session_state_store=RedisStore(...)) ``` **Key details:** - Methods are now async: `await ctx.get_state()`, `await ctx.set_state()`, `await ctx.delete_state()` - State expires after 1 day (TTL) to prevent unbounded memory growth - Works during `on_initialize` middleware when using the same session object - For distributed HTTP, session identity comes from the `mcp-session-id` header Documentation: `docs/servers/context.mdx` --- ### Visibility System Components can be enabled/disabled using the visibility system. Each `enable()` or `disable()` call adds a stateless Visibility transform that marks components via internal metadata. Later transforms override earlier ones. ```python mcp = FastMCP("Server") # Disable by name and component type mcp.disable(names={"dangerous_tool"}, components=["tool"]) # Disable by tag mcp.disable(tags={"admin"}) # Disable by version mcp.disable(names={"old_tool"}, version="1.0", components=["tool"]) # Allowlist mode - only show components with these tags mcp.enable(tags={"public"}, only=True) # Enable overrides earlier disable (later transform wins) mcp.disable(tags={"internal"}) mcp.enable(names={"safe_tool"}) # safe_tool is visible despite internal tag ``` Works at both server and provider level. Supports: - **Blocklist mode** (default): All components visible except explicitly disabled - **Allowlist mode** (`only=True`): Only explicitly enabled components visible - **Tag-based filtering**: Enable/disable groups of components by tag - **Override semantics**: Later transforms override earlier marks (enable after disable = enabled) - **Transform ordering**: Visibility transforms are injected at the point you call them, so component state is known #### Per-Session Visibility Server-level visibility changes affect all connected clients. For per-session control, use `Context` methods that apply rules only to the current session ([#2917](https://github.com/PrefectHQ/fastmcp/pull/2917)): ```python from fastmcp import FastMCP from fastmcp.server.context import Context mcp = FastMCP("Server") @mcp.tool(tags={"premium"}) def premium_analysis(data: str) -> str: return f"Premium analysis of: {data}" @mcp.tool async def unlock_premium(ctx: Context) -> str: """Unlock premium features for this session only.""" await ctx.enable_components(tags={"premium"}) return "Premium features unlocked" @mcp.tool async def reset_features(ctx: Context) -> str: """Reset to default feature set.""" await ctx.reset_visibility() return "Features reset to defaults" # Globally disabled - sessions unlock individually mcp.disable(tags={"premium"}) ``` Session visibility methods: - `await ctx.enable_components(...)`: Enable components for this session - `await ctx.disable_components(...)`: Disable components for this session - `await ctx.reset_visibility()`: Clear session rules, return to global defaults Session rules override global transforms. FastMCP automatically sends `ToolListChangedNotification` (and resource/prompt equivalents) to affected sessions when visibility changes. Documentation: `docs/servers/visibility.mdx` --- ### Component Versioning v3.0 introduces versioning support for tools, resources, and prompts. Components can declare a version, and when multiple versions of the same component exist, the highest version is automatically exposed to clients. **Declaring versions:** ```python @mcp.tool(version="1.0") def add(x: int, y: int) -> int: return x + y @mcp.tool(version="2.0") def add(x: int, y: int, z: int = 0) -> int: return x + y + z # Only v2.0 is exposed to clients via list_tools() # Calling "add" invokes the v2.0 implementation ``` **Version comparison:** - Uses PEP 440 semantic versioning (1.10 > 1.9 > 1.2) - Falls back to string comparison for non-PEP 440 versions (dates like `2025-01-15` work) - Unversioned components sort lower than any versioned component - The `v` prefix is normalized (`v1.0` equals `1.0`) **Version visibility in meta:** List operations expose all available versions in the component's `meta` field: ```python tools = await client.list_tools() # Each tool's meta includes: # - meta["fastmcp"]["version"]: the version of this component ("2.0") # - meta["fastmcp"]["versions"]: all available versions ["2.0", "1.0"] ``` **Retrieving and calling specific versions:** ```python # Get the highest version (default) tool = await server.get_tool("add") # Get a specific version tool_v1 = await server.get_tool("add", version="1.0") # Call a specific version result = await server.call_tool("add", {"x": 1, "y": 2}, version="1.0") ``` **Client version requests:** The FastMCP client supports version selection: ```python async with Client(server) as client: # Call specific tool version result = await client.call_tool("add", {"x": 1, "y": 2}, version="1.0") # Get specific prompt version prompt = await client.get_prompt("my_prompt", {"text": "..."}, version="2.0") ``` For generic MCP clients, pass version via `_meta` in arguments: ```json { "x": 1, "y": 2, "_meta": { "fastmcp": { "version": "1.0" } } } ``` **VersionFilter transform:** The `VersionFilter` transform enables serving different API versions from a single codebase: ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider from fastmcp.server.transforms import VersionFilter # Define components on a shared provider components = LocalProvider() @components.tool(version="1.0") def calculate(x: int, y: int) -> int: return x + y @components.tool(version="2.0") def calculate(x: int, y: int, z: int = 0) -> int: return x + y + z # Create servers that share the provider with different filters api_v1 = FastMCP("API v1", providers=[components]) api_v1.add_transform(VersionFilter(version_lt="2.0")) api_v2 = FastMCP("API v2", providers=[components]) api_v2.add_transform(VersionFilter(version_gte="2.0")) ``` Parameters mirror comparison operators: - `version_gte`: Versions >= this value pass through - `version_lt`: Versions < this value pass through **Key format:** Component keys now include a version suffix using `@` as a delimiter: - Versioned: `tool:add@1.0`, `resource:data://config@2.0` - Unversioned: `tool:add@`, `resource:data://config@` The `@` is always present (even for unversioned components) to enable unambiguous parsing of URIs that may contain `@`. --- ### Type-Safe Canonical Results v3.0 introduces type-safe result classes that provide explicit control over component responses while supporting MCP runtime metadata: `ToolResult` ([#2736](https://github.com/PrefectHQ/fastmcp/pull/2736)), `ResourceResult` ([#2734](https://github.com/PrefectHQ/fastmcp/pull/2734)), and `PromptResult` ([#2738](https://github.com/PrefectHQ/fastmcp/pull/2738)). #### ToolResult `ToolResult` (`src/fastmcp/tools/tool.py:79`) provides structured tool responses: ```python from fastmcp.tools import ToolResult @mcp.tool def process(data: str) -> ToolResult: return ToolResult( content=[TextContent(type="text", text="Done")], structured_content={"status": "success", "count": 42}, meta={"processing_time_ms": 150} ) ``` Fields: - `content`: List of MCP ContentBlocks (text, images, etc.) - `structured_content`: Dict matching tool's output schema - `meta`: Runtime metadata passed to MCP as `_meta` #### ResourceResult `ResourceResult` (`src/fastmcp/resources/resource.py:117`) provides structured resource responses: ```python from fastmcp.resources import ResourceResult, ResourceContent @mcp.resource("data://items") def get_items() -> ResourceResult: return ResourceResult( contents=[ ResourceContent({"key": "value"}), # auto-serialized to JSON ResourceContent(b"binary data"), ], meta={"count": 2} ) ``` Accepts strings, bytes, or `list[ResourceContent]` for flexible content handling. #### PromptResult `PromptResult` (`src/fastmcp/prompts/prompt.py:109`) provides structured prompt responses: ```python from fastmcp.prompts import PromptResult, Message @mcp.prompt def conversation() -> PromptResult: return PromptResult( messages=[ Message("What's the weather?"), Message("It's sunny today.", role="assistant"), ], meta={"generated_at": "2024-01-01"} ) ``` --- ### Background Tasks (SEP-1686) v3.0 implements MCP SEP-1686 for background task execution via Docket integration. **Configuration** (`src/fastmcp/server/tasks/config.py`): ```python from fastmcp.server.tasks import TaskConfig @mcp.tool(task=TaskConfig(mode="required")) async def long_running_task(): # Must be executed as background task ... @mcp.tool(task=TaskConfig(mode="optional")) async def flexible_task(): # Supports both sync and task execution ... @mcp.tool(task=True) # Shorthand for mode="optional" async def simple_task(): ... ``` Task modes: - `"forbidden"`: Component does not support task execution (default) - `"optional"`: Supports both synchronous and task execution - `"required"`: Must be executed as background task Requires Docket server for task scheduling and result polling. --- ### Decorators Return Functions v3.0 changes what decorators (`@tool`, `@resource`, `@prompt`) return ([#2856](https://github.com/PrefectHQ/fastmcp/pull/2856)). Decorators now return the original function unchanged, rather than transforming it into a component object. **v3 behavior (default):** ```python @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" # greet is still your function - call it directly greet("World") # "Hello, World!" ``` **Why this matters:** - Functions stay callable - useful for testing and reuse - Instance methods just work: `mcp.add_tool(obj.method)` - Matches how Flask, FastAPI, and Typer decorators behave **For v2 compatibility:** ```python import fastmcp # v2 behavior: decorators return FunctionTool/FunctionResource/FunctionPrompt objects fastmcp.settings.decorator_mode = "object" ``` Environment variable: `FASTMCP_DECORATOR_MODE=object` --- ### CLI Auto-Reload The `--reload` flag enables file watching with automatic server restarts for development ([#2816](https://github.com/PrefectHQ/fastmcp/pull/2816)). ```bash # Watch for changes and restart fastmcp run server.py --reload # Watch specific directories fastmcp run server.py --reload --reload-dir ./src --reload-dir ./lib # Works with any transport fastmcp run server.py --reload --transport http --port 8080 ``` Implementation (`src/fastmcp/cli/run.py`): - Uses `watchfiles` for efficient file monitoring - Runs server as subprocess for clean restarts - Stateless mode for seamless reconnection after restart - stdio: Full MCP features including elicitation - HTTP: Limited bidirectional features during reload Also available with `fastmcp dev inspector`: ```bash fastmcp dev inspector server.py # Includes --reload by default ``` --- ### Component Authorization v3.0 introduces callable-based authorization for tools, resources, and prompts ([#2855](https://github.com/PrefectHQ/fastmcp/pull/2855)). **Component-level auth**: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP() @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` **Server-wide auth via middleware**: ```python from fastmcp.server.middleware import AuthMiddleware from fastmcp.server.auth import require_scopes, restrict_tag # Require specific scope for all components mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) # Tag-based restrictions mcp = FastMCP(middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ]) ``` Built-in checks: - `require_scopes(*scopes)`: Requires specific OAuth scopes - `restrict_tag(tag, scopes)`: Requires scopes only for tagged components Custom checks receive `AuthContext` with `token` and `component`: ```python def custom_check(ctx: AuthContext) -> bool: return ctx.token is not None and "admin" in ctx.token.scopes ``` STDIO transport bypasses all auth checks (no OAuth concept). --- ### FileSystemProvider v3.0 introduces `FileSystemProvider`, a fundamentally different approach to organizing MCP servers. Instead of importing a server instance and decorating functions with `@server.tool`, you use standalone decorators in separate files and let the provider discover them. **The problem it solves**: Traditional servers require coordination between files—either tool files import the server (creating coupling) or the server imports all tool modules (creating a registry bottleneck). FileSystemProvider removes this coupling entirely. **Usage** ([#2823](https://github.com/PrefectHQ/fastmcp/pull/2823)): ```python from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider # Scans mcp/ directory for decorated functions mcp = FastMCP("server", providers=[FileSystemProvider("mcp/")]) ``` **Tool files are self-contained**: ```python # mcp/tools/greet.py from fastmcp.tools import tool @tool def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" ``` Features: - **Standalone decorators**: `@tool`, `@resource`, `@prompt` from `fastmcp.tools`, `fastmcp.resources`, `fastmcp.prompts` ([#2832](https://github.com/PrefectHQ/fastmcp/pull/2832)) - **Reload mode**: `FileSystemProvider("mcp/", reload=True)` re-scans on every request for development - **Package support**: Directories with `__init__.py` support relative imports - **Warning deduplication**: Broken imports warn once per file modification Documentation: [FileSystemProvider](/servers/providers/filesystem) --- ### SkillsProvider v3.0 introduces `SkillsProvider` for exposing agent skills as MCP resources ([#2944](https://github.com/PrefectHQ/fastmcp/pull/2944)). Skills are directories containing instructions and supporting files that teach AI assistants how to perform tasks—used by Claude Code, Cursor, VS Code Copilot, and other AI coding tools. **Usage**: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider mcp = FastMCP("Skills Server") mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) ``` Each subdirectory with a `SKILL.md` file becomes a discoverable skill. Clients see: - `skill://{name}/SKILL.md` - Main instruction file - `skill://{name}/_manifest` - JSON listing of all files with sizes and hashes - `skill://{name}/{path}` - Supporting files (via template or resources) **Two-layer architecture**: - `SkillProvider` - Handles a single skill folder - `SkillsDirectoryProvider` - Scans directories, creates a `SkillProvider` per valid skill **Vendor providers** with locked default paths: | Provider | Directory | |----------|-----------| | `ClaudeSkillsProvider` | `~/.claude/skills/` | | `CursorSkillsProvider` | `~/.cursor/skills/` | | `VSCodeSkillsProvider` | `~/.copilot/skills/` | | `CodexSkillsProvider` | `/etc/codex/skills/`, `~/.codex/skills/` | | `GeminiSkillsProvider` | `~/.gemini/skills/` | | `GooseSkillsProvider` | `~/.config/agents/skills/` | | `CopilotSkillsProvider` | `~/.copilot/skills/` | | `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` | **Progressive disclosure**: By default, supporting files are hidden from `list_resources()` and accessed via template. Set `supporting_files="resources"` for full enumeration. Documentation: [Skills Provider](/servers/providers/skills) --- ### OpenTelemetry Tracing v3.0 adds OpenTelemetry instrumentation for observability into server and client operations ([#2869](https://github.com/PrefectHQ/fastmcp/pull/2869)). **Server spans**: Created for tool calls, resource reads, and prompt renders with attributes including component key, provider type, session ID, and auth context. **Client spans**: Wrap outgoing calls with W3C trace context propagation via request meta. ```python # Tracing is passive - configure an OTel SDK to export spans from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter provider = TracerProvider() provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) trace.set_tracer_provider(provider) # Use fastmcp normally - spans export to your configured backend ``` Components provide their own span attributes through a `get_span_attributes()` method that subclasses override—this lets LocalProvider, FastMCPProvider, and ProxyProvider each include relevant context (original names, backend URIs, etc.). Documentation: [Telemetry](/servers/telemetry) --- ### Pagination v3.0 adds pagination support for list operations when servers expose many components ([#2903](https://github.com/PrefectHQ/fastmcp/pull/2903)). ```python from fastmcp import FastMCP # Enable pagination with 50 items per page server = FastMCP("ComponentRegistry", list_page_size=50) ``` When `list_page_size` is set, `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` paginate responses with `nextCursor` for subsequent pages. **Client behavior**: The FastMCP Client fetches all pages automatically—`list_tools()` and similar methods return the complete list. For manual pagination (memory constraints, progress reporting), use `_mcp` variants: ```python async with Client(server) as client: result = await client.list_tools_mcp() while result.nextCursor: result = await client.list_tools_mcp(cursor=result.nextCursor) ``` Documentation: [Pagination](/servers/pagination) --- ### Composable Lifespans Lifespans can be combined with the `|` operator for modular setup/teardown ([#2828](https://github.com/PrefectHQ/fastmcp/pull/2828)): ```python from fastmcp import FastMCP from fastmcp.server.lifespan import lifespan @lifespan async def db_lifespan(server): db = await connect_db() try: yield {"db": db} finally: await db.close() @lifespan async def cache_lifespan(server): cache = await connect_cache() try: yield {"cache": cache} finally: await cache.close() mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) ``` Both enter lifespans in order and exit in reverse (LIFO). Context dicts are merged. Also adds `combine_lifespans()` utility for FastAPI integration: ```python from fastmcp.utilities.lifespan import combine_lifespans app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) ``` Documentation: [Lifespan](/servers/lifespan) --- ### Tool Timeout Tools can limit foreground execution time with a `timeout` parameter ([#2872](https://github.com/PrefectHQ/fastmcp/pull/2872)): ```python @mcp.tool(timeout=30.0) async def fetch_data(url: str) -> dict: """Fetch with 30-second timeout.""" ... ``` When exceeded, clients receive MCP error code `-32000`. Both sync and async tools are supported—sync functions run in thread pools so the timeout applies regardless of execution model. Note: This timeout applies to foreground execution only. Background tasks (`task=True`) execute in Docket workers where this timeout isn't enforced. --- ### PingMiddleware Sends periodic server-to-client pings to keep long-lived connections alive ([#2838](https://github.com/PrefectHQ/fastmcp/pull/2838)): ```python from fastmcp import FastMCP from fastmcp.server.middleware import PingMiddleware mcp = FastMCP("server") mcp.add_middleware(PingMiddleware(interval_ms=5000)) ``` The middleware starts a background ping task on first message from each session, using the session's existing task group for automatic cleanup when the session ends. --- ### Context.transport Property Tools can detect which transport is active ([#2850](https://github.com/PrefectHQ/fastmcp/pull/2850)): ```python from fastmcp import FastMCP, Context mcp = FastMCP("example") @mcp.tool def my_tool(ctx: Context) -> str: if ctx.transport == "stdio": return "short response" return "detailed response with more context" ``` Returns `Literal["stdio", "sse", "streamable-http"]` when running, or `None` outside a server context. --- ### Automatic Threadpool for Sync Functions Synchronous tools, resources, and prompts now automatically run in a threadpool, preventing event loop blocking during concurrent requests ([#2865](https://github.com/PrefectHQ/fastmcp/pull/2865)): ```python import time @mcp.tool def slow_tool(): time.sleep(10) # No longer blocks other requests return "done" ``` Three concurrent calls now execute in parallel (~10s) rather than sequentially (30s). Uses `anyio.to_thread.run_sync()` which properly propagates contextvars, so `Context` and `Depends` continue to work. --- ### CLI Update Notifications The CLI notifies users when a newer FastMCP version is available on PyPI ([#2840](https://github.com/PrefectHQ/fastmcp/pull/2840)). **Setting**: `FASTMCP_CHECK_FOR_UPDATES` - `"stable"` - Check for stable releases (default) - `"prerelease"` - Include alpha/beta/rc versions - `"off"` - Disable 12-hour cache, 2-second timeout, fails silently on network errors. --- ### Deprecated Features These emit deprecation warnings but continue to work. #### Mount Prefix Parameter The `prefix` parameter for `mount()` renamed to `namespace`: ```python # Deprecated main.mount(subserver, prefix="api") # New main.mount(subserver, namespace="api") ``` #### Tag Filtering, Tool Serializer, Tool Transformations Init Parameters These constructor parameters have been **removed** (not just deprecated) as of rc1. See "Breaking: Deprecated `FastMCP()` Constructor Kwargs Removed" in the rc1 section above. The `add_tool_transformation()` and `remove_tool_transformation()` methods remain as deprecated shims. --- ### Breaking Changes #### WSTransport Removed The deprecated `WSTransport` client transport has been removed ([#2826](https://github.com/PrefectHQ/fastmcp/pull/2826)). Use `StreamableHttpTransport` instead. #### Decorators Return Functions Decorators (`@tool`, `@resource`, `@prompt`) now return the original function instead of component objects. Code that treats the decorated function as a `FunctionTool`, `FunctionResource`, or `FunctionPrompt` will break. ```python # v2.x @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" isinstance(greet, FunctionTool) # True # v3.0 @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" isinstance(greet, FunctionTool) # False callable(greet) # True - it's still your function greet("World") # "Hello, World!" ``` Set `FASTMCP_DECORATOR_MODE=object` or `fastmcp.settings.decorator_mode = "object"` for v2 behavior. #### Component Enable/Disable Moved to Server/Provider The `enabled` field and `enable()`/`disable()` methods removed from component objects: ```python # v2.x tool = await server.get_tool("my_tool") tool.disable() # v3.0 server.disable(names={"my_tool"}, components=["tool"]) ``` #### Component Lookup Methods Server lookup and listing methods have updated signatures: - Parameter names: `get_tool(name=...)`, `get_resource(uri=...)`, etc. (was `key`) - Plural listing methods renamed: `get_tools()` → `list_tools()`, `get_resources()` → `list_resources()`, etc. - Return types: `list_tools()`, `list_resources()`, etc. return lists instead of dicts ```python # v2.x tools = await server.get_tools() tool = tools["my_tool"] # v3.0 tools = await server.list_tools() tool = next((t for t in tools if t.name == "my_tool"), None) ``` #### Prompt Return Types Prompt functions now use `Message` instead of `mcp.types.PromptMessage`: ```python # v2.x from mcp.types import PromptMessage, TextContent @mcp.prompt def my_prompt() -> PromptMessage: return PromptMessage(role="user", content=TextContent(type="text", text="Hello")) # v3.0 from fastmcp.prompts import Message @mcp.prompt def my_prompt() -> Message: return Message("Hello") # role defaults to "user" ``` #### Auth Provider Environment Variables Removed Auth providers no longer auto-load from environment variables ([#2752](https://github.com/PrefectHQ/fastmcp/pull/2752)): ```python # v2.x - auto-loaded from FASTMCP_SERVER_AUTH_GITHUB_* auth = GitHubProvider() # v3.0 - explicit configuration import os auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], ) ``` See `docs/development/v3-notes/auth-provider-env-vars.mdx` for rationale. #### Server Banner Environment Variable `FASTMCP_SHOW_CLI_BANNER` → `FASTMCP_SHOW_SERVER_BANNER` ([#2771](https://github.com/PrefectHQ/fastmcp/pull/2771)) Now applies to all server startup methods, not just the CLI. #### Context State Methods Are Async `ctx.set_state()` and `ctx.get_state()` are now async and session-scoped: ```python # v2.x ctx.set_state("key", "value") value = ctx.get_state("key") # v3.0 await ctx.set_state("key", "value") value = await ctx.get_state("key") ``` State now persists across requests within a session. See "Session-Scoped State" above. ================================================ FILE: docs/docs.json ================================================ { "$schema": "https://mintlify.com/docs.json", "appearance": { "default": "system", "strict": false }, "background": { "color": { "dark": "#222831", "light": "#EEEEEE" }, "decoration": "gradient" }, "banner": { "content": "Deploy FastMCP servers for free on [Prefect Horizon](https://www.prefect.io/horizon)" }, "colors": { "dark": "#f72585", "light": "#4cc9f0", "primary": "#2d00f7" }, "contextual": { "options": [ "copy", "view" ] }, "description": "The fast, Pythonic way to build MCP servers and clients.", "errors": { "404": { "description": "You\u2019ve wandered outside the context.", "redirect": false, "title": "Don't panic." } }, "favicon": { "dark": "/assets/brand/favicon-dark.svg", "light": "/assets/brand/favicon-light.svg" }, "footer": { "socials": { "discord": "https://discord.gg/uu8dJCgttd", "github": "https://github.com/PrefectHQ/fastmcp", "website": "https://www.prefect.io", "x": "https://x.com/fastmcp" } }, "integrations": { "ga4": { "measurementId": "G-64R5W1TJXG" } }, "interaction": { "drilldown": false }, "logo": { "dark": "/assets/brand/wordmark-white.png", "light": "/assets/brand/wordmark.png" }, "name": "FastMCP", "navbar": { "links": [ { "href": "https://discord.gg/uu8dJCgttd", "icon": "discord", "label": "" }, { "href": "https://prefect.io/horizon", "icon": "cloud", "label": "Prefect Horizon" } ], "primary": { "href": "https://github.com/PrefectHQ/fastmcp", "type": "github" } }, "navigation": { "versions": [ { "dropdowns": [ { "dropdown": "Documentation", "groups": [ { "group": "Get Started", "pages": [ "getting-started/welcome", "getting-started/installation", "getting-started/quickstart" ] }, { "group": "Servers", "pages": [ "servers/server", { "collapsed": true, "group": "Core Components", "icon": "toolbox", "pages": [ "servers/tools", "servers/resources", "servers/prompts", "servers/context" ] }, { "collapsed": true, "group": "Features", "icon": "stars", "pages": [ "servers/tasks", "servers/composition", "servers/dependency-injection", "servers/elicitation", "servers/icons", "servers/lifespan", "servers/logging", "servers/middleware", "servers/pagination", "servers/progress", "servers/sampling", "servers/storage-backends", "servers/telemetry", "servers/testing", "servers/versioning" ], "tag": "UPDATED" }, { "collapsed": true, "group": "Providers", "icon": "layer-group", "pages": [ "servers/providers/overview", "servers/providers/local", "servers/providers/filesystem", "servers/providers/proxy", "servers/providers/skills", "servers/providers/custom" ], "tag": "NEW" }, { "collapsed": true, "group": "Transforms", "icon": "wand-magic-sparkles", "pages": [ "servers/transforms/transforms", "servers/transforms/namespace", "servers/transforms/tool-transformation", "servers/visibility", "servers/transforms/code-mode", "servers/transforms/tool-search", "servers/transforms/resources-as-tools", "servers/transforms/prompts-as-tools" ], "tag": "NEW" }, { "collapsed": true, "group": "Authentication", "icon": "key", "pages": [ "servers/auth/authentication", "servers/auth/token-verification", "servers/auth/remote-oauth", "servers/auth/oauth-proxy", "servers/auth/oidc-proxy", "servers/auth/full-oauth-server", "servers/auth/multi-auth" ], "tag": "UPDATED" }, "servers/authorization", { "collapsed": true, "group": "Deployment", "icon": "rocket", "pages": [ "deployment/running-server", "deployment/http", "deployment/prefect-horizon", "deployment/server-configuration" ] } ] }, { "group": "Apps", "pages": [ "apps/overview", "apps/prefab", "apps/patterns", "apps/development", "apps/low-level" ] }, { "group": "Clients", "pages": [ "clients/client", "clients/transports", { "collapsed": true, "group": "Core Operations", "icon": "toolbox", "pages": [ "clients/tools", "clients/resources", "clients/prompts" ] }, { "collapsed": true, "group": "Handlers", "icon": "hand", "pages": [ "clients/notifications", "clients/sampling", "clients/elicitation", "clients/tasks", "clients/progress", "clients/logging", "clients/roots" ], "tag": "UPDATED" }, { "collapsed": true, "group": "Authentication", "icon": "key", "pages": [ "clients/auth/oauth", "clients/auth/cimd", "clients/auth/bearer" ], "tag": "UPDATED" } ] }, { "group": "Integrations", "pages": [ { "collapsed": true, "group": "Auth", "icon": "key", "pages": [ "integrations/auth0", "integrations/authkit", "integrations/aws-cognito", "integrations/azure", "integrations/descope", "integrations/discord", "integrations/eunomia-authorization", "integrations/github", "integrations/google", "integrations/oci", "integrations/permit", "integrations/propelauth", "integrations/scalekit", "integrations/supabase", "integrations/workos" ] }, { "collapsed": true, "group": "Web Frameworks", "icon": "code", "pages": [ "integrations/fastapi", "integrations/openapi" ] }, { "collapsed": true, "group": "AI Assistants", "icon": "robot", "pages": [ "integrations/chatgpt", "integrations/claude-code", "integrations/claude-desktop", "integrations/cursor", "integrations/gemini-cli", "integrations/goose" ] }, { "collapsed": true, "group": "AI SDKs", "icon": "microchip", "pages": [ "integrations/anthropic", "integrations/gemini", "integrations/openai" ] }, "integrations/mcp-json-configuration" ] }, { "group": "CLI", "pages": [ "cli/overview", "cli/running", "cli/install-mcp", "cli/inspecting", "cli/client", "cli/generate-cli", "cli/auth" ] }, { "group": "More", "pages": [ "more/settings", { "collapsed": true, "group": "Upgrading", "icon": "up", "pages": [ "getting-started/upgrading/from-fastmcp-2", "getting-started/upgrading/from-mcp-sdk", "getting-started/upgrading/from-low-level-sdk" ] }, { "collapsed": true, "group": "Development", "icon": "code", "pages": [ "development/contributing", "development/tests", "development/releases", "patterns/contrib" ] }, { "collapsed": true, "group": "What's New", "icon": "sparkles", "pages": [ "updates", "changelog" ] } ] } ], "icon": "book" }, { "anchors": [ { "anchor": "Python SDK", "icon": "python", "pages": [ "python-sdk/fastmcp-decorators", "python-sdk/fastmcp-dependencies", "python-sdk/fastmcp-exceptions", "python-sdk/fastmcp-mcp_config", "python-sdk/fastmcp-settings", "python-sdk/fastmcp-telemetry", { "group": "fastmcp.cli", "pages": [ "python-sdk/fastmcp-cli-__init__", "python-sdk/fastmcp-cli-apps_dev", "python-sdk/fastmcp-cli-auth", "python-sdk/fastmcp-cli-cimd", "python-sdk/fastmcp-cli-cli", "python-sdk/fastmcp-cli-client", "python-sdk/fastmcp-cli-discovery", "python-sdk/fastmcp-cli-generate", { "group": "install", "pages": [ "python-sdk/fastmcp-cli-install-__init__", "python-sdk/fastmcp-cli-install-claude_code", "python-sdk/fastmcp-cli-install-claude_desktop", "python-sdk/fastmcp-cli-install-cursor", "python-sdk/fastmcp-cli-install-gemini_cli", "python-sdk/fastmcp-cli-install-goose", "python-sdk/fastmcp-cli-install-mcp_json", "python-sdk/fastmcp-cli-install-shared", "python-sdk/fastmcp-cli-install-stdio" ] }, "python-sdk/fastmcp-cli-run", "python-sdk/fastmcp-cli-tasks" ] }, { "group": "fastmcp.client", "pages": [ "python-sdk/fastmcp-client-__init__", { "group": "auth", "pages": [ "python-sdk/fastmcp-client-auth-__init__", "python-sdk/fastmcp-client-auth-bearer", "python-sdk/fastmcp-client-auth-oauth" ] }, "python-sdk/fastmcp-client-client", "python-sdk/fastmcp-client-elicitation", "python-sdk/fastmcp-client-logging", "python-sdk/fastmcp-client-messages", { "group": "mixins", "pages": [ "python-sdk/fastmcp-client-mixins-__init__", "python-sdk/fastmcp-client-mixins-prompts", "python-sdk/fastmcp-client-mixins-resources", "python-sdk/fastmcp-client-mixins-task_management", "python-sdk/fastmcp-client-mixins-tools" ] }, "python-sdk/fastmcp-client-oauth_callback", "python-sdk/fastmcp-client-progress", "python-sdk/fastmcp-client-roots", { "group": "sampling", "pages": [ "python-sdk/fastmcp-client-sampling-__init__", { "group": "handlers", "pages": [ "python-sdk/fastmcp-client-sampling-handlers-__init__", "python-sdk/fastmcp-client-sampling-handlers-anthropic", "python-sdk/fastmcp-client-sampling-handlers-google_genai", "python-sdk/fastmcp-client-sampling-handlers-openai" ] } ] }, "python-sdk/fastmcp-client-tasks", "python-sdk/fastmcp-client-telemetry", { "group": "transports", "pages": [ "python-sdk/fastmcp-client-transports-__init__", "python-sdk/fastmcp-client-transports-base", "python-sdk/fastmcp-client-transports-config", "python-sdk/fastmcp-client-transports-http", "python-sdk/fastmcp-client-transports-inference", "python-sdk/fastmcp-client-transports-memory", "python-sdk/fastmcp-client-transports-sse", "python-sdk/fastmcp-client-transports-stdio" ] } ] }, { "group": "fastmcp.experimental", "pages": [ "python-sdk/fastmcp-experimental-__init__", { "group": "sampling", "pages": [ "python-sdk/fastmcp-experimental-sampling-__init__", "python-sdk/fastmcp-experimental-sampling-handlers" ] }, { "group": "transforms", "pages": [ "python-sdk/fastmcp-experimental-transforms-__init__", "python-sdk/fastmcp-experimental-transforms-code_mode" ] } ] }, { "group": "fastmcp.prompts", "pages": [ "python-sdk/fastmcp-prompts-__init__", "python-sdk/fastmcp-prompts-base", "python-sdk/fastmcp-prompts-function_prompt" ] }, { "group": "fastmcp.resources", "pages": [ "python-sdk/fastmcp-resources-__init__", "python-sdk/fastmcp-resources-base", "python-sdk/fastmcp-resources-function_resource", "python-sdk/fastmcp-resources-template", "python-sdk/fastmcp-resources-types" ] }, { "group": "fastmcp.server", "pages": [ "python-sdk/fastmcp-server-__init__", "python-sdk/fastmcp-server-app", "python-sdk/fastmcp-server-apps", { "group": "auth", "pages": [ "python-sdk/fastmcp-server-auth-__init__", "python-sdk/fastmcp-server-auth-auth", "python-sdk/fastmcp-server-auth-authorization", "python-sdk/fastmcp-server-auth-cimd", "python-sdk/fastmcp-server-auth-jwt_issuer", "python-sdk/fastmcp-server-auth-middleware", { "group": "oauth_proxy", "pages": [ "python-sdk/fastmcp-server-auth-oauth_proxy-__init__", "python-sdk/fastmcp-server-auth-oauth_proxy-consent", "python-sdk/fastmcp-server-auth-oauth_proxy-models", "python-sdk/fastmcp-server-auth-oauth_proxy-proxy", "python-sdk/fastmcp-server-auth-oauth_proxy-ui" ] }, "python-sdk/fastmcp-server-auth-oidc_proxy", { "group": "providers", "pages": [ "python-sdk/fastmcp-server-auth-providers-__init__", "python-sdk/fastmcp-server-auth-providers-auth0", "python-sdk/fastmcp-server-auth-providers-aws", "python-sdk/fastmcp-server-auth-providers-azure", "python-sdk/fastmcp-server-auth-providers-debug", "python-sdk/fastmcp-server-auth-providers-descope", "python-sdk/fastmcp-server-auth-providers-discord", "python-sdk/fastmcp-server-auth-providers-github", "python-sdk/fastmcp-server-auth-providers-google", "python-sdk/fastmcp-server-auth-providers-in_memory", "python-sdk/fastmcp-server-auth-providers-introspection", "python-sdk/fastmcp-server-auth-providers-jwt", "python-sdk/fastmcp-server-auth-providers-oci", "python-sdk/fastmcp-server-auth-providers-propelauth", "python-sdk/fastmcp-server-auth-providers-scalekit", "python-sdk/fastmcp-server-auth-providers-supabase", "python-sdk/fastmcp-server-auth-providers-workos" ] }, "python-sdk/fastmcp-server-auth-redirect_validation", "python-sdk/fastmcp-server-auth-ssrf" ] }, "python-sdk/fastmcp-server-context", "python-sdk/fastmcp-server-dependencies", "python-sdk/fastmcp-server-elicitation", "python-sdk/fastmcp-server-event_store", "python-sdk/fastmcp-server-http", "python-sdk/fastmcp-server-lifespan", "python-sdk/fastmcp-server-low_level", { "group": "middleware", "pages": [ "python-sdk/fastmcp-server-middleware-__init__", "python-sdk/fastmcp-server-middleware-authorization", "python-sdk/fastmcp-server-middleware-caching", "python-sdk/fastmcp-server-middleware-dereference", "python-sdk/fastmcp-server-middleware-error_handling", "python-sdk/fastmcp-server-middleware-logging", "python-sdk/fastmcp-server-middleware-middleware", "python-sdk/fastmcp-server-middleware-ping", "python-sdk/fastmcp-server-middleware-rate_limiting", "python-sdk/fastmcp-server-middleware-response_limiting", "python-sdk/fastmcp-server-middleware-timing", "python-sdk/fastmcp-server-middleware-tool_injection" ] }, { "group": "mixins", "pages": [ "python-sdk/fastmcp-server-mixins-__init__", "python-sdk/fastmcp-server-mixins-lifespan", "python-sdk/fastmcp-server-mixins-mcp_operations", "python-sdk/fastmcp-server-mixins-transport" ] }, { "group": "openapi", "pages": [ "python-sdk/fastmcp-server-openapi-__init__", "python-sdk/fastmcp-server-openapi-components", "python-sdk/fastmcp-server-openapi-routing", "python-sdk/fastmcp-server-openapi-server" ] }, { "group": "providers", "pages": [ "python-sdk/fastmcp-server-providers-__init__", "python-sdk/fastmcp-server-providers-aggregate", "python-sdk/fastmcp-server-providers-base", "python-sdk/fastmcp-server-providers-fastmcp_provider", "python-sdk/fastmcp-server-providers-filesystem", "python-sdk/fastmcp-server-providers-filesystem_discovery", { "group": "local_provider", "pages": [ "python-sdk/fastmcp-server-providers-local_provider-__init__", { "group": "decorators", "pages": [ "python-sdk/fastmcp-server-providers-local_provider-decorators-__init__", "python-sdk/fastmcp-server-providers-local_provider-decorators-prompts", "python-sdk/fastmcp-server-providers-local_provider-decorators-resources", "python-sdk/fastmcp-server-providers-local_provider-decorators-tools" ] }, "python-sdk/fastmcp-server-providers-local_provider-local_provider" ] }, { "group": "openapi", "pages": [ "python-sdk/fastmcp-server-providers-openapi-__init__", "python-sdk/fastmcp-server-providers-openapi-components", "python-sdk/fastmcp-server-providers-openapi-provider", "python-sdk/fastmcp-server-providers-openapi-routing" ] }, "python-sdk/fastmcp-server-providers-proxy", { "group": "skills", "pages": [ "python-sdk/fastmcp-server-providers-skills-__init__", "python-sdk/fastmcp-server-providers-skills-claude_provider", "python-sdk/fastmcp-server-providers-skills-directory_provider", "python-sdk/fastmcp-server-providers-skills-skill_provider", "python-sdk/fastmcp-server-providers-skills-vendor_providers" ] }, "python-sdk/fastmcp-server-providers-wrapped_provider" ] }, "python-sdk/fastmcp-server-proxy", { "group": "sampling", "pages": [ "python-sdk/fastmcp-server-sampling-__init__", "python-sdk/fastmcp-server-sampling-run", "python-sdk/fastmcp-server-sampling-sampling_tool" ] }, "python-sdk/fastmcp-server-server", { "group": "tasks", "pages": [ "python-sdk/fastmcp-server-tasks-__init__", "python-sdk/fastmcp-server-tasks-capabilities", "python-sdk/fastmcp-server-tasks-config", "python-sdk/fastmcp-server-tasks-elicitation", "python-sdk/fastmcp-server-tasks-handlers", "python-sdk/fastmcp-server-tasks-keys", "python-sdk/fastmcp-server-tasks-notifications", "python-sdk/fastmcp-server-tasks-requests", "python-sdk/fastmcp-server-tasks-routing", "python-sdk/fastmcp-server-tasks-subscriptions" ] }, "python-sdk/fastmcp-server-telemetry", { "group": "transforms", "pages": [ "python-sdk/fastmcp-server-transforms-__init__", "python-sdk/fastmcp-server-transforms-catalog", "python-sdk/fastmcp-server-transforms-namespace", "python-sdk/fastmcp-server-transforms-prompts_as_tools", "python-sdk/fastmcp-server-transforms-resources_as_tools", { "group": "search", "pages": [ "python-sdk/fastmcp-server-transforms-search-__init__", "python-sdk/fastmcp-server-transforms-search-base", "python-sdk/fastmcp-server-transforms-search-bm25", "python-sdk/fastmcp-server-transforms-search-regex" ] }, "python-sdk/fastmcp-server-transforms-tool_transform", "python-sdk/fastmcp-server-transforms-version_filter", "python-sdk/fastmcp-server-transforms-visibility" ] } ] }, { "group": "fastmcp.tools", "pages": [ "python-sdk/fastmcp-tools-__init__", "python-sdk/fastmcp-tools-base", "python-sdk/fastmcp-tools-function_parsing", "python-sdk/fastmcp-tools-function_tool", "python-sdk/fastmcp-tools-tool_transform" ] }, { "group": "fastmcp.utilities", "pages": [ "python-sdk/fastmcp-utilities-__init__", "python-sdk/fastmcp-utilities-async_utils", "python-sdk/fastmcp-utilities-auth", "python-sdk/fastmcp-utilities-cli", "python-sdk/fastmcp-utilities-components", "python-sdk/fastmcp-utilities-exceptions", "python-sdk/fastmcp-utilities-http", "python-sdk/fastmcp-utilities-inspect", "python-sdk/fastmcp-utilities-json_schema", "python-sdk/fastmcp-utilities-json_schema_type", "python-sdk/fastmcp-utilities-lifespan", "python-sdk/fastmcp-utilities-logging", { "group": "mcp_server_config", "pages": [ "python-sdk/fastmcp-utilities-mcp_server_config-__init__", { "group": "v1", "pages": [ "python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__", { "group": "environments", "pages": [ "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__", "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base", "python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv" ] }, "python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config", { "group": "sources", "pages": [ "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__", "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base", "python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem" ] } ] } ] }, { "group": "openapi", "pages": [ "python-sdk/fastmcp-utilities-openapi-__init__", "python-sdk/fastmcp-utilities-openapi-director", "python-sdk/fastmcp-utilities-openapi-formatters", "python-sdk/fastmcp-utilities-openapi-json_schema_converter", "python-sdk/fastmcp-utilities-openapi-models", "python-sdk/fastmcp-utilities-openapi-parser", "python-sdk/fastmcp-utilities-openapi-schemas" ] }, "python-sdk/fastmcp-utilities-pagination", "python-sdk/fastmcp-utilities-skills", "python-sdk/fastmcp-utilities-tests", "python-sdk/fastmcp-utilities-timeout", "python-sdk/fastmcp-utilities-token_cache", "python-sdk/fastmcp-utilities-types", "python-sdk/fastmcp-utilities-ui", "python-sdk/fastmcp-utilities-version_check", "python-sdk/fastmcp-utilities-versions" ] } ] } ], "dropdown": "SDK Reference", "icon": "code" } ], "version": "v3" }, { "dropdowns": [ { "dropdown": "Documentation", "groups": [ { "group": "Get Started", "pages": [ "v2/getting-started/welcome", "v2/getting-started/installation", "v2/getting-started/quickstart", "v2/updates" ] }, { "group": "Servers", "pages": [ "v2/servers/server", { "group": "Core Components", "icon": "toolbox", "pages": [ "v2/servers/tools", "v2/servers/resources", "v2/servers/prompts" ] }, { "group": "Advanced Features", "icon": "stars", "pages": [ "v2/servers/composition", "v2/servers/context", "v2/servers/elicitation", "v2/servers/icons", "v2/servers/logging", "v2/servers/middleware", "v2/servers/progress", "v2/servers/proxy", "v2/servers/sampling", "v2/servers/storage-backends", "v2/servers/tasks" ] }, { "group": "Authentication", "icon": "shield-check", "pages": [ "v2/servers/auth/authentication", "v2/servers/auth/token-verification", "v2/servers/auth/remote-oauth", "v2/servers/auth/oauth-proxy", "v2/servers/auth/oidc-proxy", "v2/servers/auth/full-oauth-server" ] }, { "group": "Deployment", "icon": "rocket", "pages": [ "v2/deployment/running-server", "v2/deployment/http", "deployment/prefect-horizon", "v2/deployment/server-configuration" ] } ] }, { "group": "Clients", "pages": [ { "group": "Essentials", "icon": "cube", "pages": [ "v2/clients/client", "v2/clients/transports" ] }, { "group": "Core Operations", "icon": "handshake", "pages": [ "v2/clients/tools", "v2/clients/resources", "v2/clients/prompts" ] }, { "group": "Advanced Features", "icon": "stars", "pages": [ "v2/clients/elicitation", "v2/clients/logging", "v2/clients/progress", "v2/clients/sampling", "v2/clients/tasks", "v2/clients/messages", "v2/clients/roots" ] }, { "group": "Authentication", "icon": "user-shield", "pages": [ "v2/clients/auth/oauth", "v2/clients/auth/bearer" ] } ] }, { "group": "Integrations", "pages": [ { "group": "Authentication", "icon": "key", "pages": [ "v2/integrations/auth0", "v2/integrations/authkit", "v2/integrations/aws-cognito", "v2/integrations/azure", "v2/integrations/descope", "v2/integrations/discord", "v2/integrations/github", "v2/integrations/google", "v2/integrations/oci", "v2/integrations/scalekit", "v2/integrations/supabase", "v2/integrations/workos" ] }, { "group": "Authorization", "icon": "shield-check", "pages": [ "v2/integrations/eunomia-authorization", "v2/integrations/permit" ] }, { "group": "AI Assistants", "icon": "robot", "pages": [ "v2/integrations/chatgpt", "v2/integrations/claude-code", "v2/integrations/claude-desktop", "v2/integrations/cursor", "v2/integrations/gemini-cli", "v2/integrations/mcp-json-configuration" ] }, { "group": "AI SDKs", "icon": "code", "pages": [ "v2/integrations/anthropic", "v2/integrations/gemini", "v2/integrations/openai" ] }, { "group": "API Integration", "icon": "globe", "pages": [ "v2/integrations/fastapi", "v2/integrations/openapi" ] } ] }, { "group": "Patterns", "pages": [ "v2/patterns/tool-transformation", "v2/patterns/decorating-methods", "v2/patterns/cli", "v2/patterns/contrib", "v2/patterns/testing" ] }, { "group": "Development", "pages": [ "v2/development/contributing", "v2/development/tests", "v2/development/releases", "v2/development/upgrade-guide", "v2/changelog" ] } ], "icon": "book" } ], "version": "v2.14.5" } ] }, "redirects": [ { "destination": "/cli/overview", "source": "/patterns/cli" }, { "destination": "/servers/testing", "source": "/patterns/testing" }, { "destination": "/cli/client", "source": "/clients/cli" }, { "destination": "/cli/generate-cli", "source": "/clients/generate-cli" }, { "destination": "/deployment/prefect-horizon", "source": "/deployment/fastmcp-cloud" }, { "destination": "/deployment/prefect-horizon", "source": "/v2/deployment/fastmcp-cloud" }, { "destination": "/v2/clients/messages", "source": "/clients/messages" }, { "destination": "/v2/patterns/decorating-methods", "source": "/patterns/decorating-methods" }, { "destination": "/servers/providers/proxy", "source": "/patterns/proxy" }, { "destination": "/servers/composition", "source": "/patterns/composition" }, { "destination": "/servers/providers/proxy", "source": "/servers/proxy" }, { "destination": "/servers/composition", "source": "/servers/providers/mounting" }, { "destination": "/servers/transforms/transforms", "source": "/servers/providers/namespacing" }, { "destination": "/servers/transforms/transforms", "source": "/patterns/tool-transformation" }, { "destination": "/getting-started/upgrading/from-fastmcp-2", "source": "/development/upgrade-guide" }, { "destination": "/getting-started/upgrading/from-mcp-sdk", "source": "/getting-started/upgrading-from-sdk" }, { "destination": "/getting-started/upgrading/from-low-level-sdk", "source": "/getting-started/low-level-sdk" } ], "search": { "prompt": "Search the docs..." }, "styling": { "codeblocks": { "theme": { "dark": "dark-plus", "light": "snazzy-light" } } }, "theme": "almond", "thumbnails": { "appearance": "light", "background": "/assets/brand/thumbnail-background-4.jpeg" } } ================================================ FILE: docs/getting-started/installation.mdx ================================================ --- title: Installation description: Install FastMCP and verify your setup icon: arrow-down-to-line --- ## Install FastMCP We recommend using [uv](https://docs.astral.sh/uv/getting-started/installation/) to install and manage FastMCP. ```bash pip install fastmcp ``` Or with uv: ```bash uv add fastmcp ``` ### Optional Dependencies FastMCP provides optional extras for specific features. For example, to install the background tasks extra: ```bash pip install "fastmcp[tasks]" ``` See [Background Tasks](/servers/tasks) for details on the task system. ### Verify Installation To verify that FastMCP is installed correctly, you can run the following command: ```bash fastmcp version ``` You should see output like the following: ```bash $ fastmcp version FastMCP version: 3.0.0 MCP version: 1.25.0 Python version: 3.12.2 Platform: macOS-15.3.1-arm64-arm-64bit FastMCP root path: ~/Developer/fastmcp ``` ### Dependency Licensing FastMCP depends on Cyclopts for CLI functionality. Cyclopts v4 includes docutils as a transitive dependency, which has complex licensing that may trigger compliance reviews in some organizations. If this is a concern, you can install Cyclopts v5 alpha which removes this dependency: ```bash pip install "cyclopts>=5.0.0a1" ``` Alternatively, wait for the stable v5 release. See [this issue](https://github.com/BrianPugh/cyclopts/issues/672) for details. ## Upgrading ### From FastMCP 2.0 See the [Upgrade Guide](/getting-started/upgrading/from-fastmcp-2) for a complete list of breaking changes and migration steps. ### From the MCP SDK #### From FastMCP 1.0 If you're using FastMCP 1.0 via the `mcp` package (meaning you import FastMCP as `from mcp.server.fastmcp import FastMCP`), upgrading is straightforward — for most servers, it's a single import change. See the [full upgrade guide](/getting-started/upgrading/from-mcp-sdk) for details. #### From the Low-Level Server API If you built your server directly on the `mcp` package's `Server` class — with `list_tools()`/`call_tool()` handlers and hand-written JSON Schema — see the [migration guide](/getting-started/upgrading/from-low-level-sdk) for a full walkthrough. ## Versioning Policy FastMCP follows semantic versioning with pragmatic adaptations for the rapidly evolving MCP ecosystem. Breaking changes may occur in minor versions (e.g., 2.3.x to 2.4.0) when necessary to stay current with the MCP Protocol. For production use, always pin to exact versions: ``` fastmcp==3.0.0 # Good fastmcp>=3.0.0 # Bad - may install breaking changes ``` See the full [versioning and release policy](/development/releases#versioning-policy) for details on our public API, deprecation practices, and breaking change philosophy. ## Contributing to FastMCP Interested in contributing to FastMCP? See the [Contributing Guide](/development/contributing) for details on: - Setting up your development environment - Running tests and pre-commit hooks - Submitting issues and pull requests - Code standards and review process ================================================ FILE: docs/getting-started/quickstart.mdx ================================================ --- title: Quickstart icon: rocket-launch --- Welcome! This guide will help you quickly set up FastMCP, run your first MCP server, and deploy a server to Prefect Horizon. If you haven't already installed FastMCP, follow the [installation instructions](/getting-started/installation). ## Create a FastMCP Server A FastMCP server is a collection of tools, resources, and other MCP components. To create a server, start by instantiating the `FastMCP` class. Create a new file called `my_server.py` and add the following code: ```python my_server.py from fastmcp import FastMCP mcp = FastMCP("My MCP Server") ``` That's it! You've created a FastMCP server, albeit a very boring one. Let's add a tool to make it more interesting. ## Add a Tool To add a tool that returns a simple greeting, write a function and decorate it with `@mcp.tool` to register it with the server: ```python my_server.py {5-7} from fastmcp import FastMCP mcp = FastMCP("My MCP Server") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" ``` ## Run the Server The simplest way to run your FastMCP server is to call its `run()` method. You can choose between different transports, like `stdio` for local servers, or `http` for remote access: ```python my_server.py (stdio) {9, 10} from fastmcp import FastMCP mcp = FastMCP("My MCP Server") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" if __name__ == "__main__": mcp.run() ``` ```python my_server.py (HTTP) {9, 10} from fastmcp import FastMCP mcp = FastMCP("My MCP Server") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` This lets us run the server with `python my_server.py`. The stdio transport is the traditional way to connect MCP servers to clients, while the HTTP transport enables remote connections. Why do we need the `if __name__ == "__main__":` block? The `__main__` block is recommended for consistency and compatibility, ensuring your server works with all MCP clients that execute your server file as a script. Users who will exclusively run their server with the FastMCP CLI can omit it, as the CLI imports the server object directly. ### Using the FastMCP CLI You can also use the `fastmcp run` command to start your server. Note that the FastMCP CLI **does not** execute the `__main__` block of your server file. Instead, it imports your server object and runs it with whatever transport and options you provide. For example, to run this server with the default stdio transport (no matter how you called `mcp.run()`), you can use the following command: ```bash fastmcp run my_server.py:mcp ``` To run this server with the HTTP transport, you can use the following command: ```bash fastmcp run my_server.py:mcp --transport http --port 8000 ``` ## Call Your Server Once your server is running with HTTP transport, you can connect to it with a FastMCP client or any LLM client that supports the MCP protocol: ```python my_client.py import asyncio from fastmcp import Client client = Client("http://localhost:8000/mcp") async def call_tool(name: str): async with client: result = await client.call_tool("greet", {"name": name}) print(result) asyncio.run(call_tool("Ford")) ``` Note that: - FastMCP clients are asynchronous, so we need to use `asyncio.run` to run the client - We must enter a client context (`async with client:`) before using the client - You can make multiple client calls within the same context ## Deploy to Prefect Horizon [Prefect Horizon](https://horizon.prefect.io) is the enterprise MCP platform built by the FastMCP team at [Prefect](https://www.prefect.io). It provides managed hosting, authentication, access control, and observability for MCP servers. Horizon is **free for personal projects** and offers enterprise governance for teams. To deploy your server, you'll need a [GitHub account](https://github.com). Once you have one, you can deploy your server in three steps: 1. Push your `my_server.py` file to a GitHub repository 2. Sign in to [Prefect Horizon](https://horizon.prefect.io) with your GitHub account 3. Create a new project from your repository and enter `my_server.py:mcp` as the server entrypoint That's it! Horizon will build and deploy your server, making it available at a URL like `https://your-project.fastmcp.app/mcp`. You can chat with it to test its functionality, or connect to it from any LLM client that supports the MCP protocol. For more details, see the [Prefect Horizon guide](/deployment/prefect-horizon). ================================================ FILE: docs/getting-started/upgrading/from-fastmcp-2.mdx ================================================ --- title: Upgrading from FastMCP 2 sidebarTitle: "From FastMCP 2" description: Migration instructions for upgrading between FastMCP versions icon: up --- This guide covers breaking changes and migration steps when upgrading FastMCP. ## v3.0.0 For most servers, upgrading to v3 is straightforward. The breaking changes below affect deprecated constructor kwargs, sync-to-async shifts, a few renamed methods, and some less commonly used features. ### Install Since you already have `fastmcp` installed, you need to explicitly request the new version — `pip install fastmcp` won't upgrade an existing installation: ```bash pip install --upgrade fastmcp # or uv add --upgrade fastmcp ``` If you pin versions in a requirements file or `pyproject.toml`, update your pin to `fastmcp>=3.0.0,<4`. **New repository home.** As part of the v3 release, FastMCP's GitHub repository has moved from `jlowin/fastmcp` to [`PrefectHQ/fastmcp`](https://github.com/PrefectHQ/fastmcp) under [Prefect](https://prefect.io)'s stewardship. GitHub automatically redirects existing clones and bookmarks, so nothing breaks — but you can update your local remote whenever convenient: ```bash git remote set-url origin https://github.com/PrefectHQ/fastmcp.git ``` If you reference the repository URL in dependency specifications (e.g., `git+https://github.com/jlowin/fastmcp.git`), update those to the new location. You are upgrading a FastMCP v2 server to FastMCP v3.0. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2 and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. BREAKING CHANGES (will crash at import or runtime): 1. CONSTRUCTOR KWARGS REMOVED: FastMCP() no longer accepts these kwargs (raises TypeError): - Transport settings: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http Fix: pass to run() or run_http_async() instead, e.g. mcp.run(transport="http", host="0.0.0.0", port=8080) - message_path: set via environment variable FASTMCP_MESSAGE_PATH only (not a run() kwarg) - Duplicate handling: on_duplicate_tools, on_duplicate_resources, on_duplicate_prompts Fix: use unified on_duplicate= parameter - Tool settings: tool_serializer, include_tags, exclude_tags, tool_transformations Fix: use ToolResult returns, server.enable()/disable(), server.add_transform() 2. COMPONENT METHODS REMOVED: - tool.enable()/disable() raises NotImplementedError Fix: server.disable(names={"tool_name"}, components={"tool"}) or server.disable(tags={"tag"}) - get_tools()/get_resources()/get_prompts()/get_resource_templates() removed Fix: use list_tools()/list_resources()/list_prompts()/list_resource_templates() — these return lists, not dicts 3. ASYNC STATE: ctx.set_state() and ctx.get_state() are now async (must be awaited). State values must be JSON-serializable unless serializable=False is passed. Each FastMCP instance has its own state store, so serializable state set by parent middleware isn't visible to mounted tools by default. Fix: pass the same session_state_store to both servers, or use serializable=False (request-scoped state is always shared). 4. PROMPTS: mcp.types.PromptMessage replaced by fastmcp.prompts.Message. Before: PromptMessage(role="user", content=TextContent(type="text", text="Hello")) After: Message("Hello") # role defaults to "user", accepts plain strings Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects. v2 silently coerced dicts; v3 requires typed Message objects or plain strings. 5. AUTH PROVIDERS: No longer auto-load from env vars. Pass client_id, client_secret explicitly via os.environ. 6. WSTRANSPORT: Removed. Use StreamableHttpTransport. 7. OPENAPI: timeout parameter removed from OpenAPIProvider. Set timeout on the httpx.AsyncClient instead. 8. METADATA: Namespace changed from "_fastmcp" to "fastmcp" in tool.meta. The include_fastmcp_meta parameter is removed (always included). 9. ENV VAR: FASTMCP_SHOW_CLI_BANNER renamed to FASTMCP_SHOW_SERVER_BANNER. 10. DECORATORS: @mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name, .description, or other component attributes on the decorated result will crash with AttributeError. Fix: set FASTMCP_DECORATOR_MODE=object for v2 compat (itself deprecated). 11. OAUTH STORAGE: Default OAuth client storage changed from DiskStore to FileTreeStore due to pickle deserialization vulnerability in diskcache (CVE-2025-69872). Clients using default storage will re-register automatically on first connection. If using DiskStore explicitly, switch to FileTreeStore or add pip install 'py-key-value-aio[disk]'. 12. REPO MOVE: GitHub repository moved from jlowin/fastmcp to PrefectHQ/fastmcp. Update git remotes and dependency URLs that reference the old location. 13. BACKGROUND TASKS: FastMCP's background task system (SEP-1686) is now an optional dependency. If the code uses task=True or TaskConfig, add pip install "fastmcp[tasks]". DEPRECATIONS (still work but emit warnings): - mount(prefix="x") -> mount(namespace="x") - import_server(sub) -> mount(sub) - FastMCP.as_proxy(url) -> from fastmcp.server import create_proxy; create_proxy(url) - from fastmcp.server.proxy -> from fastmcp.server.providers.proxy - from fastmcp.server.openapi import FastMCPOpenAPI -> from fastmcp.server.providers.openapi import OpenAPIProvider; use FastMCP("name", providers=[OpenAPIProvider(...)]) - mcp.add_tool_transformation(name, cfg) -> from fastmcp.server.transforms import ToolTransform; mcp.add_transform(ToolTransform(...)) For each issue found, show the original line, explain why it breaks, and provide the corrected code. ### Breaking Changes **Transport and server settings removed from constructor** In v2, you could configure transport settings directly in the `FastMCP()` constructor. In v3, `FastMCP()` is purely about your server's identity and behavior — transport configuration happens when you actually start serving. Passing any of the old kwargs now raises `TypeError` with a migration hint. ```python # Before mcp = FastMCP("server", host="0.0.0.0", port=8080) mcp.run() # After mcp = FastMCP("server") mcp.run(transport="http", host="0.0.0.0", port=8080) ``` The full list of removed kwargs and their replacements: - `host`, `port`, `log_level`, `debug`, `sse_path`, `streamable_http_path`, `json_response`, `stateless_http` — pass to `run()`, `run_http_async()`, or `http_app()`, or set via environment variables (e.g. `FASTMCP_HOST`) - `message_path` — set via environment variable `FASTMCP_MESSAGE_PATH` only (not a `run()` kwarg) - `on_duplicate_tools`, `on_duplicate_resources`, `on_duplicate_prompts` — consolidated into a single `on_duplicate=` parameter - `tool_serializer` — return [`ToolResult`](/servers/tools#custom-serialization) from your tools instead - `include_tags` / `exclude_tags` — use `server.enable(tags=..., only=True)` / `server.disable(tags=...)` after construction - `tool_transformations` — use `server.add_transform(ToolTransform(...))` after construction **OAuth storage backend changed (diskcache CVE)** The default OAuth client storage has moved from `DiskStore` to `FileTreeStore` to address a pickle deserialization vulnerability in diskcache ([CVE-2025-69872](https://github.com/PrefectHQ/fastmcp/issues/3166)). If you were using the default storage (i.e., not passing an explicit `client_storage`), clients will need to re-register on their first connection after upgrading. This happens automatically — no user action required, and it's the same flow that already occurs whenever a server restarts with in-memory storage. If you were passing a `DiskStore` explicitly, you can either [switch to `FileTreeStore`](/servers/storage-backends) (recommended) or keep using `DiskStore` by adding the dependency yourself: Keeping `DiskStore` requires `pip install 'py-key-value-aio[disk]'`, which re-introduces the vulnerable `diskcache` package into your dependency tree. **Component enable()/disable() moved to server** In v2, you could enable or disable individual components by calling methods on the component object itself. In v3, visibility is controlled through the server (or provider), which lets you target components by name, tag, or type without needing a reference to the object: ```python # Before tool = await server.get_tool("my_tool") tool.disable() # After server.disable(names={"my_tool"}, components={"tool"}) ``` Calling `.enable()` or `.disable()` on a component object now raises `NotImplementedError`. See [Visibility](/servers/visibility) for the full API, including tag-based filtering and per-session visibility. **Listing methods renamed and return lists** The `get_tools()`, `get_resources()`, `get_prompts()`, and `get_resource_templates()` methods have been renamed to `list_tools()`, `list_resources()`, `list_prompts()`, and `list_resource_templates()`. More importantly, they now return lists instead of dicts — so code that indexes by name needs to change: ```python # Before tools = await server.get_tools() tool = tools["my_tool"] # After tools = await server.list_tools() tool = next((t for t in tools if t.name == "my_tool"), None) ``` **Prompts use Message class** Prompt functions now use FastMCP's `Message` class instead of `mcp.types.PromptMessage`. The new class is simpler — it accepts a plain string and defaults to `role="user"`, so most prompts become one-liners: ```python # Before from mcp.types import PromptMessage, TextContent @mcp.prompt def my_prompt() -> PromptMessage: return PromptMessage(role="user", content=TextContent(type="text", text="Hello")) # After from fastmcp.prompts import Message @mcp.prompt def my_prompt() -> Message: return Message("Hello") ``` If your prompt functions return raw dicts with `role` and `content` keys, those also need to change. v2 silently coerced dicts into prompt messages, but v3 requires typed `Message` objects (or plain strings for single user messages): ```python # Before (v2 accepted this) @mcp.prompt def my_prompt(): return [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "How can I help?"}, ] # After from fastmcp.prompts import Message @mcp.prompt def my_prompt() -> list[Message]: return [ Message("Hello"), Message("How can I help?", role="assistant"), ] ``` **Context state methods are async** `ctx.set_state()` and `ctx.get_state()` are now async because state in v3 is session-scoped and backed by a pluggable storage backend (rather than a simple dict). This means state persists across multiple tool calls within the same session: ```python # Before ctx.set_state("key", "value") value = ctx.get_state("key") # After await ctx.set_state("key", "value") value = await ctx.get_state("key") ``` State values must also be JSON-serializable by default (dicts, lists, strings, numbers, etc.). If you need to store non-serializable values like an HTTP client, pass `serializable=False` — these values are request-scoped and only available during the current tool call: ```python await ctx.set_state("client", my_http_client, serializable=False) ``` **Mounted servers have isolated state stores** Each `FastMCP` instance has its own state store. In v2 this wasn't noticeable because mounted tools ran in the parent's context, but in v3's provider architecture each server is isolated. Non-serializable state (`serializable=False`) is request-scoped and automatically shared across mount boundaries. For serializable state, pass the same `session_state_store` to both servers: ```python from fastmcp import FastMCP from key_value.aio.stores.memory import MemoryStore store = MemoryStore() parent = FastMCP("Parent", session_state_store=store) child = FastMCP("Child", session_state_store=store) parent.mount(child, namespace="child") ``` **Auth provider environment variables removed** In v2, auth providers like `GitHubProvider` could auto-load configuration from environment variables with a `FASTMCP_SERVER_AUTH_*` prefix. This magic has been removed — pass values explicitly: ```python # Before (v2) — client_id and client_secret loaded automatically # from FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID, etc. auth = GitHubProvider() # After (v3) — pass values explicitly import os from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], ) ``` **WSTransport removed** The deprecated WebSocket client transport has been removed. Use `StreamableHttpTransport` instead: ```python # Before from fastmcp.client.transports import WSTransport transport = WSTransport("ws://localhost:8000/ws") # After from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport("http://localhost:8000/mcp") ``` **OpenAPI `timeout` parameter removed** `OpenAPIProvider` no longer accepts a `timeout` parameter. Configure timeout on the httpx client directly. The `client` parameter is also now optional — when omitted, a default client is created from the spec's `servers` URL with a 30-second timeout: ```python # Before provider = OpenAPIProvider(spec, client, timeout=60) # After client = httpx.AsyncClient(base_url="https://api.example.com", timeout=60) provider = OpenAPIProvider(spec, client) ``` **Metadata namespace renamed** The FastMCP metadata key in component `meta` dicts changed from `_fastmcp` to `fastmcp`. If you read metadata from tool or resource objects, update the key: ```python # Before tags = tool.meta.get("_fastmcp", {}).get("tags", []) # After tags = tool.meta.get("fastmcp", {}).get("tags", []) ``` Metadata is now always included — the `include_fastmcp_meta` parameter has been removed from `FastMCP()` and `to_mcp_tool()`, so there is no way to suppress it. **Server banner environment variable renamed** `FASTMCP_SHOW_CLI_BANNER` is now `FASTMCP_SHOW_SERVER_BANNER`. **Decorators return functions** In v2, `@mcp.tool` transformed your function into a `FunctionTool` object. In v3, decorators return your original function unchanged — which means decorated functions stay callable for testing, reuse, and composition: ```python @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" greet("World") # Works! Returns "Hello, World!" ``` If you have code that treats the decorated result as a `FunctionTool` (e.g., accessing `.name` or `.description`), set `FASTMCP_DECORATOR_MODE=object` for v2 compatibility. This escape hatch is itself deprecated and will be removed in a future release. **Background tasks require optional dependency** FastMCP's background task system (SEP-1686) is now behind an optional extra. If your server uses background tasks, install with: ```bash pip install "fastmcp[tasks]" ``` Without the extra, configuring a tool with `task=True` or `TaskConfig` will raise an import error at runtime. See [Background Tasks](/servers/tasks) for details. ### Deprecated Features These still work but emit warnings. Update when convenient. **mount() prefix → namespace** ```python # Deprecated main.mount(subserver, prefix="api") # New main.mount(subserver, namespace="api") ``` **import_server() → mount()** ```python # Deprecated main.import_server(subserver) # New main.mount(subserver) ``` **Module import paths for proxy and OpenAPI** The proxy and OpenAPI modules have moved under `providers` to reflect v3's provider-based architecture: ```python # Deprecated from fastmcp.server.proxy import FastMCPProxy from fastmcp.server.openapi import FastMCPOpenAPI # New from fastmcp.server.providers.proxy import FastMCPProxy from fastmcp.server.providers.openapi import OpenAPIProvider ``` `FastMCPOpenAPI` itself is deprecated — use `FastMCP` with an `OpenAPIProvider` instead: ```python # Deprecated from fastmcp.server.openapi import FastMCPOpenAPI server = FastMCPOpenAPI(spec, client) # New from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider server = FastMCP("my_api", providers=[OpenAPIProvider(spec, client)]) ``` **add_tool_transformation() → add_transform()** ```python # Deprecated mcp.add_tool_transformation("name", config) # New from fastmcp.server.transforms import ToolTransform mcp.add_transform(ToolTransform({"name": config})) ``` **FastMCP.as_proxy() → create_proxy()** ```python # Deprecated proxy = FastMCP.as_proxy("http://example.com/mcp") # New from fastmcp.server import create_proxy proxy = create_proxy("http://example.com/mcp") ``` ## v2.14.0 ### OpenAPI Parser Promotion The experimental OpenAPI parser is now standard. Update imports: ```python # Before from fastmcp.experimental.server.openapi import FastMCPOpenAPI # After from fastmcp.server.openapi import FastMCPOpenAPI ``` ### Removed Deprecated Features - `BearerAuthProvider` → use `JWTVerifier` - `Context.get_http_request()` → use `get_http_request()` from dependencies - `from fastmcp import Image` → use `from fastmcp.utilities.types import Image` - `FastMCP(dependencies=[...])` → use `fastmcp.json` configuration - `FastMCPProxy(client=...)` → use `client_factory=lambda: ...` - `output_schema=False` → use `output_schema=None` ## v2.13.0 ### OAuth Token Key Management The OAuth proxy now issues its own JWT tokens. For production, provide explicit keys: ```python auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=RedisStore(host="redis.example.com"), ) ``` See [OAuth Token Security](/deployment/http#oauth-token-security) for details. ================================================ FILE: docs/getting-started/upgrading/from-low-level-sdk.mdx ================================================ --- title: Upgrading from the MCP Low-Level SDK sidebarTitle: "From MCP Low-Level SDK" description: Upgrade your MCP server from the low-level Python SDK's Server class to FastMCP icon: up --- If you've been building MCP servers directly on the `mcp` package's `Server` class — writing `list_tools()` and `call_tool()` handlers, hand-crafting JSON Schema dicts, and wiring up transport boilerplate — this guide is for you. FastMCP replaces all of that machinery with a declarative, Pythonic API where your functions *are* the protocol surface. The core idea: instead of telling the SDK what your tools look like and then separately implementing them, you write ordinary Python functions and let FastMCP derive the protocol layer from your code. Type hints become JSON Schema. Docstrings become descriptions. Return values are serialized automatically. The plumbing you wrote to satisfy the protocol just disappears. This guide covers upgrading from **v1** of the `mcp` package. We'll provide a separate guide when v2 ships. Already using FastMCP 1.0 via `from mcp.server.fastmcp import FastMCP`? Your upgrade is simpler — see the [FastMCP 1.0 upgrade guide](/getting-started/upgrading/from-mcp-sdk) instead. You are upgrading an MCP server from the `mcp` package's low-level Server class (v1) to FastMCP 3.0. The server currently uses `mcp.server.Server` (or `mcp.server.lowlevel.server.Server`) with manual handler registration. Analyze the provided code and rewrite it using FastMCP's high-level API. The full guide is at https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. UPGRADE RULES: 1. IMPORTS: Replace all `mcp.*` imports with FastMCP equivalents. - `from mcp.server import Server` or `from mcp.server.lowlevel.server import Server` → `from fastmcp import FastMCP` - `import mcp.types as types` → remove (not needed for most code) - `from mcp.server.stdio import stdio_server` → remove (handled by mcp.run()) - `from mcp.server.sse import SseServerTransport` → remove (handled by mcp.run()) 2. SERVER: Replace `Server("name")` with `FastMCP("name")`. 3. TOOLS: Replace the list_tools + call_tool handler pair with individual @mcp.tool decorators. - Delete the `@server.list_tools()` handler entirely - Delete the `@server.call_tool()` handler entirely - For each tool that was listed in list_tools and dispatched in call_tool, create a new function: - Decorate it with `@mcp.tool` - Use the tool name as the function name (or pass name= to the decorator) - Use the docstring for the description (or pass description= to the decorator) - Convert the inputSchema JSON Schema into typed Python parameters (e.g., `{"type": "integer"}` → `int`, `{"type": "string"}` → `str`, `{"type": "array", "items": {"type": "string"}}` → `list[str]`) - Return plain Python values (`str`, `int`, `dict`, etc.) instead of `list[types.TextContent(...)]` - If the tool returned `types.ImageContent` or `types.EmbeddedResource`, use `from fastmcp.utilities.types import Image` or return the appropriate type 4. RESOURCES: Replace the list_resources + list_resource_templates + read_resource handler trio with individual @mcp.resource decorators. - Delete all three handlers - For each static resource, create a function decorated with `@mcp.resource("uri://...")` - For each resource template, use `@mcp.resource("uri://{param}/path")` with `{param}` in the URI and a matching function parameter - Return str for text content, bytes for binary content - Set `mime_type=` in the decorator if needed 5. PROMPTS: Replace the list_prompts + get_prompt handler pair with individual @mcp.prompt decorators. - Delete both handlers - For each prompt, create a function decorated with `@mcp.prompt` - Convert PromptArgument definitions into typed function parameters - Return str for simple single-message prompts (auto-wrapped as user message) - Return `list[Message]` for multi-message prompts: `from fastmcp.prompts import Message` - `Message("text")` defaults to `role="user"`; use `Message("text", role="assistant")` for assistant messages 6. TRANSPORT: Replace all transport boilerplate with mcp.run(). - `async with stdio_server() as (r, w): await server.run(r, w, ...)` → `mcp.run()` (`stdio` is the default) - SSE/Starlette setup → `mcp.run(transport="sse", host="...", port=...)` - Streamable HTTP setup → `mcp.run(transport="http", host="...", port=...)` - Delete asyncio.run(main()) boilerplate — use `if __name__ == "__main__": mcp.run()` 7. CONTEXT: Replace `server.request_context` with FastMCP's Context parameter. - Add `from fastmcp import Context` and add a `ctx: Context` parameter to any tool that needs it - `server.request_context.session.send_log_message(...)` → `await ctx.info("message")` or `await ctx.warning("message")` - Progress reporting → `await ctx.report_progress(current, total)` For each change, show the original code, explain what it did, and provide the FastMCP equivalent. ## Install ```bash pip install --upgrade fastmcp # or uv add fastmcp ``` FastMCP includes the `mcp` package as a transitive dependency, so you don't lose access to anything. ## Server and Transport The `Server` class requires you to choose a transport, connect streams, build initialization options, and run an event loop. FastMCP collapses all of that into a constructor and a `run()` call. ```python Before import asyncio from mcp.server import Server from mcp.server.stdio import stdio_server server = Server("my-server") # ... register handlers ... async def main(): async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options(), ) asyncio.run(main()) ``` ```python After from fastmcp import FastMCP mcp = FastMCP("my-server") # ... register tools, resources, prompts ... if __name__ == "__main__": mcp.run() ``` Need HTTP instead of stdio? With the `Server` class, you'd wire up Starlette routes and `SseServerTransport` or `StreamableHTTPSessionManager`. With FastMCP: ```python mcp.run(transport="http", host="0.0.0.0", port=8000) ``` ## Tools This is where the difference is most dramatic. The `Server` class requires two handlers — one to describe your tools (with hand-written JSON Schema) and another to dispatch calls by name. FastMCP eliminates both by deriving everything from your function signature. ```python Before import mcp.types as types from mcp.server import Server server = Server("math") @server.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="add", description="Add two numbers", inputSchema={ "type": "object", "properties": { "a": {"type": "number"}, "b": {"type": "number"}, }, "required": ["a", "b"], }, ), types.Tool( name="multiply", description="Multiply two numbers", inputSchema={ "type": "object", "properties": { "a": {"type": "number"}, "b": {"type": "number"}, }, "required": ["a", "b"], }, ), ] @server.call_tool() async def call_tool( name: str, arguments: dict ) -> list[types.TextContent]: if name == "add": result = arguments["a"] + arguments["b"] return [types.TextContent(type="text", text=str(result))] elif name == "multiply": result = arguments["a"] * arguments["b"] return [types.TextContent(type="text", text=str(result))] raise ValueError(f"Unknown tool: {name}") ``` ```python After from fastmcp import FastMCP mcp = FastMCP("math") @mcp.tool def add(a: float, b: float) -> float: """Add two numbers""" return a + b @mcp.tool def multiply(a: float, b: float) -> float: """Multiply two numbers""" return a * b ``` Each `@mcp.tool` function is self-contained: its name becomes the tool name, its docstring becomes the description, its type annotations become the JSON Schema, and its return value is serialized automatically. No routing. No schema dictionaries. No content-type wrappers. ### Type Mapping When converting your `inputSchema` to Python type hints: | JSON Schema | Python Type | |---|---| | `{"type": "string"}` | `str` | | `{"type": "number"}` | `float` | | `{"type": "integer"}` | `int` | | `{"type": "boolean"}` | `bool` | | `{"type": "array", "items": {"type": "string"}}` | `list[str]` | | `{"type": "object"}` | `dict` | | Optional property (not in `required`) | `param: str \| None = None` | ### Return Values With the `Server` class, tools return `list[types.TextContent | types.ImageContent | ...]`. In FastMCP, return plain Python values — strings, numbers, dicts, lists, dataclasses, Pydantic models — and serialization is handled for you. For images or other non-text content, FastMCP provides helpers: ```python from fastmcp import FastMCP from fastmcp.utilities.types import Image mcp = FastMCP("media") @mcp.tool def create_chart(data: list[float]) -> Image: """Generate a chart from data.""" png_bytes = generate_chart(data) # your logic return Image(data=png_bytes, format="png") ``` ## Resources The `Server` class uses three handlers for resources: `list_resources()` to enumerate them, `list_resource_templates()` for URI templates, and `read_resource()` to serve content — all with manual routing by URI. FastMCP replaces all three with per-resource decorators. ```python Before import json import mcp.types as types from mcp.server import Server from pydantic import AnyUrl server = Server("data") @server.list_resources() async def list_resources() -> list[types.Resource]: return [ types.Resource( uri=AnyUrl("config://app"), name="app_config", description="Application configuration", mimeType="application/json", ), types.Resource( uri=AnyUrl("config://features"), name="feature_flags", description="Active feature flags", mimeType="application/json", ), ] @server.list_resource_templates() async def list_resource_templates() -> list[types.ResourceTemplate]: return [ types.ResourceTemplate( uriTemplate="users://{user_id}/profile", name="user_profile", description="User profile by ID", ), types.ResourceTemplate( uriTemplate="projects://{project_id}/status", name="project_status", description="Project status by ID", ), ] @server.read_resource() async def read_resource(uri: AnyUrl) -> str: uri_str = str(uri) if uri_str == "config://app": return json.dumps({"debug": False, "version": "1.0"}) if uri_str == "config://features": return json.dumps({"dark_mode": True, "beta": False}) if uri_str.startswith("users://"): user_id = uri_str.split("/")[2] return json.dumps({"id": user_id, "name": f"User {user_id}"}) if uri_str.startswith("projects://"): project_id = uri_str.split("/")[2] return json.dumps({"id": project_id, "status": "active"}) raise ValueError(f"Unknown resource: {uri}") ``` ```python After import json from fastmcp import FastMCP mcp = FastMCP("data") @mcp.resource("config://app", mime_type="application/json") def app_config() -> str: """Application configuration""" return json.dumps({"debug": False, "version": "1.0"}) @mcp.resource("config://features", mime_type="application/json") def feature_flags() -> str: """Active feature flags""" return json.dumps({"dark_mode": True, "beta": False}) @mcp.resource("users://{user_id}/profile") def user_profile(user_id: str) -> str: """User profile by ID""" return json.dumps({"id": user_id, "name": f"User {user_id}"}) @mcp.resource("projects://{project_id}/status") def project_status(project_id: str) -> str: """Project status by ID""" return json.dumps({"id": project_id, "status": "active"}) ``` Static resources and URI templates use the same `@mcp.resource` decorator — FastMCP detects `{placeholders}` in the URI and automatically registers a template. The function parameter `user_id` maps directly to the `{user_id}` placeholder. ## Prompts Same pattern: the `Server` class uses `list_prompts()` and `get_prompt()` with manual routing. FastMCP uses one decorator per prompt. ```python Before import mcp.types as types from mcp.server import Server server = Server("prompts") @server.list_prompts() async def list_prompts() -> list[types.Prompt]: return [ types.Prompt( name="review_code", description="Review code for issues", arguments=[ types.PromptArgument( name="code", description="The code to review", required=True, ), types.PromptArgument( name="language", description="Programming language", required=False, ), ], ) ] @server.get_prompt() async def get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: if name == "review_code": code = (arguments or {}).get("code", "") language = (arguments or {}).get("language", "") lang_note = f" (written in {language})" if language else "" return types.GetPromptResult( description="Code review prompt", messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Please review this code{lang_note}:\n\n{code}", ), ) ], ) raise ValueError(f"Unknown prompt: {name}") ``` ```python After from fastmcp import FastMCP mcp = FastMCP("prompts") @mcp.prompt def review_code(code: str, language: str | None = None) -> str: """Review code for issues""" lang_note = f" (written in {language})" if language else "" return f"Please review this code{lang_note}:\n\n{code}" ``` Returning a `str` from a prompt function automatically wraps it as a user message. For multi-turn prompts, return a `list[Message]`: ```python from fastmcp import FastMCP from fastmcp.prompts import Message mcp = FastMCP("prompts") @mcp.prompt def debug_session(error: str) -> list[Message]: """Start a debugging conversation""" return [ Message(f"I'm seeing this error:\n\n{error}"), Message("I'll help you debug that. Can you share the relevant code?", role="assistant"), ] ``` ## Request Context The `Server` class exposes request context through `server.request_context`, which gives you the raw `ServerSession` for sending notifications. FastMCP replaces this with a typed `Context` object injected into any function that declares it. ```python Before import mcp.types as types from mcp.server import Server server = Server("worker") @server.call_tool() async def call_tool(name: str, arguments: dict): if name == "process_data": ctx = server.request_context await ctx.session.send_log_message( level="info", data="Starting processing..." ) # ... do work ... await ctx.session.send_log_message( level="info", data="Done!" ) return [types.TextContent(type="text", text="Processed")] ``` ```python After from fastmcp import FastMCP, Context mcp = FastMCP("worker") @mcp.tool async def process_data(ctx: Context) -> str: """Process data with progress logging""" await ctx.info("Starting processing...") # ... do work ... await ctx.info("Done!") return "Processed" ``` The `Context` object provides logging (`ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`), progress reporting (`ctx.report_progress()`), resource subscriptions, session state, and more. See [Context](/servers/context) for the full API. ## Complete Example A full server upgrade, showing how all the pieces fit together: ```python Before expandable import asyncio import json import mcp.types as types from mcp.server import Server from mcp.server.stdio import stdio_server from pydantic import AnyUrl server = Server("demo") @server.list_tools() async def list_tools() -> list[types.Tool]: return [ types.Tool( name="greet", description="Greet someone by name", inputSchema={ "type": "object", "properties": { "name": {"type": "string"}, }, "required": ["name"], }, ) ] @server.call_tool() async def call_tool(name: str, arguments: dict) -> list[types.TextContent]: if name == "greet": return [types.TextContent(type="text", text=f"Hello, {arguments['name']}!")] raise ValueError(f"Unknown tool: {name}") @server.list_resources() async def list_resources() -> list[types.Resource]: return [ types.Resource( uri=AnyUrl("info://version"), name="version", description="Server version", ) ] @server.read_resource() async def read_resource(uri: AnyUrl) -> str: if str(uri) == "info://version": return json.dumps({"version": "1.0.0"}) raise ValueError(f"Unknown resource: {uri}") @server.list_prompts() async def list_prompts() -> list[types.Prompt]: return [ types.Prompt( name="summarize", description="Summarize text", arguments=[ types.PromptArgument(name="text", required=True) ], ) ] @server.get_prompt() async def get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: if name == "summarize": return types.GetPromptResult( description="Summarize text", messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Summarize:\n\n{(arguments or {}).get('text', '')}", ), ) ], ) raise ValueError(f"Unknown prompt: {name}") async def main(): async with stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, server.create_initialization_options(), ) asyncio.run(main()) ``` ```python After import json from fastmcp import FastMCP mcp = FastMCP("demo") @mcp.tool def greet(name: str) -> str: """Greet someone by name""" return f"Hello, {name}!" @mcp.resource("info://version") def version() -> str: """Server version""" return json.dumps({"version": "1.0.0"}) @mcp.prompt def summarize(text: str) -> str: """Summarize text""" return f"Summarize:\n\n{text}" if __name__ == "__main__": mcp.run() ``` ## What's Next Once you've upgraded, you have access to everything FastMCP provides beyond the basics: - **[Server composition](/servers/composition)** — Mount sub-servers to build modular applications - **[Middleware](/servers/middleware)** — Add logging, rate limiting, error handling, and caching - **[Proxy servers](/servers/providers/proxy)** — Create a proxy to any existing MCP server - **[OpenAPI integration](/integrations/openapi)** — Generate an MCP server from an OpenAPI spec - **[Authentication](/servers/auth/authentication)** — Built-in OAuth and token verification - **[Testing](/servers/testing)** — Test your server directly in Python without running a subprocess Explore the full documentation at [gofastmcp.com](https://gofastmcp.com). ================================================ FILE: docs/getting-started/upgrading/from-mcp-sdk.mdx ================================================ --- title: Upgrading from the MCP SDK sidebarTitle: "From MCP SDK" description: Upgrade from FastMCP in the MCP Python SDK to the standalone FastMCP framework icon: up --- If your server starts with `from mcp.server.fastmcp import FastMCP`, you're using FastMCP 1.0 — the version bundled with v1 of the `mcp` package. Upgrading to the standalone FastMCP framework is easy. **For most servers, it's a single import change.** ```python # Before from mcp.server.fastmcp import FastMCP # After from fastmcp import FastMCP ``` That's it. Your `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` decorators, your `mcp.run()` call, and the rest of your server code all work as-is. **Why upgrade?** FastMCP 1.0 pioneered the Pythonic MCP server experience, and we're proud it was bundled into the `mcp` package. The standalone FastMCP project has since grown into a full framework for taking MCP servers from prototype to production — with composition, middleware, proxy servers, authentication, and much more. Upgrading gives you access to all of that, plus ongoing updates and fixes. ## Install ```bash pip install --upgrade fastmcp # or uv add fastmcp ``` FastMCP includes the `mcp` package as a dependency, so you don't lose access to anything. Update your import, run your server, and if your tools work, you're done. You are upgrading an MCP server from FastMCP 1.0 (bundled in the `mcp` package v1) to standalone FastMCP 3.0. Analyze the provided code and identify every change needed. The full upgrade guide is at https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk and the complete FastMCP documentation is at https://gofastmcp.com — fetch these for complete context. STEP 1 — IMPORT (required for all servers): Change "from mcp.server.fastmcp import FastMCP" to "from fastmcp import FastMCP". STEP 2 — CONSTRUCTOR KWARGS (only if FastMCP() receives transport settings): FastMCP() no longer accepts: host, port, log_level, debug, sse_path, streamable_http_path, json_response, stateless_http. Fix: pass these to run() instead. Before: `mcp = FastMCP("server", host="0.0.0.0", port=8080); mcp.run()` After: `mcp = FastMCP("server"); mcp.run(transport="http", host="0.0.0.0", port=8080)` STEP 3 — PROMPTS (only if using PromptMessage directly or returning dicts): mcp.types.PromptMessage is replaced by fastmcp.prompts.Message. Before: `PromptMessage(role="user", content=TextContent(type="text", text="Hello"))` After: `Message("Hello")` — role defaults to "user", accepts plain strings. Also: if prompts return raw dicts like `{"role": "user", "content": "..."}`, these must become Message objects or plain strings. The MCP SDK's FastMCP 1.0 silently coerced dicts; standalone FastMCP requires typed returns. STEP 4 — OTHER MCP IMPORTS (only if importing from mcp.* directly): Direct imports from the `mcp` package (e.g., `import mcp.types`, `from mcp.server.stdio import stdio_server`) still work because FastMCP includes `mcp` as a dependency. However, prefer FastMCP's own APIs where equivalents exist: - mcp.types.TextContent for tool returns → just return plain Python values (str, int, dict, etc.) - mcp.types.ImageContent → fastmcp.utilities.types.Image - from mcp.server.stdio import stdio_server → not needed, mcp.run() handles transport STEP 5 — DECORATORS (only if treating decorated functions as objects): @mcp.tool, @mcp.resource, @mcp.prompt now return the original function, not a component object. Code that accesses .name or .description on the decorated result needs updating. Set FASTMCP_DECORATOR_MODE=object temporarily to restore v1 behavior (this compat setting is itself deprecated). For each issue found, show the original line, explain what changed, and provide the corrected code. ## What Might Need Updating Most servers need nothing beyond the import change. Skim the sections below to see if any apply. ### Constructor Settings If you passed transport settings like `host` or `port` directly to `FastMCP()`, those now belong on `run()`. This keeps your server definition independent of how it's deployed: ```python # Before mcp = FastMCP("my-server", host="0.0.0.0", port=8080) mcp.run() # After mcp = FastMCP("my-server") mcp.run(transport="http", host="0.0.0.0", port=8080) ``` If you pass the old kwargs, you'll get a clear `TypeError` with a migration hint. ### Prompts If your prompt functions return `mcp.types.PromptMessage` objects or raw dicts with `role`/`content` keys, you'll need to upgrade to FastMCP's `Message` class. Or just return a plain string — it's automatically wrapped as a user message. The MCP SDK's bundled FastMCP 1.0 silently coerced dicts into messages; standalone FastMCP requires typed `Message` objects or strings. ```python from fastmcp import FastMCP mcp = FastMCP("prompts") @mcp.prompt def review(code: str) -> str: """Review code for issues""" return f"Please review this code:\n\n{code}" ``` For multi-turn prompts: ```python from fastmcp.prompts import Message @mcp.prompt def debug(error: str) -> list[Message]: """Start a debugging session""" return [ Message(f"I'm seeing this error:\n\n{error}"), Message("I'll help debug that. Can you share the relevant code?", role="assistant"), ] ``` ### Other `mcp.*` Imports If your server imports directly from the `mcp` package — like `import mcp.types` or `from mcp.server.stdio import stdio_server` — those still work. FastMCP includes `mcp` as a dependency, so nothing breaks. Where FastMCP provides its own API for the same thing, it's worth switching over: | mcp Package | FastMCP Equivalent | |---|---| | `mcp.types.TextContent(type="text", text=str(x))` | Just return `x` from your tool | | `mcp.types.ImageContent(...)` | `from fastmcp.utilities.types import Image` | | `mcp.types.PromptMessage(...)` | `from fastmcp.prompts import Message` | | `from mcp.server.stdio import stdio_server` | Not needed — `mcp.run()` handles transport | For anything without a FastMCP equivalent (e.g., specific protocol types you use directly), the `mcp.*` import is fine to keep. ### Decorated Functions In FastMCP 1.0, `@mcp.tool` returned a `FunctionTool` object. Now decorators return your original function unchanged — so decorated functions stay callable for testing, reuse, and composition: ```python @mcp.tool def greet(name: str) -> str: """Greet someone""" return f"Hello, {name}!" # This works now — the function is still a regular function assert greet("World") == "Hello, World!" ``` If you have code that accesses `.name`, `.description`, or other attributes on the decorated result, that will need updating. This is uncommon — most servers don't interact with the tool object directly. If you need the old behavior temporarily, set `FASTMCP_DECORATOR_MODE=object` to restore it (this compatibility setting is itself deprecated and will be removed in a future release). ## Verify the Upgrade ```bash # Install pip install --upgrade fastmcp # Check version fastmcp version # Run your server python my_server.py ``` You can also inspect your server's registered components with the FastMCP CLI: ```bash fastmcp inspect my_server.py ``` ## Looking Ahead The MCP ecosystem is evolving fast. Part of FastMCP's job is to absorb that complexity on your behalf — as the protocol and its tooling grow, we do the work so your server code doesn't have to change. ================================================ FILE: docs/getting-started/welcome.mdx ================================================ --- title: "Welcome to FastMCP" sidebarTitle: "Welcome!" description: The fast, Pythonic way to build MCP servers, clients, and applications. icon: hand-wave mode: center --- {/* 'F' logo on a watercolor background 'F' logo on a watercolor background */} **FastMCP is the standard framework for building MCP applications.** The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production — build servers that expose capabilities, connect clients to any MCP service, and give your tools interactive UIs: ```python {1} from fastmcp import FastMCP mcp = FastMCP("Demo 🚀") @mcp.tool def add(a: int, b: int) -> int: """Add two numbers""" return a + b if __name__ == "__main__": mcp.run() ``` ## Move Fast and Make Things The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) lets you give agents access to your tools and data. But building an effective MCP application is harder than it looks. FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.** **That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages. FastMCP has three pillars: Expose tools, resources, and prompts to LLMs. Give your tools interactive UIs rendered directly in the conversation. Connect to any MCP server — local or remote, programmatic or CLI. **[Servers](/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](/clients/client)** connect to any server with full protocol support. And **[Apps](/apps/overview)** give your tools interactive UIs rendered directly in the conversation. Ready to build? Start with the [installation guide](/getting-started/installation) or jump straight to the [quickstart](/getting-started/quickstart). When you're ready to deploy, [Prefect Horizon](https://www.prefect.io/horizon) offers free hosting for FastMCP users. FastMCP is made with 💙 by [Prefect](https://www.prefect.io/). **This documentation reflects FastMCP's `main` branch**, meaning it always reflects the latest development version. Features are generally marked with version badges (e.g. `New in version: 3.0.0`) to indicate when they were introduced. Note that this may include features that are not yet released. ## LLM-Friendly Docs The FastMCP documentation is available in multiple LLM-friendly formats: ### MCP Server The FastMCP docs are accessible via MCP! The server URL is `https://gofastmcp.com/mcp`. In fact, you can use FastMCP to search the FastMCP docs: ```python import asyncio from fastmcp import Client async def main(): async with Client("https://gofastmcp.com/mcp") as client: result = await client.call_tool( name="SearchFastMcp", arguments={"query": "deploy a FastMCP server"} ) print(result) asyncio.run(main()) ``` ### Text Formats The docs are also available in [llms.txt format](https://llmstxt.org/): - [llms.txt](https://gofastmcp.com/llms.txt) - A sitemap listing all documentation pages - [llms-full.txt](https://gofastmcp.com/llms-full.txt) - The entire documentation in one file (may exceed context windows) Any page can be accessed as markdown by appending `.md` to the URL. For example, this page becomes `https://gofastmcp.com/getting-started/welcome.md`. You can also copy any page as markdown by pressing "Cmd+C" (or "Ctrl+C" on Windows) on your keyboard. ================================================ FILE: docs/integrations/anthropic.mdx ================================================ --- title: Anthropic API 🤝 FastMCP sidebarTitle: Anthropic API description: Connect FastMCP servers to the Anthropic API icon: message-code --- import { VersionBadge } from "/snippets/version-badge.mdx" Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) supports MCP servers as remote tool sources. This tutorial will show you how to create a FastMCP server and deploy it to a public URL, then how to call it from the Messages API. Currently, the MCP connector only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to Claude. Other MCP features like resources and prompts are not currently supported. You can read more about the MCP connector in the [Anthropic documentation](https://docs.anthropic.com/en/docs/agents-and-tools/mcp-connector). ## Create a Server First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` ## Deploy the Server Your server must be deployed to a public URL in order for Anthropic to access it. The MCP connector supports both SSE and Streamable HTTP transports. For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server. Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet: ```bash FastMCP server python server.py ``` ```bash ngrok ngrok http 8000 ``` This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. ## Call the Server To use the Messages API with MCP servers, you'll need to install the Anthropic Python SDK (not included with FastMCP): ```bash pip install anthropic ``` You'll also need to authenticate with Anthropic. You can do this by setting the `ANTHROPIC_API_KEY` environment variable. Consult the Anthropic SDK documentation for more information. ```bash export ANTHROPIC_API_KEY="your-api-key" ``` Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. **At this time you must also include the `extra_headers` parameter with the `anthropic-beta` header.** ```python {5, 13-22} import anthropic from rich import print # Your server URL (replace with your actual URL) url = 'https://your-server-url.com' client = anthropic.Anthropic() response = client.beta.messages.create( model="claude-sonnet-4-20250514", max_tokens=1000, messages=[{"role": "user", "content": "Roll a few dice!"}], mcp_servers=[ { "type": "url", "url": f"{url}/mcp/", "name": "dice-server", } ], extra_headers={ "anthropic-beta": "mcp-client-2025-04-04" } ) print(response.content) ``` If you run this code, you'll see something like the following output: ```text I'll roll some dice for you! Let me use the dice rolling tool. I rolled 3 dice and got: 4, 2, 6 The results were 4, 2, and 6. Would you like me to roll again or roll a different number of dice? ``` ## Authentication The MCP connector supports OAuth authentication through authorization tokens, which means you can secure your server while still allowing Anthropic to access it. ### Server Authentication The simplest way to add authentication to the server is to use a bearer token scheme. For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPair` utility, but this may not be appropriate for production use. For more details, see the complete server-side [Token Verification](/servers/auth/token-verification) documentation. We'll start by creating an RSA key pair to sign and verify tokens. ```python from fastmcp.server.auth.providers.jwt import RSAKeyPair key_pair = RSAKeyPair.generate() access_token = key_pair.create_token(audience="dice-server") ``` FastMCP's `RSAKeyPair` utility is for development and testing only. Next, we'll create a `JWTVerifier` to authenticate the server. ```python from fastmcp import FastMCP from fastmcp.server.auth import JWTVerifier auth = JWTVerifier( public_key=key_pair.public_key, audience="dice-server", ) mcp = FastMCP(name="Dice Roller", auth=auth) ``` Here is a complete example that you can copy/paste. For simplicity and the purposes of this example only, it will print the token to the console. **Do NOT do this in production!** ```python server.py [expandable] from fastmcp import FastMCP from fastmcp.server.auth import JWTVerifier from fastmcp.server.auth.providers.jwt import RSAKeyPair import random key_pair = RSAKeyPair.generate() access_token = key_pair.create_token(audience="dice-server") auth = JWTVerifier( public_key=key_pair.public_key, audience="dice-server", ) mcp = FastMCP(name="Dice Roller", auth=auth) @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": print(f"\n---\n\n🔑 Dice Roller access token:\n\n{access_token}\n\n---\n") mcp.run(transport="http", port=8000) ``` ### Client Authentication If you try to call the authenticated server with the same Anthropic code we wrote earlier, you'll get an error indicating that the server rejected the request because it's not authenticated. ```python Error code: 400 - { "type": "error", "error": { "type": "invalid_request_error", "message": "MCP server 'dice-server' requires authentication. Please provide an authorization_token.", }, } ``` To authenticate the client, you can pass the token using the `authorization_token` parameter in your MCP server configuration: ```python {8, 21} import anthropic from rich import print # Your server URL (replace with your actual URL) url = 'https://your-server-url.com' # Your access token (replace with your actual token) access_token = 'your-access-token' client = anthropic.Anthropic() response = client.beta.messages.create( model="claude-sonnet-4-20250514", max_tokens=1000, messages=[{"role": "user", "content": "Roll a few dice!"}], mcp_servers=[ { "type": "url", "url": f"{url}/mcp/", "name": "dice-server", "authorization_token": access_token } ], extra_headers={ "anthropic-beta": "mcp-client-2025-04-04" } ) print(response.content) ``` You should now see the dice roll results in the output. ================================================ FILE: docs/integrations/auth0.mdx ================================================ --- title: Auth0 OAuth 🤝 FastMCP sidebarTitle: Auth0 description: Secure your FastMCP server with Auth0 OAuth icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Auth0 OAuth**. While Auth0 does have support for Dynamic Client Registration, it is not enabled by default so this integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern to bridge Auth0's dynamic OIDC configuration with MCP's authentication requirements. ## Configuration ### Prerequisites Before you begin, you will need: 1. An **[Auth0 Account](https://auth0.com/)** with access to create Applications 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create an Auth0 Application Create an Application in your Auth0 settings to get the credentials needed for authentication: Go to **Applications → Applications** in your Auth0 account. Click **"+ Create Application"** to create a new application. - **Name**: Choose a name users will recognize (e.g., "My FastMCP Server") - **Choose an application type**: Choose "Single Page Web Applications" - Click **Create** to create the application Select the "Settings" tab for your application, then find the "Application URIs" section. - **Allowed Callback URLs**: Your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - Click **Save** to save your changes The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. If you want to use a custom callback path (e.g., `/auth/auth0/callback`), make sure to set the same path in both your Auth0 Application settings and the `redirect_path` parameter when configuring the Auth0Provider. After creating the app, in the "Basic Information" section you'll see: - **Client ID**: A public identifier like `tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB` - **Client Secret**: A private hidden value that should always be stored securely Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. Go to **Applications → APIs** in your Auth0 account. - Find the API that you want to use for your application - **API Audience**: A URL that uniquely identifies the API Store this along with of the credentials above. Never commit this to version control. Use environment variables or a secrets manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `Auth0Provider`. ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider # The Auth0Provider utilizes Auth0 OIDC configuration auth_provider = Auth0Provider( config_url="https://.../.well-known/openid-configuration", # Your Auth0 configuration URL client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", # Your Auth0 application Client ID client_secret="vPYqbjemq...", # Your Auth0 application Client Secret audience="https://...", # Your Auth0 API audience base_url="http://localhost:8000", # Must match your application configuration # redirect_path="/auth/callback" # Default value, customize if needed ) mcp = FastMCP(name="Auth0 Secured App", auth=auth_provider) # Add a protected tool to test authentication @mcp.tool async def get_token_info() -> dict: """Returns information about the Auth0 token.""" from fastmcp.server.dependencies import get_access_token token = get_access_token() return { "issuer": token.claims.get("iss"), "audience": token.claims.get("aud"), "scope": token.claims.get("scope") } ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by Auth0 authentication. ### Testing with a Client Create a test client that authenticates with your Auth0-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle Auth0 OAuth flows async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Auth0 login in your browser print("✓ Authenticated with Auth0!") # Test the protected tool result = await client.call_tool("get_token_info") print(f"Auth0 audience: {result['audience']}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to Auth0's authorization page 2. After you authorize the app, you'll be redirected back 3. The client receives the token and can make authenticated requests ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = Auth0Provider( config_url="https://.../.well-known/openid-configuration", client_id="tv2ObNgaZAWWhhycr7Bz1LU2mxlnsmsB", client_secret="vPYqbjemq...", audience="https://...", base_url="https://your-production-domain.com", # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production Auth0 App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ================================================ FILE: docs/integrations/authkit.mdx ================================================ --- title: AuthKit 🤝 FastMCP sidebarTitle: AuthKit description: Secure your FastMCP server with AuthKit by WorkOS icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using WorkOS's **AuthKit**, a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where AuthKit handles user login and your FastMCP server validates the tokens. AuthKit does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. If you need resource-specific audience validation, consider using [WorkOSProvider](/integrations/workos) (OAuth proxy pattern) instead. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[WorkOS Account](https://workos.com/)** and a new **Project**. 2. An **[AuthKit](https://www.authkit.com/)** instance configured within your WorkOS project. 3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`). ### Step 1: AuthKit Configuration In your WorkOS Dashboard, enable AuthKit and configure the following settings: Go to **Applications → Configuration** and enable **Dynamic Client Registration**. This allows MCP clients register with your application automatically. ![Enable Dynamic Client Registration](./images/authkit/enable_dcr.png) Find your **AuthKit Domain** on the configuration page. It will look like `https://your-project-12345.authkit.app`. You'll need this for your FastMCP server configuration. ### Step 2: FastMCP Configuration Create your FastMCP server file and use the `AuthKitProvider` to handle all the OAuth integration automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider # The AuthKitProvider automatically discovers WorkOS endpoints # and configures JWT token validation auth_provider = AuthKitProvider( authkit_domain="https://your-project-12345.authkit.app", base_url="http://localhost:8000" # Use your actual server URL ) mcp = FastMCP(name="AuthKit Secured App", auth=auth_provider) ``` ## Testing To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the `authkit_domain` and `base_url` with your actual values!), you can run the following command: ```bash fastmcp run server.py --transport http --port 8000 ``` AuthKit defaults DCR clients to `client_secret_basic` for token exchange, which conflicts with how some MCP clients send credentials. To avoid token exchange errors, register as a public client by setting `token_endpoint_auth_method` to `"none"`: ```python client.py from fastmcp import Client from fastmcp.client.auth import OAuth import asyncio auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) async def main(): async with Client("http://localhost:8000/mcp", auth=auth) as client: assert await client.ping() if __name__ == "__main__": asyncio.run(main()) ``` ## Production Configuration For production deployments, load sensitive configuration from environment variables: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider # Load configuration from environment variables auth = AuthKitProvider( authkit_domain=os.environ.get("AUTHKIT_DOMAIN"), base_url=os.environ.get("BASE_URL", "https://your-server.com") ) mcp = FastMCP(name="AuthKit Secured App", auth=auth) ``` ================================================ FILE: docs/integrations/aws-cognito.mdx ================================================ --- title: AWS Cognito OAuth 🤝 FastMCP sidebarTitle: AWS Cognito description: Secure your FastMCP server with AWS Cognito user pools icon: aws --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **AWS Cognito user pools**. Since AWS Cognito doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge AWS Cognito's traditional OAuth with MCP's authentication requirements. It also includes robust JWT token validation, ensuring enterprise-grade authentication. ## Configuration ### Prerequisites Before you begin, you will need: 1. An **[AWS Account](https://aws.amazon.com/)** with access to create AWS Cognito user pools 2. Basic familiarity with AWS Cognito concepts (user pools, app clients) 3. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create an AWS Cognito User Pool and App Client Set up AWS Cognito user pool with an app client to get the credentials needed for authentication: Go to the **[AWS Cognito Console](https://console.aws.amazon.com/cognito/)** and ensure you're in your desired AWS region. Select **"User pools"** from the side navigation (click on the hamburger icon at the top left in case you don't see any), and click **"Create user pool"** to create a new user pool. AWS Cognito now provides a streamlined setup experience: 1. **Application type**: Select **"Traditional web application"** (this is the correct choice for FastMCP server-side authentication) 2. **Name your application**: Enter a descriptive name (e.g., `FastMCP Server`) The traditional web application type automatically configures: - Server-side authentication with client secrets - Authorization code grant flow - Appropriate security settings for confidential clients Choose "Traditional web application" rather than SPA, Mobile app, or Machine-to-machine options. This ensures proper OAuth 2.0 configuration for FastMCP. AWS will guide you through configuration options: - **Sign-in identifiers**: Choose how users will sign in (email, username, or phone) - **Required attributes**: Select any additional user information you need - **Return URL**: Add your callback URL (e.g., `http://localhost:8000/auth/callback` for development) The simplified interface handles most OAuth security settings automatically based on your application type selection. Review your configuration and click **"Create user pool"**. After creation, you'll see your user pool details. Save these important values: - **User pool ID** (format: `eu-central-1_XXXXXXXXX`) - **Client ID** (found under → "Applications" → "App clients" in the side navigation → \ → "App client information") - **Client Secret** (found under → "Applications" → "App clients" in the side navigation → \ → "App client information") The user pool ID and app client credentials are all you need for FastMCP configuration. Under "Login pages" in your app client's settings, you can double check and adjust the OAuth configuration: - **Allowed callback URLs**: Add your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) - **Allowed sign-out URLs**: Optional, for logout functionality - **OAuth 2.0 grant types**: Ensure "Authorization code grant" is selected - **OpenID Connect scopes**: Select scopes your application needs (e.g., `openid`, `email`, `profile`) For local development, you can use `http://localhost` URLs. For production, you must use HTTPS. AWS Cognito requires a resource server entry to support OAuth with protected resources. Without this, token exchange will fail with an `invalid_grant` error. Navigate to **"Branding" → "Domain"** in the side navigation, then: 1. Click **"Create resource server"** 2. **Resource server name**: Enter a descriptive name (e.g., `My MCP Server`) 3. **Resource server identifier**: Enter your MCP endpoint URL exactly as it will be accessed (e.g., `http://localhost:8000/mcp` for development, or `https://your-server.com/mcp` for production) 4. Click **"Create resource server"** The resource server identifier must exactly match your `base_url + mcp_path`. For the default configuration with `base_url="http://localhost:8000"` and `path="/mcp"`, use `http://localhost:8000/mcp`. After setup, you'll have: - **User Pool ID**: Format like `eu-central-1_XXXXXXXXX` - **Client ID**: Your application's client identifier - **Client Secret**: Generated client secret (keep secure) - **AWS Region**: Where Your AWS Cognito user pool is located Store these credentials securely. Never commit them to version control. Use environment variables or AWS Secrets Manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `AWSCognitoProvider`, which handles AWS Cognito's JWT tokens and user claims automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.aws import AWSCognitoProvider from fastmcp.server.dependencies import get_access_token # The AWSCognitoProvider handles JWT validation and user claims auth_provider = AWSCognitoProvider( user_pool_id="eu-central-1_XXXXXXXXX", # Your AWS Cognito user pool ID aws_region="eu-central-1", # AWS region (defaults to eu-central-1) client_id="your-app-client-id", # Your app client ID client_secret="your-app-client-secret", # Your app client Secret base_url="http://localhost:8000", # Must match your callback URL # redirect_path="/auth/callback" # Default value, customize if needed ) mcp = FastMCP(name="AWS Cognito Secured App", auth=auth_provider) # Add a protected tool to test authentication @mcp.tool async def get_access_token_claims() -> dict: """Get the authenticated user's access token claims.""" token = get_access_token() return { "sub": token.claims.get("sub"), "username": token.claims.get("username"), "cognito:groups": token.claims.get("cognito:groups", []), } ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by AWS Cognito OAuth authentication. ### Testing with a Client Create a test client that authenticates with Your AWS Cognito-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle AWS Cognito OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open AWS Cognito login in your browser print("✓ Authenticated with AWS Cognito!") # Test the protected tool print("Calling protected tool: get_access_token_claims") result = await client.call_tool("get_access_token_claims") user_data = result.data print("Available access token claims:") print(f"- sub: {user_data.get('sub', 'N/A')}") print(f"- username: {user_data.get('username', 'N/A')}") print(f"- cognito:groups: {user_data.get('cognito:groups', [])}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to AWS Cognito's hosted UI login page 2. After you sign in (or sign up), you'll be redirected back to your MCP server 3. The client receives the JWT token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.aws import AWSCognitoProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = AWSCognitoProvider( user_pool_id="eu-central-1_XXXXXXXXX", aws_region="eu-central-1", client_id="your-app-client-id", client_secret="your-app-client-secret", base_url="https://your-production-domain.com", # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production AWS Cognito App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ## Features ### JWT Token Validation The AWS Cognito provider includes robust JWT token validation: - **Signature Verification**: Validates tokens against AWS Cognito's public keys (JWKS) - **Expiration Checking**: Automatically rejects expired tokens - **Issuer Validation**: Ensures tokens come from your specific AWS Cognito user pool - **Scope Enforcement**: Verifies required OAuth scopes are present ### User Claims and Groups Access rich user information from AWS Cognito JWT tokens: ```python from fastmcp.server.dependencies import get_access_token @mcp.tool async def admin_only_tool() -> str: """A tool only available to admin users.""" token = get_access_token() user_groups = token.claims.get("cognito:groups", []) if "admin" not in user_groups: raise ValueError("This tool requires admin access") return "Admin access granted!" ``` ### Enterprise Integration Perfect for enterprise environments with: - **Single Sign-On (SSO)**: Integrate with corporate identity providers - **Multi-Factor Authentication (MFA)**: Leverage AWS Cognito's built-in MFA - **User Groups**: Role-based access control through AWS Cognito groups - **Custom Attributes**: Access custom user attributes defined in your AWS Cognito user pool - **Compliance**: Meet enterprise security and compliance requirements ================================================ FILE: docs/integrations/azure.mdx ================================================ --- title: Azure (Microsoft Entra ID) OAuth 🤝 FastMCP sidebarTitle: Azure (Entra ID) description: Secure your FastMCP server with Azure/Microsoft Entra OAuth icon: microsoft --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Azure OAuth** (Microsoft Entra ID). Since Azure doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Azure's traditional OAuth with MCP's authentication requirements. FastMCP validates Azure JWTs against your application's client_id. ## Configuration ### Prerequisites Before you begin, you will need: 1. An **[Azure Account](https://portal.azure.com/)** with access to create App registrations 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) 3. Your Azure tenant ID (found in Azure Portal under Microsoft Entra ID) ### Step 1: Create an Azure App Registration Create an App registration in Azure Portal to get the credentials needed for authentication: Go to the [Azure Portal](https://portal.azure.com) and navigate to **Microsoft Entra ID → App registrations**. Click **"New registration"** to create a new application. Fill in the application details: - **Name**: Choose a name users will recognize (e.g., "My FastMCP Server") - **Supported account types**: Choose based on your needs: - **Single tenant**: Only users in your organization - **Multitenant**: Users in any Microsoft Entra directory - **Multitenant + personal accounts**: Any Microsoft account - **Redirect URI**: Select "Web" and enter your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) The redirect URI must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, Azure allows `http://localhost` URLs. For production, you must use HTTPS. If you want to use a custom callback path (e.g., `/auth/azure/callback`), make sure to set the same path in both your Azure App registration and the `redirect_path` parameter when configuring the AzureProvider. - **Expose an API**: Configure your Application ID URI and define scopes - Go to **Expose an API** in the App registration sidebar. - Click **Set** next to "Application ID URI" and choose one of: - Keep the default `api://{client_id}` - Set a custom value, following the supported formats (see [Identifier URI restrictions](https://learn.microsoft.com/en-us/entra/identity-platform/identifier-uri-restrictions)) - Click **Add a scope** and create a scope your app will require, for example: - Scope name: `read` (or `write`, etc.) - Admin consent display name/description: as appropriate for your org - Who can consent: as needed (Admins only or Admins and users) - **Configure Access Token Version**: Ensure your app uses access token v2 - Go to **Manifest** in the App registration sidebar. - Find the `requestedAccessTokenVersion` property and set it to `2`: ```json "api": { "requestedAccessTokenVersion": 2 } ``` - Click **Save** at the top of the manifest editor. Access token v2 is required for FastMCP's Azure integration to work correctly. If this is not set, you may encounter authentication errors. In FastMCP's `AzureProvider`, set `identifier_uri` to your Application ID URI (optional; defaults to `api://{client_id}`) and set `required_scopes` to the unprefixed scope names (e.g., `read`, `write`). During authorization, FastMCP automatically prefixes scopes with your `identifier_uri`. After registration, navigate to **Certificates & secrets** in your app's settings. - Click **"New client secret"** - Add a description (e.g., "FastMCP Server") - Choose an expiration period - Click **"Add"** Copy the secret value immediately - it won't be shown again! You'll need to create a new secret if you lose it. From the **Overview** page of your app registration, note: - **Application (client) ID**: A UUID like `835f09b6-0f0f-40cc-85cb-f32c5829a149` - **Directory (tenant) ID**: A UUID like `08541b6e-646d-43de-a0eb-834e6713d6d5` - **Client Secret**: The value you copied in the previous step Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `AzureProvider`, which handles Azure's OAuth flow automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider # The AzureProvider handles Azure's token format and validation auth_provider = AzureProvider( client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", # Your Azure App Client ID client_secret="your-client-secret", # Your Azure App Client Secret tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", # Your Azure Tenant ID (REQUIRED) base_url="http://localhost:8000", # Must match your App registration required_scopes=["your-scope"], # At least one scope REQUIRED - name of scope from your App # identifier_uri defaults to api://{client_id} # identifier_uri="api://your-api-id", # Optional: request additional upstream scopes in the authorize request # additional_authorize_scopes=["User.Read", "openid", "email"], # redirect_path="/auth/callback" # Default value, customize if needed # base_authority="login.microsoftonline.us" # For Azure Government (default: login.microsoftonline.com) ) mcp = FastMCP(name="Azure Secured App", auth=auth_provider) # Add a protected tool to test authentication @mcp.tool async def get_user_info() -> dict: """Returns information about the authenticated Azure user.""" from fastmcp.server.dependencies import get_access_token token = get_access_token() # The AzureProvider stores user data in token claims return { "azure_id": token.claims.get("sub"), "email": token.claims.get("email"), "name": token.claims.get("name"), "job_title": token.claims.get("job_title"), "office_location": token.claims.get("office_location") } ``` **Important**: The `tenant_id` parameter is **REQUIRED**. Azure no longer supports using "common" for new applications due to security requirements. You must use one of: - **Your specific tenant ID**: Found in Azure Portal (e.g., `08541b6e-646d-43de-a0eb-834e6713d6d5`) - **"organizations"**: For work and school accounts only - **"consumers"**: For personal Microsoft accounts only Using your specific tenant ID is recommended for better security and control. **Important**: The `required_scopes` parameter is **REQUIRED** and must include at least one scope. Azure's OAuth API requires the `scope` parameter in all authorization requests - you cannot authenticate without specifying at least one scope. Use the unprefixed scope names from your Azure App registration (e.g., `["read", "write"]`). These scopes must be created under **Expose an API** in your App registration. ### Scope Handling FastMCP automatically prefixes `required_scopes` with your `identifier_uri` (e.g., `api://your-client-id`) since these are your custom API scopes. Scopes in `additional_authorize_scopes` are sent as-is since they target external resources like Microsoft Graph. **`required_scopes`** — Your custom API scopes, defined in Azure "Expose an API": | You write | Sent to Azure | Validated on tokens | |-----------|---------------|---------------------| | `mcp-read` | `api://xxx/mcp-read` | ✓ | | `my.scope` | `api://xxx/my.scope` | ✓ | | `openid` | `openid` | ✗ (OIDC scope) | | `api://xxx/read` | `api://xxx/read` | ✓ | **`additional_authorize_scopes`** — External scopes (e.g., Microsoft Graph) for server-side use: | You write | Sent to Azure | Validated on tokens | |-----------|---------------|---------------------| | `User.Read` | `User.Read` | ✗ | | `Mail.Send` | `Mail.Send` | ✗ | `offline_access` is automatically included to obtain refresh tokens. FastMCP manages token refreshing automatically. **Why aren't `additional_authorize_scopes` validated?** Azure issues separate tokens per resource. The access token FastMCP receives is for *your API*—Graph scopes aren't in its `scp` claim. To call Graph APIs, your server uses the upstream Azure token in an on-behalf-of (OBO) flow. OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are never prefixed and excluded from validation because Azure doesn't include them in access token `scp` claims. ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by Azure OAuth authentication. ### Testing with a Client Create a test client that authenticates with your Azure-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle Azure OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Azure login in your browser print("✓ Authenticated with Azure!") # Test the protected tool result = await client.call_tool("get_user_info") print(f"Azure user: {result['email']}") print(f"Name: {result['name']}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to Microsoft's authorization page 2. Sign in with your Microsoft account (work, school, or personal based on your tenant configuration) 3. Grant the requested permissions 4. After authorization, you'll be redirected back 5. The client receives the token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = AzureProvider( client_id="835f09b6-0f0f-40cc-85cb-f32c5829a149", client_secret="your-client-secret", tenant_id="08541b6e-646d-43de-a0eb-834e6713d6d5", base_url="https://your-production-domain.com", required_scopes=["your-scope"], # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production Azure App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ## Token Verification Only (Managed Identity) For deployments where your server only needs to **validate incoming tokens** — such as Azure Container Apps with Managed Identity — use `AzureJWTVerifier` with `RemoteAuthProvider` instead of the full `AzureProvider`. This pattern is ideal when: - Your infrastructure handles authentication (e.g., Managed Identity) - You don't need the OAuth proxy flow (no `client_secret` required) - You just need to verify that incoming Azure AD tokens are valid ```python server.py from fastmcp import FastMCP from fastmcp.server.auth import RemoteAuthProvider from fastmcp.server.auth.providers.azure import AzureJWTVerifier from pydantic import AnyHttpUrl tenant_id = "your-tenant-id" client_id = "your-client-id" # AzureJWTVerifier auto-configures JWKS, issuer, and audience verifier = AzureJWTVerifier( client_id=client_id, tenant_id=tenant_id, required_scopes=["access_as_user"], # Scope names from Azure Portal ) auth = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[ AnyHttpUrl(f"https://login.microsoftonline.com/{tenant_id}/v2.0") ], base_url="https://your-container-app.azurecontainerapps.io", ) mcp = FastMCP(name="Azure MI App", auth=auth) ``` `AzureJWTVerifier` handles Azure's scope format automatically. You write scope names exactly as they appear in Azure Portal under **Expose an API** (e.g., `access_as_user`). The verifier validates tokens using the short-form scopes that Azure puts in the `scp` claim, while advertising the full URI scopes (e.g., `api://your-client-id/access_as_user`) in OAuth metadata so MCP clients know what to request. For Azure Government, pass `base_authority="login.microsoftonline.us"` to `AzureJWTVerifier`. ## On-Behalf-Of (OBO) The On-Behalf-Of (OBO) flow allows your FastMCP server to call downstream Microsoft APIs—like Microsoft Graph—using the authenticated user's identity. When a user authenticates to your MCP server, you receive a token for your API. OBO exchanges that token for a new token that can call other services, maintaining the user's identity and permissions throughout the chain. This pattern is useful when your tools need to access user-specific data from Microsoft services: reading emails, accessing calendar events, querying SharePoint, or any other Graph API operation that requires user context. OBO features require the `azure` extra: ```bash pip install 'fastmcp[azure]' ``` ### Azure Portal Setup OBO requires additional configuration in your Azure App registration beyond basic authentication. In your App registration, navigate to **API permissions** and add the Microsoft Graph permissions your tools will need. - Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** - Select the permissions required for your use case (e.g., `Mail.Read`, `Calendars.Read`, `User.Read`) - Repeat for any other APIs you need to call Only add delegated permissions for OBO. Application permissions bypass user context entirely and are inappropriate for the OBO flow. OBO requires admin consent for the permissions you've added. In the **API permissions** page, click **Grant admin consent for [Your Organization]**. Without admin consent, OBO token exchanges will fail with an `AADSTS65001` error indicating the user or administrator hasn't consented to use the application. For development, you can grant consent for just your own account. For production, an Azure AD administrator must grant tenant-wide consent. ### Configure AzureProvider for OBO The `additional_authorize_scopes` parameter tells Azure which downstream API permissions to include during the initial authorization. These scopes establish what your server can request through OBO later. ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider auth_provider = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", tenant_id="your-tenant-id", base_url="http://localhost:8000", required_scopes=["mcp-access"], # Your API scope # Include Graph scopes for OBO additional_authorize_scopes=[ "https://graph.microsoft.com/Mail.Read", "https://graph.microsoft.com/User.Read", "offline_access", # Enables refresh tokens ], ) mcp = FastMCP(name="Graph-Enabled Server", auth=auth_provider) ``` Scopes listed in `additional_authorize_scopes` are requested during the initial OAuth flow but aren't validated on incoming tokens. They establish permission for your server to later exchange the user's token for downstream API access. Use fully-qualified scope URIs for downstream APIs (e.g., `https://graph.microsoft.com/Mail.Read`). Short forms like `Mail.Read` work for authorization requests, but fully-qualified URIs are clearer and avoid ambiguity. ### EntraOBOToken Dependency The `EntraOBOToken` dependency handles the complete OBO flow automatically. Declare it as a parameter default with the scopes you need, and FastMCP exchanges the user's token for a downstream API token before your function runs. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider, EntraOBOToken import httpx auth_provider = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", tenant_id="your-tenant-id", base_url="http://localhost:8000", required_scopes=["mcp-access"], additional_authorize_scopes=[ "https://graph.microsoft.com/Mail.Read", "https://graph.microsoft.com/User.Read", ], ) mcp = FastMCP(name="Email Reader", auth=auth_provider) @mcp.tool async def get_recent_emails( count: int = 10, graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]), ) -> list[dict]: """Get the user's recent emails from Microsoft Graph.""" async with httpx.AsyncClient() as client: response = await client.get( f"https://graph.microsoft.com/v1.0/me/messages?$top={count}", headers={"Authorization": f"Bearer {graph_token}"}, ) response.raise_for_status() data = response.json() return [ {"subject": msg["subject"], "from": msg["from"]["emailAddress"]["address"]} for msg in data.get("value", []) ] ``` The `graph_token` parameter receives a ready-to-use access token for Microsoft Graph. FastMCP handles the OBO exchange transparently—your function just uses the token to call the API. **Scope alignment is critical.** The scopes passed to `EntraOBOToken` must be a subset of the scopes in `additional_authorize_scopes`. If you request a scope during OBO that wasn't included in the initial authorization, the exchange will fail. For advanced OBO scenarios, use `CurrentAccessToken()` to get the user's token, then construct an `azure.identity.aio.OnBehalfOfCredential` directly with your Azure credentials. For a complete working example of Azure OBO with FastMCP, see [Pamela Fox's blog post on OBO flow for Entra-based MCP servers](https://blog.pamelafox.org/2026/01/using-on-behalf-of-flow-for-entra-based.html). ================================================ FILE: docs/integrations/chatgpt.mdx ================================================ --- title: ChatGPT 🤝 FastMCP sidebarTitle: ChatGPT description: Connect FastMCP servers to ChatGPT in Chat and Deep Research modes icon: message-smile --- [ChatGPT](https://chatgpt.com/) supports MCP servers through remote HTTP connections in two modes: **Chat mode** for interactive conversations and **Deep Research mode** for comprehensive information retrieval. **Developer Mode Required for Chat Mode**: To use MCP servers in regular ChatGPT conversations, you must first enable Developer Mode in your ChatGPT settings. This feature is available for ChatGPT Pro, Team, Enterprise, and Edu users. OpenAI's official MCP documentation and examples are built with **FastMCP v2**! Learn more from their [MCP documentation](https://platform.openai.com/docs/mcp) and [Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode). ## Build a Server First, let's create a simple FastMCP server: ```python server.py from fastmcp import FastMCP import random mcp = FastMCP("Demo Server") @mcp.tool def roll_dice(sides: int = 6) -> int: """Roll a dice with the specified number of sides.""" return random.randint(1, sides) if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` ### Deploy Your Server Your server must be accessible from the internet. For development, use `ngrok`: ```bash Terminal 1 python server.py ``` ```bash Terminal 2 ngrok http 8000 ``` Note your public URL (e.g., `https://abc123.ngrok.io`) for the next steps. ## Chat Mode Chat mode lets you use MCP tools directly in ChatGPT conversations. See [OpenAI's Developer Mode guide](https://platform.openai.com/docs/guides/developer-mode) for the latest requirements. ### Add to ChatGPT #### 1. Enable Developer Mode 1. Open ChatGPT and go to **Settings** → **Connectors** 2. Under **Advanced**, toggle **Developer Mode** to enabled #### 2. Create Connector 1. In **Settings** → **Connectors**, click **Create** 2. Enter: - **Name**: Your server name - **Server URL**: `https://your-server.ngrok.io/mcp/` 3. Check **I trust this provider** 4. Add authentication if needed 5. Click **Create** **Without Developer Mode**: If you don't have search/fetch tools, ChatGPT will reject the server. With Developer Mode enabled, you don't need search/fetch tools for Chat mode. #### 3. Use in Chat 1. Start a new chat 2. Click the **+** button → **More** → **Developer Mode** 3. **Enable your MCP server connector** (required - the connector must be explicitly added to each chat) 4. Now you can use your tools: Example usage: - "Roll a 20-sided dice" - "Roll dice" (uses default 6 sides) The connector must be explicitly enabled in each chat session through Developer Mode. Once added, it remains active for the entire conversation. ### Skip Confirmations Use `annotations={"readOnlyHint": True}` to skip confirmation prompts for read-only tools: ```python @mcp.tool(annotations={"readOnlyHint": True}) def get_status() -> str: """Check system status.""" return "All systems operational" @mcp.tool() # No annotation - ChatGPT may ask for confirmation def delete_item(id: str) -> str: """Delete an item.""" return f"Deleted {id}" ``` ## Deep Research Mode Deep Research mode provides systematic information retrieval with citations. See [OpenAI's MCP documentation](https://platform.openai.com/docs/mcp) for the latest Deep Research specifications. **Search and Fetch Required**: Without Developer Mode, ChatGPT will reject any server that doesn't have both `search` and `fetch` tools. Even in Developer Mode, Deep Research only uses these two tools. ### Tool Implementation Deep Research tools must follow this pattern: ```python @mcp.tool() def search(query: str) -> dict: """ Search for records matching the query. Must return {"ids": [list of string IDs]} """ # Your search logic matching_ids = ["id1", "id2", "id3"] return {"ids": matching_ids} @mcp.tool() def fetch(id: str) -> dict: """ Fetch a complete record by ID. Return the full record data for ChatGPT to analyze. """ # Your fetch logic return { "id": id, "title": "Record Title", "content": "Full record content...", "metadata": {"author": "Jane Doe", "date": "2024"} } ``` ### Using Deep Research 1. Ensure your server is added to ChatGPT's connectors (same as Chat mode) 2. Start a new chat 3. Click **+** → **Deep Research** 4. Select your MCP server as a source 5. Ask research questions ChatGPT will use your `search` and `fetch` tools to find and cite relevant information. ================================================ FILE: docs/integrations/claude-code.mdx ================================================ --- title: Claude Code 🤝 FastMCP sidebarTitle: Claude Code description: Install and use FastMCP servers in Claude Code icon: message-smile --- import { VersionBadge } from "/snippets/version-badge.mdx" import { LocalFocusTip } from "/snippets/local-focus.mdx" [Claude Code](https://docs.anthropic.com/en/docs/claude-code) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. ## Requirements This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly using Claude Code's built-in MCP management commands. ## Create a Server The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ## Install the Server ### FastMCP CLI The easiest way to install a FastMCP server in Claude Code is using the `fastmcp install claude-code` command. This automatically handles the configuration, dependency management, and calls Claude Code's built-in MCP management system. ```bash fastmcp install claude-code server.py ``` The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: ```bash # These are equivalent if your server object is named 'mcp' fastmcp install claude-code server.py fastmcp install claude-code server.py:mcp # Use explicit object name if your server has a different name fastmcp install claude-code server.py:my_custom_server ``` The command will automatically configure the server with Claude Code's `claude mcp add` command. #### Dependencies FastMCP provides flexible dependency management options for your Claude Code servers: **Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: ```bash fastmcp install claude-code server.py --with pandas --with requests ``` **Requirements file**: If you maintain a `requirements.txt` file with all your dependencies, use `--with-requirements` to install them: ```bash fastmcp install claude-code server.py --with-requirements requirements.txt ``` **Editable packages**: For local packages under development, use `--with-editable` to install them in editable mode: ```bash fastmcp install claude-code server.py --with-editable ./my-local-package ``` Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] } } ``` #### Python Version and Project Configuration Control the Python environment for your server with these options: **Python version**: Use `--python` to specify which Python version your server requires. This ensures compatibility when your server needs specific Python features: ```bash fastmcp install claude-code server.py --python 3.11 ``` **Project directory**: Use `--project` to run your server within a specific project context. This tells `uv` to use the project's configuration files and virtual environment: ```bash fastmcp install claude-code server.py --project /path/to/my-project ``` #### Environment Variables If your server needs environment variables (like API keys), you must include them: ```bash fastmcp install claude-code server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` Or load them from a `.env` file: ```bash fastmcp install claude-code server.py --server-name "Weather Server" --env-file .env ``` **Claude Code must be installed**. The integration looks for the Claude Code CLI at the default installation location (`~/.claude/local/claude`) and uses the `claude mcp add` command to register servers. ### Manual Configuration For more control over the configuration, you can manually use Claude Code's built-in MCP management commands. This gives you direct control over how your server is launched: ```bash # Add a server with custom configuration claude mcp add dice-roller -- uv run --with fastmcp fastmcp run server.py # Add with environment variables claude mcp add weather-server -e API_KEY=secret -e DEBUG=true -- uv run --with fastmcp fastmcp run server.py # Add with specific scope (local, user, or project) claude mcp add my-server --scope user -- uv run --with fastmcp fastmcp run server.py ``` You can also manually specify Python versions and project directories in your Claude Code commands: ```bash # With specific Python version claude mcp add ml-server -- uv run --python 3.11 --with fastmcp fastmcp run server.py # Within a project directory claude mcp add project-server -- uv run --project /path/to/project --with fastmcp fastmcp run server.py ``` ## Using the Server Once your server is installed, you can start using your FastMCP server with Claude Code. Try asking Claude something like: > "Roll some dice for me" Claude will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: > I'll roll some dice for you! Here are your results: [4, 2, 6] > > You rolled three dice and got a 4, a 2, and a 6! Claude Code can now access all the tools, resources, and prompts you've defined in your FastMCP server. If your server provides resources, you can reference them with `@` mentions using the format `@server:protocol://resource/path`. If your server provides prompts, you can use them as slash commands with `/mcp__servername__promptname`. ================================================ FILE: docs/integrations/claude-desktop.mdx ================================================ --- title: Claude Desktop 🤝 FastMCP sidebarTitle: Claude Desktop description: Connect FastMCP servers to Claude Desktop icon: message-smile --- import { VersionBadge } from "/snippets/version-badge.mdx" import { LocalFocusTip } from "/snippets/local-focus.mdx" [Claude Desktop](https://www.claude.com/download) supports MCP servers through local STDIO connections and remote servers (beta), allowing you to extend Claude's capabilities with custom tools, resources, and prompts from your FastMCP servers. Remote MCP server support is currently in beta and available for users on Claude Pro, Max, Team, and Enterprise plans (as of June 2025). Most users will still need to use local STDIO connections. This guide focuses specifically on using FastMCP servers with Claude Desktop. For general Claude Desktop MCP setup and official examples, see the [official Claude Desktop quickstart guide](https://modelcontextprotocol.io/quickstart/user). ## Requirements Claude Desktop traditionally requires MCP servers to run locally using STDIO transport, where your server communicates with Claude through standard input/output rather than HTTP. However, users on certain plans now have access to remote server support as well. If you don't have access to remote server support or need to connect to remote servers, you can create a **proxy server** that runs locally via STDIO and forwards requests to remote HTTP servers. See the [Proxy Servers](#proxy-servers) section below. ## Create a Server The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ## Install the Server ### FastMCP CLI The easiest way to install a FastMCP server in Claude Desktop is using the `fastmcp install claude-desktop` command. This automatically handles the configuration and dependency management. Prior to version 2.10.3, Claude Desktop could be managed by running `fastmcp install ` without specifying the client. ```bash fastmcp install claude-desktop server.py ``` The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: ```bash # These are equivalent if your server object is named 'mcp' fastmcp install claude-desktop server.py fastmcp install claude-desktop server.py:mcp # Use explicit object name if your server has a different name fastmcp install claude-desktop server.py:my_custom_server ``` After installation, restart Claude Desktop completely. You should see a hammer icon (🔨) in the bottom left of the input box, indicating that MCP tools are available. #### Dependencies FastMCP provides several ways to manage your server's dependencies when installing in Claude Desktop: **Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: ```bash fastmcp install claude-desktop server.py --with pandas --with requests ``` **Requirements file**: If you have a `requirements.txt` file listing all your dependencies, use `--with-requirements` to install them all at once: ```bash fastmcp install claude-desktop server.py --with-requirements requirements.txt ``` **Editable packages**: For local packages in development, use `--with-editable` to install them in editable mode: ```bash fastmcp install claude-desktop server.py --with-editable ./my-local-package ``` Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] } } ``` #### Python Version and Project Directory FastMCP allows you to control the Python environment for your server: **Python version**: Use `--python` to specify which Python version your server should run with. This is particularly useful when your server requires a specific Python version: ```bash fastmcp install claude-desktop server.py --python 3.11 ``` **Project directory**: Use `--project` to run your server within a specific project directory. This ensures that `uv` will discover all `pyproject.toml`, `uv.toml`, and `.python-version` files from that project: ```bash fastmcp install claude-desktop server.py --project /path/to/my-project ``` When you specify a project directory, all relative paths in your server will be resolved from that directory, and the project's virtual environment will be used. #### Environment Variables Claude Desktop runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. If your server needs environment variables (like API keys), you must include them: ```bash fastmcp install claude-desktop server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` Or load them from a `.env` file: ```bash fastmcp install claude-desktop server.py --server-name "Weather Server" --env-file .env ``` - **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. - **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop. ### Manual Configuration For more control over the configuration, you can manually edit Claude Desktop's configuration file. You can open the configuration file from Claude's developer settings, or find it in the following locations: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` The configuration file is a JSON object with a `mcpServers` key, which contains the configuration for each MCP server. ```json { "mcpServers": { "dice-roller": { "command": "python", "args": ["path/to/your/server.py"] } } } ``` After updating the configuration file, restart Claude Desktop completely. Look for the hammer icon (🔨) to confirm your server is loaded. #### Dependencies If your server has dependencies, you can use `uv` or another package manager to set up the environment. When manually configuring dependencies, the recommended approach is to use `uv` with FastMCP. The configuration uses `uv run` to create an isolated environment with your specified packages: ```json { "mcpServers": { "dice-roller": { "command": "uv", "args": [ "run", "--with", "fastmcp", "--with", "pandas", "--with", "requests", "fastmcp", "run", "path/to/your/server.py" ] } } } ``` You can also manually specify Python versions and project directories in your configuration. Add `--python` to use a specific Python version, or `--project` to run within a project directory: ```json { "mcpServers": { "dice-roller": { "command": "uv", "args": [ "run", "--python", "3.11", "--project", "/path/to/project", "--with", "fastmcp", "fastmcp", "run", "path/to/your/server.py" ] } } } ``` The order of arguments matters: Python version and project settings come before package specifications, which come before the actual command to run. - **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. - **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop. #### Environment Variables You can also specify environment variables in the configuration: ```json { "mcpServers": { "weather-server": { "command": "python", "args": ["path/to/weather_server.py"], "env": { "API_KEY": "your-api-key", "DEBUG": "true" } } } } ``` Claude Desktop runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. ## Remote Servers Users on Claude Pro, Max, Team, and Enterprise plans have first-class remote server support via integrations. For other users, or as an alternative approach, FastMCP can create a proxy server that forwards requests to a remote HTTP server. You can install the proxy server in Claude Desktop. Create a proxy server that connects to a remote HTTP server: ```python proxy_server.py from fastmcp.server import create_proxy # Create a proxy to a remote server proxy = create_proxy( "https://example.com/mcp/sse", name="Remote Server Proxy" ) if __name__ == "__main__": proxy.run() # Runs via STDIO for Claude Desktop ``` ### Authentication For authenticated remote servers, create an authenticated client following the guidance in the [client auth documentation](/clients/auth/bearer) and pass it to the proxy: ```python auth_proxy_server.py {7} from fastmcp import Client from fastmcp.client.auth import BearerAuth from fastmcp.server import create_proxy # Create authenticated client client = Client( "https://api.example.com/mcp/sse", auth=BearerAuth(token="your-access-token") ) # Create proxy using the authenticated client proxy = create_proxy(client, name="Authenticated Proxy") if __name__ == "__main__": proxy.run() ``` ================================================ FILE: docs/integrations/cursor.mdx ================================================ --- title: Cursor 🤝 FastMCP sidebarTitle: Cursor description: Install and use FastMCP servers in Cursor icon: message-smile --- import { VersionBadge } from "/snippets/version-badge.mdx" import { LocalFocusTip } from "/snippets/local-focus.mdx" [Cursor](https://www.cursor.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and Streamable HTTP, allowing you to extend Cursor's AI assistant with custom tools, resources, and prompts from your FastMCP servers. ## Requirements This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly in Cursor's settings. ## Create a Server The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ## Install the Server ### FastMCP CLI The easiest way to install a FastMCP server in Cursor is using the `fastmcp install cursor` command. This automatically handles the configuration, dependency management, and opens Cursor with a deeplink to install the server. ```bash fastmcp install cursor server.py ``` #### Workspace Installation By default, FastMCP installs servers globally for Cursor. You can also install servers to project-specific workspaces using the `--workspace` flag: ```bash # Install to current directory's .cursor/ folder fastmcp install cursor server.py --workspace . # Install to specific workspace fastmcp install cursor server.py --workspace /path/to/project ``` This creates a `.cursor/mcp.json` configuration file in the specified workspace directory, allowing different projects to have their own MCP server configurations. The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: ```bash # These are equivalent if your server object is named 'mcp' fastmcp install cursor server.py fastmcp install cursor server.py:mcp # Use explicit object name if your server has a different name fastmcp install cursor server.py:my_custom_server ``` After running the command, Cursor will open automatically and prompt you to install the server. The command will be `uv`, which is expected as this is a Python STDIO server. Click "Install" to confirm: ![Cursor install prompt](./cursor-install-mcp.png) #### Dependencies FastMCP offers multiple ways to manage dependencies for your Cursor servers: **Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: ```bash fastmcp install cursor server.py --with pandas --with requests ``` **Requirements file**: For projects with a `requirements.txt` file, use `--with-requirements` to install all dependencies at once: ```bash fastmcp install cursor server.py --with-requirements requirements.txt ``` **Editable packages**: When developing local packages, use `--with-editable` to install them in editable mode: ```bash fastmcp install cursor server.py --with-editable ./my-local-package ``` Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] } } ``` #### Python Version and Project Configuration Control your server's Python environment with these options: **Python version**: Use `--python` to specify which Python version your server should use. This is essential when your server requires specific Python features: ```bash fastmcp install cursor server.py --python 3.11 ``` **Project directory**: Use `--project` to run your server within a specific project context. This ensures `uv` discovers all project configuration files and uses the correct virtual environment: ```bash fastmcp install cursor server.py --project /path/to/my-project ``` #### Environment Variables Cursor runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. If your server needs environment variables (like API keys), you must include them: ```bash fastmcp install cursor server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` Or load them from a `.env` file: ```bash fastmcp install cursor server.py --server-name "Weather Server" --env-file .env ``` **`uv` must be installed and available in your system PATH**. Cursor runs in its own isolated environment and needs `uv` to manage dependencies. ### Generate MCP JSON **Use the first-class integration above for the best experience.** The MCP JSON generation is useful for advanced use cases, manual configuration, or integration with other tools. You can generate MCP JSON configuration for manual use: ```bash # Generate configuration and output to stdout fastmcp install mcp-json server.py --server-name "Dice Roller" --with pandas # Copy configuration to clipboard for easy pasting fastmcp install mcp-json server.py --server-name "Dice Roller" --copy ``` This generates the standard `mcpServers` configuration format that can be used with any MCP-compatible client. ### Manual Configuration For more control over the configuration, you can manually edit Cursor's configuration file. The configuration file is located at: - **All platforms**: `~/.cursor/mcp.json` The configuration file is a JSON object with a `mcpServers` key, which contains the configuration for each MCP server. ```json { "mcpServers": { "dice-roller": { "command": "python", "args": ["path/to/your/server.py"] } } } ``` After updating the configuration file, your server should be available in Cursor. #### Dependencies If your server has dependencies, you can use `uv` or another package manager to set up the environment. When manually configuring dependencies, the recommended approach is to use `uv` with FastMCP. The configuration should use `uv run` to create an isolated environment with your specified packages: ```json { "mcpServers": { "dice-roller": { "command": "uv", "args": [ "run", "--with", "fastmcp", "--with", "pandas", "--with", "requests", "fastmcp", "run", "path/to/your/server.py" ] } } } ``` You can also manually specify Python versions and project directories in your configuration: ```json { "mcpServers": { "dice-roller": { "command": "uv", "args": [ "run", "--python", "3.11", "--project", "/path/to/project", "--with", "fastmcp", "fastmcp", "run", "path/to/your/server.py" ] } } } ``` Note that the order of arguments is important: Python version and project settings should come before package specifications. **`uv` must be installed and available in your system PATH**. Cursor runs in its own isolated environment and needs `uv` to manage dependencies. #### Environment Variables You can also specify environment variables in the configuration: ```json { "mcpServers": { "weather-server": { "command": "python", "args": ["path/to/weather_server.py"], "env": { "API_KEY": "your-api-key", "DEBUG": "true" } } } } ``` Cursor runs servers in a completely isolated environment with no access to your shell environment or locally installed applications. You must explicitly pass any environment variables your server needs. ## Using the Server Once your server is installed, you can start using your FastMCP server with Cursor's AI assistant. Try asking Cursor something like: > "Roll some dice for me" Cursor will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: > 🎲 Here are your dice rolls: 4, 6, 4 > > You rolled 3 dice with a total of 14! The 6 was a nice high roll there! The AI assistant can now access all the tools, resources, and prompts you've defined in your FastMCP server. ================================================ FILE: docs/integrations/descope.mdx ================================================ --- title: Descope 🤝 FastMCP sidebarTitle: Descope description: Secure your FastMCP server with Descope icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx"; This guide shows you how to secure your FastMCP server using [**Descope**](https://www.descope.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Descope handles user login and your FastMCP server validates the tokens. ## Configuration ### Prerequisites Before you begin, you will need: 1. To [sign up](https://www.descope.com/sign-up) for a Free Forever Descope account 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:3000`) ### Step 1: Configure Descope 1. Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console, and create a new MCP Server. 2. Give the MCP server a name and description. 3. Ensure that **Dynamic Client Registration (DCR)** is enabled. Then click **Create**. 4. Once you've created the MCP Server, note your Well-Known URL. DCR is required for FastMCP clients to automatically register with your authentication server. Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers): ``` Well-Known URL: https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration ``` ### Step 2: Environment Setup Create a `.env` file with your Descope configuration: ```bash DESCOPE_CONFIG_URL=https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration # Your Descope Well-Known URL SERVER_URL=http://localhost:3000 # Your server's base URL ``` ### Step 3: FastMCP Configuration Create your FastMCP server file and use the DescopeProvider to handle all the OAuth integration automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.descope import DescopeProvider # The DescopeProvider automatically discovers Descope endpoints # and configures JWT token validation auth_provider = DescopeProvider( config_url=https://.../.well-known/openid-configuration, # Your MCP Server .well-known URL base_url=SERVER_URL, # Your server's public URL ) # Create FastMCP server with auth mcp = FastMCP(name="My Descope Protected Server", auth=auth_provider) ``` ## Testing To test your server, you can use the `fastmcp` CLI to run it locally. Assuming you've saved the above code to `server.py` (after replacing the environment variables with your actual values!), you can run the following command: ```bash fastmcp run server.py --transport http --port 8000 ``` Now, you can use a FastMCP client to test that you can reach your server after authenticating: ```python from fastmcp import Client import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: assert await client.ping() if __name__ == "__main__": asyncio.run(main()) ``` ## Production Configuration For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.descope import DescopeProvider # Load configuration from environment variables auth = DescopeProvider( config_url=os.environ.get("DESCOPE_CONFIG_URL"), base_url=os.environ.get("BASE_URL", "https://your-server.com") ) mcp = FastMCP(name="My Descope Protected Server", auth=auth) ``` ================================================ FILE: docs/integrations/discord.mdx ================================================ --- title: Discord OAuth 🤝 FastMCP sidebarTitle: Discord description: Secure your FastMCP server with Discord OAuth icon: discord --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Discord OAuth**. Since Discord doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Discord's traditional OAuth with MCP's authentication requirements. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[Discord Account](https://discord.com/)** with access to create applications 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create a Discord Application Create an application in the Discord Developer Portal to get the credentials needed for authentication: Go to the [Discord Developer Portal](https://discord.com/developers/applications). Click **"New Application"** and give it a name users will recognize (e.g., "My FastMCP Server"). In the left sidebar, click **"OAuth2"**. In the **Redirects** section, click **"Add Redirect"** and enter your callback URL: - For development: `http://localhost:8000/auth/callback` - For production: `https://your-domain.com/auth/callback` The redirect URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. Discord allows `http://localhost` URLs for development. For production, use HTTPS. On the same OAuth2 page, you'll find: - **Client ID**: A numeric string like `12345` - **Client Secret**: Click "Reset Secret" to generate one Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `DiscordProvider`, which handles Discord's OAuth flow automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.discord import DiscordProvider auth_provider = DiscordProvider( client_id="12345", # Your Discord Application Client ID client_secret="your-client-secret", # Your Discord OAuth Client Secret base_url="http://localhost:8000", # Must match your OAuth configuration ) mcp = FastMCP(name="Discord Secured App", auth=auth_provider) @mcp.tool async def get_user_info() -> dict: """Returns information about the authenticated Discord user.""" from fastmcp.server.dependencies import get_access_token token = get_access_token() return { "discord_id": token.claims.get("sub"), "username": token.claims.get("username"), "avatar": token.claims.get("avatar"), } ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by Discord OAuth authentication. ### Testing with a Client Create a test client that authenticates with your Discord-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: print("✓ Authenticated with Discord!") result = await client.call_tool("get_user_info") print(f"Discord user: {result['username']}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to Discord's authorization page 2. Sign in with your Discord account and authorize the app 3. After authorization, you'll be redirected back 4. The client receives the token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Discord Scopes Discord OAuth supports several scopes for accessing different types of user data: | Scope | Description | |-------|-------------| | `identify` | Access username, avatar, and discriminator (default) | | `email` | Access the user's email address | | `guilds` | Access the user's list of servers | | `guilds.join` | Ability to add the user to a server | To request additional scopes: ```python auth_provider = DiscordProvider( client_id="...", client_secret="...", base_url="http://localhost:8000", required_scopes=["identify", "email"], ) ``` ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.discord import DiscordProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet auth_provider = DiscordProvider( client_id="12345", client_secret=os.environ["DISCORD_CLIENT_SECRET"], base_url="https://your-production-domain.com", jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production Discord App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ================================================ FILE: docs/integrations/eunomia-authorization.mdx ================================================ --- title: Eunomia Authorization 🤝 FastMCP sidebarTitle: Eunomia Auth description: Add policy-based authorization to your FastMCP servers with Eunomia icon: shield-check --- Add **policy-based authorization** to your FastMCP servers with one-line code addition with the **[Eunomia][eunomia-github] authorization middleware**. Control which tools, resources and prompts MCP clients can view and execute on your server. Define dynamic JSON-based policies and obtain a comprehensive audit log of all access attempts and violations. ## How it Works Exploiting FastMCP's [Middleware][fastmcp-middleware], the Eunomia middleware intercepts all MCP requests to your server and automatically maps MCP methods to authorization checks. ### Listing Operations The middleware behaves as a filter for listing operations (`tools/list`, `resources/list`, `prompts/list`), hiding to the client components that are not authorized by the defined policies. ```mermaid sequenceDiagram participant MCPClient as MCP Client participant EunomiaMiddleware as Eunomia Middleware participant MCPServer as FastMCP Server participant EunomiaServer as Eunomia Server MCPClient->>EunomiaMiddleware: MCP Listing Request (e.g., tools/list) EunomiaMiddleware->>MCPServer: MCP Listing Request MCPServer-->>EunomiaMiddleware: MCP Listing Response EunomiaMiddleware->>EunomiaServer: Authorization Checks EunomiaServer->>EunomiaMiddleware: Authorization Decisions EunomiaMiddleware-->>MCPClient: Filtered MCP Listing Response ``` ### Execution Operations The middleware behaves as a firewall for execution operations (`tools/call`, `resources/read`, `prompts/get`), blocking operations that are not authorized by the defined policies. ```mermaid sequenceDiagram participant MCPClient as MCP Client participant EunomiaMiddleware as Eunomia Middleware participant MCPServer as FastMCP Server participant EunomiaServer as Eunomia Server MCPClient->>EunomiaMiddleware: MCP Execution Request (e.g., tools/call) EunomiaMiddleware->>EunomiaServer: Authorization Check EunomiaServer->>EunomiaMiddleware: Authorization Decision EunomiaMiddleware-->>MCPClient: MCP Unauthorized Error (if denied) EunomiaMiddleware->>MCPServer: MCP Execution Request (if allowed) MCPServer-->>EunomiaMiddleware: MCP Execution Response (if allowed) EunomiaMiddleware-->>MCPClient: MCP Execution Response (if allowed) ``` ## Add Authorization to Your Server Eunomia is an AI-specific authorization server that handles policy decisions. The server runs embedded within your MCP server by default for a zero-effort configuration, but can alternatively be run remotely for centralized policy decisions. ### Create a Server with Authorization First, install the `eunomia-mcp` package: ```bash pip install eunomia-mcp ``` Then create a FastMCP server and add the Eunomia middleware in one line: ```python server.py from fastmcp import FastMCP from eunomia_mcp import create_eunomia_middleware # Create your FastMCP server mcp = FastMCP("Secure MCP Server 🔒") @mcp.tool() def add(a: int, b: int) -> int: """Add two numbers""" return a + b # Add middleware to your server middleware = create_eunomia_middleware(policy_file="mcp_policies.json") mcp.add_middleware(middleware) if __name__ == "__main__": mcp.run() ``` ### Configure Access Policies Use the `eunomia-mcp` CLI in your terminal to manage your authorization policies: ```bash # Create a default policy file eunomia-mcp init # Or create a policy file customized for your FastMCP server eunomia-mcp init --custom-mcp "app.server:mcp" ``` This creates `mcp_policies.json` file that you can further edit to your access control needs. ```bash # Once edited, validate your policy file eunomia-mcp validate mcp_policies.json ``` ### Run the Server Start your FastMCP server normally: ```bash python server.py ``` The middleware will now intercept all MCP requests and check them against your policies. Requests include agent identification through headers like `X-Agent-ID`, `X-User-ID`, `User-Agent`, or `Authorization` and an automatic mapping of MCP methods to authorization resources and actions. For detailed policy configuration, custom authentication, and remote deployments, visit the [Eunomia MCP Middleware repository][eunomia-mcp-github]. [eunomia-github]: https://github.com/whataboutyou-ai/eunomia [eunomia-mcp-github]: https://github.com/whataboutyou-ai/eunomia/tree/main/pkgs/extensions/mcp [fastmcp-middleware]: /servers/middleware ================================================ FILE: docs/integrations/fastapi.mdx ================================================ --- title: FastAPI 🤝 FastMCP sidebarTitle: FastAPI description: Integrate FastMCP with FastAPI applications icon: bolt --- import { VersionBadge } from '/snippets/version-badge.mdx' FastMCP provides two powerful ways to integrate with FastAPI applications: 1. **[Generate an MCP server FROM your FastAPI app](#generating-an-mcp-server)** - Convert existing API endpoints into MCP tools 2. **[Mount an MCP server INTO your FastAPI app](#mounting-an-mcp-server)** - Add MCP functionality to your web application When generating an MCP server from FastAPI, FastMCP uses OpenAPIProvider (v3.0.0+) under the hood to source tools from your FastAPI app's OpenAPI spec. See [Providers](/servers/providers/overview) to understand how FastMCP sources components. Generating MCP servers from OpenAPI is a great way to get started with FastMCP, but in practice LLMs achieve **significantly better performance** with well-designed and curated MCP servers than with auto-converted OpenAPI servers. This is especially true for complex APIs with many endpoints and parameters. We recommend using the FastAPI integration for bootstrapping and prototyping, not for mirroring your API to LLM clients. See the post [Stop Converting Your REST APIs to MCP](https://www.jlowin.dev/blog/stop-converting-rest-apis-to-mcp) for more details. FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration. ## Example FastAPI Application Throughout this guide, we'll use this e-commerce API as our example (click the `Copy` button to copy it for use with other code blocks): ```python [expandable] # Copy this FastAPI server into other code blocks in this guide from fastapi import FastAPI, HTTPException from pydantic import BaseModel # Models class Product(BaseModel): name: str price: float category: str description: str | None = None class ProductResponse(BaseModel): id: int name: str price: float category: str description: str | None = None # Create FastAPI app app = FastAPI(title="E-commerce API", version="1.0.0") # In-memory database products_db = { 1: ProductResponse( id=1, name="Laptop", price=999.99, category="Electronics" ), 2: ProductResponse( id=2, name="Mouse", price=29.99, category="Electronics" ), 3: ProductResponse( id=3, name="Desk Chair", price=299.99, category="Furniture" ), } next_id = 4 @app.get("/products", response_model=list[ProductResponse]) def list_products( category: str | None = None, max_price: float | None = None, ) -> list[ProductResponse]: """List all products with optional filtering.""" products = list(products_db.values()) if category: products = [p for p in products if p.category == category] if max_price: products = [p for p in products if p.price <= max_price] return products @app.get("/products/{product_id}", response_model=ProductResponse) def get_product(product_id: int): """Get a specific product by ID.""" if product_id not in products_db: raise HTTPException(status_code=404, detail="Product not found") return products_db[product_id] @app.post("/products", response_model=ProductResponse) def create_product(product: Product): """Create a new product.""" global next_id product_response = ProductResponse(id=next_id, **product.model_dump()) products_db[next_id] = product_response next_id += 1 return product_response @app.put("/products/{product_id}", response_model=ProductResponse) def update_product(product_id: int, product: Product): """Update an existing product.""" if product_id not in products_db: raise HTTPException(status_code=404, detail="Product not found") products_db[product_id] = ProductResponse( id=product_id, **product.model_dump(), ) return products_db[product_id] @app.delete("/products/{product_id}") def delete_product(product_id: int): """Delete a product.""" if product_id not in products_db: raise HTTPException(status_code=404, detail="Product not found") del products_db[product_id] return {"message": "Product deleted"} ``` All subsequent code examples in this guide assume you have the above FastAPI application code already defined. Each example builds upon this base application, `app`. ## Generating an MCP Server One of the most common ways to bootstrap an MCP server is to generate it from an existing FastAPI application. FastMCP will expose your FastAPI endpoints as MCP components (tools, by default) in order to expose your API to LLM clients. ### Basic Conversion Convert the FastAPI app to an MCP server with a single line: ```python {5} # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP # Convert to MCP server mcp = FastMCP.from_fastapi(app=app) if __name__ == "__main__": mcp.run() ``` ### Adding Components Your converted MCP server is a full FastMCP instance, meaning you can add new tools, resources, and other components to it just like you would with any other FastMCP instance. ```python {8-11} # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP # Convert to MCP server mcp = FastMCP.from_fastapi(app=app) # Add a new tool @mcp.tool def get_product(product_id: int) -> ProductResponse: """Get a product by ID.""" return products_db[product_id] # Run the MCP server if __name__ == "__main__": mcp.run() ``` ### Interacting with the MCP Server Once you've converted your FastAPI app to an MCP server, you can interact with it using the FastMCP client to test functionality before deploying it to an LLM-based application. ```python {3, } # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP from fastmcp.client import Client import asyncio # Convert to MCP server mcp = FastMCP.from_fastapi(app=app) async def demo(): async with Client(mcp) as client: # List available tools tools = await client.list_tools() print(f"Available tools: {[t.name for t in tools]}") # Create a product result = await client.call_tool( "create_product_products_post", { "name": "Wireless Keyboard", "price": 79.99, "category": "Electronics", "description": "Bluetooth mechanical keyboard" } ) print(f"Created product: {result.data}") # List electronics under $100 result = await client.call_tool( "list_products_products_get", {"category": "Electronics", "max_price": 100} ) print(f"Affordable electronics: {result.data}") if __name__ == "__main__": asyncio.run(demo()) ``` ### Custom Route Mapping Because FastMCP's FastAPI integration is based on its [OpenAPI integration](/integrations/openapi), you can customize how endpoints are converted to MCP components in exactly the same way. For example, here we use a `RouteMap` to map all GET requests to MCP resources, and all POST/PUT/DELETE requests to MCP tools: ```python # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType # Custom mapping rules mcp = FastMCP.from_fastapi( app=app, route_maps=[ # GET with path params → ResourceTemplates RouteMap( methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE ), # Other GETs → Resources RouteMap( methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE ), # POST/PUT/DELETE → Tools (default) ], ) # Now: # - GET /products → Resource # - GET /products/{id} → ResourceTemplate # - POST/PUT/DELETE → Tools ``` To learn more about customizing the conversion process, see the [OpenAPI Integration guide](/integrations/openapi). ### Authentication and Headers You can configure headers and other client options via the `httpx_client_kwargs` parameter. For example, to add authentication to your FastAPI app, you can pass a `headers` dictionary to the `httpx_client_kwargs` parameter: ```python {27-31} # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP # Add authentication to your FastAPI app from fastapi import Depends, Header from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials security = HTTPBearer() def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): if credentials.credentials != "secret-token": raise HTTPException(status_code=401, detail="Invalid authentication") return credentials.credentials # Add a protected endpoint @app.get("/admin/stats", dependencies=[Depends(verify_token)]) def get_admin_stats(): return { "total_products": len(products_db), "categories": list(set(p.category for p in products_db.values())) } # Create MCP server with authentication headers mcp = FastMCP.from_fastapi( app=app, httpx_client_kwargs={ "headers": { "Authorization": "Bearer secret-token", } } ) ``` ## Mounting an MCP Server In addition to generating servers, FastMCP can facilitate adding MCP servers to your existing FastAPI application. You can do this by mounting the MCP ASGI application. ### Basic Mounting To mount an MCP server, you can use the `http_app` method on your FastMCP instance. This will return an ASGI application that can be mounted to your FastAPI application. ```python {23-30} from fastmcp import FastMCP from fastapi import FastAPI # Create MCP server mcp = FastMCP("Analytics Tools") @mcp.tool def analyze_pricing(category: str) -> dict: """Analyze pricing for a category.""" products = [p for p in products_db.values() if p.category == category] if not products: return {"error": f"No products in {category}"} prices = [p.price for p in products] return { "category": category, "avg_price": round(sum(prices) / len(prices), 2), "min": min(prices), "max": max(prices), } # Create ASGI app from MCP server mcp_app = mcp.http_app(path='/mcp') # Key: Pass lifespan to FastAPI app = FastAPI(title="E-commerce API", lifespan=mcp_app.lifespan) # Mount the MCP server app.mount("/analytics", mcp_app) # Now: API at /products/*, MCP at /analytics/mcp/ ``` ## Offering an LLM-Friendly API A common pattern is to generate an MCP server from your FastAPI app and serve both interfaces from the same application. This provides an LLM-optimized interface alongside your regular API: ```python # Assumes the FastAPI app from above is already defined from fastmcp import FastMCP from fastapi import FastAPI # 1. Generate MCP server from your API mcp = FastMCP.from_fastapi(app=app, name="E-commerce MCP") # 2. Create the MCP's ASGI app mcp_app = mcp.http_app(path='/mcp') # 3. Create a new FastAPI app that combines both sets of routes combined_app = FastAPI( title="E-commerce API with MCP", routes=[ *mcp_app.routes, # MCP routes *app.routes, # Original API routes ], lifespan=mcp_app.lifespan, ) # Now you have: # - Regular API: http://localhost:8000/products # - LLM-friendly MCP: http://localhost:8000/mcp # Both served from the same FastAPI application! ``` This approach lets you maintain a single codebase while offering both traditional REST endpoints and MCP-compatible endpoints for LLM clients. ## Key Considerations ### Operation IDs FastAPI operation IDs become MCP component names. Always specify meaningful operation IDs: ```python # Good - explicit operation_id @app.get("/users/{user_id}", operation_id="get_user_by_id") def get_user(user_id: int): return {"id": user_id} # Less ideal - auto-generated name @app.get("/users/{user_id}") def get_user(user_id: int): return {"id": user_id} ``` ### Lifespan Management When mounting MCP servers, always pass the lifespan context: ```python # Correct - lifespan passed, path="/" since we mount at /mcp mcp_app = mcp.http_app(path="/") app = FastAPI(lifespan=mcp_app.lifespan) app.mount("/mcp", mcp_app) # MCP endpoint at /mcp # Incorrect - missing lifespan app = FastAPI() app.mount("/mcp", mcp.http_app(path="/")) # Session manager won't initialize ``` If you're mounting an authenticated MCP server under a path prefix, see [Mounting Authenticated Servers](/deployment/http#mounting-authenticated-servers) for important OAuth routing considerations. ### CORS Middleware If your FastAPI app uses `CORSMiddleware` and you're mounting an OAuth-protected FastMCP server, avoid adding application-wide CORS middleware. FastMCP and the MCP SDK already handle CORS for OAuth routes, and layering CORS middleware can cause conflicts (such as 404 errors on `.well-known` routes or OPTIONS requests). If you need CORS on your own FastAPI routes, use the sub-app pattern: mount your API and FastMCP as separate apps, each with their own middleware, rather than adding top-level `CORSMiddleware` to the combined application. ### Combining Lifespans If your FastAPI app already has a lifespan (for database connections, startup tasks, etc.), you can't simply replace it with the MCP lifespan. Use `combine_lifespans` to run both: ```python from fastapi import FastAPI from fastmcp import FastMCP from fastmcp.utilities.lifespan import combine_lifespans from contextlib import asynccontextmanager # Your existing lifespan @asynccontextmanager async def app_lifespan(app: FastAPI): print("Starting up the app...") yield print("Shutting down the app...") # Create MCP server mcp = FastMCP("Tools") mcp_app = mcp.http_app(path="/") # Combine both lifespans app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) app.mount("/mcp", mcp_app) # MCP endpoint at /mcp ``` `combine_lifespans` enters lifespans in order and exits in reverse order. ### Performance Tips 1. **Use in-memory transport for testing** - Pass MCP servers directly to clients 2. **Design purpose-built MCP tools** - Better than auto-converting complex APIs 3. **Keep tool parameters simple** - LLMs perform better with focused interfaces For more details on configuration options, see the [OpenAPI Integration guide](/integrations/openapi). ================================================ FILE: docs/integrations/gemini-cli.mdx ================================================ --- title: Gemini CLI 🤝 FastMCP sidebarTitle: Gemini CLI description: Install and use FastMCP servers in Gemini CLI icon: message-smile --- import { VersionBadge } from "/snippets/version-badge.mdx" import { LocalFocusTip } from "/snippets/local-focus.mdx" [Gemini CLI](https://geminicli.com/) supports MCP servers through multiple transport methods including STDIO, SSE, and HTTP, allowing you to extend Gemini's capabilities with custom tools, resources, and prompts from your FastMCP servers. ## Requirements This integration uses STDIO transport to run your FastMCP server locally. For remote deployments, you can run your FastMCP server with HTTP or SSE transport and configure it directly using Gemini CLI's built-in MCP management commands. ## Create a Server The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ## Install the Server ### FastMCP CLI The easiest way to install a FastMCP server in Gemini CLI is using the `fastmcp install gemini-cli` command. This automatically handles the configuration, dependency management, and calls Gemini CLI's built-in MCP management system. ```bash fastmcp install gemini-cli server.py ``` The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: ```bash # These are equivalent if your server object is named 'mcp' fastmcp install gemini-cli server.py fastmcp install gemini-cli server.py:mcp # Use explicit object name if your server has a different name fastmcp install gemini-cli server.py:my_custom_server ``` The command will automatically configure the server with Gemini CLI's `gemini mcp add` command. #### Dependencies FastMCP provides flexible dependency management options for your Gemini CLI servers: **Individual packages**: Use the `--with` flag to specify packages your server needs. You can use this flag multiple times: ```bash fastmcp install gemini-cli server.py --with pandas --with requests ``` **Requirements file**: If you maintain a `requirements.txt` file with all your dependencies, use `--with-requirements` to install them: ```bash fastmcp install gemini-cli server.py --with-requirements requirements.txt ``` **Editable packages**: For local packages under development, use `--with-editable` to install them in editable mode: ```bash fastmcp install gemini-cli server.py --with-editable ./my-local-package ``` Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] } } ``` #### Python Version and Project Configuration Control the Python environment for your server with these options: **Python version**: Use `--python` to specify which Python version your server requires. This ensures compatibility when your server needs specific Python features: ```bash fastmcp install gemini-cli server.py --python 3.11 ``` **Project directory**: Use `--project` to run your server within a specific project context. This tells `uv` to use the project's configuration files and virtual environment: ```bash fastmcp install gemini-cli server.py --project /path/to/my-project ``` #### Environment Variables If your server needs environment variables (like API keys), you must include them: ```bash fastmcp install gemini-cli server.py --server-name "Weather Server" \ --env API_KEY=your-api-key \ --env DEBUG=true ``` Or load them from a `.env` file: ```bash fastmcp install gemini-cli server.py --server-name "Weather Server" --env-file .env ``` **Gemini CLI must be installed**. The integration looks for the Gemini CLI and uses the `gemini mcp add` command to register servers. ### Manual Configuration For more control over the configuration, you can manually use Gemini CLI's built-in MCP management commands. This gives you direct control over how your server is launched: ```bash # Add a server with custom configuration gemini mcp add dice-roller uv -- run --with fastmcp fastmcp run server.py # Add with environment variables gemini mcp add weather-server -e API_KEY=secret -e DEBUG=true uv -- run --with fastmcp fastmcp run server.py # Add with specific scope (user, or project) gemini mcp add my-server --scope user uv -- run --with fastmcp fastmcp run server.py ``` You can also manually specify Python versions and project directories in your Gemini CLI commands: ```bash # With specific Python version gemini mcp add ml-server uv -- run --python 3.11 --with fastmcp fastmcp run server.py # Within a project directory gemini mcp add project-server uv -- run --project /path/to/project --with fastmcp fastmcp run server.py ``` ## Using the Server Once your server is installed, you can start using your FastMCP server with Gemini CLI. Try asking Gemini something like: > "Roll some dice for me" Gemini will automatically detect your `roll_dice` tool and use it to fulfill your request. Gemini CLI can now access all the tools and prompts you've defined in your FastMCP server. If your server provides prompts, you can use them as slash commands with `/prompt_name`. ================================================ FILE: docs/integrations/gemini.mdx ================================================ --- title: Gemini SDK 🤝 FastMCP sidebarTitle: Gemini SDK description: Connect FastMCP servers to the Google Gemini SDK icon: message-code --- import { VersionBadge } from "/snippets/version-badge.mdx" Google's Gemini API includes built-in support for MCP servers in their Python and JavaScript SDKs, allowing you to connect directly to MCP servers and use their tools seamlessly with Gemini models. ## Gemini Python SDK Google's [Gemini Python SDK](https://ai.google.dev/gemini-api/docs) can use FastMCP clients directly. Google's MCP integration is currently experimental and available in the Python and JavaScript SDKs. The API automatically calls MCP tools when needed and can connect to both local and remote MCP servers. Currently, Gemini's MCP support only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to the AI. Other MCP features like resources and prompts are not currently supported. ### Create a Server First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ### Call the Server To use the Gemini API with MCP, you'll need to install the Google Generative AI SDK: ```bash pip install google-genai ``` You'll also need to authenticate with Google. You can do this by setting the `GEMINI_API_KEY` environment variable. Consult the Gemini SDK documentation for more information. ```bash export GEMINI_API_KEY="your-api-key" ``` Gemini's SDK interacts directly with the MCP client session. To call the server, you'll need to instantiate a FastMCP client, enter its connection context, and pass the client session to the Gemini SDK. ```python {5, 9, 15} from fastmcp import Client from google import genai import asyncio mcp_client = Client("server.py") gemini_client = genai.Client() async def main(): async with mcp_client: response = await gemini_client.aio.models.generate_content( model="gemini-2.0-flash", contents="Roll 3 dice!", config=genai.types.GenerateContentConfig( temperature=0, tools=[mcp_client.session], # Pass the FastMCP client session ), ) print(response.text) if __name__ == "__main__": asyncio.run(main()) ``` If you run this code, you'll see output like: ```text Okay, I rolled 3 dice and got a 5, 4, and 1. ``` ### Remote & Authenticated Servers In the above example, we connected to our local server using `stdio` transport. Because we're using a FastMCP client, you can also connect to any local or remote MCP server, using any [transport](/clients/transports) or [auth](/clients/auth) method supported by FastMCP, simply by changing the client configuration. For example, to connect to a remote, authenticated server, you can use the following client: ```python from fastmcp import Client from fastmcp.client.auth import BearerAuth mcp_client = Client( "https://my-server.com/mcp/", auth=BearerAuth(""), ) ``` The rest of the code remains the same. ================================================ FILE: docs/integrations/github.mdx ================================================ --- title: GitHub OAuth 🤝 FastMCP sidebarTitle: GitHub description: Secure your FastMCP server with GitHub OAuth icon: github --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **GitHub OAuth**. Since GitHub doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge GitHub's traditional OAuth with MCP's authentication requirements. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[GitHub Account](https://github.com/)** with access to create OAuth Apps 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create a GitHub OAuth App Create an OAuth App in your GitHub settings to get the credentials needed for authentication: Go to **Settings → Developer settings → OAuth Apps** in your GitHub account, or visit [github.com/settings/developers](https://github.com/settings/developers). Click **"New OAuth App"** to create a new application. Fill in the application details: - **Application name**: Choose a name users will recognize (e.g., "My FastMCP Server") - **Homepage URL**: Your application's homepage or documentation URL - **Authorization callback URL**: Your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, GitHub allows `http://localhost` URLs. For production, you must use HTTPS. If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider. After creating the app, you'll see: - **Client ID**: A public identifier like `Ov23liAbcDefGhiJkLmN` - **Client Secret**: Click "Generate a new client secret" and save the value securely Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider # The GitHubProvider handles GitHub's token format and validation auth_provider = GitHubProvider( client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID client_secret="github_pat_...", # Your GitHub OAuth App Client Secret base_url="http://localhost:8000", # Must match your OAuth App configuration # redirect_path="/auth/callback" # Default value, customize if needed ) mcp = FastMCP(name="GitHub Secured App", auth=auth_provider) # Add a protected tool to test authentication @mcp.tool async def get_user_info() -> dict: """Returns information about the authenticated GitHub user.""" from fastmcp.server.dependencies import get_access_token token = get_access_token() # The GitHubProvider stores user data in token claims return { "github_user": token.claims.get("login"), "name": token.claims.get("name"), "email": token.claims.get("email") } ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by GitHub OAuth authentication. ### Testing with a Client Create a test client that authenticates with your GitHub-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle GitHub OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open GitHub login in your browser print("✓ Authenticated with GitHub!") # Test the protected tool result = await client.call_tool("get_user_info") print(f"GitHub user: {result['github_user']}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to GitHub's authorization page 2. After you authorize the app, you'll be redirected back 3. The client receives the token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = GitHubProvider( client_id="Ov23liAbcDefGhiJkLmN", client_secret="github_pat_...", base_url="https://your-production-domain.com", # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production GitHub App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ================================================ FILE: docs/integrations/google.mdx ================================================ --- title: Google OAuth 🤝 FastMCP sidebarTitle: Google description: Secure your FastMCP server with Google OAuth icon: google --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Google OAuth**. Since Google doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Google's traditional OAuth with MCP's authentication requirements. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[Google Cloud Account](https://console.cloud.google.com/)** with access to create OAuth 2.0 Client IDs 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create a Google OAuth 2.0 Client ID Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credentials needed for authentication: Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) and select your project (or create a new one). First, configure the OAuth consent screen by navigating to **APIs & Services → OAuth consent screen**. Choose "External" for testing or "Internal" for G Suite organizations. Navigate to **APIs & Services → Credentials** and click **"+ CREATE CREDENTIALS"** → **"OAuth client ID"**. Configure your OAuth client: - **Application type**: Web application - **Name**: Choose a descriptive name (e.g., "FastMCP Server") - **Authorized JavaScript origins**: Add your server's base URL (e.g., `http://localhost:8000`) - **Authorized redirect URIs**: Add your server URL + `/auth/callback` (e.g., `http://localhost:8000/auth/callback`) The redirect URI must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. For local development, Google allows `http://localhost` URLs with various ports. For production, you must use HTTPS. If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider. After creating the client, you'll receive: - **Client ID**: A string ending in `.apps.googleusercontent.com` - **Client Secret**: A string starting with `GOCSPX-` Download the JSON credentials or copy these values securely. Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production. ### Step 2: FastMCP Configuration Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.google import GoogleProvider # The GoogleProvider handles Google's token format and validation auth_provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret base_url="http://localhost:8000", # Must match your OAuth configuration required_scopes=[ # Request user information "openid", "https://www.googleapis.com/auth/userinfo.email", ], # redirect_path="/auth/callback" # Default value, customize if needed ) mcp = FastMCP(name="Google Secured App", auth=auth_provider) # Add a protected tool to test authentication @mcp.tool async def get_user_info() -> dict: """Returns information about the authenticated Google user.""" from fastmcp.server.dependencies import get_access_token token = get_access_token() # The GoogleProvider stores user data in token claims return { "google_id": token.claims.get("sub"), "email": token.claims.get("email"), "name": token.claims.get("name"), "picture": token.claims.get("picture"), "locale": token.claims.get("locale") } ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by Google OAuth authentication. ### Testing with a Client Create a test client that authenticates with your Google-protected server: ```python test_client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle Google OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open Google login in your browser print("✓ Authenticated with Google!") # Test the protected tool result = await client.call_tool("get_user_info") print(f"Google user: {result['email']}") print(f"Name: {result['name']}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to Google's authorization page 2. Sign in with your Google account and grant the requested permissions 3. After authorization, you'll be redirected back 4. The client receives the token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key` and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.google import GoogleProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth_provider = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-abc123...", base_url="https://your-production-domain.com", required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"], # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production Google App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ================================================ FILE: docs/integrations/goose.mdx ================================================ --- title: Goose 🤝 FastMCP sidebarTitle: Goose description: Install and use FastMCP servers in Goose icon: message-smile --- import { VersionBadge } from "/snippets/version-badge.mdx" import { LocalFocusTip } from "/snippets/local-focus.mdx" [Goose](https://block.github.io/goose/) is an open-source AI agent from Block that supports MCP servers as extensions. FastMCP can install your server directly into Goose using its deeplink protocol — one command opens Goose with an install dialog ready to go. ## Requirements This integration uses Goose's deeplink protocol to register your server as a STDIO extension running via `uvx`. You must have Goose installed on your system for the deeplink to open automatically. For remote deployments, configure your FastMCP server with HTTP transport and add it to Goose directly using `goose configure` or the config file. ## Create a Server The examples in this guide will use the following simple dice-rolling server, saved as `server.py`. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run() ``` ## Install the Server ### FastMCP CLI The easiest way to install a FastMCP server in Goose is using the `fastmcp install goose` command. This generates a `goose://` deeplink and opens it, prompting Goose to install the server. ```bash fastmcp install goose server.py ``` The install command supports the same `file.py:object` notation as the `run` command. If no object is specified, it will automatically look for a FastMCP server object named `mcp`, `server`, or `app` in your file: ```bash # These are equivalent if your server object is named 'mcp' fastmcp install goose server.py fastmcp install goose server.py:mcp # Use explicit object name if your server has a different name fastmcp install goose server.py:my_custom_server ``` Under the hood, the generated command uses `uvx` to run your server in an isolated environment. Goose requires `uvx` rather than `uv run`, so the install produces a command like: ```bash uvx --with pandas fastmcp run /path/to/server.py ``` #### Dependencies Use the `--with` flag to specify additional packages your server needs: ```bash fastmcp install goose server.py --with pandas --with requests ``` Alternatively, you can use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "requests"] } } ``` #### Python Version Use `--python` to specify which Python version your server should use: ```bash fastmcp install goose server.py --python 3.11 ``` The Goose install uses `uvx`, which does not support `--project`, `--with-requirements`, or `--with-editable`. If you need these options, use `fastmcp install mcp-json` to generate a full configuration and add it to Goose manually. #### Environment Variables Goose's deeplink protocol does not support environment variables. If your server needs them (like API keys), you have two options: 1. **Configure after install**: Run `goose configure` and add environment variables to the extension. 2. **Manual config**: Use `fastmcp install mcp-json` to generate the full configuration, then add it to `~/.config/goose/config.yaml` with the `envs` field. ### Manual Configuration For more control, you can manually edit Goose's configuration file at `~/.config/goose/config.yaml`: ```yaml extensions: dice-roller: name: Dice Roller cmd: uvx args: [fastmcp, run, /path/to/server.py] enabled: true type: stdio timeout: 300 ``` #### Dependencies When manually configuring, add packages using `--with` flags in the args: ```yaml extensions: dice-roller: name: Dice Roller cmd: uvx args: [--with, pandas, --with, requests, fastmcp, run, /path/to/server.py] enabled: true type: stdio timeout: 300 ``` #### Environment Variables Environment variables can be specified in the `envs` field: ```yaml extensions: weather-server: name: Weather Server cmd: uvx args: [fastmcp, run, /path/to/weather_server.py] enabled: true envs: API_KEY: your-api-key DEBUG: "true" type: stdio timeout: 300 ``` You can also use `goose configure` to add extensions interactively, which prompts for environment variables. **`uvx` (from `uv`) must be installed and available in your system PATH**. Goose uses `uvx` to run Python-based extensions in isolated environments. ## Using the Server Once your server is installed, you can start using your FastMCP server with Goose. Try asking Goose something like: > "Roll some dice for me" Goose will automatically detect your `roll_dice` tool and use it to fulfill your request, returning something like: > 🎲 Here are your dice rolls: 4, 6, 4 > > You rolled 3 dice with a total of 14! Goose can now access all the tools, resources, and prompts you've defined in your FastMCP server. ================================================ FILE: docs/integrations/mcp-json-configuration.mdx ================================================ --- title: MCP JSON Configuration 🤝 FastMCP sidebarTitle: MCP.json description: Generate standard MCP configuration files for any compatible client icon: brackets-curly --- import { VersionBadge } from "/snippets/version-badge.mdx" FastMCP can generate standard MCP JSON configuration files that work with any MCP-compatible client including Claude Desktop, VS Code, Cursor, and other applications that support the Model Context Protocol. ## MCP JSON Configuration Standard The MCP JSON configuration format is an **emergent standard** that has developed across the MCP ecosystem. This format defines how MCP clients should configure and launch MCP servers, providing a consistent way to specify server commands, arguments, and environment variables. ### Configuration Structure The standard uses a `mcpServers` object where each key represents a server name and the value contains the server's configuration: ```json { "mcpServers": { "server-name": { "command": "executable", "args": ["arg1", "arg2"], "env": { "VAR": "value" } } } } ``` ### Server Configuration Fields #### `command` (required) The executable command to run the MCP server. This should be an absolute path or a command available in the system PATH. ```json { "command": "python" } ``` #### `args` (optional) An array of command-line arguments passed to the server executable. Arguments are passed in order. ```json { "args": ["server.py", "--verbose", "--port", "8080"] } ``` #### `env` (optional) An object containing environment variables to set when launching the server. All values must be strings. ```json { "env": { "API_KEY": "secret-key", "DEBUG": "true", "PORT": "8080" } } ``` ### Client Adoption This format is widely adopted across the MCP ecosystem: - **Claude Desktop**: Uses `~/.claude/claude_desktop_config.json` - **Cursor**: Uses `~/.cursor/mcp.json` - **VS Code**: Uses workspace `.vscode/mcp.json` - **Other clients**: Many MCP-compatible applications follow this standard ## Overview **For the best experience, use FastMCP's first-class integrations:** [`fastmcp install claude-code`](/integrations/claude-code), [`fastmcp install claude-desktop`](/integrations/claude-desktop), or [`fastmcp install cursor`](/integrations/cursor). Use MCP JSON generation for advanced use cases and unsupported clients. The `fastmcp install mcp-json` command generates configuration in the standard `mcpServers` format used across the MCP ecosystem. This is useful when: - **Working with unsupported clients** - Any MCP client not directly integrated with FastMCP - **CI/CD environments** - Automated configuration generation for deployments - **Configuration sharing** - Easy distribution of server setups to team members - **Custom tooling** - Integration with your own MCP management tools - **Manual setup** - When you prefer to manually configure your MCP client ## Basic Usage Generate configuration and output to stdout (useful for piping): ```bash fastmcp install mcp-json server.py ``` This outputs the server configuration JSON with the server name as the root key: ```json { "My Server": { "command": "uv", "args": [ "run", "--with", "fastmcp", "fastmcp", "run", "/absolute/path/to/server.py" ] } } ``` To use this in a client configuration file, add it to the `mcpServers` object in your client's configuration: ```json { "mcpServers": { "My Server": { "command": "uv", "args": [ "run", "--with", "fastmcp", "fastmcp", "run", "/absolute/path/to/server.py" ] } } } ``` When using `--python`, `--project`, or `--with-requirements`, the generated configuration will include these options in the `uv run` command, ensuring your server runs with the correct Python version and dependencies. Different MCP clients may have specific configuration requirements or formatting needs. Always consult your client's documentation to ensure proper integration. ## Configuration Options ### Server Naming ```bash # Use server's built-in name (from FastMCP constructor) fastmcp install mcp-json server.py # Override with custom name fastmcp install mcp-json server.py --name "Custom Server Name" ``` ### Dependencies Add Python packages your server needs: ```bash # Single package fastmcp install mcp-json server.py --with pandas # Multiple packages fastmcp install mcp-json server.py --with pandas --with requests --with httpx # Editable local package fastmcp install mcp-json server.py --with-editable ./my-package # From requirements file fastmcp install mcp-json server.py --with-requirements requirements.txt ``` You can also use a `fastmcp.json` configuration file (recommended): ```json fastmcp.json { "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", "source": { "path": "server.py", "entrypoint": "mcp" }, "environment": { "dependencies": ["pandas", "matplotlib", "seaborn"] } } ``` Then simply install with: ```bash fastmcp install mcp-json fastmcp.json ``` ### Environment Variables ```bash # Individual environment variables fastmcp install mcp-json server.py \ --env API_KEY=your-secret-key \ --env DEBUG=true # Load from .env file fastmcp install mcp-json server.py --env-file .env ``` ### Python Version and Project Directory Specify Python version or run within a specific project: ```bash # Use specific Python version fastmcp install mcp-json server.py --python 3.11 # Run within a project directory fastmcp install mcp-json server.py --project /path/to/project ``` ### Server Object Selection Use the same `file.py:object` notation as other FastMCP commands: ```bash # Auto-detects server object (looks for 'mcp', 'server', or 'app') fastmcp install mcp-json server.py # Explicit server object fastmcp install mcp-json server.py:my_custom_server ``` ## Clipboard Integration Copy configuration directly to your clipboard for easy pasting: ```bash fastmcp install mcp-json server.py --copy ``` The `--copy` flag requires the `pyperclip` Python package. If not installed, you'll see an error message with installation instructions. ## Usage Examples ### Basic Server ```bash fastmcp install mcp-json dice_server.py ``` Output: ```json { "Dice Server": { "command": "uv", "args": [ "run", "--with", "fastmcp", "fastmcp", "run", "/home/user/dice_server.py" ] } } ``` ### Production Server with Dependencies ```bash fastmcp install mcp-json api_server.py \ --name "Production API Server" \ --with requests \ --with python-dotenv \ --env API_BASE_URL=https://api.example.com \ --env TIMEOUT=30 ``` ### Advanced Configuration ```bash fastmcp install mcp-json ml_server.py \ --name "ML Analysis Server" \ --python 3.11 \ --with-requirements requirements.txt \ --project /home/user/ml-project \ --env GPU_DEVICE=0 ``` Output: ```json { "Production API Server": { "command": "uv", "args": [ "run", "--with", "fastmcp", "--with", "python-dotenv", "--with", "requests", "fastmcp", "run", "/home/user/api_server.py" ], "env": { "API_BASE_URL": "https://api.example.com", "TIMEOUT": "30" } } } ``` The advanced configuration example generates: ```json { "ML Analysis Server": { "command": "uv", "args": [ "run", "--python", "3.11", "--project", "/home/user/ml-project", "--with", "fastmcp", "--with-requirements", "requirements.txt", "fastmcp", "run", "/home/user/ml_server.py" ], "env": { "GPU_DEVICE": "0" } } } ``` ### Pipeline Usage Save configuration to file: ```bash fastmcp install mcp-json server.py > mcp-config.json ``` Use in shell scripts: ```bash #!/bin/bash CONFIG=$(fastmcp install mcp-json server.py --name "CI Server") echo "$CONFIG" | jq '."CI Server".command' # Output: "uv" ``` ## Integration with MCP Clients The generated configuration works with any MCP-compatible application: ### Claude Desktop **Prefer [`fastmcp install claude-desktop`](/integrations/claude-desktop)** for automatic installation. Use MCP JSON for advanced configuration needs. Copy the `mcpServers` object into `~/.claude/claude_desktop_config.json` ### Cursor **Prefer [`fastmcp install cursor`](/integrations/cursor)** for automatic installation. Use MCP JSON for advanced configuration needs. Add to `~/.cursor/mcp.json` ### VS Code Add to your workspace's `.vscode/mcp.json` file ### Custom Applications Use the JSON configuration with any application that supports the MCP protocol ## Configuration Format The generated configuration outputs a server object with the server name as the root key: ```json { "": { "command": "", "args": ["", "", "..."], "env": { "": "" } } } ``` To use this in an MCP client, add it to the client's `mcpServers` configuration object. **Fields:** - `command`: The executable to run (always `uv` for FastMCP servers) - `args`: Command-line arguments including dependencies and server path - `env`: Environment variables (only included if specified) **All file paths in the generated configuration are absolute paths**. This ensures the configuration works regardless of the working directory when the MCP client starts the server. ## Requirements - **uv**: Must be installed and available in your system PATH - **pyperclip** (optional): Required only for `--copy` functionality Install uv if not already available: ```bash # macOS brew install uv # Linux/Windows curl -LsSf https://astral.sh/uv/install.sh | sh ``` ================================================ FILE: docs/integrations/oci.mdx ================================================ --- title: OCI IAM OAuth 🤝 FastMCP sidebarTitle: Oracle description: Secure your FastMCP server with OCI IAM OAuth icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **OCI IAM OAuth**. Since OCI IAM doesn't support Dynamic Client Registration, this integration uses the [**OIDC Proxy**](/servers/auth/oidc-proxy) pattern to bridge OCI's traditional OAuth with MCP's authentication requirements. ## Configuration ### Prerequisites 1. An OCI cloud Account with access to create an Integrated Application in an Identity Domain. 2. Your FastMCP server's URL (For dev environments, it is http://localhost:8000. For PROD environments, it could be https://mcp.yourdomain.com) ### Step 1: Make sure client access is enabled for JWK's URL Login to OCI console (https://cloud.oracle.com for OCI commercial cloud). From "Identity & Security" menu, open Domains page. On the Domains list page, select the domain that you are using for MCP Authentication. Open Settings tab. Click on "Edit Domain Settings" button. OCI console showing the Edit Domain Settings button in the IAM Domain settings page Enable "Configure client access" checkbox as shown in the screenshot. OCI IAM Domain Settings ### Step 2: Create OAuth client for MCP server authentication Follow the Steps as mentioned below to create an OAuth client. Login to OCI console (https://cloud.oracle.com for OCI commercial cloud). From "Identity & Security" menu, open Domains page. On the Domains list page, select the domain in which you want to create MCP server OAuth client. If you need help finding the list page for the domain, see [Listing Identity Domains.](https://docs.oracle.com/en-us/iaas/Content/Identity/domains/to-view-identity-domains.htm#view-identity-domains). On the details page, select Integrated applications. A list of applications in the domain is displayed. Select Add application. In the Add application window, select Confidential Application. Select Launch workflow. In the Add application details page, Enter name and description as shown below. Adding a Confidential Integrated Application in OCI IAM Domain Once the Integrated Application is created, Click on "OAuth configuration" tab. Click on "Edit OAuth configuration" button. Configure the application as OAuth client by selecting "Configure this application as a client now" radio button. Select "Authorization code" grant type. If you are planning to use the same OAuth client application for token exchange, select "Client credentials" grant type as well. In the sample, we will use the same client. For Authorization grant type, select redirect URL. In most cases, this will be the MCP server URL followed by "/oauth/callback". OAuth Configuration for an Integrated Application in OCI IAM Domain Click on "Submit" button to update OAuth configuration for the client application. **Note: You don't need to do any special configuration to support PKCE for the OAuth client.** Make sure to Activate the client application. Note down client ID and client secret for the application. You'll use these values when configuring the OCIProvider in your code. This is all you need to implement MCP server authentication against OCI IAM. However, you may want to use an authenticated user token to invoke OCI control plane APIs and propagate identity to the OCI control plane instead of using a service user account. In that case, you need to implement token exchange. ### Step 3: Token Exchange Setup (Only if MCP server needs to talk to OCI Control Plane) Token exchange helps you exchange a logged-in user's OCI IAM token for an OCI control plane session token, also known as UPST (User Principal Session Token). To learn more about token exchange, refer to my [Workload Identity Federation Blog](https://www.ateam-oracle.com/post/workload-identity-federation) For token exchange, we need to configure Identity propagation trust. The blog above discusses setting up the trust using REST APIs. However, you can also use OCI CLI. Before using the CLI command below, ensure that you have created a token exchange OAuth client. In most cases, you can use the same OAuth client that you created above. Replace `` and `` in the CLI command below with your actual values. ```bash oci identity-domains identity-propagation-trust create \ --schemas '["urn:ietf:params:scim:schemas:oracle:idcs:IdentityPropagationTrust"]' \ --public-key-endpoint "https://.identity.oraclecloud.com/admin/v1/SigningCert/jwk" \ --name "For Token Exchange" --type "JWT" \ --issuer "https://identity.oraclecloud.com/" --active true \ --endpoint "https://.identity.oraclecloud.com" \ --subject-claim-name "sub" --allow-impersonation false \ --subject-mapping-attribute "username" \ --subject-type "User" --client-claim-name "iss" \ --client-claim-values '["https://identity.oraclecloud.com/"]' \ --oauth-clients '[""]' ``` To exchange access token for OCI token and create a signer object, you need to add below code in MCP server. You can then use the signer object to create any OCI control plane client. ```python from fastmcp.server.dependencies import get_access_token from fastmcp.utilities.logging import get_logger from oci.auth.signers import TokenExchangeSigner import os logger = get_logger(__name__) # Load configuration from environment OCI_IAM_GUID = os.environ.get("OCI_IAM_GUID") OCI_CLIENT_ID = os.environ.get("OCI_CLIENT_ID") OCI_CLIENT_SECRET = os.environ.get("OCI_CLIENT_SECRET") _global_token_cache = {} #In memory cache for OCI session token signer def get_oci_signer() -> TokenExchangeSigner: authntoken = get_access_token() tokenID = authntoken.claims.get("jti") token = authntoken.token #Check if the signer exists for the token ID in memory cache cached_signer = _global_token_cache.get(tokenID) logger.debug(f"Global cached signer: {cached_signer}") if cached_signer: logger.debug(f"Using globally cached signer for token ID: {tokenID}") return cached_signer #If the signer is not yet created for the token then create new OCI signer object logger.debug(f"Creating new signer for token ID: {tokenID}") signer = TokenExchangeSigner( jwt_or_func=token, oci_domain_id=OCI_IAM_GUID.split(".")[0] if OCI_IAM_GUID else "", client_id=OCI_CLIENT_ID, client_secret=OCI_CLIENT_SECRET, ) logger.debug(f"Signer {signer} created for token ID: {tokenID}") #Cache the signer object in memory cache _global_token_cache[tokenID] = signer logger.debug(f"Signer cached for token ID: {tokenID}") return signer ``` ## Running MCP server Once the setup is complete, to run the MCP server, run the below command. ```bash fastmcp run server.py:mcp --transport http --port 8000 ``` To run MCP client, run the below command. ```bash python3 client.py ``` MCP Client sample is as below. ```python client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle OCI OAuth flows async with Client("http://localhost:8000/mcp/", auth="oauth") as client: # First-time connection will open OCI login in your browser print("✓ Authenticated with OCI IAM") tools = await client.list_tools() print(f"🔧 Available tools ({len(tools)}):") for tool in tools: print(f" - {tool.name}: {tool.description}") if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to OCI IAM's login page 2. Sign in with your OCI account and grant the requested consent 3. After authorization, you'll be redirected back to the redirect path 4. The client receives the token and can make authenticated requests ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.oci import OCIProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Load configuration from environment # Production setup with encrypted persistent token storage auth_provider = OCIProvider( config_url=os.environ.get("OCI_CONFIG_URL"), client_id=os.environ.get("OCI_CLIENT_ID"), client_secret=os.environ.get("OCI_CLIENT_SECRET"), base_url=os.environ.get("BASE_URL", "https://your-production-domain.com"), # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production OCI App", auth=auth_provider) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at Rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ================================================ FILE: docs/integrations/openai.mdx ================================================ --- title: OpenAI API 🤝 FastMCP sidebarTitle: OpenAI API description: Connect FastMCP servers to the OpenAI API icon: message-code --- import { VersionBadge } from "/snippets/version-badge.mdx" ## Responses API OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) supports [MCP servers](https://platform.openai.com/docs/guides/tools-remote-mcp) as remote tool sources, allowing you to extend AI capabilities with custom functions. The Responses API is a distinct API from OpenAI's Completions API or Assistants API. At this time, only the Responses API supports MCP. Currently, the Responses API only accesses **tools** from MCP servers—it queries the `list_tools` endpoint and exposes those functions to the AI agent. Other MCP features like resources and prompts are not currently supported. ### Create a Server First, create a FastMCP server with the tools you want to expose. For this example, we'll create a server with a single tool that rolls dice. ```python server.py import random from fastmcp import FastMCP mcp = FastMCP(name="Dice Roller") @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` ### Deploy the Server Your server must be deployed to a public URL in order for OpenAI to access it. For development, you can use tools like `ngrok` to temporarily expose a locally-running server to the internet. We'll do that for this example (you may need to install `ngrok` and create a free account), but you can use any other method to deploy your server. Assuming you saved the above code as `server.py`, you can run the following two commands in two separate terminals to deploy your server and expose it to the internet: ```bash FastMCP server python server.py ``` ```bash ngrok ngrok http 8000 ``` This exposes your unauthenticated server to the internet. Only run this command in a safe environment if you understand the risks. ### Call the Server To use the Responses API, you'll need to install the OpenAI Python SDK (not included with FastMCP): ```bash pip install openai ``` You'll also need to authenticate with OpenAI. You can do this by setting the `OPENAI_API_KEY` environment variable. Consult the OpenAI SDK documentation for more information. ```bash export OPENAI_API_KEY="your-api-key" ``` Here is an example of how to call your server from Python. Note that you'll need to replace `https://your-server-url.com` with the actual URL of your server. In addition, we use `/mcp/` as the endpoint because we deployed a streamable-HTTP server with the default path; you may need to use a different endpoint if you customized your server's deployment. ```python {4, 11-16} from openai import OpenAI # Your server URL (replace with your actual URL) url = 'https://your-server-url.com' client = OpenAI() resp = client.responses.create( model="gpt-4.1", tools=[ { "type": "mcp", "server_label": "dice_server", "server_url": f"{url}/mcp/", "require_approval": "never", }, ], input="Roll a few dice!", ) print(resp.output_text) ``` If you run this code, you'll see something like the following output: ```text You rolled 3 dice and got the following results: 6, 4, and 2! ``` ### Authentication The Responses API can include headers to authenticate the request, which means you don't have to worry about your server being publicly accessible. #### Server Authentication The simplest way to add authentication to the server is to use a bearer token scheme. For this example, we'll quickly generate our own tokens with FastMCP's `RSAKeyPair` utility, but this may not be appropriate for production use. For more details, see the complete server-side [Token Verification](/servers/auth/token-verification) documentation. We'll start by creating an RSA key pair to sign and verify tokens. ```python from fastmcp.server.auth.providers.jwt import RSAKeyPair key_pair = RSAKeyPair.generate() access_token = key_pair.create_token(audience="dice-server") ``` FastMCP's `RSAKeyPair` utility is for development and testing only. Next, we'll create a `JWTVerifier` to authenticate the server. ```python from fastmcp import FastMCP from fastmcp.server.auth import JWTVerifier auth = JWTVerifier( public_key=key_pair.public_key, audience="dice-server", ) mcp = FastMCP(name="Dice Roller", auth=auth) ``` Here is a complete example that you can copy/paste. For simplicity and the purposes of this example only, it will print the token to the console. **Do NOT do this in production!** ```python server.py [expandable] from fastmcp import FastMCP from fastmcp.server.auth import JWTVerifier from fastmcp.server.auth.providers.jwt import RSAKeyPair import random key_pair = RSAKeyPair.generate() access_token = key_pair.create_token(audience="dice-server") auth = JWTVerifier( public_key=key_pair.public_key, audience="dice-server", ) mcp = FastMCP(name="Dice Roller", auth=auth) @mcp.tool def roll_dice(n_dice: int) -> list[int]: """Roll `n_dice` 6-sided dice and return the results.""" return [random.randint(1, 6) for _ in range(n_dice)] if __name__ == "__main__": print(f"\n---\n\n🔑 Dice Roller access token:\n\n{access_token}\n\n---\n") mcp.run(transport="http", port=8000) ``` #### Client Authentication If you try to call the authenticated server with the same OpenAI code we wrote earlier, you'll get an error like this: ```python pythonAPIStatusError: Error code: 424 - { "error": { "message": "Error retrieving tool list from MCP server: 'dice_server'. Http status code: 401 (Unauthorized)", "type": "external_connector_error", "param": "tools", "code": "http_error" } } ``` As expected, the server is rejecting the request because it's not authenticated. To authenticate the client, you can pass the token in the `Authorization` header with the `Bearer` scheme: ```python {4, 7, 19-21} [expandable] from openai import OpenAI # Your server URL (replace with your actual URL) url = 'https://your-server-url.com' # Your access token (replace with your actual token) access_token = 'your-access-token' client = OpenAI() resp = client.responses.create( model="gpt-4.1", tools=[ { "type": "mcp", "server_label": "dice_server", "server_url": f"{url}/mcp/", "require_approval": "never", "headers": { "Authorization": f"Bearer {access_token}" } }, ], input="Roll a few dice!", ) print(resp.output_text) ``` You should now see the dice roll results in the output. ================================================ FILE: docs/integrations/openapi.mdx ================================================ --- title: OpenAPI 🤝 FastMCP sidebarTitle: OpenAPI description: Generate MCP servers from any OpenAPI specification icon: list-tree --- import { VersionBadge } from '/snippets/version-badge.mdx' FastMCP can automatically generate an MCP server from any OpenAPI specification, allowing AI models to interact with existing APIs through the MCP protocol. Instead of manually creating tools and resources, you provide an OpenAPI spec and FastMCP intelligently converts API endpoints into the appropriate MCP components. Under the hood, OpenAPI integration uses OpenAPIProvider (v3.0.0+) to source tools from the specification. See [Providers](/servers/providers/overview) to understand how FastMCP sources components. Generating MCP servers from OpenAPI is a great way to get started with FastMCP, but in practice LLMs achieve **significantly better performance** with well-designed and curated MCP servers than with auto-converted OpenAPI servers. This is especially true for complex APIs with many endpoints and parameters. We recommend using the FastAPI integration for bootstrapping and prototyping, not for mirroring your API to LLM clients. See the post [Stop Converting Your REST APIs to MCP](https://www.jlowin.dev/blog/stop-converting-rest-apis-to-mcp) for more details. ## Create a Server To convert an OpenAPI specification to an MCP server, use the `FastMCP.from_openapi()` class method: ```python server.py import httpx from fastmcp import FastMCP # Create an HTTP client for your API client = httpx.AsyncClient(base_url="https://api.example.com") # Load your OpenAPI spec openapi_spec = httpx.get("https://api.example.com/openapi.json").json() # Create the MCP server mcp = FastMCP.from_openapi( openapi_spec=openapi_spec, client=client, name="My API Server" ) if __name__ == "__main__": mcp.run() ``` ### Authentication If your API requires authentication, configure it on the HTTP client: ```python import httpx from fastmcp import FastMCP # Bearer token authentication api_client = httpx.AsyncClient( base_url="https://api.example.com", headers={"Authorization": "Bearer YOUR_TOKEN"} ) # Create MCP server with authenticated client mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, timeout=30.0 # 30 second timeout for all requests ) ``` ## Route Mapping By default, FastMCP converts **every endpoint** in your OpenAPI specification into an MCP **Tool**. This provides a simple, predictable starting point that ensures all your API's functionality is immediately available to the vast majority of LLM clients which only support MCP tools. While this is a pragmatic default for maximum compatibility, you can easily customize this behavior. Internally, FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types. Each `RouteMap` specifies a combination of methods, patterns, and tags, as well as a corresponding MCP component type. Each OpenAPI route is checked against each `RouteMap` in order, and the first one that matches every criteria is used to determine its converted MCP type. A special type, `EXCLUDE`, can be used to exclude routes from the MCP server entirely. - **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all) - **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all) - **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags. - **MCP type**: What MCP component type to create (`TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, or `EXCLUDE`) - **MCP tags**: A set of custom tags to add to components created from matching routes Here is FastMCP's default rule: ```python from fastmcp.server.openapi import RouteMap, MCPType DEFAULT_ROUTE_MAPPINGS = [ # All routes become tools RouteMap(mcp_type=MCPType.TOOL), ] ``` ### Custom Route Maps When creating your FastMCP server, you can customize routing behavior by providing your own list of `RouteMap` objects. Your custom maps are processed before the default route maps, and routes will be assigned to the first matching custom map. For example, prior to FastMCP 2.8.0, GET requests were automatically mapped to `Resource` and `ResourceTemplate` components based on whether they had path parameters. (This was changed solely for client compatibility reasons.) You can restore this behavior by providing custom route maps: ```python from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType # Restore pre-2.8.0 semantic mapping semantic_maps = [ # GET requests with path parameters become ResourceTemplates RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE), # All other GET requests become Resources RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), ] mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_maps=semantic_maps, ) ``` With these maps, `GET` requests are handled semantically, and all other methods (`POST`, `PUT`, etc.) will fall through to the default rule and become `Tool`s. Here is a more complete example that uses custom route maps to convert all `GET` endpoints under `/analytics/` to tools while excluding all admin endpoints and all routes tagged "internal". All other routes will be handled by the default rules: ```python from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_maps=[ # Analytics `GET` endpoints are tools RouteMap( methods=["GET"], pattern=r"^/analytics/.*", mcp_type=MCPType.TOOL, ), # Exclude all admin endpoints RouteMap( pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE, ), # Exclude all routes tagged "internal" RouteMap( tags={"internal"}, mcp_type=MCPType.EXCLUDE, ), ], ) ``` The default route maps are always applied after your custom maps, so you do not have to create route maps for every possible route. ### Excluding Routes To exclude routes from the MCP server, use a route map to assign them to `MCPType.EXCLUDE`. You can use this to remove sensitive or internal routes by targeting them specifically: ```python from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_maps=[ RouteMap(pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE), RouteMap(tags={"internal"}, mcp_type=MCPType.EXCLUDE), ], ) ``` Or you can use a catch-all rule to exclude everything that your maps don't handle explicitly: ```python from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_maps=[ # custom mapping logic goes here # ... your specific route maps ... # exclude all remaining routes RouteMap(mcp_type=MCPType.EXCLUDE), ], ) ``` Using a catch-all exclusion rule will prevent the default route mappings from being applied, since it will match every remaining route. This is useful if you want to explicitly allow-list certain routes. ### Advanced Route Mapping For advanced use cases that require more complex logic, you can provide a `route_map_fn` callable. After the route map logic is applied, this function is called on each matched route and its assigned MCP component type. It can optionally return a different component type to override the mapped assignment. If it returns `None`, the assigned type is used. In addition to more precise targeting of methods, patterns, and tags, this function can access any additional OpenAPI metadata about the route. The `route_map_fn` is called on all routes, even those that matched `MCPType.EXCLUDE` in your custom maps. This gives you an opportunity to customize the mapping or even override an exclusion. ```python from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType, HTTPRoute def custom_route_mapper(route: HTTPRoute, mcp_type: MCPType) -> MCPType | None: """Advanced route type mapping.""" # Convert all admin routes to tools regardless of HTTP method if "/admin/" in route.path: return MCPType.TOOL elif "internal" in route.tags: return MCPType.EXCLUDE # Convert user detail routes to templates even if they're POST elif route.path.startswith("/users/") and route.method == "POST": return MCPType.RESOURCE_TEMPLATE # Use defaults for all other routes return None mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_map_fn=custom_route_mapper, ) ``` ## Customization ### Component Names FastMCP automatically generates names for MCP components based on the OpenAPI specification. By default, it uses the `operationId` from your OpenAPI spec, up to the first double underscore (`__`). All component names are automatically: - **Slugified**: Spaces and special characters are converted to underscores or removed - **Truncated**: Limited to 56 characters maximum to ensure compatibility - **Unique**: If multiple components have the same name, a number is automatically appended to make them unique For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated. ```python mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, mcp_names={ "list_users__with_pagination": "user_list", "create_user__admin_required": "create_user", "get_user_details__admin_required": "user_detail", } ) ``` Any `operationId` not found in `mcp_names` will use the default strategy (operationId up to the first `__`). ### Tags FastMCP provides several ways to add tags to your MCP components, allowing you to categorize and organize them for better discoverability and filtering. Tags are combined from multiple sources to create the final set of tags on each component. #### RouteMap Tags You can add custom tags to components created from specific routes using the `mcp_tags` parameter in `RouteMap`. These tags will be applied to all components created from routes that match that particular route map. ```python from fastmcp.server.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, route_maps=[ # Add custom tags to all POST endpoints RouteMap( methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL, mcp_tags={"write-operation", "api-mutation"} ), # Add different tags to detail view endpoints RouteMap( methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE, mcp_tags={"detail-view", "parameterized"} ), # Add tags to list endpoints RouteMap( methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE, mcp_tags={"list-data", "collection"} ), ], ) ``` #### Global Tags You can add tags to **all** components by providing a `tags` parameter when creating your MCP server. These global tags will be applied to every component created from your OpenAPI specification. ```python mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, tags={"api-v2", "production", "external"} ) ``` #### OpenAPI Tags in Client Meta FastMCP automatically includes OpenAPI tags from your specification in the component's metadata. These tags are available to MCP clients through the `meta.fastmcp.tags` field, allowing clients to filter and organize components based on the original OpenAPI tagging: ```json {5} OpenAPI spec with tags { "paths": { "/users": { "get": { "tags": ["users", "public"], "operationId": "list_users", "summary": "List all users" } } } } ``` ```python {6-9} Access OpenAPI tags in MCP client async with client: tools = await client.list_tools() for tool in tools: if tool.meta: # OpenAPI tags are now available in fastmcp namespace! fastmcp_meta = tool.meta.get('fastmcp', {}) openapi_tags = fastmcp_meta.get('tags', []) if 'users' in openapi_tags: print(f"Found user-related tool: {tool.name}") ``` This makes it easy for clients to understand and organize API endpoints based on their original OpenAPI categorization. ### Advanced Customization By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description. At times you may want to modify those MCP components in a variety of ways, such as adding LLM-specific instructions or tags. For fine-grained customization, you can provide a `mcp_component_fn` when creating the MCP server. After each MCP component has been created, this function is called on it and has the opportunity to modify it in-place. Your `mcp_component_fn` is expected to modify the component in-place, not to return a new component. The result of the function is ignored. ```python from fastmcp.server.openapi import ( HTTPRoute, OpenAPITool, OpenAPIResource, OpenAPIResourceTemplate, ) def customize_components( route: HTTPRoute, component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate, ) -> None: # Add custom tags to all components component.tags.add("openapi") # Customize based on component type if isinstance(component, OpenAPITool): component.description = f"🔧 {component.description} (via API)" if isinstance(component, OpenAPIResource): component.description = f"📊 {component.description}" component.tags.add("data") mcp = FastMCP.from_openapi( openapi_spec=spec, client=client, mcp_component_fn=customize_components, ) ``` ## Request Parameter Handling FastMCP intelligently handles different types of parameters in OpenAPI requests: ### Query Parameters By default, FastMCP only includes query parameters that have non-empty values. Parameters with `None` values or empty strings are automatically filtered out. ```python # When calling this tool... await client.call_tool("search_products", { "category": "electronics", # ✅ Included "min_price": 100, # ✅ Included "max_price": None, # ❌ Excluded "brand": "", # ❌ Excluded }) # The HTTP request will be: GET /products?category=electronics&min_price=100 ``` ### Path Parameters Path parameters are typically required by REST APIs. FastMCP: - Filters out `None` values - Validates that all required path parameters are provided - Raises clear errors for missing required parameters ```python # ✅ This works await client.call_tool("get_user", {"user_id": 123}) # ❌ This raises: "Missing required path parameters: {'user_id'}" await client.call_tool("get_user", {"user_id": None}) ``` ### Array Parameters FastMCP handles array parameters according to OpenAPI specifications: - **Query arrays**: Serialized based on the `explode` parameter (default: `True`) - **Path arrays**: Serialized as comma-separated values (OpenAPI 'simple' style) ```python # Query array with explode=true (default) # ?tags=red&tags=blue&tags=green # Query array with explode=false # ?tags=red,blue,green # Path array (always comma-separated) # /items/red,blue,green ``` ### Headers Header parameters are automatically converted to strings and included in the HTTP request. ================================================ FILE: docs/integrations/permit.mdx ================================================ --- title: Permit.io Authorization 🤝 FastMCP sidebarTitle: Permit.io description: Add fine-grained authorization to your FastMCP servers with Permit.io icon: shield-check --- Add **policy-based authorization** to your FastMCP servers with one-line code addition with the **[Permit.io][permit-github] authorization middleware**. Control which tools, resources and prompts MCP clients can view and execute on your server. Define dynamic policies using Permit.io's powerful RBAC, ABAC, and REBAC capabilities, and obtain comprehensive audit logs of all access attempts and violations. ## How it Works Leveraging FastMCP's [Middleware][fastmcp-middleware], the Permit.io middleware intercepts all MCP requests to your server and automatically maps MCP methods to authorization checks against your Permit.io policies; covering both server methods and tool execution. ### Policy Mapping The middleware automatically maps MCP methods to Permit.io resources and actions: - **MCP server methods** (e.g., `tools/list`, `resources/read`): - **Resource**: `{server_name}_{component}` (e.g., `myserver_tools`) - **Action**: The method verb (e.g., `list`, `read`) - **Tool execution** (method `tools/call`): - **Resource**: `{server_name}` (e.g., `myserver`) - **Action**: The tool name (e.g., `greet`) ![Permit.io Policy Mapping Example](./images/permit/policy_mapping.png) *Example: In Permit.io, the 'Admin' role is granted permissions on resources and actions as mapped by the middleware. For example, 'greet', 'greet-jwt', and 'login' are actions on the 'mcp_server' resource, and 'list' is an action on the 'mcp_server_tools' resource.* > **Note:** > Don't forget to assign the relevant role (e.g., Admin, User) to the user authenticating to your MCP server (such as the user in the JWT) in the Permit.io Directory. Without the correct role assignment, users will not have access to the resources and actions you've configured in your policies. > > ![Permit.io Directory Role Assignment Example](./images/permit/role_assignement.png) > > *Example: In Permit.io Directory, both 'client' and 'admin' users are assigned the 'Admin' role, granting them the permissions defined in your policy mapping.* For detailed policy mapping examples and configuration, see [Detailed Policy Mapping](https://github.com/permitio/permit-fastmcp/blob/main/docs/policy-mapping.md). ### Listing Operations The middleware behaves as a filter for listing operations (`tools/list`, `resources/list`, `prompts/list`), hiding to the client components that are not authorized by the defined policies. ```mermaid sequenceDiagram participant MCPClient as MCP Client participant PermitMiddleware as Permit.io Middleware participant MCPServer as FastMCP Server participant PermitPDP as Permit.io PDP MCPClient->>PermitMiddleware: MCP Listing Request (e.g., tools/list) PermitMiddleware->>MCPServer: MCP Listing Request MCPServer-->>PermitMiddleware: MCP Listing Response PermitMiddleware->>PermitPDP: Authorization Checks PermitPDP->>PermitMiddleware: Authorization Decisions PermitMiddleware-->>MCPClient: Filtered MCP Listing Response ``` ### Execution Operations The middleware behaves as an enforcement point for execution operations (`tools/call`, `resources/read`, `prompts/get`), blocking operations that are not authorized by the defined policies. ```mermaid sequenceDiagram participant MCPClient as MCP Client participant PermitMiddleware as Permit.io Middleware participant MCPServer as FastMCP Server participant PermitPDP as Permit.io PDP MCPClient->>PermitMiddleware: MCP Execution Request (e.g., tools/call) PermitMiddleware->>PermitPDP: Authorization Check PermitPDP->>PermitMiddleware: Authorization Decision PermitMiddleware-->>MCPClient: MCP Unauthorized Error (if denied) PermitMiddleware->>MCPServer: MCP Execution Request (if allowed) MCPServer-->>PermitMiddleware: MCP Execution Response (if allowed) PermitMiddleware-->>MCPClient: MCP Execution Response (if allowed) ``` ## Add Authorization to Your Server Permit.io is a cloud-native authorization service. You need a Permit.io account and a running Policy Decision Point (PDP) for the middleware to function. You can run the PDP locally with Docker or use Permit.io's cloud PDP. ### Prerequisites 1. **Permit.io Account**: Sign up at [permit.io](https://permit.io) 2. **PDP Setup**: Run the Permit.io PDP locally or use the cloud PDP (RBAC only) 3. **API Key**: Get your Permit.io API key from the dashboard ### Run the Permit.io PDP Run the PDP locally with Docker: ```bash docker run -p 7766:7766 permitio/pdp:latest ``` Or use the cloud PDP URL: `https://cloudpdp.api.permit.io` ### Create a Server with Authorization First, install the `permit-fastmcp` package: ```bash # Using UV (recommended) uv add permit-fastmcp # Using pip pip install permit-fastmcp ``` Then create a FastMCP server and add the Permit.io middleware: ```python server.py from fastmcp import FastMCP from permit_fastmcp.middleware.middleware import PermitMcpMiddleware mcp = FastMCP("Secure FastMCP Server 🔒") @mcp.tool def greet(name: str) -> str: """Greet a user by name""" return f"Hello, {name}!" @mcp.tool def add(a: int, b: int) -> int: """Add two numbers""" return a + b # Add Permit.io authorization middleware mcp.add_middleware(PermitMcpMiddleware( permit_pdp_url="http://localhost:7766", permit_api_key="your-permit-api-key" )) if __name__ == "__main__": mcp.run(transport="http") ``` ### Configure Access Policies Create your authorization policies in the Permit.io dashboard: 1. **Create Resources**: Define resources like `mcp_server` and `mcp_server_tools` 2. **Define Actions**: Add actions like `greet`, `add`, `list`, `read` 3. **Create Roles**: Define roles like `Admin`, `User`, `Guest` 4. **Assign Permissions**: Grant roles access to specific resources and actions 5. **Assign Users**: Assign roles to users in the Permit.io Directory For step-by-step setup instructions and troubleshooting, see [Getting Started & FAQ](https://github.com/permitio/permit-fastmcp/blob/main/docs/getting-started.md). #### Example Policy Configuration Policies are defined in the Permit.io dashboard, but you can also use the [Permit.io Terraform provider](https://github.com/permitio/terraform-provider-permitio) to define policies in code. ```terraform # Resources resource "permitio_resource" "mcp_server" { name = "mcp_server" key = "mcp_server" actions = { "greet" = { name = "greet" } "add" = { name = "add" } } } resource "permitio_resource" "mcp_server_tools" { name = "mcp_server_tools" key = "mcp_server_tools" actions = { "list" = { name = "list" } } } # Roles resource "permitio_role" "Admin" { key = "Admin" name = "Admin" permissions = [ "mcp_server:greet", "mcp_server:add", "mcp_server_tools:list" ] } ``` You can also use the [Permit.io CLI](https://github.com/permitio/permit-cli), [API](https://api.permit.io/scalar) or [SDKs](https://github.com/permitio/permit-python) to manage policies, as well as writing policies directly in REGO (Open Policy Agent's policy language). For complete policy examples including ABAC and RBAC configurations, see [Example Policies](https://github.com/permitio/permit-fastmcp/tree/main/docs/example_policies). ### Identity Management The middleware supports multiple identity extraction modes: - **Fixed Identity**: Use a fixed identity for all requests - **Header-based**: Extract identity from HTTP headers - **JWT-based**: Extract and verify JWT tokens - **Source-based**: Use the MCP context source field For detailed identity mode configuration and environment variables, see [Identity Modes & Environment Variables](https://github.com/permitio/permit-fastmcp/blob/main/docs/identity-modes.md). #### JWT Authentication Example ```python import os # Configure JWT identity extraction os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt" os.environ["PERMIT_MCP_IDENTITY_JWT_SECRET"] = "your-jwt-secret" mcp.add_middleware(PermitMcpMiddleware( permit_pdp_url="http://localhost:7766", permit_api_key="your-permit-api-key" )) ``` ### ABAC Policies with Tool Arguments The middleware supports Attribute-Based Access Control (ABAC) policies that can evaluate tool arguments as attributes. Tool arguments are automatically flattened as individual attributes (e.g., `arg_name`, `arg_number`) for granular policy conditions. ![ABAC Condition Example](./images/permit/abac_condition_example.png) *Example: Create dynamic resources with conditions like `resource.arg_number greater-than 10` to allow the `conditional-greet` tool only when the number argument exceeds 10.* #### Example: Conditional Access Create a dynamic resource with conditions like `resource.arg_number greater-than 10` to allow the `conditional-greet` tool only when the number argument exceeds 10. ```python @mcp.tool def conditional_greet(name: str, number: int) -> str: """Greet a user only if number > 10""" return f"Hello, {name}! Your number is {number}" ``` ![ABAC Policy Example](./images/permit/abac_policy_example.png) *Example: The Admin role is granted access to the "conditional-greet" action on the "Big-greets" dynamic resource, while other tools like "greet", "greet-jwt", and "login" are granted on the base "mcp_server" resource.* For comprehensive ABAC configuration and advanced policy examples, see [ABAC Policies with Tool Arguments](https://github.com/permitio/permit-fastmcp/blob/main/docs/policy-mapping.md#abac-policies-with-tool-arguments). ### Run the Server Start your FastMCP server normally: ```bash python server.py ``` The middleware will now intercept all MCP requests and check them against your Permit.io policies. Requests include user identification through the configured identity mode and automatic mapping of MCP methods to authorization resources and actions. ## Advanced Configuration ### Environment Variables Configure the middleware using environment variables: ```bash # Permit.io configuration export PERMIT_MCP_PERMIT_PDP_URL="http://localhost:7766" export PERMIT_MCP_PERMIT_API_KEY="your-api-key" # Identity configuration export PERMIT_MCP_IDENTITY_MODE="jwt" export PERMIT_MCP_IDENTITY_JWT_SECRET="your-jwt-secret" # Method configuration export PERMIT_MCP_KNOWN_METHODS='["tools/list","tools/call"]' export PERMIT_MCP_BYPASSED_METHODS='["initialize","ping"]' # Logging configuration export PERMIT_MCP_ENABLE_AUDIT_LOGGING="true" ``` For a complete list of all configuration options and environment variables, see [Configuration Reference](https://github.com/permitio/permit-fastmcp/blob/main/docs/configuration-reference.md). ### Custom Middleware Configuration ```python from permit_fastmcp.middleware.middleware import PermitMcpMiddleware middleware = PermitMcpMiddleware( permit_pdp_url="http://localhost:7766", permit_api_key="your-api-key", enable_audit_logging=True, bypass_methods=["initialize", "ping", "health/*"] ) mcp.add_middleware(middleware) ``` For advanced configuration options and custom middleware extensions, see [Advanced Configuration](https://github.com/permitio/permit-fastmcp/blob/main/docs/advanced-configuration.md). ## Example: Complete JWT Authentication Server See the [example server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/example.py) for a full implementation with JWT-based authentication. For additional examples and usage patterns, see [Example Server](https://github.com/permitio/permit-fastmcp/blob/main/permit_fastmcp/example_server/): ```python from fastmcp import FastMCP, Context from permit_fastmcp.middleware.middleware import PermitMcpMiddleware import jwt import datetime # Configure JWT identity extraction os.environ["PERMIT_MCP_IDENTITY_MODE"] = "jwt" os.environ["PERMIT_MCP_IDENTITY_JWT_SECRET"] = "mysecretkey" mcp = FastMCP("My MCP Server") @mcp.tool def login(username: str, password: str) -> str: """Login to get a JWT token""" if username == "admin" and password == "password": token = jwt.encode( {"sub": username, "exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1)}, "mysecretkey", algorithm="HS256" ) return f"Bearer {token}" raise Exception("Invalid credentials") @mcp.tool def greet_jwt(ctx: Context) -> str: """Greet a user by extracting their name from JWT""" # JWT extraction handled by middleware return "Hello, authenticated user!" mcp.add_middleware(PermitMcpMiddleware( permit_pdp_url="http://localhost:7766", permit_api_key="your-permit-api-key" )) if __name__ == "__main__": mcp.run(transport="http") ``` For detailed policy configuration, custom authentication, and advanced deployment patterns, visit the [Permit.io FastMCP Middleware repository][permit-fastmcp-github]. For troubleshooting common issues, see [Troubleshooting](https://github.com/permitio/permit-fastmcp/blob/main/docs/troubleshooting.md). [permit.io]: https://www.permit.io [permit-github]: https://github.com/permitio [permit-fastmcp-github]: https://github.com/permitio/permit-fastmcp [Agent.Security]: https://agent.security [fastmcp-middleware]: /servers/middleware ================================================ FILE: docs/integrations/propelauth.mdx ================================================ --- title: PropelAuth 🤝 FastMCP sidebarTitle: PropelAuth description: Secure your FastMCP server with PropelAuth icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx"; This guide shows you how to secure your FastMCP server using [**PropelAuth**](https://www.propelauth.com), a complete authentication and user management solution. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where PropelAuth handles user login, consent management, and your FastMCP server validates the tokens. ## Configuration ### Prerequisites Before you begin, you will need: 1. A [PropelAuth](https://www.propelauth.com) account 2. Your FastMCP server's base URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Configure PropelAuth Navigate to the **MCP** section in your PropelAuth dashboard, click **Enable MCP**, and choose which environments to enable it for (Test, Staging, Prod). Under **MCP > Allowed MCP Clients**, add redirect URIs for each MCP client you want to allow. PropelAuth provides templates for popular clients like Claude, Cursor, and ChatGPT. Under **MCP > Scopes**, define the permissions available to MCP clients (e.g., `read:user_data`). Under **MCP > Settings > How Do Users Create OAuth Clients?**, you can optionally enable: - **Dynamic Client Registration** — clients self-register automatically via the DCR protocol - **Manually via Hosted Pages** — PropelAuth creates a UI for your users to register OAuth clients You can enable neither, one, or both. If you enable neither, you'll manage OAuth client creation yourself. Go to **MCP > Request Validation** and click **Create Credentials**. Note the **Client ID** and **Client Secret** - you'll need these to validate tokens. Find your Auth URL in the **Backend Integration** section of the dashboard (e.g., `https://auth.yourdomain.com`). For more details, see the [PropelAuth MCP documentation](https://docs.propelauth.com/mcp-authentication/overview). ### Step 2: Environment Setup Create a `.env` file with your PropelAuth configuration: ```bash PROPELAUTH_AUTH_URL=https://auth.yourdomain.com # From Backend Integration page PROPELAUTH_INTROSPECTION_CLIENT_ID=your-client-id # From MCP > Request Validation PROPELAUTH_INTROSPECTION_CLIENT_SECRET=your-client-secret # From MCP > Request Validation SERVER_URL=http://localhost:8000 # Your server's base URL ``` ### Step 3: FastMCP Configuration Create your FastMCP server file and use the PropelAuthProvider to handle all the OAuth integration automatically: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider auth_provider = PropelAuthProvider( auth_url=os.environ["PROPELAUTH_AUTH_URL"], introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], base_url=os.environ["SERVER_URL"], required_scopes=["read:user_data"], # Optional scope enforcement ) mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth_provider) ``` ## Testing With your `.env` loaded, start the server: ```bash fastmcp run server.py --transport http --port 8000 ``` Then use a FastMCP client to verify authentication works: ```python from fastmcp import Client import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: assert await client.ping() if __name__ == "__main__": asyncio.run(main()) ``` ## Accessing User Information You can use `get_access_token()` inside your tools to identify the authenticated user: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider from fastmcp.server.dependencies import get_access_token auth = PropelAuthProvider( auth_url=os.environ["PROPELAUTH_AUTH_URL"], introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], base_url=os.environ["SERVER_URL"], required_scopes=["read:user_data"], ) mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) @mcp.tool def whoami() -> dict: """Return the authenticated user's ID.""" token = get_access_token() if token is None: return {"error": "Not authenticated"} user_id = token.claims.get("sub") return {"user_id": user_id} ``` ## Advanced Configuration The `PropelAuthProvider` supports optional overrides for token introspection behavior, including caching and request timeouts: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider auth = PropelAuthProvider( auth_url=os.environ["PROPELAUTH_AUTH_URL"], introspection_client_id=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_ID"], introspection_client_secret=os.environ["PROPELAUTH_INTROSPECTION_CLIENT_SECRET"], base_url=os.environ.get("BASE_URL", "https://your-server.com"), required_scopes=["read:user_data"], resource="https://your-server.com/mcp", # Restrict to tokens intended for this server (RFC 8707) token_introspection_overrides={ "cache_ttl_seconds": 300, # Cache introspection results for 5 minutes "max_cache_size": 1000, # Maximum cached tokens "timeout_seconds": 15, # HTTP request timeout }, ) mcp = FastMCP(name="My PropelAuth Protected Server", auth=auth) ``` ================================================ FILE: docs/integrations/scalekit.mdx ================================================ --- title: Scalekit 🤝 FastMCP sidebarTitle: Scalekit description: Secure your FastMCP server with Scalekit icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" Install auth stack to your FastMCP server with [Scalekit](https://scalekit.com) using the [Remote OAuth](/servers/auth/remote-oauth) pattern: Scalekit handles user authentication, and the MCP server validates issued tokens. ### Prerequisites Before you begin 1. Get a [Scalekit account](https://app.scalekit.com/) and grab your **Environment URL** from _Dashboard > Settings_ . 2. Have your FastMCP server's base URL ready (can be localhost for development, e.g., `http://localhost:8000/`) ### Step 1: Configure MCP server in Scalekit environment In your Scalekit dashboard: 1. Open the **MCP Servers** section, then select **Create new server** 2. Enter server details: a name, a resource identifier, and the desired MCP client authentication settings 3. Save, then copy the **Resource ID** (for example, res_92015146095) In your FastMCP project's `.env`: ```sh SCALEKIT_ENVIRONMENT_URL= SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 BASE_URL=http://localhost:8000/ # Optional: additional scopes tokens must have # SCALEKIT_REQUIRED_SCOPES=read,write ``` ### Step 2: Add auth to FastMCP server Create your FastMCP server file and use the ScalekitProvider to handle all the OAuth integration automatically: > **Warning:** The legacy `mcp_url` and `client_id` parameters are deprecated and will be removed in a future release. Use `base_url` instead of `mcp_url` and remove `client_id` from your configuration. ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.scalekit import ScalekitProvider # Discovers Scalekit endpoints and set up JWT token validation auth_provider = ScalekitProvider( environment_url=SCALEKIT_ENVIRONMENT_URL, # Scalekit environment URL resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID base_url=SERVER_URL, # Public MCP endpoint required_scopes=["read"], # Optional scope enforcement ) # Create FastMCP server with auth mcp = FastMCP(name="My Scalekit Protected Server", auth=auth_provider) @mcp.tool def auth_status() -> dict: """Show Scalekit authentication status.""" # Extract user claims from the JWT return { "message": "This tool requires authentication via Scalekit", "authenticated": True, "provider": "Scalekit" } ``` Set `required_scopes` when you need tokens to carry specific permissions. Leave it unset to allow any token issued for the resource. ## Testing ### Start the MCP server ```sh uv run python server.py ``` Use any MCP client (for example, mcp-inspector, Claude, VS Code, or Windsurf) to connect to the running serve. Verify that authentication succeeds and requests are authorized as expected. ## Production Configuration For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.scalekit import ScalekitProvider # Load configuration from environment variables auth = ScalekitProvider( environment_url=os.environ.get("SCALEKIT_ENVIRONMENT_URL"), resource_id=os.environ.get("SCALEKIT_RESOURCE_ID"), base_url=os.environ.get("BASE_URL", "https://your-server.com") ) mcp = FastMCP(name="My Scalekit Protected Server", auth=auth) @mcp.tool def protected_action() -> str: """A tool that requires authentication.""" return "Access granted via Scalekit!" ``` ## Capabilities Scalekit supports OAuth 2.1 with Dynamic Client Registration for MCP clients and enterprise SSO, and provides built‑in JWT validation and security controls. **OAuth 2.1/DCR**: clients self‑register, use PKCE, and work with the Remote OAuth pattern without pre‑provisioned credentials. **Validation and SSO**: tokens are verified (keys, RS256, issuer, audience, expiry), and SAML, OIDC, OAuth 2.0, ADFS, Azure AD, and Google Workspace are supported; use HTTPS in production and review auth logs as needed. ## Debugging Enable detailed logging to troubleshoot authentication issues: ```python import logging logging.basicConfig(level=logging.DEBUG) ``` ### Token inspection You can inspect JWT tokens in your tools to understand the user context: ```python from fastmcp.server.context import request_ctx import jwt @mcp.tool def inspect_token() -> dict: """Inspect the current JWT token claims.""" context = request_ctx.get() # Extract token from Authorization header if hasattr(context, 'request') and hasattr(context.request, 'headers'): auth_header = context.request.headers.get('authorization', '') if auth_header.startswith('Bearer '): token = auth_header[7:] # Decode without verification (already verified by provider) claims = jwt.decode(token, options={"verify_signature": False}) return claims return {"error": "No token found"} ``` ================================================ FILE: docs/integrations/supabase.mdx ================================================ --- title: Supabase 🤝 FastMCP sidebarTitle: Supabase description: Secure your FastMCP server with Supabase Auth icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" This guide shows you how to secure your FastMCP server using **Supabase Auth**. This integration uses the [**Remote OAuth**](/servers/auth/remote-oauth) pattern, where Supabase handles user authentication and your FastMCP server validates the tokens. Supabase Auth does not currently support [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators, so FastMCP cannot validate that tokens were issued for the specific resource server. ## Consent UI Requirement Supabase's OAuth Server delegates the user consent screen to your application. When an MCP client initiates authorization, Supabase authenticates the user and then redirects to your application at a configured callback URL (e.g., `https://your-app.com/oauth/callback?authorization_id=...`). Your application must host a page that calls Supabase's `approveAuthorization()` or `denyAuthorization()` APIs to complete the flow. `SupabaseProvider` handles the resource server side (token verification and metadata), but you are responsible for building and hosting the consent UI separately. See [Supabase's OAuth Server documentation](https://supabase.com/docs/guides/auth/oauth-server/getting-started) for details on implementing the authorization page. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[Supabase Account](https://supabase.com/)** with a project or a self-hosted **Supabase Auth** instance 2. **OAuth Server enabled** in your Supabase Dashboard (Authentication → OAuth Server) 3. **Dynamic Client Registration enabled** in the same settings 4. A **consent UI** hosted at your configured authorization path (see above) 5. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Enable Supabase OAuth Server In your Supabase Dashboard: 1. Go to **Authentication → OAuth Server** 2. Enable the **OAuth Server** 3. Set your **Site URL** to where your consent UI is hosted 4. Set the **Authorization Path** (e.g., `/oauth/callback`) 5. Enable **Allow Dynamic OAuth Apps** for MCP client registration ### Step 2: Get Supabase Project URL In your Supabase Dashboard: 1. Go to **Project Settings** 2. Copy your **Project URL** (e.g., `https://abc123.supabase.co`) ### Step 3: FastMCP Configuration Create your FastMCP server using the `SupabaseProvider`: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.supabase import SupabaseProvider auth = SupabaseProvider( project_url="https://abc123.supabase.co", base_url="http://localhost:8000", ) mcp = FastMCP("Supabase Protected Server", auth=auth) @mcp.tool def protected_tool(message: str) -> str: """This tool requires authentication.""" return f"Authenticated user says: {message}" if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` ### Testing with a Client Create a test client that authenticates with your Supabase-protected server: ```python client.py from fastmcp import Client import asyncio async def main(): async with Client("http://localhost:8000/mcp", auth="oauth") as client: print("Authenticated with Supabase!") result = await client.call_tool("protected_tool", {"message": "Hello!"}) print(result) if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to Supabase's authorization endpoint 2. After authenticating, Supabase redirects to your consent UI 3. After you approve, the client receives the token and can make authenticated requests ## Production Configuration For production deployments, load configuration from environment variables: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.supabase import SupabaseProvider auth = SupabaseProvider( project_url=os.environ["SUPABASE_PROJECT_URL"], base_url=os.environ.get("BASE_URL", "https://your-server.com"), ) mcp = FastMCP(name="Supabase Secured App", auth=auth) ``` ================================================ FILE: docs/integrations/workos.mdx ================================================ --- title: WorkOS 🤝 FastMCP sidebarTitle: WorkOS description: Authenticate FastMCP servers with WorkOS Connect icon: shield-check --- import { VersionBadge } from "/snippets/version-badge.mdx" Secure your FastMCP server with WorkOS Connect authentication. This integration uses the OAuth Proxy pattern to handle authentication through WorkOS Connect while maintaining compatibility with MCP clients. This guide covers WorkOS Connect applications. For Dynamic Client Registration (DCR) with AuthKit, see the [AuthKit integration](/integrations/authkit) instead. ## Configuration ### Prerequisites Before you begin, you will need: 1. A **[WorkOS Account](https://workos.com/)** with access to create OAuth Apps 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`) ### Step 1: Create a WorkOS OAuth App Create an OAuth App in your WorkOS dashboard to get the credentials needed for authentication: In your WorkOS dashboard: 1. Navigate to **Applications** 2. Click **Create Application** 3. Select **OAuth Application** 4. Name your application In your OAuth application settings: 1. Copy your **Client ID** (starts with `client_`) 2. Click **Generate Client Secret** and save it securely 3. Copy your **AuthKit Domain** (e.g., `https://your-app.authkit.app`) In the **Redirect URIs** section: - Add: `http://localhost:8000/auth/callback` (for development) - For production, add your server's public URL + `/auth/callback` The callback URL must match exactly. The default path is `/auth/callback`, but you can customize it using the `redirect_path` parameter. ### Step 2: FastMCP Configuration Create your FastMCP server using the `WorkOSProvider`: ```python server.py from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import WorkOSProvider # Configure WorkOS OAuth auth = WorkOSProvider( client_id="client_YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", authkit_domain="https://your-app.authkit.app", base_url="http://localhost:8000", required_scopes=["openid", "profile", "email"] ) mcp = FastMCP("WorkOS Protected Server", auth=auth) @mcp.tool def protected_tool(message: str) -> str: """This tool requires authentication.""" return f"Authenticated user says: {message}" if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` ## Testing ### Running the Server Start your FastMCP server with HTTP transport to enable OAuth flows: ```bash fastmcp run server.py --transport http --port 8000 ``` Your server is now running and protected by WorkOS OAuth authentication. ### Testing with a Client Create a test client that authenticates with your WorkOS-protected server: ```python client.py from fastmcp import Client import asyncio async def main(): # The client will automatically handle WorkOS OAuth async with Client("http://localhost:8000/mcp", auth="oauth") as client: # First-time connection will open WorkOS login in your browser print("✓ Authenticated with WorkOS!") # Test the protected tool result = await client.call_tool("protected_tool", {"message": "Hello!"}) print(result) if __name__ == "__main__": asyncio.run(main()) ``` When you run the client for the first time: 1. Your browser will open to WorkOS's authorization page 2. After you authorize the app, you'll be redirected back 3. The client receives the token and can make authenticated requests The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache. ## Production Configuration For production deployments with persistent token management across server restarts, configure `jwt_signing_key`, and `client_storage`: ```python server.py import os from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import WorkOSProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet # Production setup with encrypted persistent token storage auth = WorkOSProvider( client_id="client_YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", authkit_domain="https://your-app.authkit.app", base_url="https://your-production-domain.com", required_scopes=["openid", "profile", "email"], # Production token management jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore( host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"]) ), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) mcp = FastMCP(name="Production WorkOS App", auth=auth) ``` Parameters (`jwt_signing_key` and `client_storage`) work together to ensure tokens and client registrations survive server restarts. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. Store secrets in environment variables and use a persistent storage backend like Redis for distributed deployments. For complete details on these parameters, see the [OAuth Proxy documentation](/servers/auth/oauth-proxy#configuration-parameters). ## Configuration Options WorkOS OAuth application client ID WorkOS OAuth application client secret Your WorkOS AuthKit domain URL (e.g., `https://your-app.authkit.app`) Your FastMCP server's public URL OAuth scopes to request OAuth callback path API request timeout ================================================ FILE: docs/more/settings.mdx ================================================ --- title: Settings description: Configure FastMCP behavior through environment variables or a .env file. icon: gear --- FastMCP uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for configuration. Every setting is available as an environment variable with a `FASTMCP_` prefix. Settings are loaded from environment variables and from a `.env` file (see the [Tasks (Docket)](#tasks-docket) section for a caveat about nested settings in `.env` files). ```bash # Set via environment export FASTMCP_LOG_LEVEL=DEBUG export FASTMCP_PORT=3000 # Or use a .env file (loaded automatically) echo "FASTMCP_LOG_LEVEL=DEBUG" >> .env ``` You can change which `.env` file is loaded by setting the `FASTMCP_ENV_FILE` environment variable (defaults to `.env`). Because this controls which file is loaded, it must be set as an environment variable — it cannot be set inside a `.env` file itself. ## Logging | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_LOG_LEVEL` | `Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]` | `INFO` | Log level for FastMCP's own logging output. Case-insensitive. | | `FASTMCP_LOG_ENABLED` | `bool` | `true` | Enable or disable FastMCP logging entirely. | | `FASTMCP_CLIENT_LOG_LEVEL` | `Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]` | None | Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. | | `FASTMCP_ENABLE_RICH_LOGGING` | `bool` | `true` | Use rich formatting for log output. Set to `false` for plain Python logging. | | `FASTMCP_ENABLE_RICH_TRACEBACKS` | `bool` | `true` | Use rich tracebacks for errors. | | `FASTMCP_DEPRECATION_WARNINGS` | `bool` | `true` | Show deprecation warnings. | ## Transport & HTTP These control how the server listens when running with an HTTP transport. | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_TRANSPORT` | `Literal["stdio", "http", "sse", "streamable-http"]` | `stdio` | Default transport. | | `FASTMCP_HOST` | `str` | `127.0.0.1` | Host to bind to. | | `FASTMCP_PORT` | `int` | `8000` | Port to bind to. | | `FASTMCP_SSE_PATH` | `str` | `/sse` | Path for SSE endpoint. | | `FASTMCP_MESSAGE_PATH` | `str` | `/messages/` | Path for SSE message endpoint. | | `FASTMCP_STREAMABLE_HTTP_PATH` | `str` | `/mcp` | Path for Streamable HTTP endpoint. | | `FASTMCP_STATELESS_HTTP` | `bool` | `false` | Enable stateless HTTP mode (new transport per request). Useful for multi-worker deployments. | | `FASTMCP_JSON_RESPONSE` | `bool` | `false` | Use JSON responses instead of SSE for Streamable HTTP. | | `FASTMCP_DEBUG` | `bool` | `false` | Enable debug mode. | ## Error Handling | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_MASK_ERROR_DETAILS` | `bool` | `false` | Mask error details before sending to clients. When enabled, only messages from explicitly raised `ToolError`, `ResourceError`, or `PromptError` are included in responses. | | `FASTMCP_STRICT_INPUT_VALIDATION` | `bool` | `false` | Strictly validate tool inputs against the JSON schema. When disabled, compatible inputs are coerced (e.g., the string `"10"` becomes the integer `10`). | | `FASTMCP_MOUNTED_COMPONENTS_RAISE_ON_LOAD_ERROR` | `bool` | `false` | Raise errors when loading mounted components instead of logging warnings. | ## Client | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_CLIENT_INIT_TIMEOUT` | `float \| None` | None | Timeout in seconds for the client initialization handshake. Set to `0` or leave unset to disable. | | `FASTMCP_CLIENT_DISCONNECT_TIMEOUT` | `float` | `5` | Maximum time in seconds to wait for a clean disconnect before giving up. | | `FASTMCP_CLIENT_RAISE_FIRST_EXCEPTIONGROUP_ERROR` | `bool` | `true` | When an `ExceptionGroup` is raised, re-raise the first error directly instead of the group. Simplifies debugging but may mask secondary errors. | ## CLI & Display | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_SHOW_SERVER_BANNER` | `bool` | `true` | Show the server banner on startup. Also controllable via `--no-banner` or `server.run(show_banner=False)`. | | `FASTMCP_CHECK_FOR_UPDATES` | `Literal["stable", "prerelease", "off"]` | `stable` | Update checking on CLI startup. `stable` checks stable releases only, `prerelease` includes pre-releases, `off` disables checking. | ## Tasks (Docket) These configure the [Docket](https://github.com/prefecthq/docket) task queue used by [server tasks](/servers/tasks). All use the `FASTMCP_DOCKET_` prefix. When setting Docket values in a `.env` file, use a **double** underscore: `FASTMCP_DOCKET__URL` (not `FASTMCP_DOCKET_URL`). This is because `.env` values are resolved through the parent `Settings` class, which uses `__` as its nested delimiter. As regular environment variables (e.g., `export`), the single-underscore form `FASTMCP_DOCKET_URL` works fine. | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_DOCKET_NAME` | `str` | `fastmcp` | Queue name. Servers and workers sharing the same name and backend URL share a task queue. | | `FASTMCP_DOCKET_URL` | `str` | `memory://` | Backend URL. Use `memory://` for single-process or `redis://host:port/db` for distributed workers. | | `FASTMCP_DOCKET_WORKER_NAME` | `str \| None` | None | Worker name. Auto-generated if unset. | | `FASTMCP_DOCKET_CONCURRENCY` | `int` | `10` | Maximum concurrent tasks per worker. | | `FASTMCP_DOCKET_REDELIVERY_TIMEOUT` | `timedelta` | `300s` | If a worker doesn't complete a task within this time, it's redelivered to another worker. | | `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. | | `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. | ## Advanced | Environment Variable | Type | Default | Description | |---|---|---|---| | `FASTMCP_HOME` | `Path` | Platform default | Data directory for FastMCP. Defaults to the platform-specific user data directory. | | `FASTMCP_ENV_FILE` | `str` | `.env` | Path to the `.env` file to load settings from. Must be set as an environment variable (see above). | | `FASTMCP_SERVER_DEPENDENCIES` | `list[str]` | `[]` | Additional dependencies to install in the server environment. | | `FASTMCP_DECORATOR_MODE` | `Literal["function", "object"]` | `function` | Controls what `@tool`, `@resource`, and `@prompt` decorators return. `function` returns the original function (default); `object` returns component objects (deprecated, will be removed). | | `FASTMCP_TEST_MODE` | `bool` | `false` | Enable test mode. | ================================================ FILE: docs/patterns/cli.mdx ================================================ --- title: FastMCP CLI sidebarTitle: CLI description: Learn how to use the FastMCP command-line interface icon: terminal --- import { VersionBadge } from "/snippets/version-badge.mdx" FastMCP provides a command-line interface (CLI) that makes it easy to run, develop, and install your MCP servers. The CLI is automatically installed when you install FastMCP. ```bash fastmcp --help ``` ## Commands Overview | Command | Purpose | Dependency Management | | ------- | ------- | --------------------- | | `list` | List tools on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) | | `call` | Call a tool on any MCP server | **Supports:** URLs, local files, MCPConfig JSON, stdio commands. **Deps:** N/A (connects to existing servers) | | `run` | Run a FastMCP server directly | **Supports:** Local files, factory functions, URLs, fastmcp.json configs, MCP configs. **Deps:** Uses your local environment directly. With `--python`, `--with`, `--project`, or `--with-requirements`: Runs via `uv run` subprocess. With fastmcp.json: Automatically manages dependencies based on configuration | | `dev` | Run a server with the MCP Inspector for testing | **Supports:** Local files and fastmcp.json configs. **Deps:** Always runs via `uv run` subprocess (never uses your local environment); dependencies must be specified or available in a uv-managed project. With fastmcp.json: Uses configured dependencies | | `install` | Install a server in MCP client applications | **Supports:** Local files and fastmcp.json configs. **Deps:** Creates an isolated environment; dependencies must be explicitly specified with `--with` and/or `--with-editable`. With fastmcp.json: Uses configured dependencies | | `inspect` | Generate a JSON report about a FastMCP server | **Supports:** Local files and fastmcp.json configs. **Deps:** Uses your current environment; you are responsible for ensuring all dependencies are available | | `project prepare` | Create a persistent uv project from fastmcp.json environment config | **Supports:** fastmcp.json configs only. **Deps:** Creates a uv project directory with all dependencies pre-installed for reuse with `--project` flag | | `auth cimd` | Create and validate CIMD documents for OAuth authentication | N/A | | `version` | Display version information | N/A | ## `fastmcp list` List tools available on any MCP server. This works with remote URLs, local Python files, MCPConfig JSON files, and arbitrary stdio commands. Together with `fastmcp call`, these commands are especially useful for giving LLMs that don't have built-in MCP support access to MCP tools via shell commands. ```bash fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list mcp.json fastmcp list --command 'npx -y @modelcontextprotocol/server-github' ``` By default, the output shows each tool's signature and description. Use `--input-schema` or `--output-schema` to include full JSON schemas, or `--json` for machine-readable output. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) | | Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) | | Resources | `--resources` | Also list resources | | Prompts | `--prompts` | Also list prompts | | Input Schema | `--input-schema` | Show full input schemas | | Output Schema | `--output-schema` | Show full output schemas | | JSON | `--json` | Output as JSON | | Timeout | `--timeout` | Connection timeout in seconds | | Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable | ### Server Targets The `` argument accepts: 1. **URLs** — `http://` or `https://` endpoints. Uses Streamable HTTP by default; pass `--transport sse` for SSE servers. 2. **Python files** — `.py` files are run via `fastmcp run` automatically. 3. **MCPConfig JSON** — `.json` files with an `mcpServers` key are treated as multi-server configs. 4. **Stdio commands** — Use `--command` to connect to any MCP server via stdio (e.g. `npx`, `uvx`). ### Examples ```bash # List tools on a remote server fastmcp list http://localhost:8000/mcp # List tools from a local Python file fastmcp list server.py # Include full input schemas fastmcp list server.py --input-schema # Machine-readable JSON fastmcp list server.py --json # SSE server fastmcp list http://localhost:8000/mcp --transport sse # Stdio command fastmcp list --command 'npx -y @modelcontextprotocol/server-github' # Include resources and prompts fastmcp list server.py --resources --prompts ``` ## `fastmcp call` Call a tool on any MCP server. Arguments can be passed as `key=value` pairs, a single JSON object, or via `--input-json`. ```bash fastmcp call server.py greet name=World fastmcp call http://localhost:8000/mcp search query=hello limit=5 fastmcp call server.py create_item '{"name": "x", "tags": ["a", "b"]}' ``` Tool arguments are automatically coerced to the correct type based on the tool's input schema — string values like `limit=5` become integers when the schema expects one. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Command | `--command` | Connect to a stdio server command (e.g. `'npx -y @mcp/server'`) | | Transport | `--transport`, `-t` | Force transport type for URL targets (`http` or `sse`) | | Input JSON | `--input-json` | JSON string of tool arguments (merged with key=value args) | | JSON | `--json` | Output raw JSON result | | Timeout | `--timeout` | Connection timeout in seconds | | Auth | `--auth` | Auth method: `oauth` (default for HTTP), a bearer token, or `none` to disable | ### Argument Passing There are three ways to pass arguments: **Key=value pairs** are the simplest for flat arguments. Values are coerced using the tool's JSON schema (strings become ints, bools, etc.): ```bash fastmcp call server.py search query=hello limit=5 verbose=true ``` **A single JSON object** works when you have structured or nested arguments: ```bash fastmcp call server.py create_item '{"name": "Widget", "tags": ["new", "sale"]}' ``` **`--input-json`** provides a base dict that key=value pairs can override: ```bash fastmcp call server.py search --input-json '{"query": "hello", "limit": 5}' limit=10 ``` ### Examples ```bash # Call a tool with simple args fastmcp call server.py greet name=World # Call with JSON object fastmcp call server.py create '{"name": "x", "tags": ["a"]}' # Get JSON output for scripting fastmcp call server.py add a=3 b=4 --json # Call a tool on a remote server fastmcp call http://localhost:8000/mcp search query=hello # Call via stdio command fastmcp call --command 'npx -y @mcp/server' tool_name arg=value # Disable OAuth for HTTP targets fastmcp call http://localhost:8000/mcp search query=hello --auth none ``` If you call a tool that doesn't exist, FastMCP will suggest similar tool names. Use `fastmcp list` to see all available tools on a server. ## `fastmcp run` Run a FastMCP server directly or proxy a remote server. ```bash fastmcp run server.py ``` By default, this command runs the server directly in your current Python environment. You are responsible for ensuring all dependencies are available. When using `--python`, `--with`, `--project`, or `--with-requirements` options, it runs the server via `uv run` subprocess instead. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Transport | `--transport`, `-t` | Transport protocol to use (`stdio`, `http`, or `sse`) | | Host | `--host` | Host to bind to when using http transport (default: 127.0.0.1) | | Port | `--port`, `-p` | Port to bind to when using http transport (default: 8000) | | 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 | | Auto-Reload | `--reload` / `--no-reload` | Enable auto-reload on file changes (development mode) | | Reload Directories | `--reload-dir` | Directories to watch for changes (can be used multiple times) | | 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 | | Requirements File | `--with-requirements` | Requirements file to install dependencies from | ### Entrypoints 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 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) 6. **MCP configuration file**: `mcp.json` - runs servers defined in a standard MCP configuration file 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 entrypoint/factory and runs it with its own transport settings If you need setup code to run, use the **factory pattern** instead. #### Inferred Server Instance If you provide a path to a file, `fastmcp run` will load the file and look for a FastMCP server instance stored as a variable named `mcp`, `server`, or `app`. If no such object is found, it will raise an error. For example, if you have a file called `server.py` with the following content: ```python server.py from fastmcp import FastMCP mcp = FastMCP("MyServer") ``` You can run it with: ```bash fastmcp run server.py ``` #### 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 entrypoint: ```bash fastmcp run server.py:custom_name ``` For example, if you have a file called `server.py` with the following content: ```python from fastmcp import FastMCP my_server = FastMCP("CustomServer") @my_server.tool def hello() -> str: return "Hello from custom server!" ``` You can run it with: ```bash fastmcp run server.py:my_server ``` #### Factory Function 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 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: ```python from fastmcp import FastMCP async def create_server() -> FastMCP: mcp = FastMCP("MyServer") @mcp.tool def add(x: int, y: int) -> int: return x + y # Setup that runs with fastmcp run tool = await mcp.get_tool("add") tool.disable() return mcp ``` You can run it with: ```bash fastmcp run server.py:create_server ``` #### Remote Server Proxy FastMCP run can also start a local proxy server that connects to a remote server. This is useful when you want to run a remote server locally for testing or development purposes, or to use with a client that doesn't support direct connections to remote servers. To start a local proxy, you can use the following syntax: ```bash fastmcp run https://example.com/mcp ``` #### FastMCP Configuration FastMCP supports declarative configuration through `fastmcp.json` files. When you run `fastmcp run` without arguments, it automatically looks for a `fastmcp.json` file in the current directory: ```bash # Auto-detect fastmcp.json in current directory fastmcp run # Or explicitly specify a configuration file fastmcp run my-config.fastmcp.json ``` The configuration file handles dependencies, environment variables, and transport settings. Command-line arguments override configuration file values: ```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 FastMCP can also run servers defined in a standard MCP configuration file. This is useful when you want to run multiple servers from a single file, or when you want to use a client that doesn't support direct connections to remote servers. To run a MCP configuration file, you can use the following syntax: ```bash fastmcp run mcp.json ``` This will run all the servers defined in the file. ## `fastmcp dev` The `dev` command group contains development tools for MCP servers. ### `fastmcp dev inspector` Run a MCP server with the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) for testing. Auto-reload is enabled by default, so your server automatically restarts when you save changes to source files. ```bash fastmcp dev inspector server.py ``` This command always runs your server via `uv run` subprocess (never your local environment) to work with the MCP Inspector. Dependencies can be: - Specified using `--with` and/or `--with-editable` options - Defined in a `fastmcp.json` configuration file - Available in a uv-managed project When using `fastmcp.json`, the dev command automatically uses the configured dependencies. The `dev inspector` command is a shortcut for testing a server over STDIO only. When the Inspector launches, you may need to: 1. Select "STDIO" from the transport dropdown 2. Connect manually This command does not support HTTP testing. To test a server over Streamable HTTP or SSE: 1. Start your server manually with the appropriate transport using either the command line: ```bash fastmcp run server.py --transport http ``` or by setting the transport in your code: ```bash python server.py # Assuming your __main__ block sets Streamable HTTP transport ``` 2. Open the MCP Inspector separately and connect to your running server #### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Editable Package | `--with-editable`, `-e` | Directory containing pyproject.toml to install in editable mode | | Additional Packages | `--with` | Additional packages to install (can be used multiple times) | | Inspector Version | `--inspector-version` | Version of the MCP Inspector to use | | UI Port | `--ui-port` | Port for the MCP Inspector UI | | Server Port | `--server-port` | Port for the MCP Inspector Proxy server | | Auto-Reload | `--reload` / `--no-reload` | Enable/disable auto-reload on file changes (enabled by default) | | Reload Directories | `--reload-dir` | Directories to watch for changes (can be used multiple times) | | Python Version | `--python` | Python version to use (e.g., 3.10, 3.11) | | Project Directory | `--project` | Run the command within the given project directory | | Requirements File | `--with-requirements` | Requirements file to install dependencies from | #### Entrypoints The `dev inspector` command supports local FastMCP server files and configuration: 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) The `dev inspector` command **only supports local files and fastmcp.json** - no URLs, remote servers, or standard MCP configuration files. **Examples** ```bash # Run dev server with editable mode and additional packages fastmcp dev inspector server.py -e . --with pandas --with matplotlib # Run dev server with fastmcp.json configuration (auto-detects) fastmcp dev inspector # Run dev server with explicit fastmcp.json file fastmcp dev inspector dev.fastmcp.json # Run dev server with specific Python version fastmcp dev inspector server.py --python 3.11 # Run dev server with requirements file fastmcp dev inspector server.py --with-requirements requirements.txt # Run dev server within a specific project directory fastmcp dev inspector server.py --project /path/to/project ``` ## `fastmcp install` Install a MCP server in MCP client applications. FastMCP currently supports the following clients: - **Claude Code** - Installs via Claude Code's built-in MCP management system - **Claude Desktop** - Installs via direct configuration file modification - **Cursor** - Installs via deeplink that opens Cursor for user confirmation - **Gemini CLI** - Installs via Gemini CLI's built-in MCP management system - **Goose** - Installs via deeplink that opens Goose for user confirmation (uses `uvx`) - **MCP JSON** - Generates standard MCP JSON configuration for manual use - **Stdio** - Outputs the shell command to run a server over stdio transport ```bash fastmcp install claude-code server.py fastmcp install claude-desktop server.py fastmcp install cursor server.py fastmcp install gemini-cli server.py fastmcp install goose server.py fastmcp install mcp-json server.py fastmcp install stdio server.py ``` Note that for security reasons, MCP clients usually run every server in a completely isolated environment. Therefore, all dependencies must be explicitly specified using the `--with` and/or `--with-editable` options (following `uv` conventions) or by attaching them to your server in code via the `dependencies` parameter. You should not assume that the MCP server will have access to your local environment. **`uv` must be installed and available in your system PATH**. Both Claude Desktop and Cursor run in isolated environments and need `uv` to manage dependencies. On macOS, install `uv` globally with Homebrew for Claude Desktop compatibility: `brew install uv`. **Python Version Considerations**: The install commands now support the `--python` option to specify a Python version directly. You can also use `--project` to run within a specific project directory or `--with-requirements` to install dependencies from a requirements file. **FastMCP `install` commands focus on local server files with STDIO transport.** For remote servers running with HTTP or SSE transport, use your client's native configuration - FastMCP's value is simplifying the complex local setup with dependencies and `uv` commands. ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Server Name | `--server-name`, `-n` | Custom name for the server (defaults to server's name attribute or file name) | | Editable Package | `--with-editable`, `-e` | Directory containing pyproject.toml to install in editable mode | | Additional Packages | `--with` | Additional packages to install (can be used multiple times) | | Environment Variables | `--env` | Environment variables in KEY=VALUE format (can be used multiple times) | | Environment File | `--env-file`, `-f` | Load environment variables from a .env file | | Python Version | `--python` | Python version to use (e.g., 3.10, 3.11) | | Project Directory | `--project` | Run the command within the given project directory | | Requirements File | `--with-requirements` | Requirements file to install dependencies from | ### Entrypoints The `install` command supports local FastMCP server files and configuration: 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 Factory functions are particularly useful for install commands since they allow setup code to run that would otherwise be ignored when the MCP client runs your server. When using fastmcp.json, dependencies are automatically handled. The `install` command **only supports local files and fastmcp.json** - no URLs, remote servers, or standard MCP configuration files. For remote servers, use your MCP client's native configuration. **Examples** ```bash # Auto-detects server entrypoint (looks for 'mcp', 'server', or 'app') fastmcp install claude-desktop server.py # Install with fastmcp.json configuration (auto-detects) fastmcp install claude-desktop # Install with explicit fastmcp.json file fastmcp install claude-desktop my-config.fastmcp.json # Uses specific server entrypoint fastmcp install claude-desktop server.py:my_server # With custom name and dependencies fastmcp install claude-desktop server.py:my_server --server-name "My Analysis Server" --with pandas # Install in Claude Code with environment variables fastmcp install claude-code server.py --env API_KEY=secret --env DEBUG=true # Install in Cursor with environment variables fastmcp install cursor server.py --env API_KEY=secret --env DEBUG=true # Install with environment file fastmcp install cursor server.py --env-file .env # Install in Goose (uses uvx deeplink) fastmcp install goose server.py --with pandas # Install with specific Python version fastmcp install claude-desktop server.py --python 3.11 # Install with requirements file fastmcp install claude-code server.py --with-requirements requirements.txt # Install within a project directory fastmcp install cursor server.py --project /path/to/project # Generate MCP JSON configuration fastmcp install mcp-json server.py --name "My Server" --with pandas # Copy JSON configuration to clipboard fastmcp install mcp-json server.py --copy # Output the stdio command for running a server fastmcp install stdio server.py # Output the stdio command from a fastmcp.json (includes configured dependencies) fastmcp install stdio fastmcp.json # Copy the stdio command to clipboard fastmcp install stdio server.py --copy ``` ### MCP JSON Generation The `mcp-json` subcommand generates standard MCP JSON configuration that can be used with any MCP-compatible client. This is useful when: - Working with MCP clients not directly supported by FastMCP - Creating configuration for CI/CD environments - Sharing server configurations with others - Integration with custom tooling The generated JSON follows the standard MCP server configuration format used by Claude Desktop, VS Code, Cursor, and other MCP clients, with the server name as the root key: ```json { "server-name": { "command": "uv", "args": [ "run", "--with", "fastmcp", "fastmcp", "run", "/path/to/server.py" ], "env": { "API_KEY": "value" } } } ``` To use this configuration with your MCP client, you'll typically need to add it to the client's `mcpServers` object. Consult your client's documentation for any specific configuration requirements or formatting needs. **Options specific to mcp-json:** | Option | Flag | Description | | ------ | ---- | ----------- | | Copy to Clipboard | `--copy` | Copy configuration to clipboard instead of printing to stdout | ### Stdio Command The `stdio` subcommand outputs the shell command an MCP host uses to start your server over stdio transport. Use it when you need a ready-to-paste `uv run --with fastmcp fastmcp run ...` command for a tool or script without a dedicated install target. ```bash # Print the command to stdout fastmcp install stdio server.py # Output: uv run --with fastmcp fastmcp run /absolute/path/to/server.py ``` When you pass a `fastmcp.json`, FastMCP automatically includes dependencies from the configuration: ```bash fastmcp install stdio fastmcp.json # Output: uv run --with fastmcp --with pillow --with 'qrcode[pil]>=8.0' fastmcp run /absolute/path/to/qr_server.py ``` Use `--copy` to send the command directly to your clipboard: ```bash fastmcp install stdio server.py --copy # ✓ Command copied to clipboard ``` **Options specific to stdio:** | Option | Flag | Description | | ------ | ---- | ----------- | | Copy to Clipboard | `--copy` | Copy command to clipboard instead of printing to stdout | ## `fastmcp inspect` Inspect a FastMCP server to view summary information or generate a detailed JSON report. ```bash # Show text summary fastmcp inspect server.py # Output FastMCP JSON to stdout fastmcp inspect server.py --format fastmcp # Save MCP JSON to file (format required with -o) fastmcp inspect server.py --format mcp -o manifest.json ``` ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Format | `--format`, `-f` | Output format: `fastmcp` (FastMCP-specific) or `mcp` (MCP protocol). Required when using `-o` | | Output File | `--output`, `-o` | Save JSON report to file instead of stdout. Requires `--format` | ### Output Formats #### FastMCP Format (`--format fastmcp`) The default and most comprehensive format, includes all FastMCP-specific metadata: - Server name, instructions, and version - FastMCP version and MCP version - Tool tags and enabled status - Output schemas for tools - Annotations and custom metadata - Uses snake_case field names - **Use this for**: Complete server introspection and debugging FastMCP servers #### MCP Protocol Format (`--format mcp`) Shows exactly what MCP clients will see via the protocol: - Only includes standard MCP protocol fields - Matches output from `client.list_tools()`, `client.list_prompts()`, etc. - Uses camelCase field names (e.g., `inputSchema`) - Excludes FastMCP-specific fields like tags and enabled status - **Use this for**: Debugging client visibility and ensuring MCP compatibility ### Entrypoints The `inspect` command supports local FastMCP server files and configuration: 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 The `inspect` command **only supports local files and fastmcp.json** - no URLs, remote servers, or standard MCP configuration files. ### Examples ```bash # Show text summary (no JSON output) fastmcp inspect server.py # Output: # Server: MyServer # Instructions: A helpful MCP server # Version: 1.0.0 # # Components: # Tools: 5 # Prompts: 2 # Resources: 3 # Templates: 1 # # Environment: # FastMCP: 2.0.0 # MCP: 1.0.0 # # Use --format [fastmcp|mcp] for complete JSON output # Output FastMCP format to stdout fastmcp inspect server.py --format fastmcp # Specify server entrypoint fastmcp inspect server.py:my_server # Output MCP protocol format to stdout fastmcp inspect server.py --format mcp # Save to file (format required) fastmcp inspect server.py --format fastmcp -o server-manifest.json # Save MCP format with custom server object fastmcp inspect server.py:my_server --format mcp -o mcp-manifest.json # Error: format required with output file fastmcp inspect server.py -o output.json # Error: --format is required when using -o/--output ``` ## `fastmcp project prepare` Create a persistent uv project directory from a fastmcp.json file's environment configuration. This allows you to pre-install all dependencies once and reuse them with the `--project` flag. ```bash fastmcp project prepare fastmcp.json --output-dir ./env ``` ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Output Directory | `--output-dir` | **Required.** Directory where the persistent uv project will be created | ### Usage Pattern ```bash # Step 1: Prepare the environment (installs dependencies) fastmcp project prepare fastmcp.json --output-dir ./my-env # Step 2: Run using the prepared environment (fast, no dependency installation) fastmcp run fastmcp.json --project ./my-env ``` The prepare command creates a uv project with: - A `pyproject.toml` containing all dependencies from the fastmcp.json - A `.venv` with all packages pre-installed - A `uv.lock` file for reproducible environments This is useful when you want to separate environment setup from server execution, such as in deployment scenarios where dependencies are installed once and the server is run multiple times. ## `fastmcp auth` Authentication-related utilities and configuration commands. ### `fastmcp auth cimd create` Generate a CIMD (Client ID Metadata Document) for hosting. This creates a JSON document that you can host at an HTTPS URL to use as your OAuth client identity. ```bash fastmcp auth cimd create --name "My App" --redirect-uri "http://localhost:*/callback" ``` #### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Name | `--name` | **Required.** Human-readable name of the client application | | Redirect URI | `--redirect-uri` | **Required.** Allowed redirect URIs (can specify multiple) | | Client URI | `--client-uri` | URL of the client's home page | | Logo URI | `--logo-uri` | URL of the client's logo image | | Scope | `--scope` | Space-separated list of scopes the client may request | | Output | `--output`, `-o` | Output file path (default: stdout) | | Pretty | `--pretty` | Pretty-print JSON output (default: true) | #### Example ```bash # Generate document to stdout fastmcp auth cimd create \ --name "My Production App" \ --redirect-uri "http://localhost:*/callback" \ --redirect-uri "https://myapp.example.com/callback" \ --client-uri "https://myapp.example.com" \ --scope "read write" # Save to file fastmcp auth cimd create \ --name "My App" \ --redirect-uri "http://localhost:*/callback" \ --output client.json ``` The generated document includes a placeholder `client_id` that you must update to match the URL where you'll host the document before deploying. ### `fastmcp auth cimd validate` Validate a hosted CIMD document by fetching it from its URL and checking that it conforms to the CIMD specification. ```bash fastmcp auth cimd validate https://myapp.example.com/oauth/client.json ``` #### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Timeout | `--timeout`, `-t` | HTTP request timeout in seconds (default: 10) | The validator checks: - The URL is a valid CIMD URL (HTTPS with non-root path) - The document is valid JSON and conforms to the CIMD schema - The `client_id` field in the document matches the URL - No shared-secret authentication methods are used On success, it displays the document details: ``` → Fetching https://myapp.example.com/oauth/client.json... ✓ Valid CIMD document Document details: client_id: https://myapp.example.com/oauth/client.json client_name: My App token_endpoint_auth_method: none redirect_uris: • http://localhost:*/callback ``` ## `fastmcp version` Display version information about FastMCP and related components. ```bash fastmcp version ``` ### Options | Option | Flag | Description | | ------ | ---- | ----------- | | Copy to Clipboard | `--copy` | Copy version information to clipboard | ================================================ FILE: docs/patterns/contrib.mdx ================================================ --- title: "Contrib Modules" description: "Community-contributed modules extending FastMCP" icon: "cubes" --- import { VersionBadge } from "/snippets/version-badge.mdx" FastMCP includes a `contrib` package that holds community-contributed modules. These modules extend FastMCP's functionality but aren't officially maintained by the core team. Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. The available modules can be viewed in the [contrib directory](https://github.com/PrefectHQ/fastmcp/tree/main/src/fastmcp/contrib). ## Usage To use a contrib module, import it from the `fastmcp.contrib` package: ```python from fastmcp.contrib import my_module ``` ## Important Considerations - **Stability**: Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. - **Compatibility**: Changes to core FastMCP might break modules in `contrib` without explicit warnings in the main changelog. - **Dependencies**: Contrib modules may have additional dependencies not required by the core library. These dependencies are typically documented in the module's README or separate requirements files. ## Contributing We welcome contributions to the `contrib` package! If you have a module that extends FastMCP in a useful way, consider contributing it: 1. Create a new directory in `src/fastmcp/contrib/` for your module 3. Add proper tests for your module in `tests/contrib/` 2. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions 5. Submit a pull request The ideal contrib module: - Solves a specific use case or integration need - Follows FastMCP coding standards - Includes thorough documentation and examples - Has comprehensive tests - Specifies any additional dependencies ================================================ FILE: docs/patterns/testing.mdx ================================================ --- title: Testing your FastMCP Server sidebarTitle: Testing description: How to test your FastMCP server. icon: vial --- The best way to ensure a reliable and maintainable FastMCP Server is to test it! The FastMCP Client combined with Pytest provides a simple and powerful way to test your FastMCP servers. ## Prerequisites Testing FastMCP servers requires `pytest-asyncio` to handle async test functions and fixtures. Install it as a development dependency: ```bash pip install pytest-asyncio ``` We recommend configuring pytest to automatically handle async tests by setting the asyncio mode to `auto` in your `pyproject.toml`: ```toml [tool.pytest.ini_options] asyncio_mode = "auto" ``` This eliminates the need to decorate every async test with `@pytest.mark.asyncio`. ## Testing with Pytest Fixtures Using Pytest Fixtures, you can wrap your FastMCP Server in a Client instance that makes interacting with your server fast and easy. This is especially useful when building your own MCP Servers and enables a tight development loop by allowing you to avoid using a separate tool like MCP Inspector during development: ```python import pytest from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from my_project.main import mcp @pytest.fixture async def main_mcp_client(): async with Client(transport=mcp) as mcp_client: yield mcp_client async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): list_tools = await main_mcp_client.list_tools() assert len(list_tools) == 5 ``` We recommend the [inline-snapshot library](https://github.com/15r10nk/inline-snapshot) for asserting complex data structures coming from your MCP Server. This library allows you to write tests that are easy to read and understand, and are also easy to update when the data structure changes. ```python from inline_snapshot import snapshot async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): list_tools = await main_mcp_client.list_tools() assert list_tools == snapshot() ``` Simply run `pytest --inline-snapshot=fix,create` to fill in the `snapshot()` with actual data. For values that change you can leverage the [dirty-equals](https://github.com/samuelcolvin/dirty-equals) library to perform flexible equality assertions on dynamic or non-deterministic values. Using the pytest `parametrize` decorator, you can easily test your tools with a wide variety of inputs. ```python import pytest from my_project.main import mcp from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport @pytest.fixture async def main_mcp_client(): async with Client(mcp) as client: yield client @pytest.mark.parametrize( "first_number, second_number, expected", [ (1, 2, 3), (2, 3, 5), (3, 4, 7), ], ) async def test_add( first_number: int, second_number: int, expected: int, main_mcp_client: Client[FastMCPTransport], ): result = await main_mcp_client.call_tool( name="add", arguments={"x": first_number, "y": second_number} ) assert result.data is not None assert isinstance(result.data, int) assert result.data == expected ``` The [FastMCP Repository contains thousands of tests](https://github.com/PrefectHQ/fastmcp/tree/main/tests) for the FastMCP Client and Server. Everything from connecting to remote MCP servers, to testing tools, resources, and prompts is covered, take a look for inspiration! ================================================ FILE: docs/public/schemas/fastmcp.json/latest.json ================================================ { "$defs": { "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { "enum": [ "stdio", "http", "sse", "streamable-http" ], "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" }, "FileSystemSource": { "description": "Source for local Python files.", "properties": { "type": { "const": "filesystem", "default": "filesystem", "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" }, "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "type": { "const": "uv", "default": "uv", "title": "Type", "type": "string" }, "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": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to requirements.txt file", "examples": [ "requirements.txt", "../requirements/prod.txt" ], "title": "Requirements" }, "project": { "anyOf": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to project directory containing pyproject.toml", "examples": [ ".", "../my-project" ], "title": "Project" }, "editable": { "anyOf": [ { "items": { "format": "path", "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Directories to install in editable mode", "examples": [ [ ".", "../my-package" ], [ "/path/to/package" ] ], "title": "Editable" } }, "title": "UVEnvironment", "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/UVEnvironment", "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" } ================================================ FILE: docs/public/schemas/fastmcp.json/v1.json ================================================ { "$defs": { "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { "enum": [ "stdio", "http", "sse", "streamable-http" ], "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" }, "FileSystemSource": { "description": "Source for local Python files.", "properties": { "type": { "const": "filesystem", "default": "filesystem", "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" }, "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "type": { "const": "uv", "default": "uv", "title": "Type", "type": "string" }, "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": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to requirements.txt file", "examples": [ "requirements.txt", "../requirements/prod.txt" ], "title": "Requirements" }, "project": { "anyOf": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to project directory containing pyproject.toml", "examples": [ ".", "../my-project" ], "title": "Project" }, "editable": { "anyOf": [ { "items": { "format": "path", "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Directories to install in editable mode", "examples": [ [ ".", "../my-package" ], [ "/path/to/package" ] ], "title": "Editable" } }, "title": "UVEnvironment", "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/UVEnvironment", "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" } ================================================ FILE: docs/python-sdk/fastmcp-cli-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.cli` FastMCP CLI package. ================================================ FILE: docs/python-sdk/fastmcp-cli-apps_dev.mdx ================================================ --- title: apps_dev sidebarTitle: apps_dev --- # `fastmcp.cli.apps_dev` Dev server for previewing FastMCPApp UIs locally. Starts the user's MCP server on a configurable port, then starts a lightweight Starlette dev server that: - Serves a Prefab-based tool picker at GET / - Proxies /mcp to the user's server (avoids browser CORS restrictions) - Serves the AppBridge host page at GET /launch The host page uses @modelcontextprotocol/ext-apps to connect to the MCP server and render the selected UI tool inside an iframe. Startup sequence ---------------- 1. Download ext-apps app-bridge.js from npm and patch its bare ``@modelcontextprotocol/sdk/…`` imports to use concrete esm.sh URLs. 2. Detect the exact Zod v4 module URL that esm.sh serves for that SDK version and build an import-map entry that redirects the broken ``v4.mjs`` (which only re-exports ``{z, default}``) to ``v4/classic/index.mjs`` (which correctly exports every named Zod v4 function). Import maps apply to the full module graph in the document, including cross-origin esm.sh modules. 3. Serve both the patched JS and the import-map JSON from the dev server. ## Functions ### `run_dev_apps` ```python run_dev_apps(server_spec: str) -> None ``` Start the full dev environment for a FastMCPApp server. Starts the user's MCP server on *mcp_port*, starts the Prefab dev UI on *dev_port* (with an /mcp proxy to the user's server), then opens the browser. ================================================ FILE: docs/python-sdk/fastmcp-cli-auth.mdx ================================================ --- title: auth sidebarTitle: auth --- # `fastmcp.cli.auth` Authentication-related CLI commands. ================================================ FILE: docs/python-sdk/fastmcp-cli-cimd.mdx ================================================ --- title: cimd sidebarTitle: cimd --- # `fastmcp.cli.cimd` CIMD (Client ID Metadata Document) CLI commands. ## Functions ### `create_command` ```python create_command() -> None ``` Generate a CIMD document for hosting. Create a Client ID Metadata Document that you can host at an HTTPS URL. The URL where you host this document becomes your client_id. After creating the document, host it at an HTTPS URL with a non-root path, for example: https://myapp.example.com/oauth/client.json ### `validate_command` ```python validate_command(url: Annotated[str, cyclopts.Parameter(help='URL of the CIMD document to validate')]) -> None ``` Validate a hosted CIMD document. Fetches the document from the given URL and validates: - URL is valid CIMD URL (HTTPS, non-root path) - Document is valid JSON - Document conforms to CIMD schema - client_id in document matches the URL ================================================ FILE: docs/python-sdk/fastmcp-cli-cli.mdx ================================================ --- title: cli sidebarTitle: cli --- # `fastmcp.cli.cli` FastMCP CLI tools using Cyclopts. ## Functions ### `with_argv` ```python with_argv(args: list[str] | None) ``` Temporarily replace sys.argv if args provided. 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. Args are provided without the script name, so we preserve sys.argv[0] and replace the rest. ### `version` ```python version() ``` Display version information and platform details. ### `inspector` ```python inspector(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, or None to auto-detect fastmcp.json ### `apps` ```python apps(server_spec: str) -> None ``` Preview a FastMCPApp UI in the browser. Starts the MCP server from SERVER_SPEC on --mcp-port, launches a local dev UI on --dev-port with a tool picker and AppBridge host, then opens the browser automatically. Requires fastmcp[apps] to be installed (prefab-ui). ### `run` ```python 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 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 7. Module mode: "-m my_module" - runs the module directly via python -m Server arguments can be passed after -- : fastmcp run server.py -- --config config.json --debug **Args:** - `server_spec`: Python file, object specification (file\:obj), config file, URL, or None to auto-detect ### `inspect` ```python inspect(server_spec: str | None = None) -> None ``` Inspect an MCP server and display information or generate a JSON report. This command analyzes an MCP server. Without flags, it displays a text summary. Use --format to output complete JSON data. **Examples:** # Show text summary fastmcp inspect server.py # Output FastMCP format JSON to stdout fastmcp inspect server.py --format fastmcp # Save MCP protocol format to file (format required with -o) fastmcp inspect server.py --format mcp -o manifest.json # Inspect from fastmcp.json configuration fastmcp inspect fastmcp.json fastmcp inspect # auto-detect fastmcp.json **Args:** - `server_spec`: Python file to inspect, optionally with \:object suffix, or fastmcp.json ### `prepare` ```python prepare(config_path: Annotated[str | None, cyclopts.Parameter(help='Path to fastmcp.json configuration file')] = None, output_dir: Annotated[str | None, cyclopts.Parameter(help='Directory to create the persistent environment in')] = None, skip_source: Annotated[bool, cyclopts.Parameter(help='Skip source preparation (e.g., git clone)')] = False) -> None ``` Prepare a FastMCP project by creating a persistent uv environment. This command creates a persistent uv project with all dependencies installed: - Creates a pyproject.toml with dependencies from the config - Installs all Python packages into a .venv - Prepares the source (git clone, download, etc.) unless --skip-source After running this command, you can use: fastmcp run <config> --project <output-dir> This is useful for: - CI/CD pipelines with separate build and run stages - Docker images where you prepare during build - Production deployments where you want fast startup times ================================================ FILE: docs/python-sdk/fastmcp-cli-client.mdx ================================================ --- title: client sidebarTitle: client --- # `fastmcp.cli.client` Client-side CLI commands for querying and invoking MCP servers. ## Functions ### `resolve_server_spec` ```python resolve_server_spec(server_spec: str | None) -> str | dict[str, Any] | ClientTransport ``` Turn CLI inputs into something ``Client()`` accepts. Exactly one of ``server_spec`` or ``command`` should be provided. Resolution order for ``server_spec``: 1. URLs (``http://``, ``https://``) — passed through as-is. If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` so ``infer_transport`` picks the right transport. 2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. 3. Anything else — name-based resolution via ``resolve_name``. When ``command`` is provided, the string is shell-split into a ``StdioTransport(command, args)``. ### `coerce_value` ```python coerce_value(raw: str, schema: dict[str, Any]) -> Any ``` Coerce a string CLI value according to a JSON-Schema type hint. ### `parse_tool_arguments` ```python parse_tool_arguments(raw_args: tuple[str, ...], input_json: str | None, input_schema: dict[str, Any]) -> dict[str, Any] ``` Build a tool-call argument dict from CLI inputs. A single JSON object argument is treated as the full argument dict. ``--input-json`` provides the base dict; ``key=value`` pairs override. Values are coerced using the tool's ``inputSchema``. ### `format_tool_signature` ```python format_tool_signature(tool: mcp.types.Tool) -> str ``` Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas. ### `list_command` ```python list_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None) -> None ``` List tools available on an MCP server. **Examples:** fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list mcp.json --json fastmcp list --command 'npx -y @mcp/server' --resources fastmcp list http://server/mcp --transport sse ### `call_command` ```python call_command(server_spec: Annotated[str | None, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, or .js file')] = None, target: Annotated[str, cyclopts.Parameter(help='Tool name, resource URI, or prompt name (with --prompt)')] = '', *arguments: str) -> None ``` Call a tool, read a resource, or get a prompt on an MCP server. By default the target is treated as a tool name. If the target contains ``://`` it is treated as a resource URI. Pass ``--prompt`` to treat it as a prompt name. Arguments are passed as key=value pairs. Use --input-json for complex or nested arguments. **Examples:** ``` fastmcp call server.py greet name=World fastmcp call server.py resource://docs/readme fastmcp call server.py analyze --prompt data='[1,2,3]' fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' ``` ### `discover_command` ```python discover_command() -> None ``` Discover MCP servers configured in editor and project configs. Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and project-level mcp.json files for MCP server definitions. Discovered server names can be used directly with ``fastmcp list`` and ``fastmcp call`` instead of specifying a URL or file path. **Examples:** fastmcp discover fastmcp discover --source claude-code fastmcp discover --source cursor --source gemini --json fastmcp list weather fastmcp call cursor:weather get_forecast city=London ================================================ FILE: docs/python-sdk/fastmcp-cli-discovery.mdx ================================================ --- title: discovery sidebarTitle: discovery --- # `fastmcp.cli.discovery` Discover MCP servers configured in editor config files. Scans filesystem-readable config files from editors like Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level ``mcp.json`` files. Each discovered server can be resolved by name (or ``source:name``) so the CLI can connect without requiring a URL or file path. ## Functions ### `discover_servers` ```python discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer] ``` Run all scanners and return the combined results. Duplicate names across sources are preserved — callers can use :pyattr:`DiscoveredServer.qualified_name` to disambiguate. ### `resolve_name` ```python resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport ``` Resolve a server name (or ``source:name``) to a transport. Raises :class:`ValueError` when the name is not found or is ambiguous. ## Classes ### `DiscoveredServer` A single MCP server found in an editor or project config. **Methods:** #### `qualified_name` ```python qualified_name(self) -> str ``` Fully qualified ``source:name`` identifier. #### `transport_summary` ```python transport_summary(self) -> str ``` Human-readable one-liner describing the transport. ================================================ FILE: docs/python-sdk/fastmcp-cli-generate.mdx ================================================ --- title: generate sidebarTitle: generate --- # `fastmcp.cli.generate` Generate a standalone CLI script and agent skill from an MCP server. ## Functions ### `serialize_transport` ```python serialize_transport(resolved: str | dict[str, Any] | ClientTransport) -> tuple[str, set[str]] ``` Serialize a resolved transport to a Python expression string. Returns ``(expression, extra_imports)`` where *extra_imports* is a set of import lines needed by the expression. ### `generate_cli_script` ```python generate_cli_script(server_name: str, server_spec: str, transport_code: str, extra_imports: set[str], tools: list[mcp.types.Tool]) -> str ``` Generate the full CLI script source code. ### `generate_skill_content` ```python generate_skill_content(server_name: str, cli_filename: str, tools: list[mcp.types.Tool]) -> str ``` Generate a SKILL.md file for a generated CLI script. ### `generate_cli_command` ```python generate_cli_command(server_spec: Annotated[str, cyclopts.Parameter(help='Server URL, Python file, MCPConfig JSON, discovered name, or .js file')], output: Annotated[str, cyclopts.Parameter(help='Output file path (default: cli.py)')] = 'cli.py') -> None ``` Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes a Python script that can invoke them directly. Also generates a SKILL.md agent skill file unless --no-skill is passed. **Examples:** fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f fastmcp generate-cli weather --no-skill ================================================ FILE: docs/python-sdk/fastmcp-cli-install-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.cli.install` Install subcommands for FastMCP CLI using Cyclopts. ================================================ FILE: docs/python-sdk/fastmcp-cli-install-claude_code.mdx ================================================ --- title: claude_code sidebarTitle: claude_code --- # `fastmcp.cli.install.claude_code` Claude Code integration for FastMCP install using Cyclopts. ## Functions ### `find_claude_command` ```python find_claude_command() -> str | None ``` Find the Claude Code CLI command. Checks common installation locations since 'claude' is often a shell alias that doesn't work with subprocess calls. ### `check_claude_code_available` ```python check_claude_code_available() -> bool ``` Check if Claude Code CLI is available. ### `install_claude_code` ```python install_claude_code(file: Path, server_object: str | None, name: str) -> bool ``` Install FastMCP server in Claude Code. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Claude Code - `with_editable`: Optional list of directories 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 ### `claude_code_command` ```python claude_code_command(server_spec: str) -> None ``` Install an MCP server in Claude Code. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-claude_desktop.mdx ================================================ --- title: claude_desktop sidebarTitle: claude_desktop --- # `fastmcp.cli.install.claude_desktop` Claude Desktop integration for FastMCP install using Cyclopts. ## Functions ### `get_claude_config_path` ```python get_claude_config_path(config_path: Path | None = None) -> Path | None ``` Get the Claude config directory based on platform. **Args:** - `config_path`: Optional custom path to the Claude Desktop config directory ### `install_claude_desktop` ```python install_claude_desktop(file: Path, server_object: str | None, name: str) -> bool ``` Install FastMCP server in Claude Desktop. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Claude's config - `with_editable`: Optional list of directories 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 - `config_path`: Optional custom path to Claude Desktop config directory **Returns:** - True if installation was successful, False otherwise ### `claude_desktop_command` ```python claude_desktop_command(server_spec: str) -> None ``` Install an MCP server in Claude Desktop. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-cursor.mdx ================================================ --- title: cursor sidebarTitle: cursor --- # `fastmcp.cli.install.cursor` Cursor integration for FastMCP install using Cyclopts. ## Functions ### `generate_cursor_deeplink` ```python generate_cursor_deeplink(server_name: str, server_config: StdioMCPServer) -> str ``` Generate a Cursor deeplink for installing the MCP server. **Args:** - `server_name`: Name of the server - `server_config`: Server configuration **Returns:** - Deeplink URL that can be clicked to install the server ### `open_deeplink` ```python open_deeplink(deeplink: str) -> bool ``` Attempt to open a Cursor deeplink URL using the system's default handler. **Args:** - `deeplink`: The deeplink URL to open **Returns:** - True if the command succeeded, False otherwise ### `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 list of directories 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 ``` Install FastMCP server in Cursor. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Cursor - `with_editable`: Optional list of directories 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 - `workspace`: Optional workspace directory for project-specific installation **Returns:** - True if installation was successful, False otherwise ### `cursor_command` ```python cursor_command(server_spec: str) -> None ``` Install an MCP server in Cursor. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-gemini_cli.mdx ================================================ --- title: gemini_cli sidebarTitle: gemini_cli --- # `fastmcp.cli.install.gemini_cli` Gemini CLI integration for FastMCP install using Cyclopts. ## Functions ### `find_gemini_command` ```python find_gemini_command() -> str | None ``` Find the Gemini CLI command. ### `check_gemini_cli_available` ```python check_gemini_cli_available() -> bool ``` Check if Gemini CLI is available. ### `install_gemini_cli` ```python install_gemini_cli(file: Path, server_object: str | None, name: str) -> bool ``` Install FastMCP server in Gemini CLI. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in Gemini CLI - `with_editable`: Optional list of directories 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 ### `gemini_cli_command` ```python gemini_cli_command(server_spec: str) -> None ``` Install an MCP server in Gemini CLI. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-goose.mdx ================================================ --- title: goose sidebarTitle: goose --- # `fastmcp.cli.install.goose` Goose integration for FastMCP install using Cyclopts. ## Functions ### `generate_goose_deeplink` ```python generate_goose_deeplink(name: str, command: str, args: list[str]) -> str ``` Generate a Goose deeplink for installing an MCP extension. **Args:** - `name`: Human-readable display name for the extension. - `command`: The executable command (e.g. "uv"). - `args`: Arguments to the command. - `description`: Short description shown in Goose. **Returns:** - A goose://extension?... deeplink URL. ### `install_goose` ```python install_goose(file: Path, server_object: str | None, name: str) -> bool ``` Install FastMCP server in Goose via deeplink. **Args:** - `file`: Path to the server file. - `server_object`: Optional server object name (for \:object suffix). - `name`: Name for the extension in Goose. - `with_packages`: Optional list of additional packages to install. - `python_version`: Optional Python version to use. **Returns:** - True if installation was successful, False otherwise. ### `goose_command` ```python goose_command(server_spec: str) -> None ``` Install an MCP server in Goose. Uses uvx to run the server. Environment variables are not included in the deeplink; use `fastmcp install mcp-json` to generate a full config for manual installation. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-mcp_json.mdx ================================================ --- title: mcp_json sidebarTitle: mcp_json --- # `fastmcp.cli.install.mcp_json` MCP configuration JSON generation for FastMCP install using Cyclopts. ## Functions ### `install_mcp_json` ```python install_mcp_json(file: Path, server_object: str | None, name: str) -> bool ``` Generate MCP configuration JSON for manual installation. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `name`: Name for the server in MCP config - `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `env_vars`: Optional dictionary of environment variables - `copy`: If True, copy to clipboard instead of printing to stdout - `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 generation was successful, False otherwise ### `mcp_json_command` ```python mcp_json_command(server_spec: str) -> None ``` Generate MCP configuration JSON for manual installation. **Args:** - `server_spec`: Python file to install, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-install-shared.mdx ================================================ --- title: shared sidebarTitle: shared --- # `fastmcp.cli.install.shared` Shared utilities for install commands. ## Functions ### `validate_server_name` ```python validate_server_name(name: str) -> str ``` Validate that a server name is safe for use as a subprocess argument. Raises SystemExit if the name contains shell metacharacters. ### `parse_env_var` ```python parse_env_var(env_var: str) -> tuple[str, str] ``` Parse environment variable string in format KEY=VALUE. ### `process_common_args` ```python process_common_args(server_spec: str, server_name: str | None, with_packages: list[str] | None, env_vars: list[str] | None, env_file: Path | None) -> tuple[Path, str | None, str, list[str], dict[str, str] | None] ``` Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. ### `open_deeplink` ```python open_deeplink(url: str) -> bool ``` Attempt to open a deeplink URL using the system's default handler. **Args:** - `url`: The deeplink URL to open. - `expected_scheme`: The URL scheme to validate (e.g. "cursor", "goose"). **Returns:** - True if the command succeeded, False otherwise. ================================================ FILE: docs/python-sdk/fastmcp-cli-install-stdio.mdx ================================================ --- title: stdio sidebarTitle: stdio --- # `fastmcp.cli.install.stdio` Stdio command generation for FastMCP install using Cyclopts. ## Functions ### `install_stdio` ```python install_stdio(file: Path, server_object: str | None) -> bool ``` Generate the stdio command for running a FastMCP server. **Args:** - `file`: Path to the server file - `server_object`: Optional server object name (for \:object suffix) - `with_editable`: Optional list of directories to install in editable mode - `with_packages`: Optional list of additional packages to install - `copy`: If True, copy to clipboard instead of printing to stdout - `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 generation was successful, False otherwise ### `stdio_command` ```python stdio_command(server_spec: str) -> None ``` Generate the stdio command for running a FastMCP server. Outputs the shell command that an MCP host would use to start this server over stdio transport. Useful for manual configuration or debugging. **Args:** - `server_spec`: Python file to run, optionally with \:object suffix ================================================ FILE: docs/python-sdk/fastmcp-cli-run.mdx ================================================ --- title: run sidebarTitle: run --- # `fastmcp.cli.run` FastMCP run command implementation with enhanced type hints. ## Functions ### `is_url` ```python is_url(path: str) -> bool ``` Check if a string is a URL. ### `create_client_server` ```python create_client_server(url: str) -> Any ``` Create a FastMCP server from a client URL. **Args:** - `url`: The URL to connect to **Returns:** - A FastMCP server instance ### `create_mcp_config_server` ```python create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None] ``` Create a FastMCP server from a MCPConfig. ### `load_mcp_server_config` ```python load_mcp_server_config(config_path: Path) -> MCPServerConfig ``` Load a FastMCP configuration from a fastmcp.json file. **Args:** - `config_path`: Path to fastmcp.json file **Returns:** - MCPServerConfig object ### `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, skip_source: bool = False, stateless: bool = False) -> None ``` Run a MCP server or connect to a remote one. **Args:** - `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 - `path`: Path to bind to when using http transport - `log_level`: Log level - `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 - `stateless`: Whether to run in stateless mode (no session) ### `run_module_command` ```python run_module_command(module_name: str) -> None ``` Run a Python module directly using ``python -m ``. When ``-m`` is used, the module manages its own server startup. No server-object discovery or transport overrides are applied. **Args:** - `module_name`: Dotted module name (e.g. ``my_package``). - `env_command_builder`: An optional callable that wraps a command list with environment setup (e.g. ``UVEnvironment.build_command``). - `extra_args`: Extra arguments forwarded after the module name. ### `run_v1_server_async` ```python run_v1_server_async(server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None) -> None ``` Run a FastMCP 1.x server using async methods. **Args:** - `server`: FastMCP 1.x server instance - `host`: Host to bind to - `port`: Port to bind to - `transport`: Transport protocol to use ### `run_with_reload` ```python run_with_reload(cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False) -> None ``` Run a command with file watching and auto-reload. **Args:** - `cmd`: Command to run as subprocess (should include --no-reload) - `reload_dirs`: Directories to watch for changes (default\: cwd) - `is_stdio`: Whether this is stdio transport ================================================ FILE: docs/python-sdk/fastmcp-cli-tasks.mdx ================================================ --- title: tasks sidebarTitle: tasks --- # `fastmcp.cli.tasks` FastMCP tasks CLI for Docket task management. ## Functions ### `check_distributed_backend` ```python check_distributed_backend() -> None ``` Check if Docket is configured with a distributed backend. The CLI worker runs as a separate process, so it needs Redis/Valkey to coordinate with the main server process. **Raises:** - `SystemExit`: If using memory\:// URL ### `worker` ```python worker(server_spec: Annotated[str | None, cyclopts.Parameter(help='Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json')] = None) -> None ``` Start an additional worker to process background tasks. Connects to your Docket backend and processes tasks in parallel with any other running workers. Configure via environment variables (FASTMCP_DOCKET_*). ================================================ FILE: docs/python-sdk/fastmcp-client-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-client-auth-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client.auth` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-client-auth-bearer.mdx ================================================ --- title: bearer sidebarTitle: bearer --- # `fastmcp.client.auth.bearer` ## Classes ### `BearerAuth` **Methods:** #### `auth_flow` ```python auth_flow(self, request) ``` ================================================ FILE: docs/python-sdk/fastmcp-client-auth-oauth.mdx ================================================ --- title: oauth sidebarTitle: oauth --- # `fastmcp.client.auth.oauth` ## Functions ### `check_if_auth_required` ```python check_if_auth_required(mcp_url: str, httpx_kwargs: dict[str, Any] | None = None) -> bool ``` Check if the MCP endpoint requires authentication by making a test request. **Returns:** - True if auth appears to be required, False otherwise ## Classes ### `ClientNotFoundError` Raised when OAuth client credentials are not found on the server. ### `TokenStorageAdapter` **Methods:** #### `clear` ```python clear(self) -> None ``` #### `get_tokens` ```python get_tokens(self) -> OAuthToken | None ``` #### `set_tokens` ```python set_tokens(self, tokens: OAuthToken) -> None ``` #### `get_client_info` ```python get_client_info(self) -> OAuthClientInformationFull | None ``` #### `set_client_info` ```python set_client_info(self, client_info: OAuthClientInformationFull) -> None ``` ### `OAuth` OAuth client provider for MCP servers with browser-based authentication. This class provides OAuth authentication for FastMCP clients by opening a browser for user authorization and running a local callback server. **Methods:** #### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None ``` Open browser for authorization, with pre-flight check for invalid client. #### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] ``` Handle OAuth callback and return (auth_code, state). #### `async_auth_flow` ```python async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response] ``` HTTPX auth flow with automatic retry on stale cached credentials. If the OAuth flow fails due to invalid/stale client credentials, clears the cache and retries once with fresh registration. ================================================ FILE: docs/python-sdk/fastmcp-client-client.mdx ================================================ --- title: client sidebarTitle: client --- # `fastmcp.client.client` ## Classes ### `ClientSessionState` Holds all session-related state for a Client instance. This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). ### `CallToolResult` Parsed result from a tool call. ### `Client` MCP client that delegates connection management to a Transport instance. The Client class is responsible for MCP protocol logic, while the Transport handles connection establishment and management. Client provides methods for working with resources, prompts, tools and other MCP capabilities. This client supports reentrant context managers (multiple concurrent `async with client:` blocks) using reference counting and background session management. This allows efficient session reuse in any scenario with nested or concurrent client usage. MCP SDK 1.10 introduced automatic list_tools() calls during call_tool() execution. This created a race condition where events could be reset while other tasks were waiting on them, causing deadlocks. The issue was exposed in proxy scenarios but affects any reentrant usage. The solution uses reference counting to track active context managers, a background task to manage the session lifecycle, events to coordinate between tasks, and ensures all session state changes happen within a lock. Events are only created when needed, never reset outside locks. This design prevents race conditions where tasks wait on events that get replaced by other tasks, ensuring reliable coordination in concurrent scenarios. **Args:** - `transport`: Connection source specification, which can be\: - ClientTransport\: Direct transport instance - FastMCP\: In-process FastMCP server - AnyUrl or str\: URL to connect to - Path\: File path for local socket - MCPConfig\: MCP server configuration - dict\: Transport configuration - `roots`: Optional RootsList or RootsHandler for filesystem access - `sampling_handler`: Optional handler for sampling requests - `log_handler`: Optional handler for log messages - `message_handler`: Optional handler for protocol messages - `progress_handler`: Optional handler for progress notifications - `timeout`: Optional timeout for requests (seconds or timedelta) - `init_timeout`: Optional timeout for initial connection (seconds or timedelta). Set to 0 to disable. If None, uses the value in the FastMCP global settings. **Examples:** ```python # Connect to FastMCP server client = Client("http://localhost:8080") async with client: # List available resources resources = await client.list_resources() # Call a tool result = await client.call_tool("my_tool", {"param": "value"}) ``` **Methods:** #### `session` ```python session(self) -> ClientSession ``` Get the current active session. Raises RuntimeError if not connected. #### `initialize_result` ```python initialize_result(self) -> mcp.types.InitializeResult | None ``` Get the result of the initialization request. #### `set_roots` ```python 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` ```python set_sampling_callback(self, sampling_callback: SamplingHandler, sampling_capabilities: mcp.types.SamplingCapability | None = None) -> None ``` Set the sampling callback for the client. #### `set_elicitation_callback` ```python set_elicitation_callback(self, elicitation_callback: ElicitationHandler) -> None ``` Set the elicitation callback for the client. #### `is_connected` ```python is_connected(self) -> bool ``` Check if the client is currently connected. #### `new` ```python new(self) -> Client[ClientTransportT] ``` Create a new client instance with the same configuration but fresh session state. This creates a new client with the same transport, handlers, and configuration, but with no active session. Useful for creating independent sessions that don't share state with the original client. **Returns:** - A new Client instance with the same configuration but disconnected state. #### `initialize` ```python initialize(self, timeout: datetime.timedelta | float | int | None = None) -> mcp.types.InitializeResult ``` Send an initialize request to the server. This method performs the MCP initialization handshake with the server, exchanging capabilities and server information. It is idempotent - calling it multiple times returns the cached result from the first call. The initialization happens automatically when entering the client context manager unless `auto_initialize=False` was set during client construction. Manual calls to this method are only needed when auto-initialization is disabled. **Args:** - `timeout`: Optional timeout for the initialization request (seconds or timedelta). If None, uses the client's init_timeout setting. **Returns:** - The server's initialization response containing server info, capabilities, protocol version, and optional instructions. **Raises:** - `RuntimeError`: If the client is not connected or initialization times out. #### `close` ```python close(self) ``` #### `ping` ```python ping(self) -> bool ``` Send a ping request. #### `cancel` ```python cancel(self, request_id: str | int, reason: str | None = None) -> None ``` Send a cancellation notification for an in-progress request. #### `progress` ```python progress(self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None) -> None ``` Send a progress notification. #### `set_logging_level` ```python set_logging_level(self, level: mcp.types.LoggingLevel) -> None ``` Send a logging/setLevel request. #### `send_roots_list_changed` ```python send_roots_list_changed(self) -> None ``` Send a roots/list_changed notification. #### `complete_mcp` ```python complete_mcp(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.CompleteResult ``` Send a completion request and return the complete MCP protocol result. **Args:** - `ref`: The reference to complete. - `argument`: Arguments to pass to the completion request. - `context_arguments`: Optional context arguments to include with the completion request. Defaults to None. **Returns:** - mcp.types.CompleteResult: The complete response object from the protocol, containing the completion and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `complete` ```python complete(self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None) -> mcp.types.Completion ``` Send a completion request to the server. **Args:** - `ref`: The reference to complete. - `argument`: Arguments to pass to the completion request. - `context_arguments`: Optional context arguments to include with the completion request. Defaults to None. **Returns:** - mcp.types.Completion: The completion object. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` ================================================ FILE: docs/python-sdk/fastmcp-client-elicitation.mdx ================================================ --- title: elicitation sidebarTitle: elicitation --- # `fastmcp.client.elicitation` ## Functions ### `create_elicitation_callback` ```python create_elicitation_callback(elicitation_handler: ElicitationHandler) -> ElicitationFnT ``` ## Classes ### `ElicitResult` ================================================ FILE: docs/python-sdk/fastmcp-client-logging.mdx ================================================ --- title: logging sidebarTitle: logging --- # `fastmcp.client.logging` ## Functions ### `default_log_handler` ```python default_log_handler(message: LogMessage) -> None ``` Default handler that properly routes server log messages to appropriate log levels. ### `create_log_callback` ```python create_log_callback(handler: LogHandler | None = None) -> LoggingFnT ``` ================================================ FILE: docs/python-sdk/fastmcp-client-messages.mdx ================================================ --- title: messages sidebarTitle: messages --- # `fastmcp.client.messages` ## Classes ### `MessageHandler` This class is used to handle MCP messages sent to the client. It is used to handle all messages, requests, notifications, and exceptions. Users can override any of the hooks **Methods:** #### `dispatch` ```python dispatch(self, message: Message) -> None ``` #### `on_message` ```python on_message(self, message: Message) -> None ``` #### `on_request` ```python on_request(self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult]) -> None ``` #### `on_ping` ```python on_ping(self, message: mcp.types.PingRequest) -> None ``` #### `on_list_roots` ```python on_list_roots(self, message: mcp.types.ListRootsRequest) -> None ``` #### `on_create_message` ```python on_create_message(self, message: mcp.types.CreateMessageRequest) -> None ``` #### `on_notification` ```python on_notification(self, message: mcp.types.ServerNotification) -> None ``` #### `on_exception` ```python on_exception(self, message: Exception) -> None ``` #### `on_progress` ```python on_progress(self, message: mcp.types.ProgressNotification) -> None ``` #### `on_logging_message` ```python on_logging_message(self, message: mcp.types.LoggingMessageNotification) -> None ``` #### `on_tool_list_changed` ```python on_tool_list_changed(self, message: mcp.types.ToolListChangedNotification) -> None ``` #### `on_resource_list_changed` ```python on_resource_list_changed(self, message: mcp.types.ResourceListChangedNotification) -> None ``` #### `on_prompt_list_changed` ```python on_prompt_list_changed(self, message: mcp.types.PromptListChangedNotification) -> None ``` #### `on_resource_updated` ```python on_resource_updated(self, message: mcp.types.ResourceUpdatedNotification) -> None ``` #### `on_cancelled` ```python on_cancelled(self, message: mcp.types.CancelledNotification) -> None ``` ================================================ FILE: docs/python-sdk/fastmcp-client-mixins-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client.mixins` Client mixins for FastMCP. ================================================ FILE: docs/python-sdk/fastmcp-client-mixins-prompts.mdx ================================================ --- title: prompts sidebarTitle: prompts --- # `fastmcp.client.mixins.prompts` Prompt-related methods for FastMCP Client. ## Classes ### `ClientPromptsMixin` Mixin providing prompt-related methods for Client. **Methods:** #### `list_prompts_mcp` ```python list_prompts_mcp(self: Client) -> mcp.types.ListPromptsResult ``` Send a prompts/list request and return the complete MCP protocol result. **Args:** - `cursor`: Optional pagination cursor from a previous request's nextCursor. **Returns:** - mcp.types.ListPromptsResult: The complete response object from the protocol, containing the list of prompts and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_prompts` ```python list_prompts(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Prompt] ``` Retrieve all prompts available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_prompts_mcp() with the cursor parameter. **Args:** - `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. **Returns:** - list\[mcp.types.Prompt]: A list of all Prompt objects. **Raises:** - `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `get_prompt_mcp` ```python get_prompt_mcp(self: Client, name: str, arguments: dict[str, Any] | None = None, meta: dict[str, Any] | None = None) -> mcp.types.GetPromptResult ``` Send a prompts/get request and return the complete MCP protocol result. **Args:** - `name`: The name of the prompt to retrieve. - `arguments`: Arguments to pass to the prompt. Defaults to None. - `meta`: Request metadata (e.g., for SEP-1686 tasks). Defaults to None. **Returns:** - mcp.types.GetPromptResult: The complete response object from the protocol, containing the prompt messages and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult ``` #### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> PromptTask ``` #### `get_prompt` ```python get_prompt(self: Client, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.GetPromptResult | PromptTask ``` Retrieve a rendered prompt message list from the server. **Args:** - `name`: The name of the prompt to retrieve. - `arguments`: Arguments to pass to the prompt. Defaults to None. - `version`: Specific prompt version to get. If None, gets highest version. - `meta`: Optional request-level metadata. - `task`: If True, execute as background task (SEP-1686). Defaults to False. - `task_id`: Optional client-provided task ID (auto-generated if not provided). - `ttl`: Time to keep results available in milliseconds (default 60s). **Returns:** - mcp.types.GetPromptResult | PromptTask: The complete response object if task=False, or a PromptTask object if task=True. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError ================================================ FILE: docs/python-sdk/fastmcp-client-mixins-resources.mdx ================================================ --- title: resources sidebarTitle: resources --- # `fastmcp.client.mixins.resources` Resource-related methods for FastMCP Client. ## Classes ### `ClientResourcesMixin` Mixin providing resource-related methods for Client. **Methods:** #### `list_resources_mcp` ```python list_resources_mcp(self: Client) -> mcp.types.ListResourcesResult ``` Send a resources/list request and return the complete MCP protocol result. **Args:** - `cursor`: Optional pagination cursor from a previous request's nextCursor. **Returns:** - mcp.types.ListResourcesResult: The complete response object from the protocol, containing the list of resources and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_resources` ```python list_resources(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Resource] ``` Retrieve all resources available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resources_mcp() with the cursor parameter. **Args:** - `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. **Returns:** - list\[mcp.types.Resource]: A list of all Resource objects. **Raises:** - `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_resource_templates_mcp` ```python list_resource_templates_mcp(self: Client) -> mcp.types.ListResourceTemplatesResult ``` Send a resources/listResourceTemplates request and return the complete MCP protocol result. **Args:** - `cursor`: Optional pagination cursor from a previous request's nextCursor. **Returns:** - mcp.types.ListResourceTemplatesResult: The complete response object from the protocol, containing the list of resource templates and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_resource_templates` ```python list_resource_templates(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.ResourceTemplate] ``` Retrieve all resource templates available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resource_templates_mcp() with the cursor parameter. **Args:** - `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. **Returns:** - list\[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects. **Raises:** - `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `read_resource_mcp` ```python read_resource_mcp(self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None) -> mcp.types.ReadResourceResult ``` Send a resources/read request and return the complete MCP protocol result. **Args:** - `uri`: The URI of the resource to read. Can be a string or an AnyUrl object. - `meta`: Request metadata (e.g., for SEP-1686 tasks). Defaults to None. **Returns:** - mcp.types.ReadResourceResult: The complete response object from the protocol, containing the resource contents and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] ``` #### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> ResourceTask ``` #### `read_resource` ```python read_resource(self: Client, uri: AnyUrl | str) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask ``` Read the contents of a resource or resolved template. **Args:** - `uri`: The URI of the resource to read. Can be a string or an AnyUrl object. - `version`: Specific version to read. If None, reads highest version. - `meta`: Optional request-level metadata. - `task`: If True, execute as background task (SEP-1686). Defaults to False. - `task_id`: Optional client-provided task ID (auto-generated if not provided). - `ttl`: Time to keep results available in milliseconds (default 60s). **Returns:** - list\[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask: A list of content objects if task=False, or a ResourceTask object if task=True. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError ================================================ FILE: docs/python-sdk/fastmcp-client-mixins-task_management.mdx ================================================ --- title: task_management sidebarTitle: task_management --- # `fastmcp.client.mixins.task_management` Task management methods for FastMCP Client. ## Classes ### `ClientTaskManagementMixin` Mixin providing task management methods for Client. **Methods:** #### `get_task_status` ```python get_task_status(self: Client, task_id: str) -> GetTaskResult ``` Query the status of a background task. Sends a 'tasks/get' MCP protocol request over the existing transport. **Args:** - `task_id`: The task ID returned from call_tool_as_task **Returns:** - Status information including taskId, status, pollInterval, etc. **Raises:** - `RuntimeError`: If client not connected - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `get_task_result` ```python get_task_result(self: Client, task_id: str) -> Any ``` Retrieve the raw result of a completed background task. Sends a 'tasks/result' MCP protocol request over the existing transport. Returns the raw result - callers should parse it appropriately. **Args:** - `task_id`: The task ID returned from call_tool_as_task **Returns:** - The raw result (could be tool, prompt, or resource result) **Raises:** - `RuntimeError`: If client not connected, task not found, or task failed - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_tasks` ```python list_tasks(self: Client, cursor: str | None = None, limit: int = 50) -> dict[str, Any] ``` List background tasks. Sends a 'tasks/list' MCP protocol request to the server. If the server returns an empty list (indicating client-side tracking), falls back to querying status for locally tracked task IDs. **Args:** - `cursor`: Optional pagination cursor - `limit`: Maximum number of tasks to return (default 50) **Returns:** - Response with structure: - tasks: List of task status dicts with taskId, status, etc. - nextCursor: Optional cursor for next page **Raises:** - `RuntimeError`: If client not connected - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `cancel_task` ```python cancel_task(self: Client, task_id: str) -> mcp.types.CancelTaskResult ``` Cancel a task, transitioning it to cancelled state. Sends a 'tasks/cancel' MCP protocol request. Task will halt execution and transition to cancelled state. **Args:** - `task_id`: The task ID to cancel **Returns:** - The task status showing cancelled state **Raises:** - `RuntimeError`: If task doesn't exist - `McpError`: If the request results in a TimeoutError | JSONRPCError ================================================ FILE: docs/python-sdk/fastmcp-client-mixins-tools.mdx ================================================ --- title: tools sidebarTitle: tools --- # `fastmcp.client.mixins.tools` Tool-related methods for FastMCP Client. ## Classes ### `ClientToolsMixin` Mixin providing tool-related methods for Client. **Methods:** #### `list_tools_mcp` ```python list_tools_mcp(self: Client) -> mcp.types.ListToolsResult ``` Send a tools/list request and return the complete MCP protocol result. **Args:** - `cursor`: Optional pagination cursor from a previous request's nextCursor. **Returns:** - mcp.types.ListToolsResult: The complete response object from the protocol, containing the list of tools and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `list_tools` ```python list_tools(self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES) -> list[mcp.types.Tool] ``` Retrieve all tools available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_tools_mcp() with the cursor parameter. **Args:** - `max_pages`: Maximum number of pages to fetch before raising. Defaults to 250. **Returns:** - list\[mcp.types.Tool]: A list of all Tool objects. **Raises:** - `RuntimeError`: If the page limit is reached before pagination completes. - `McpError`: If the request results in a TimeoutError | JSONRPCError #### `call_tool_mcp` ```python call_tool_mcp(self: Client, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None) -> mcp.types.CallToolResult ``` Send a tools/call request and return the complete MCP protocol result. This method returns the raw CallToolResult object, which includes an isError flag and other metadata. It does not raise an exception if the tool call results in an error. **Args:** - `name`: The name of the tool to call. - `arguments`: Arguments to pass to the tool. - `timeout`: The timeout for the tool call. Defaults to None. - `progress_handler`: The progress handler to use for the tool call. Defaults to None. - `meta`: Additional metadata to include with the request. This is useful for passing contextual information (like user IDs, trace IDs, or preferences) that shouldn't be tool arguments but may influence server-side processing. The server can access this via `context.request_context.meta`. Defaults to None. **Returns:** - mcp.types.CallToolResult: The complete response object from the protocol, containing the tool result and any additional metadata. **Raises:** - `RuntimeError`: If called while the client is not connected. - `McpError`: If the tool call requests results in a TimeoutError | JSONRPCError #### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult ``` #### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> ToolTask ``` #### `call_tool` ```python call_tool(self: Client, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult | ToolTask ``` Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. **Args:** - `name`: The name of the tool to call. - `arguments`: Arguments to pass to the tool. Defaults to None. - `version`: Specific tool version to call. If None, calls highest version. - `timeout`: The timeout for the tool call. Defaults to None. - `progress_handler`: The progress handler to use for the tool call. Defaults to None. - `raise_on_error`: Whether to raise an exception if the tool call results in an error. Defaults to True. - `meta`: Additional metadata to include with the request. This is useful for passing contextual information (like user IDs, trace IDs, or preferences) that shouldn't be tool arguments but may influence server-side processing. The server can access this via `context.request_context.meta`. Defaults to None. - `task`: If True, execute as background task (SEP-1686). Defaults to False. - `task_id`: Optional client-provided task ID (auto-generated if not provided). - `ttl`: Time to keep results available in milliseconds (default 60s). **Returns:** - CallToolResult | ToolTask: The content returned by the tool if task=False, or a ToolTask object if task=True. If the tool returns structured outputs, they are returned as a dataclass (if an output schema is available) or a dictionary; otherwise, a list of content blocks is returned. Note: to receive both structured and unstructured outputs, use call_tool_mcp instead and access the raw result object. **Raises:** - `ToolError`: If the tool call results in an error. - `McpError`: If the tool call request results in a TimeoutError | JSONRPCError - `RuntimeError`: If called while the client is not connected. ================================================ FILE: docs/python-sdk/fastmcp-client-oauth_callback.mdx ================================================ --- title: oauth_callback sidebarTitle: oauth_callback --- # `fastmcp.client.oauth_callback` OAuth callback server for handling authorization code flows. This module provides a reusable callback server that can handle OAuth redirects and display styled responses to users. ## Functions ### `create_callback_html` ```python create_callback_html(message: str, is_success: bool = True, title: str = 'FastMCP OAuth', server_url: str | None = None) -> str ``` Create a styled HTML response for OAuth callbacks. ### `create_oauth_callback_server` ```python create_oauth_callback_server(port: int, callback_path: str = '/callback', server_url: str | None = None, result_container: OAuthCallbackResult | None = None, result_ready: anyio.Event | None = None) -> Server ``` Create an OAuth callback server. **Args:** - `port`: The port to run the server on - `callback_path`: The path to listen for OAuth redirects on - `server_url`: Optional server URL to display in success messages - `result_container`: Optional container to store callback results - `result_ready`: Optional event to signal when callback is received **Returns:** - Configured uvicorn Server instance (not yet running) ## Classes ### `CallbackResponse` **Methods:** #### `from_dict` ```python from_dict(cls, data: dict[str, str]) -> CallbackResponse ``` #### `to_dict` ```python to_dict(self) -> dict[str, str] ``` ### `OAuthCallbackResult` Container for OAuth callback results, used with anyio.Event for async coordination. ================================================ FILE: docs/python-sdk/fastmcp-client-progress.mdx ================================================ --- title: progress sidebarTitle: progress --- # `fastmcp.client.progress` ## Functions ### `default_progress_handler` ```python default_progress_handler(progress: float, total: float | None, message: str | None) -> None ``` Default handler for progress notifications. Logs progress updates at debug level, properly handling missing total or message values. **Args:** - `progress`: Current progress value - `total`: Optional total expected value - `message`: Optional status message ================================================ FILE: docs/python-sdk/fastmcp-client-roots.mdx ================================================ --- title: roots sidebarTitle: roots --- # `fastmcp.client.roots` ## Functions ### `convert_roots_list` ```python convert_roots_list(roots: RootsList) -> list[mcp.types.Root] ``` ### `create_roots_callback` ```python create_roots_callback(handler: RootsList | RootsHandler) -> ListRootsFnT ``` ================================================ FILE: docs/python-sdk/fastmcp-client-sampling-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client.sampling` ## Functions ### `create_sampling_callback` ```python create_sampling_callback(sampling_handler: SamplingHandler) -> SamplingFnT ``` ================================================ FILE: docs/python-sdk/fastmcp-client-sampling-handlers-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client.sampling.handlers` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-client-sampling-handlers-anthropic.mdx ================================================ --- title: anthropic sidebarTitle: anthropic --- # `fastmcp.client.sampling.handlers.anthropic` Anthropic sampling handler for FastMCP. ## Classes ### `AnthropicSamplingHandler` Sampling handler that uses the Anthropic API. ================================================ FILE: docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx ================================================ --- title: google_genai sidebarTitle: google_genai --- # `fastmcp.client.sampling.handlers.google_genai` Google GenAI sampling handler with tool support for FastMCP 3.0. ## Classes ### `GoogleGenaiSamplingHandler` Sampling handler that uses the Google GenAI API with tool support. ================================================ FILE: docs/python-sdk/fastmcp-client-sampling-handlers-openai.mdx ================================================ --- title: openai sidebarTitle: openai --- # `fastmcp.client.sampling.handlers.openai` OpenAI sampling handler for FastMCP. ## Classes ### `OpenAISamplingHandler` Sampling handler that uses the OpenAI API. ================================================ FILE: docs/python-sdk/fastmcp-client-tasks.mdx ================================================ --- title: tasks sidebarTitle: tasks --- # `fastmcp.client.tasks` SEP-1686 client Task classes. ## Classes ### `TaskNotificationHandler` MessageHandler that routes task status notifications to Task objects. **Methods:** #### `dispatch` ```python dispatch(self, message: Message) -> None ``` Dispatch messages, including task status notifications. ### `Task` Abstract base class for MCP background tasks (SEP-1686). Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). **Methods:** #### `task_id` ```python task_id(self) -> str ``` Get the task ID. #### `returned_immediately` ```python returned_immediately(self) -> bool ``` Check if server executed the task immediately. **Returns:** - True if server executed synchronously (graceful degradation or no task support) - False if server accepted background execution #### `on_status_change` ```python on_status_change(self, callback: Callable[[GetTaskResult], None | Awaitable[None]]) -> None ``` Register callback for status change notifications. The callback will be invoked when a notifications/tasks/status is received for this task (optional server feature per SEP-1686 lines 436-444). Supports both sync and async callbacks (auto-detected). **Args:** - `callback`: Function to call with GetTaskResult when status changes. Can return None (sync) or Awaitable[None] (async). #### `status` ```python status(self) -> GetTaskResult ``` Get current task status. If server executed immediately, returns synthetic completed status. Otherwise queries the server for current status. #### `result` ```python result(self) -> TaskResultT ``` Wait for and return the task result. Must be implemented by subclasses to return the appropriate result type. #### `wait` ```python wait(self) -> GetTaskResult ``` Wait for task to reach a specific state or complete. Uses event-based waiting when notifications are available (fast), with fallback to polling (reliable). Optimally wakes up immediately on status changes when server sends notifications/tasks/status. **Args:** - `state`: Desired state ('submitted', 'working', 'completed', 'failed'). If None, waits for any terminal state (completed/failed) - `timeout`: Maximum time to wait in seconds **Returns:** - Final task status **Raises:** - `TimeoutError`: If desired state not reached within timeout #### `cancel` ```python cancel(self) -> None ``` Cancel this task, transitioning it to cancelled state. Sends a tasks/cancel protocol request. The server will attempt to halt execution and move the task to cancelled state. Note: If server executed immediately (graceful degradation), this is a no-op as there's no server-side task to cancel. ### `ToolTask` Represents a tool call that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). **Methods:** #### `result` ```python result(self) -> CallToolResult ``` Wait for and return the tool result. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. **Returns:** - The parsed tool result (same as call_tool returns) ### `PromptTask` Represents a prompt call that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). **Methods:** #### `result` ```python result(self) -> mcp.types.GetPromptResult ``` Wait for and return the prompt result. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. **Returns:** - The prompt result with messages and description ### `ResourceTask` Represents a resource read that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). **Methods:** #### `result` ```python result(self) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] ``` Wait for and return the resource contents. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. **Returns:** - list\[ReadResourceContents]: The resource contents ================================================ FILE: docs/python-sdk/fastmcp-client-telemetry.mdx ================================================ --- title: telemetry sidebarTitle: telemetry --- # `fastmcp.client.telemetry` Client-side telemetry helpers. ## Functions ### `client_span` ```python client_span(name: str, method: str, component_key: str, session_id: str | None = None, resource_uri: str | None = None) -> Generator[Span, None, None] ``` Create a CLIENT span with standard MCP attributes. Automatically records any exception on the span and sets error status. ================================================ FILE: docs/python-sdk/fastmcp-client-transports-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.client.transports` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-client-transports-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.client.transports.base` ## Classes ### `SessionKwargs` Keyword arguments for the MCP ClientSession constructor. ### `ClientTransport` Abstract base class for different MCP client transport mechanisms. A Transport is responsible for establishing and managing connections to an MCP server, and providing a ClientSession within an async context. **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` Establishes a connection and yields an active ClientSession. The ClientSession is *not* expected to be initialized in this context manager. The session is guaranteed to be valid only within the scope of the async context manager. Connection setup and teardown are handled within this context. **Args:** - `**session_kwargs`: Keyword arguments to pass to the ClientSession constructor (e.g., callbacks, timeouts). #### `close` ```python close(self) ``` Close the transport. #### `get_session_id` ```python get_session_id(self) -> str | None ``` Get the session ID for this transport, if available. ================================================ FILE: docs/python-sdk/fastmcp-client-transports-config.mdx ================================================ --- title: config sidebarTitle: config --- # `fastmcp.client.transports.config` ## Classes ### `MCPConfigTransport` Transport for connecting to one or more MCP servers defined in an MCPConfig. This transport provides a unified interface to multiple MCP servers defined in an MCPConfig object or dictionary matching the MCPConfig schema. It supports two key scenarios: 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server. 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting all servers on a single FastMCP instance, with each server's name, by default, used as its mounting prefix. In the multiserver case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` and resources with the pattern `protocol://{server_name}/path/to/resource`. This is particularly useful for creating clients that need to interact with multiple specialized MCP servers through a single interface, simplifying client code. **Examples:** ```python from fastmcp import Client # Create a config with multiple servers config = { "mcpServers": { "weather": { "url": "https://weather-api.example.com/mcp", "transport": "http" }, "calendar": { "url": "https://calendar-api.example.com/mcp", "transport": "http" } } } # Create a client with the config client = Client(config) async with client: # Access tools with prefixes weather = await client.call_tool("weather_get_forecast", {"city": "London"}) events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"}) # Access resources with prefixed URIs icons = await client.read_resource("weather://weather/icons/sunny") ``` **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` #### `close` ```python close(self) ``` ================================================ FILE: docs/python-sdk/fastmcp-client-transports-http.mdx ================================================ --- title: http sidebarTitle: http --- # `fastmcp.client.transports.http` Streamable HTTP transport for FastMCP Client. ## Classes ### `StreamableHttpTransport` Transport implementation that connects to an MCP server via Streamable HTTP Requests. **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` #### `get_session_id` ```python get_session_id(self) -> str | None ``` #### `close` ```python close(self) ``` ================================================ FILE: docs/python-sdk/fastmcp-client-transports-inference.mdx ================================================ --- title: inference sidebarTitle: inference --- # `fastmcp.client.transports.inference` ## Functions ### `infer_transport` ```python infer_transport(transport: ClientTransport | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str) -> ClientTransport ``` Infer the appropriate transport type from the given transport argument. This function attempts to infer the correct transport type from the provided argument, handling various input types and converting them to the appropriate ClientTransport subclass. The function supports these input types: - ClientTransport: Used directly without modification - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. For MCPConfig with multiple servers, a composite client is created where each server is mounted with its name as prefix. This allows accessing tools and resources from multiple servers through a single unified client interface, using naming patterns like `servername_toolname` for tools and `protocol://servername/path` for resources. If the MCPConfig contains only one server, a direct connection is established without prefixing. **Examples:** ```python # Connect to a local Python script transport = infer_transport("my_script.py") # Connect to a remote server via HTTP transport = infer_transport("http://example.com/mcp") # Connect to multiple servers using MCPConfig config = { "mcpServers": { "weather": {"url": "http://weather.example.com/mcp"}, "calendar": {"url": "http://calendar.example.com/mcp"} } } transport = infer_transport(config) ``` ================================================ FILE: docs/python-sdk/fastmcp-client-transports-memory.mdx ================================================ --- title: memory sidebarTitle: memory --- # `fastmcp.client.transports.memory` ## Classes ### `FastMCPTransport` In-memory transport for FastMCP servers. This transport connects directly to a FastMCP server instance in the same Python process. It works with both FastMCP 2.x servers and FastMCP 1.0 servers from the low-level MCP SDK. This is particularly useful for unit tests or scenarios where client and server run in the same runtime. **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` ================================================ FILE: docs/python-sdk/fastmcp-client-transports-sse.mdx ================================================ --- title: sse sidebarTitle: sse --- # `fastmcp.client.transports.sse` Server-Sent Events (SSE) transport for FastMCP Client. ## Classes ### `SSETransport` Transport implementation that connects to an MCP server via Server-Sent Events. **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` ================================================ FILE: docs/python-sdk/fastmcp-client-transports-stdio.mdx ================================================ --- title: stdio sidebarTitle: stdio --- # `fastmcp.client.transports.stdio` ## Classes ### `StdioTransport` Base transport for connecting to an MCP server via subprocess with stdio. This is a base class that can be subclassed for specific command-based transports like Python, Node, Uvx, etc. **Methods:** #### `connect_session` ```python connect_session(self, **session_kwargs: Unpack[SessionKwargs]) -> AsyncIterator[ClientSession] ``` #### `connect` ```python connect(self, **session_kwargs: Unpack[SessionKwargs]) -> ClientSession | None ``` #### `disconnect` ```python disconnect(self) ``` #### `close` ```python close(self) ``` ### `PythonStdioTransport` Transport for running Python scripts. ### `FastMCPStdioTransport` Transport for running FastMCP servers using the FastMCP CLI. ### `NodeStdioTransport` Transport for running Node.js scripts. ### `UvStdioTransport` Transport for running commands via the uv tool. ### `UvxStdioTransport` Transport for running commands via the uvx tool. ### `NpxStdioTransport` Transport for running commands via the npx tool. ================================================ FILE: docs/python-sdk/fastmcp-decorators.mdx ================================================ --- title: decorators sidebarTitle: decorators --- # `fastmcp.decorators` Shared decorator utilities for FastMCP. ## Functions ### `resolve_task_config` ```python resolve_task_config(task: bool | TaskConfig | None) -> bool | TaskConfig ``` Resolve task config, defaulting None to False. ### `get_fastmcp_meta` ```python get_fastmcp_meta(fn: Any) -> Any | None ``` Extract FastMCP metadata from a function, handling bound methods and wrappers. ## Classes ### `HasFastMCPMeta` Protocol for callables decorated with FastMCP metadata. ================================================ FILE: docs/python-sdk/fastmcp-dependencies.mdx ================================================ --- title: dependencies sidebarTitle: dependencies --- # `fastmcp.dependencies` Dependency injection exports for FastMCP. This module re-exports dependency injection symbols to provide a clean, centralized import location for all dependency-related functionality. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, CurrentWorker) and background task execution require fastmcp[tasks]. ================================================ FILE: docs/python-sdk/fastmcp-exceptions.mdx ================================================ --- title: exceptions sidebarTitle: exceptions --- # `fastmcp.exceptions` Custom exceptions for FastMCP. ## Classes ### `FastMCPError` Base error for FastMCP. ### `ValidationError` Error in validating parameters or return values. ### `ResourceError` Error in resource operations. ### `ToolError` Error in tool operations. ### `PromptError` Error in prompt operations. ### `InvalidSignature` Invalid signature for use with FastMCP. ### `ClientError` Error in client operations. ### `NotFoundError` Object not found. ### `DisabledError` Object is disabled. ### `AuthorizationError` Error when authorization check fails. ================================================ FILE: docs/python-sdk/fastmcp-experimental-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.experimental` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-experimental-sampling-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.experimental.sampling` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-experimental-sampling-handlers.mdx ================================================ --- title: handlers sidebarTitle: handlers --- # `fastmcp.experimental.sampling.handlers` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-experimental-transforms-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.experimental.transforms` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx ================================================ --- title: code_mode sidebarTitle: code_mode --- # `fastmcp.experimental.transforms.code_mode` ## Classes ### `SandboxProvider` Interface for executing LLM-generated Python code in a sandbox. WARNING: The ``code`` parameter passed to ``run`` contains untrusted, LLM-generated Python. Implementations MUST execute it in an isolated sandbox — never with plain ``exec()``. Use ``MontySandboxProvider`` (backed by ``pydantic-monty``) for production workloads. **Methods:** #### `run` ```python run(self, code: str) -> Any ``` ### `MontySandboxProvider` Sandbox provider backed by `pydantic-monty`. **Args:** - `limits`: Resource limits for sandbox execution. Supported keys\: ``max_duration_secs`` (float), ``max_allocations`` (int), ``max_memory`` (int), ``max_recursion_depth`` (int), ``gc_interval`` (int). All are optional; omit a key to leave that limit uncapped. **Methods:** #### `run` ```python run(self, code: str) -> Any ``` ### `Search` Discovery tool factory that searches the catalog by query. **Args:** - `search_fn`: Async callable ``(tools, query) -> matching_tools``. Defaults to BM25 ranking. - `name`: Name of the synthetic tool exposed to the LLM. - `default_detail`: Default detail level for search results. ``"brief"`` returns tool names and descriptions only. ``"detailed"`` returns compact markdown with parameter schemas. ``"full"`` returns complete JSON tool definitions. - `default_limit`: Maximum number of results to return. The LLM can override this per call. ``None`` means no limit. ### `GetSchemas` Discovery tool factory that returns schemas for tools by name. **Args:** - `name`: Name of the synthetic tool exposed to the LLM. - `default_detail`: Default detail level for schema results. ``"brief"`` returns tool names and descriptions only. ``"detailed"`` renders compact markdown with parameter names, types, and required markers. ``"full"`` returns the complete JSON schema. ### `GetTags` Discovery tool factory that lists tool tags from the catalog. Reads ``tool.tags`` from the catalog and groups tools by tag. Tools without tags appear under ``"untagged"``. **Args:** - `name`: Name of the synthetic tool exposed to the LLM. - `default_detail`: Default detail level. ``"brief"`` returns tag names with tool counts. ``"full"`` lists all tools under each tag. ### `ListTools` Discovery tool factory that lists all tools in the catalog. **Args:** - `name`: Name of the synthetic tool exposed to the LLM. - `default_detail`: Default detail level. ``"brief"`` returns tool names and one-line descriptions. ``"detailed"`` returns compact markdown with parameter schemas. ``"full"`` returns the complete JSON schema. ### `CodeMode` Transform that collapses all tools into discovery + execute meta-tools. Discovery tools are composable via the ``discovery_tools`` parameter. Each is a callable that receives catalog access and returns a ``Tool``. By default, ``Search`` and ``GetSchemas`` are included for progressive disclosure: search finds candidates, get_schema retrieves parameter details, and execute runs code. The ``execute`` tool is always present and provides a sandboxed Python environment with ``call_tool(name, params)`` in scope. **Methods:** #### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` ================================================ FILE: docs/python-sdk/fastmcp-mcp_config.mdx ================================================ --- title: mcp_config sidebarTitle: mcp_config --- # `fastmcp.mcp_config` Canonical MCP Configuration Format. This module defines the standard configuration format for Model Context Protocol (MCP) servers. It provides a client-agnostic, extensible format that can be used across all MCP implementations. The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive field definitions for server metadata, authentication, and execution parameters. Example configuration: ```json { "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "@my/mcp-server"], "env": {"API_KEY": "secret"}, "timeout": 30000, "description": "My MCP server" } } } ``` ## Functions ### `infer_transport_type_from_url` ```python infer_transport_type_from_url(url: str | AnyUrl) -> Literal['http', 'sse'] ``` Infer the appropriate transport type from the given URL. ### `update_config_file` ```python update_config_file(file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes) -> None ``` Update an MCP configuration file from a server object, preserving existing fields. This is used for updating the mcpServer configurations of third-party tools so we do not worry about transforming server objects here. ## Classes ### `StdioMCPServer` MCP server configuration for stdio transport. This is the canonical configuration format for MCP servers using stdio transport. **Methods:** #### `to_transport` ```python to_transport(self) -> StdioTransport ``` ### `TransformingStdioMCPServer` A Stdio server with tool transforms. ### `RemoteMCPServer` MCP server configuration for HTTP/SSE transport. This is the canonical configuration format for MCP servers using remote transports. **Methods:** #### `to_transport` ```python to_transport(self) -> StreamableHttpTransport | SSETransport ``` ### `TransformingRemoteMCPServer` A Remote server with tool transforms. ### `MCPConfig` A configuration object for MCP Servers that conforms to the canonical MCP configuration format while adding additional fields for enabling FastMCP-specific features like tool transformations and filtering by tags. For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. **Methods:** #### `wrap_servers_at_root` ```python wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any] ``` If there's no mcpServers key but there are server configs at root, wrap them. #### `add_server` ```python add_server(self, name: str, server: MCPServerTypes) -> None ``` Add or update a server in the configuration. #### `from_dict` ```python from_dict(cls, config: dict[str, Any]) -> Self ``` Parse MCP configuration from dictionary format. #### `to_dict` ```python to_dict(self) -> dict[str, Any] ``` Convert MCPConfig to dictionary format, preserving all fields. #### `write_to_file` ```python write_to_file(self, file_path: Path) -> None ``` Write configuration to JSON file. #### `from_file` ```python from_file(cls, file_path: Path) -> Self ``` Load configuration from JSON file. ### `CanonicalMCPConfig` Canonical MCP configuration format. This defines the standard configuration format for Model Context Protocol servers. The format is designed to be client-agnostic and extensible for future use cases. **Methods:** #### `add_server` ```python add_server(self, name: str, server: CanonicalMCPServerTypes) -> None ``` Add or update a server in the configuration. ================================================ FILE: docs/python-sdk/fastmcp-prompts-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.prompts` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-prompts-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.prompts.base` Base classes for FastMCP prompts. ## Classes ### `Message` Wrapper for prompt message with auto-serialization. Accepts any content - strings pass through, other types (dict, list, BaseModel) are JSON-serialized to text. **Methods:** #### `to_mcp_prompt_message` ```python to_mcp_prompt_message(self) -> PromptMessage ``` Convert to MCP PromptMessage. ### `PromptArgument` An argument that can be passed to a prompt. ### `PromptResult` Canonical result type for prompt rendering. Provides explicit control over prompt responses: multiple messages, roles, and metadata at both the message and result level. **Methods:** #### `to_mcp_prompt_result` ```python to_mcp_prompt_result(self) -> GetPromptResult ``` Convert to MCP GetPromptResult. ### `Prompt` A prompt template that can be rendered with parameters. **Methods:** #### `to_mcp_prompt` ```python to_mcp_prompt(self, **overrides: Any) -> SDKPrompt ``` Convert the prompt to an MCP prompt. #### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt ``` Create a Prompt from a function. The function can return: - str: wrapped as single user Message - list\[Message | str]: converted to list\[Message] - PromptResult: used directly #### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> str | list[Message | str] | PromptResult ``` Render the prompt with arguments. Subclasses must implement this method. Return one of: - str: Wrapped as single user Message - list\[Message | str]: Converted to list\[Message] - PromptResult: Used directly #### `convert_result` ```python convert_result(self, raw_value: Any) -> PromptResult ``` Convert a raw return value to PromptResult. **Raises:** - `TypeError`: for unsupported types #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this prompt with docket for background execution. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution ``` Schedule this prompt for background execution via docket. **Args:** - `docket`: The Docket instance - `arguments`: Prompt arguments - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ================================================ FILE: docs/python-sdk/fastmcp-prompts-function_prompt.mdx ================================================ --- title: function_prompt sidebarTitle: function_prompt --- # `fastmcp.prompts.function_prompt` Standalone @prompt decorator for FastMCP. ## Functions ### `prompt` ```python prompt(name_or_fn: str | Callable[..., Any] | None = None) -> Any ``` Standalone decorator to mark a function as an MCP prompt. Returns the original function with metadata attached. Register with a server using mcp.add_prompt(). ## Classes ### `DecoratedPrompt` Protocol for functions decorated with @prompt. ### `PromptMeta` Metadata attached to functions by the @prompt decorator. ### `FunctionPrompt` A prompt that is a function. **Methods:** #### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionPrompt ``` Create a Prompt from a function. **Args:** - `fn`: The function to wrap - `metadata`: PromptMeta object with all configuration. If provided, individual parameters must not be passed. - `name, title, etc.`: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. The function can return: - str: wrapped as single user Message - list\[Message | str]: converted to list\[Message] - PromptResult: used directly #### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult ``` Render the prompt with arguments. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this prompt with docket for background execution. FunctionPrompt registers the underlying function, which has the user's Depends parameters for docket to resolve. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any] | None, **kwargs: Any) -> Execution ``` Schedule this prompt for background execution via docket. FunctionPrompt splats the arguments dict since .fn expects **kwargs. **Args:** - `docket`: The Docket instance - `arguments`: Prompt arguments - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() ================================================ FILE: docs/python-sdk/fastmcp-resources-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.resources` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-resources-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.resources.base` Base classes and interfaces for FastMCP resources. ## Classes ### `ResourceContent` Wrapper for resource content with optional MIME type and metadata. Accepts any value for content - strings and bytes pass through directly, other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. **Methods:** #### `to_mcp_resource_contents` ```python to_mcp_resource_contents(self, uri: AnyUrl | str) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents ``` Convert to MCP resource contents type. **Args:** - `uri`: The URI of the resource (required by MCP types) **Returns:** - TextResourceContents for str content, BlobResourceContents for bytes ### `ResourceResult` Canonical result type for resource reads. Provides explicit control over resource responses: multiple content items, per-item MIME types, and metadata at both the item and result level. **Methods:** #### `to_mcp_result` ```python to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult ``` Convert to MCP ReadResourceResult. **Args:** - `uri`: The URI of the resource (required by MCP types) **Returns:** - MCP ReadResourceResult with converted contents ### `Resource` Base class for all resources. **Methods:** #### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl) -> FunctionResource ``` #### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str ``` Set default MIME type if not provided. #### `set_default_name` ```python set_default_name(self) -> Self ``` Set default name from URI if not provided. #### `read` ```python read(self) -> str | bytes | ResourceResult ``` Read the resource content. Subclasses implement this to return resource data. Supported return types: - str: Text content - bytes: Binary content - ResourceResult: Full control over contents and result-level meta #### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult ``` Convert a raw result to ResourceResult. This is used in two contexts: 1. In _read() to convert user function return values to ResourceResult 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. When the raw value is a plain string or bytes, the resource's own ``mime_type`` is forwarded so that ``ui://`` resources (and others with non-default MIME types) don't fall back to ``text/plain``. The resource's component-level ``meta`` (e.g. ``ui`` metadata for MCP Apps CSP/permissions) is propagated to each content item so that hosts can read it from the ``resources/read`` response. #### `to_mcp_resource` ```python to_mcp_resource(self, **overrides: Any) -> SDKResource ``` Convert the resource to an SDKResource. #### `key` ```python key(self) -> str ``` The globally unique lookup key for this resource. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this resource with docket for background execution. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, **kwargs: Any) -> Execution ``` Schedule this resource for background execution via docket. **Args:** - `docket`: The Docket instance - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ================================================ FILE: docs/python-sdk/fastmcp-resources-function_resource.mdx ================================================ --- title: function_resource sidebarTitle: function_resource --- # `fastmcp.resources.function_resource` Standalone @resource decorator for FastMCP. ## Functions ### `resource` ```python resource(uri: str) -> Callable[[F], F] ``` Standalone decorator to mark a function as an MCP resource. Returns the original function with metadata attached. Register with a server using mcp.add_resource(). ## Classes ### `DecoratedResource` Protocol for functions decorated with @resource. ### `ResourceMeta` Metadata attached to functions by the @resource decorator. ### `FunctionResource` A resource that defers data loading by wrapping a function. The function is only called when the resource is read, allowing for lazy loading of potentially expensive data. This is particularly useful when listing resources, as the function won't be called until the resource is actually accessed. The function can return: - str for text content (default) - bytes for binary content - other types will be converted to JSON **Methods:** #### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None) -> FunctionResource ``` Create a FunctionResource from a function. **Args:** - `fn`: The function to wrap - `uri`: The URI for the resource (required if metadata not provided) - `metadata`: ResourceMeta object with all configuration. If provided, individual parameters must not be passed. - `name, title, etc.`: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. #### `read` ```python read(self) -> str | bytes | ResourceResult ``` Read the resource by calling the wrapped function. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this resource with docket for background execution. FunctionResource registers the underlying function, which has the user's Depends parameters for docket to resolve. ================================================ FILE: docs/python-sdk/fastmcp-resources-template.mdx ================================================ --- title: template sidebarTitle: template --- # `fastmcp.resources.template` Resource template functionality. ## Functions ### `extract_query_params` ```python extract_query_params(uri_template: str) -> set[str] ``` Extract query parameter names from RFC 6570 `{?param1,param2}` syntax. ### `build_regex` ```python build_regex(template: str) -> re.Pattern[str] | None ``` Build regex pattern for URI template, handling RFC 6570 syntax. Supports: - `{var}` - simple path parameter - `{var*}` - wildcard path parameter (captures multiple segments) - `{?var1,var2}` - query parameters (ignored in path matching) Returns None if the template produces an invalid regex (e.g. parameter names with hyphens, leading digits, or duplicates from a remote server). ### `match_uri_template` ```python match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None ``` Match URI against template and extract both path and query parameters. Supports RFC 6570 URI templates: - Path params: `{var}`, `{var*}` - Query params: `{?var1,var2}` ## Classes ### `ResourceTemplate` A template for dynamically creating resources. **Methods:** #### `from_function` ```python from_function(fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` #### `set_default_mime_type` ```python set_default_mime_type(cls, mime_type: str | None) -> str ``` Set default MIME type if not provided. #### `matches` ```python matches(self, uri: str) -> dict[str, Any] | None ``` Check if URI matches template and extract parameters. #### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult ``` Read the resource content. #### `convert_result` ```python convert_result(self, raw_value: Any) -> ResourceResult ``` Convert a raw result to ResourceResult. This is used in two contexts: 1. In _read() to convert user function return values to ResourceResult 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource ``` Create a resource from the template with the given parameters. The base implementation does not support background tasks. Use FunctionResourceTemplate for task support. #### `to_mcp_template` ```python to_mcp_template(self, **overrides: Any) -> SDKResourceTemplate ``` Convert the resource template to an SDKResourceTemplate. #### `from_mcp_template` ```python from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate ``` Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object. #### `key` ```python key(self) -> str ``` The globally unique lookup key for this template. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this template with docket for background execution. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution ``` Schedule this template for background execution via docket. **Args:** - `docket`: The Docket instance - `params`: Template parameters - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `FunctionResourceTemplate` A template for dynamically creating resources. **Methods:** #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource ``` Create a resource from the template with the given parameters. #### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult ``` Read the resource content. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this template with docket for background execution. FunctionResourceTemplate registers the underlying function, which has the user's Depends parameters for docket to resolve. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution ``` Schedule this template for background execution via docket. FunctionResourceTemplate splats the params dict since .fn expects **kwargs. **Args:** - `docket`: The Docket instance - `params`: Template parameters - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() #### `from_function` ```python from_function(cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None) -> FunctionResourceTemplate ``` Create a template from a function. ================================================ FILE: docs/python-sdk/fastmcp-resources-types.mdx ================================================ --- title: types sidebarTitle: types --- # `fastmcp.resources.types` Concrete resource implementations. ## Classes ### `TextResource` A resource that reads from a string. **Methods:** #### `read` ```python read(self) -> ResourceResult ``` Read the text content. ### `BinaryResource` A resource that reads from bytes. **Methods:** #### `read` ```python read(self) -> ResourceResult ``` Read the binary content. ### `FileResource` A resource that reads from a file. Set is_binary=True to read file as binary data instead of text. **Methods:** #### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path ``` Ensure path is absolute. #### `set_binary_from_mime_type` ```python set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool ``` Set is_binary based on mime_type if not explicitly set. #### `read` ```python read(self) -> ResourceResult ``` Read the file content. ### `HttpResource` A resource that reads from an HTTP endpoint. **Methods:** #### `read` ```python read(self) -> ResourceResult ``` Read the HTTP content. ### `DirectoryResource` A resource that lists files in a directory. **Methods:** #### `validate_absolute_path` ```python validate_absolute_path(cls, path: Path) -> Path ``` Ensure path is absolute. #### `list_files` ```python list_files(self) -> list[Path] ``` List files in the directory. #### `read` ```python read(self) -> ResourceResult ``` Read the directory listing. ================================================ FILE: docs/python-sdk/fastmcp-server-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-server-app.mdx ================================================ --- title: app sidebarTitle: app --- # `fastmcp.server.app` FastMCPApp — a Provider that represents a composable MCP application. FastMCPApp binds entry-point tools (model calls these) together with backend tools (the UI calls these via CallTool). Backend tools get global keys — UUID-suffixed stable identifiers that survive namespace transforms when servers are composed — so ``CallTool(save_contact)`` keeps working even when the app is mounted under a namespace. Usage:: from fastmcp import FastMCP, FastMCPApp app = FastMCPApp("Dashboard") @app.ui() def show_dashboard() -> Component: return Column(...) @app.tool() def save_contact(name: str, email: str) -> dict: return {"name": name, "email": email} server = FastMCP("Platform") server.add_provider(app) ## Functions ### `get_global_tool` ```python get_global_tool(name: str) -> Tool | None ``` Look up a tool by its global key, or return None. ## Classes ### `FastMCPApp` A Provider that represents an MCP application. Binds together entry-point tools (``@app.ui``), backend tools (``@app.tool``), the Prefab renderer resource, and global-key infrastructure so that composed/namespaced servers can still reach backend tools by stable identifiers. **Methods:** #### `tool` ```python tool(self, name_or_fn: F) -> F ``` #### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Any ``` Register a backend tool that the UI calls via CallTool. Backend tools get a global key for composition safety and default to ``visibility=["app"]``. Pass ``model=True`` to also expose the tool to the model (``visibility=["app", "model"]``). Supports multiple calling patterns:: @app.tool def save(name: str): ... @app.tool() def save(name: str): ... @app.tool("custom_name") def save(name: str): ... #### `ui` ```python ui(self, name_or_fn: F) -> F ``` #### `ui` ```python ui(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `ui` ```python ui(self, name_or_fn: str | AnyFunction | None = None) -> Any ``` Register a UI entry-point tool that the model calls. Entry-point tools default to ``visibility=["model"]`` and auto-wire the Prefab renderer resource and CSP. They do NOT get a global key — the model resolves them through the normal transform chain. Supports multiple calling patterns:: @app.ui def dashboard() -> Component: ... @app.ui() def dashboard() -> Component: ... @app.ui("my_dashboard") def dashboard() -> Component: ... #### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool ``` Add a tool to this app programmatically. If the tool has ``meta["ui"]["globalKey"]``, it is assumed to already be configured (but still registered for lookup). Otherwise it is treated as a backend tool and gets a global key assigned automatically. Pass ``fn`` to register the original callable in the resolver so that ``CallTool(fn)`` can resolve to the global key. #### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` #### `run` ```python run(self, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, **kwargs: Any) -> None ``` Create a temporary FastMCP server and run this app standalone. ================================================ FILE: docs/python-sdk/fastmcp-server-apps.mdx ================================================ --- title: apps sidebarTitle: apps --- # `fastmcp.server.apps` MCP Apps support — extension negotiation and typed UI metadata models. Provides constants and Pydantic models for the MCP Apps extension (io.modelcontextprotocol/ui), enabling tools and resources to carry UI metadata for clients that support interactive app rendering. ## Functions ### `app_config_to_meta_dict` ```python app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any] ``` Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``. ### `resolve_ui_mime_type` ```python resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None ``` Return the appropriate MIME type for a resource URI. For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no explicit MIME type is provided. This ensures UI resources are correctly identified regardless of how they're registered (via FastMCP.resource, the standalone @resource decorator, or resource templates). **Args:** - `uri`: The resource URI string - `explicit_mime_type`: The MIME type explicitly provided by the user **Returns:** - The resolved MIME type (explicit value, UI default, or None) ## Classes ### `ResourceCSP` Content Security Policy for MCP App resources. Declares which external origins the app is allowed to connect to or load resources from. Hosts use these declarations to build the ``Content-Security-Policy`` header for the sandboxed iframe. ### `ResourcePermissions` Iframe sandbox permissions for MCP App resources. Each field, when set (typically to ``{}``), requests that the host grant the corresponding Permission Policy feature to the sandboxed iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. ### `AppConfig` Configuration for MCP App tools and resources. Controls how a tool or resource participates in the MCP Apps extension. On tools, ``resource_uri`` and ``visibility`` specify which UI resource to render and where the tool appears. On resources, those fields must be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format (camelCase). ================================================ FILE: docs/python-sdk/fastmcp-server-auth-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.auth` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-server-auth-auth.mdx ================================================ --- title: auth sidebarTitle: auth --- # `fastmcp.server.auth.auth` ## Classes ### `AccessToken` AccessToken that includes all JWT claims. ### `TokenHandler` TokenHandler that returns MCP-compliant error responses. This handler addresses two SDK issues: 1. Error code: The SDK returns `unauthorized_client` for client authentication failures, but RFC 6749 Section 5.2 requires `invalid_client` with HTTP 401. This distinction matters for client re-registration behavior. 2. Status code: The SDK returns HTTP 400 for all token errors including `invalid_grant` (expired/invalid tokens). However, the MCP spec requires: "Invalid or expired tokens MUST receive a HTTP 401 response." This handler transforms responses to be compliant with both OAuth 2.1 and MCP specs. **Methods:** #### `handle` ```python handle(self, request: Any) ``` Wrap SDK handle() and transform auth error responses. ### `PrivateKeyJWTClientAuthenticator` Client authenticator with private_key_jwt support for CIMD clients. Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` authentication method per RFC 7523. This is required for CIMD (Client ID Metadata Document) clients that use asymmetric keys for authentication. The authenticator: 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) 2. Adds private_key_jwt handling for CIMD clients 3. Validates JWT assertions against client's JWKS **Methods:** #### `authenticate_request` ```python authenticate_request(self, request: Request) -> OAuthClientInformationFull ``` Authenticate a client from an HTTP request. Extends SDK authentication to support private_key_jwt for CIMD clients. Delegates to SDK for client_secret_basic (Authorization header) and client_secret_post (form body) authentication. ### `AuthProvider` Base class for all FastMCP authentication providers. This class provides a unified interface for all authentication providers, whether they are simple token verifiers or full OAuth authorization servers. All providers must be able to verify tokens and can optionally provide custom authentication routes. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token and return access info if valid. All auth providers must implement token verification. **Args:** - `token`: The token string to validate **Returns:** - AccessToken object if valid, None if invalid or expired #### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None ``` Set the MCP endpoint path and compute resource URL. This method is called by get_routes() to configure the expected resource URL before route creation. Subclasses can override to perform additional initialization that depends on knowing the MCP endpoint path. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get all routes for this authentication provider. This includes both well-known discovery routes and operational routes. Each provider is responsible for creating whatever routes it needs: - TokenVerifier: typically no routes (default implementation) - RemoteAuthProvider: protected resource metadata routes - OAuthProvider: full OAuth authorization server routes - Custom providers: whatever routes they need **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata, but the provider does not create the actual MCP endpoint route. **Returns:** - List of all routes for this provider (excluding the MCP endpoint itself) #### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get well-known discovery routes for this authentication provider. This is a utility method that filters get_routes() to return only well-known discovery routes (those starting with /.well-known/). Well-known routes provide OAuth metadata and discovery endpoints that clients use to discover authentication capabilities. These routes should be mounted at the root level of the application to comply with RFC 8414 and RFC 9728. Common well-known routes: - /.well-known/oauth-authorization-server (authorization server metadata) - /.well-known/oauth-protected-resource/* (protected resource metadata) **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to construct path-scoped well-known URLs. **Returns:** - List of well-known discovery routes (typically mounted at root level) #### `get_middleware` ```python get_middleware(self) -> list ``` Get HTTP application-level middleware for this auth provider. **Returns:** - List of Starlette Middleware instances to apply to the HTTP app ### `TokenVerifier` Base class for token verifiers (Resource Servers). This class provides token verification capability without OAuth server functionality. Token verifiers typically don't provide authentication routes by default. **Methods:** #### `scopes_supported` ```python scopes_supported(self) -> list[str] ``` Scopes to advertise in OAuth metadata. Defaults to required_scopes. Override in subclasses when the advertised scopes differ from the validation scopes (e.g., Azure AD where tokens contain short-form scopes but clients request full URI scopes). #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token and return access info if valid. ### `RemoteAuthProvider` Authentication provider for resource servers that verify tokens from known authorization servers. This provider composes a TokenVerifier with authorization server metadata to create standardized OAuth 2.0 Protected Resource endpoints (RFC 9728). Perfect for: - JWT verification with known issuers - Remote token introspection services - Any resource server that knows where its tokens come from Use this when you have token verification logic and want to advertise the authorization servers that issue valid tokens. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify token using the configured token verifier. #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get routes for this provider. Creates protected resource metadata routes (RFC 9728). ### `MultiAuth` Composes an optional auth server with additional token verifiers. Use this when a single server needs to accept tokens from multiple sources. For example, an OAuth proxy for interactive clients combined with a JWT verifier for machine-to-machine tokens. Token verification tries the server first (if present), then each verifier in order, returning the first successful result. Routes and OAuth metadata come from the server; verifiers contribute only token verification. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a token by trying the server, then each verifier in order. Each source is tried independently. If a source raises an exception, it is logged and treated as a non-match so that remaining sources still get a chance to verify the token. #### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None ``` Propagate MCP path to the server and all verifiers. #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Delegate route creation to the server. #### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] ``` Delegate well-known route creation to the server. This ensures that server-specific well-known route logic (e.g., OAuthProvider's RFC 8414 path-aware discovery) is preserved. ### `OAuthProvider` OAuth Authorization Server provider. This class provides full OAuth server functionality including client registration, authorization flows, token issuance, and token verification. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. **Args:** - `token`: The token string to validate **Returns:** - AccessToken object if valid, None if invalid or expired #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth authorization server routes and optional protected resource routes. This method creates the full set of OAuth routes including: - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - Optional protected resource routes **Returns:** - List of OAuth routes #### `get_well_known_routes` ```python get_well_known_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get well-known discovery routes with RFC 8414 path-aware support. Overrides the base implementation to support path-aware authorization server metadata discovery per RFC 8414. If issuer_url has a path component, the authorization server metadata route is adjusted to include that path. For example, if issuer_url is "http://example.com/api", the discovery endpoint will be at "/.well-known/oauth-authorization-server/api" instead of just "/.well-known/oauth-authorization-server". **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") **Returns:** - List of well-known discovery routes ================================================ FILE: docs/python-sdk/fastmcp-server-auth-authorization.mdx ================================================ --- title: authorization sidebarTitle: authorization --- # `fastmcp.server.auth.authorization` Authorization checks for FastMCP components. This module provides callable-based authorization for tools, resources, and prompts. Auth checks are functions that receive an AuthContext and return True to allow access or False to deny. Auth checks can also raise exceptions: - AuthorizationError: Propagates with the custom message for explicit denial - Other exceptions: Masked for security (logged, treated as auth failure) Example: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP() @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` ## Functions ### `require_scopes` ```python require_scopes(*scopes: str) -> AuthCheck ``` Require specific OAuth scopes. Returns an auth check that requires ALL specified scopes to be present in the token (AND logic). **Args:** - `*scopes`: One or more scope strings that must all be present. ### `restrict_tag` ```python restrict_tag(tag: str) -> AuthCheck ``` Restrict components with a specific tag to require certain scopes. If the component has the specified tag, the token must have ALL the required scopes. If the component doesn't have the tag, access is allowed. **Args:** - `tag`: The tag that triggers the scope requirement. - `scopes`: List of scopes required when the tag is present. ### `run_auth_checks` ```python run_auth_checks(checks: AuthCheck | list[AuthCheck], ctx: AuthContext) -> bool ``` Run auth checks with AND logic. All checks must pass for authorization to succeed. Checks can be synchronous or asynchronous functions. Auth checks can: - Return True to allow access - Return False to deny access - Raise AuthorizationError to deny with a custom message (propagates) - Raise other exceptions (masked for security, treated as denial) **Args:** - `checks`: A single check function or list of check functions. Each check can be sync (returns bool) or async (returns Awaitable[bool]). - `ctx`: The auth context to pass to each check. **Returns:** - True if all checks pass, False if any check fails. **Raises:** - `AuthorizationError`: If an auth check explicitly raises it. ## Classes ### `AuthContext` Context passed to auth check callables. This object is passed to each auth check function and provides access to the current authentication token and the component being accessed. **Attributes:** - `token`: The current access token, or None if unauthenticated. - `component`: The component (tool, resource, or prompt) being accessed. - `tool`: Backwards-compatible alias for component when it's a Tool. **Methods:** #### `tool` ```python tool(self) -> Tool | None ``` Backwards-compatible access to the component as a Tool. Returns the component if it's a Tool, None otherwise. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-cimd.mdx ================================================ --- title: cimd sidebarTitle: cimd --- # `fastmcp.server.auth.cimd` CIMD (Client ID Metadata Document) support for FastMCP. .. warning:: **Beta Feature**: CIMD support is currently in beta. The API may change in future releases. Please report any issues you encounter. CIMD is a simpler alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL, and that URL becomes their client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document This module provides: - CIMDDocument: Pydantic model for CIMD document validation - CIMDFetcher: Fetch and validate CIMD documents with SSRF protection - CIMDClientManager: Manages CIMD client operations ## Classes ### `CIMDDocument` CIMD document per draft-parecki-oauth-client-id-metadata-document. The client metadata document is a JSON document containing OAuth client metadata. The client_id property MUST match the URL where this document is hosted. Key constraint: token_endpoint_auth_method MUST NOT use shared secrets (client_secret_post, client_secret_basic, client_secret_jwt). redirect_uris is required and must contain at least one entry. **Methods:** #### `validate_auth_method` ```python validate_auth_method(cls, v: str) -> str ``` Ensure no shared-secret auth methods are used. #### `validate_redirect_uris` ```python validate_redirect_uris(cls, v: list[str]) -> list[str] ``` Ensure redirect_uris is non-empty and each entry is a valid URI. ### `CIMDValidationError` Raised when CIMD document validation fails. ### `CIMDFetchError` Raised when CIMD document fetching fails. ### `CIMDFetcher` Fetch and validate CIMD documents with SSRF protection. Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS pinning, IP validation, size limits, and timeout enforcement. Documents are cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with a TTL fallback when response headers do not define caching behavior. **Methods:** #### `is_cimd_client_id` ```python is_cimd_client_id(self, client_id: str) -> bool ``` Check if a client_id looks like a CIMD URL. CIMD URLs must be HTTPS with a host and non-root path. #### `fetch` ```python fetch(self, client_id_url: str) -> CIMDDocument ``` Fetch and validate a CIMD document with SSRF protection. Uses ssrf_safe_fetch_response for the HTTP layer, which provides: - HTTPS only, DNS resolution with IP validation - DNS pinning (connects to validated IP directly) - Blocks private/loopback/link-local/multicast IPs - Response size limit and timeout enforcement - Redirects disabled **Args:** - `client_id_url`: The URL to fetch (also the expected client_id) **Returns:** - Validated CIMDDocument **Raises:** - `CIMDValidationError`: If document is invalid or URL blocked - `CIMDFetchError`: If document cannot be fetched #### `validate_redirect_uri` ```python validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool ``` Validate that a redirect_uri is allowed by the CIMD document. **Args:** - `doc`: The CIMD document - `redirect_uri`: The redirect URI to validate **Returns:** - True if valid, False otherwise ### `CIMDAssertionValidator` Validates JWT assertions for private_key_jwt CIMD clients. Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants) for CIMD client authentication. JTI replay protection uses TTL-based caching to ensure proper security: - JTIs are cached with expiration matching the JWT's exp claim - Expired JTIs are automatically cleaned up - Maximum assertion lifetime is enforced (5 minutes) **Methods:** #### `validate_assertion` ```python validate_assertion(self, assertion: str, client_id: str, token_endpoint: str, cimd_doc: CIMDDocument) -> bool ``` Validate JWT assertion from client. **Args:** - `assertion`: The JWT assertion string - `client_id`: Expected client_id (must match iss and sub claims) - `token_endpoint`: Token endpoint URL (must match aud claim) - `cimd_doc`: CIMD document containing JWKS for key verification **Returns:** - True if valid **Raises:** - `ValueError`: If validation fails ### `CIMDClientManager` Manages all CIMD client operations for OAuth proxy. This class encapsulates: - CIMD client detection - Document fetching and validation - Synthetic OAuth client creation - Private key JWT assertion validation This allows the OAuth proxy to delegate all CIMD-specific logic to a single, focused manager class. **Methods:** #### `is_cimd_client_id` ```python is_cimd_client_id(self, client_id: str) -> bool ``` Check if client_id is a CIMD URL. **Args:** - `client_id`: Client ID to check **Returns:** - True if client_id is an HTTPS URL (CIMD format) #### `get_client` ```python get_client(self, client_id_url: str) ``` Fetch CIMD document and create synthetic OAuth client. **Args:** - `client_id_url`: HTTPS URL pointing to CIMD document **Returns:** - OAuthProxyClient with CIMD document attached, or None if fetch fails #### `validate_private_key_jwt` ```python validate_private_key_jwt(self, assertion: str, client, token_endpoint: str) -> bool ``` Validate JWT assertion for private_key_jwt auth. **Args:** - `assertion`: JWT assertion string from client - `client`: OAuth proxy client (must have cimd_document) - `token_endpoint`: Token endpoint URL for aud validation **Returns:** - True if assertion is valid **Raises:** - `ValueError`: If client doesn't have CIMD document or validation fails ================================================ FILE: docs/python-sdk/fastmcp-server-auth-jwt_issuer.mdx ================================================ --- title: jwt_issuer sidebarTitle: jwt_issuer --- # `fastmcp.server.auth.jwt_issuer` JWT token issuance and verification for FastMCP OAuth Proxy. This module implements the token factory pattern for OAuth proxies, where the proxy issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token audience boundaries. ## Functions ### `derive_jwt_key` ```python derive_jwt_key() -> bytes ``` Derive JWT signing key from a high-entropy or low-entropy key material and server salt. ## Classes ### `JWTIssuer` Issues and validates FastMCP-signed JWT tokens using HS256. This issuer creates JWT tokens for MCP clients with proper audience claims, maintaining OAuth 2.0 token boundaries. Tokens are signed with HS256 using a key derived from the upstream client secret. **Methods:** #### `issue_access_token` ```python issue_access_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600, upstream_claims: dict[str, Any] | None = None) -> str ``` Issue a minimal FastMCP access token. FastMCP tokens are reference tokens containing only the minimal claims needed for validation and lookup. The JTI maps to the upstream token which contains actual user identity and authorization data. **Args:** - `client_id`: MCP client ID - `scopes`: Token scopes - `jti`: Unique token identifier (maps to upstream token) - `expires_in`: Token lifetime in seconds - `upstream_claims`: Optional claims from upstream IdP token to include **Returns:** - Signed JWT token #### `issue_refresh_token` ```python issue_refresh_token(self, client_id: str, scopes: list[str], jti: str, expires_in: int, upstream_claims: dict[str, Any] | None = None) -> str ``` Issue a minimal FastMCP refresh token. FastMCP refresh tokens are reference tokens containing only the minimal claims needed for validation and lookup. The JTI maps to the upstream token which contains actual user identity and authorization data. **Args:** - `client_id`: MCP client ID - `scopes`: Token scopes - `jti`: Unique token identifier (maps to upstream token) - `expires_in`: Token lifetime in seconds (should match upstream refresh expiry) - `upstream_claims`: Optional claims from upstream IdP token to include **Returns:** - Signed JWT token #### `verify_token` ```python verify_token(self, token: str, expected_token_use: str = 'access') -> dict[str, Any] ``` Verify and decode a FastMCP token. Validates JWT signature, expiration, issuer, audience, and token type. **Args:** - `token`: JWT token to verify - `expected_token_use`: Expected token type ("access" or "refresh"). Defaults to "access", which rejects refresh tokens. **Returns:** - Decoded token payload **Raises:** - `JoseError`: If token is invalid, expired, or has wrong claims ================================================ FILE: docs/python-sdk/fastmcp-server-auth-middleware.mdx ================================================ --- title: middleware sidebarTitle: middleware --- # `fastmcp.server.auth.middleware` Enhanced authentication middleware with better error messages. This module provides enhanced versions of MCP SDK authentication middleware that return more helpful error messages for developers troubleshooting authentication issues. ## Classes ### `RequireAuthMiddleware` Enhanced authentication middleware with detailed error messages. Extends the SDK's RequireAuthMiddleware to provide more actionable error messages when authentication fails. This helps developers understand what went wrong and how to fix it. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oauth_proxy-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.auth.oauth_proxy` OAuth Proxy Provider for FastMCP. This package provides OAuth proxy functionality split across multiple modules: - models: Pydantic models and constants - ui: HTML generation functions - consent: Consent management mixin - proxy: Main OAuthProxy class ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oauth_proxy-consent.mdx ================================================ --- title: consent sidebarTitle: consent --- # `fastmcp.server.auth.oauth_proxy.consent` OAuth Proxy Consent Management. This module contains consent management functionality for the OAuth proxy. The ConsentMixin class provides methods for handling user consent flows, cookie management, and consent page rendering. ## Classes ### `ConsentMixin` Mixin class providing consent management functionality for OAuthProxy. This mixin contains all methods related to: - Cookie signing and verification - Consent page rendering - Consent approval/denial handling - URI normalization for consent tracking ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oauth_proxy-models.mdx ================================================ --- title: models sidebarTitle: models --- # `fastmcp.server.auth.oauth_proxy.models` OAuth Proxy Models and Constants. This module contains all Pydantic models and constants used by the OAuth proxy. ## Classes ### `OAuthTransaction` OAuth transaction state for consent flow. Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. ### `ClientCode` Client authorization code with PKCE and upstream tokens. Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. ### `UpstreamTokenSet` Stored upstream OAuth tokens from identity provider. These tokens are obtained from the upstream provider (Google, GitHub, etc.) and stored in plaintext within this model. Encryption is handled transparently at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. ### `JTIMapping` Maps FastMCP token JTI to upstream token ID. This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. ### `RefreshTokenMetadata` Metadata for a refresh token, stored keyed by token hash. We store only metadata (not the token itself) for security - if storage is compromised, attackers get hashes they can't reverse into usable tokens. ### `ProxyDCRClient` 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: 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 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 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 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` ```python validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl ``` Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. For CIMD clients: validates against BOTH the CIMD document's redirect_uris AND the proxy's allowed patterns (if configured). Both must pass. For DCR clients: validates against proxy patterns first, falling back to base validation (registered redirect_uris) if patterns don't match. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oauth_proxy-proxy.mdx ================================================ --- title: proxy sidebarTitle: proxy --- # `fastmcp.server.auth.oauth_proxy.proxy` OAuth Proxy Provider for FastMCP. This provider acts as a transparent proxy to an upstream OAuth Authorization Server, handling Dynamic Client Registration locally while forwarding all other OAuth flows. This enables authentication with upstream providers that don't support DCR or have restricted client registration policies. Key features: - Proxies authorization and token endpoints to upstream server - Implements local Dynamic Client Registration with fixed upstream credentials - Validates tokens using upstream JWKS - Maintains minimal local state for bookkeeping - Enhanced logging with request correlation This implementation is based on the OAuth 2.1 specification and is designed for production use with enterprise identity providers. ## Classes ### `OAuthProxy` OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. Purpose ------- MCP clients expect OAuth providers to support Dynamic Client Registration (DCR), where clients can register themselves dynamically and receive unique credentials. Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require pre-registered OAuth applications with fixed credentials. This proxy bridges that gap by: - Presenting a full DCR-compliant OAuth interface to MCP clients - Translating DCR registration requests to use pre-configured upstream credentials - Proxying all OAuth flows to the upstream IDP with appropriate translations - Managing the state and security requirements of both protocols Architecture Overview -------------------- The proxy maintains a single OAuth app registration with the upstream provider while allowing unlimited MCP clients to register and authenticate dynamically. It implements the complete OAuth 2.1 + DCR specification for clients while translating to whatever OAuth variant the upstream provider requires. Key Translation Challenges Solved --------------------------------- 1. Dynamic Client Registration: - MCP clients expect to register dynamically and get unique credentials - Upstream IDPs require pre-registered apps with fixed credentials - Solution: Accept DCR requests, return shared upstream credentials 2. Dynamic Redirect URIs: - MCP clients use random localhost ports that change between sessions - Upstream IDPs require fixed, pre-registered redirect URIs - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI 3. Authorization Code Mapping: - Upstream returns codes for the proxy's redirect URI - Clients expect codes for their own redirect URIs - Solution: Exchange upstream code server-side, issue new code to client 4. State Parameter Collision: - Both client and proxy need to maintain state through the flow - Only one state parameter available in OAuth - Solution: Use transaction ID as state with upstream, preserve client's state 5. Token Management: - Clients may expect different token formats/claims than upstream provides - Need to track tokens for revocation and refresh - Solution: Store token relationships, forward upstream tokens transparently OAuth Flow Implementation ------------------------ 1. Client Registration (DCR): - Accept any client registration request - Store ProxyDCRClient that accepts dynamic redirect URIs 2. Authorization: - Store transaction mapping client details to proxy flow - Redirect to upstream with proxy's fixed redirect URI - Use transaction ID as state parameter with upstream 3. Upstream Callback: - Exchange upstream authorization code for tokens (server-side) - Generate new authorization code bound to client's PKCE challenge - Redirect to client's original dynamic redirect URI 4. Token Exchange: - Validate client's code and PKCE verifier - Return previously obtained upstream tokens - Clean up one-time use authorization code 5. Token Refresh: - Forward refresh requests to upstream using authlib - Handle token rotation if upstream issues new refresh token - Update local token mappings State Management --------------- The proxy maintains minimal but crucial state via pluggable storage (client_storage): - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs - _refresh_token_store: Refresh token metadata (keyed by token hash) All state is stored in the configured client_storage backend (Redis, disk, etc.) enabling horizontal scaling across multiple instances. Security Considerations ---------------------- - Refresh tokens stored by hash only (defense in depth if storage compromised) - PKCE enforced end-to-end (client to proxy, proxy to upstream) - Authorization codes are single-use with short expiry - Transaction IDs are cryptographically random - All state is cleaned up after use to prevent replay - Token validation delegates to upstream provider Provider Compatibility --------------------- Works with any OAuth 2.0 provider that supports: - Authorization code flow - Fixed redirect URI (configured in provider's app settings) - Standard token endpoint Handles provider-specific requirements: - Google: Ensures minimum scope requirements - GitHub: Compatible with OAuth Apps and GitHub Apps - Azure AD: Handles tenant-specific endpoints - Generic: Works with any spec-compliant provider **Methods:** #### `set_mcp_path` ```python set_mcp_path(self, mcp_path: str | None) -> None ``` Set the MCP endpoint path and create JWTIssuer with correct audience. This method is called by get_routes() to configure the resource URL and create the JWTIssuer. The JWT audience is set to the full resource URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to this specific MCP endpoint. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") #### `jwt_issuer` ```python jwt_issuer(self) -> JWTIssuer ``` Get the JWT issuer, ensuring it has been initialized. The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. #### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None ``` Get client information by ID. This is generally the random ID provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). CIMD clients (URL-based client IDs) are looked up and cached automatically. #### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None ``` Register a client locally When a client registers, we create a ProxyDCRClient that is more forgiving about validating redirect URIs, since the DCR client's redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. #### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str ``` Start OAuth transaction and route through consent interstitial. Flow: 1. Validate client's resource matches server's resource URL (security check) 2. Store transaction with client details and PKCE (if forwarding) 3. Return local /consent URL; browser visits consent first 4. Consent handler redirects to upstream IdP if approved/already approved If consent is disabled (require_authorization_consent=False), skip the consent screen and redirect directly to the upstream IdP. #### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None ``` Load authorization code for validation. Look up our client code and return authorization code object with PKCE challenge for validation. #### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken ``` Exchange authorization code for FastMCP-issued tokens. Implements the token factory pattern: 1. Retrieves upstream tokens from stored authorization code 2. Extracts user identity from upstream token 3. Encrypts and stores upstream tokens 4. Issues FastMCP-signed JWT tokens 5. Returns FastMCP tokens (NOT upstream tokens) PKCE validation is handled by the MCP framework before this method is called. #### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None ``` Load refresh token metadata from distributed storage. Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. #### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken ``` Exchange FastMCP refresh token for new FastMCP access token. Implements two-tier refresh: 1. Verify FastMCP refresh token 2. Look up upstream token via JTI mapping 3. Refresh upstream token with upstream provider 4. Update stored upstream token 5. Issue new FastMCP access token 6. Keep same FastMCP refresh token (unless upstream rotates) #### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None ``` Validate FastMCP JWT by swapping for upstream token. This implements the token swap pattern: 1. Verify FastMCP JWT signature (proves it's our token) 2. Look up upstream token via JTI mapping 3. Decrypt upstream token 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) 5. Return upstream validation result The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. #### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None ``` Revoke token locally and with upstream server if supported. For refresh tokens, removes from local storage by hash. For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes with custom handlers for better error UX. This method creates standard OAuth routes and replaces: - /authorize endpoint: Enhanced error responses for unregistered clients - /token endpoint: OAuth 2.1 compliant error codes **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx ================================================ --- title: ui sidebarTitle: ui --- # `fastmcp.server.auth.oauth_proxy.ui` OAuth Proxy UI Generation Functions. This module contains HTML generation functions for consent and error pages. ## Functions ### `create_consent_html` ```python create_consent_html(client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = 'Application Access Request', server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, is_cimd_client: bool = False, cimd_domain: str | None = None) -> str ``` Create a styled HTML consent page for OAuth authorization requests. **Args:** - `csp_policy`: Content Security Policy override. If None, uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. ### `create_error_html` ```python create_error_html(error_title: str, error_message: str, error_details: dict[str, str] | None = None, server_name: str | None = None, server_icon_url: str | None = None) -> str ``` Create a styled HTML error page for OAuth errors. **Args:** - `error_title`: The error title (e.g., "OAuth Error", "Authorization Failed") - `error_message`: The main error message to display - `error_details`: Optional dictionary of error details to show (e.g., `{"Error Code"\: "invalid_client"}`) - `server_name`: Optional server name to display - `server_icon_url`: Optional URL to server icon/logo **Returns:** - Complete HTML page as a string ================================================ FILE: docs/python-sdk/fastmcp-server-auth-oidc_proxy.mdx ================================================ --- title: oidc_proxy sidebarTitle: oidc_proxy --- # `fastmcp.server.auth.oidc_proxy` OIDC Proxy Provider for FastMCP. This provider acts as a transparent proxy to an upstream OIDC compliant Authorization Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and forwarding of all OAuth flows. This implementation is based on: OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 ## Classes ### `OIDCConfiguration` OIDC Configuration. **Methods:** #### `get_oidc_configuration` ```python get_oidc_configuration(cls, config_url: AnyHttpUrl) -> Self ``` Get the OIDC configuration for the specified config URL. **Args:** - `config_url`: The OIDC config URL - `strict`: The strict flag for the configuration - `timeout_seconds`: HTTP request timeout in seconds ### `OIDCProxy` OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. This provider makes it easier to add OAuth protection for any upstream provider that is OIDC compliant. **Methods:** #### `get_oidc_configuration` ```python get_oidc_configuration(self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None) -> OIDCConfiguration ``` Gets the OIDC configuration for the specified configuration URL. **Args:** - `config_url`: The OIDC configuration URL - `strict`: The strict flag for the configuration - `timeout_seconds`: HTTP request timeout in seconds #### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier ``` Creates the token verifier for the specified OIDC configuration and arguments. **Args:** - `algorithm`: Optional token verifier algorithm - `audience`: Optional token verifier audience - `required_scopes`: Optional token verifier required_scopes - `timeout_seconds`: HTTP request timeout in seconds ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.auth.providers` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-auth0.mdx ================================================ --- title: auth0 sidebarTitle: auth0 --- # `fastmcp.server.auth.providers.auth0` Auth0 OAuth provider for FastMCP. This module provides a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider # Simple Auth0 OAuth protection auth = Auth0Provider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", audience="your-auth0-api-audience", base_url="http://localhost:8000", ) mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `Auth0Provider` An Auth0 provider implementation for FastMCP. This provider is a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-aws.mdx ================================================ --- title: aws sidebarTitle: aws --- # `fastmcp.server.auth.providers.aws` AWS Cognito OAuth provider for FastMCP. This module provides a complete AWS Cognito OAuth integration that's ready to use with a user pool ID, domain prefix, client ID and client secret. It handles all the complexity of AWS Cognito's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider # Simple AWS Cognito OAuth protection auth = AWSCognitoProvider( user_pool_id="your-user-pool-id", aws_region="eu-central-1", client_id="your-cognito-client-id", client_secret="your-cognito-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `AWSCognitoTokenVerifier` Token verifier that filters claims to Cognito-specific subset. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify token and filter claims to Cognito-specific subset. ### `AWSCognitoProvider` Complete AWS Cognito OAuth provider for FastMCP. This provider makes it trivial to add AWS Cognito OAuth protection to any FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, client credentials, and a base URL, and you're ready to go. Features: - Automatic OIDC Discovery from AWS Cognito User Pool - Automatic JWT token validation via Cognito's public keys - Cognito-specific claim filtering (sub, username, cognito:groups) - Support for Cognito User Pools **Methods:** #### `get_token_verifier` ```python get_token_verifier(self) -> AWSCognitoTokenVerifier ``` Creates a Cognito-specific token verifier with claim filtering. **Args:** - `algorithm`: Optional token verifier algorithm - `audience`: Optional token verifier audience - `required_scopes`: Optional token verifier required_scopes - `timeout_seconds`: HTTP request timeout in seconds ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-azure.mdx ================================================ --- title: azure sidebarTitle: azure --- # `fastmcp.server.auth.providers.azure` Azure (Microsoft Entra) OAuth provider for FastMCP. This provider implements Azure/Microsoft Entra ID OAuth authentication using the OAuth Proxy pattern for non-DCR OAuth flows. ## Functions ### `EntraOBOToken` ```python EntraOBOToken(scopes: list[str]) -> str ``` Exchange the user's Entra token for a downstream API token via OBO. This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, allowing your MCP server to call downstream APIs (like Microsoft Graph) on behalf of the authenticated user. **Args:** - `scopes`: The scopes to request for the downstream API. For Microsoft Graph, use scopes like ["https\://graph.microsoft.com/Mail.Read"] or ["https\://graph.microsoft.com/.default"]. **Returns:** - A dependency that resolves to the downstream API access token string **Raises:** - `ImportError`: If fastmcp[azure] is not installed - `RuntimeError`: If no access token is available, provider is not Azure, or OBO exchange fails ## Classes ### `AzureProvider` Azure (Microsoft Entra) OAuth provider for FastMCP. This provider implements Azure/Microsoft Entra ID authentication using the OAuth Proxy pattern. It supports both organizational accounts and personal Microsoft accounts depending on the tenant configuration. Scope Handling: - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) → Automatically prefixed with identifier_uri during initialization → Validated on all tokens and advertised to MCP clients - additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) → NOT prefixed, NOT validated, NOT advertised to clients → Used to request Microsoft Graph or other upstream API permissions Features: - OAuth proxy to Azure/Microsoft identity platform - JWT validation using tenant issuer and JWKS - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - Custom API scopes and Microsoft Graph scopes in a single provider Setup: 1. Create an App registration in Azure Portal 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) 4. Add custom scopes (e.g., "read", "write") under "Expose an API" 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 6. Create a client secret 7. Get Application (client) ID, Directory (tenant) ID, and client secret **Methods:** #### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str ``` Start OAuth transaction and redirect to Azure AD. Override parent's authorize method to filter out the 'resource' parameter which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use scopes to determine the resource/audience instead of a separate parameter. **Args:** - `client`: OAuth client information - `params`: Authorization parameters from the client **Returns:** - Authorization URL to redirect the user to Azure AD #### `get_obo_credential` ```python get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential ``` Get a cached or new OnBehalfOfCredential for OBO token exchange. Credentials are cached by user assertion so the Azure SDK's internal token cache can avoid redundant OBO exchanges when the same user calls multiple tools with the same scopes. **Args:** - `user_assertion`: The user's access token to exchange via OBO. **Returns:** - A configured OnBehalfOfCredential ready for get_token() calls. **Raises:** - `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). #### `close_obo_credentials` ```python close_obo_credentials(self) -> None ``` Close all cached OBO credentials. ### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. Auto-configures JWKS URI, issuer, audience, and scope handling from your Azure app registration details. Designed for Managed Identity and other token-verification-only scenarios where AzureProvider's full OAuth proxy isn't needed. Handles Azure's scope format automatically: - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) - Advertises full-URI scopes in OAuth metadata (what clients need to request) Example:: from fastmcp.server.auth import RemoteAuthProvider from fastmcp.server.auth.providers.azure import AzureJWTVerifier from pydantic import AnyHttpUrl verifier = AzureJWTVerifier( client_id="your-client-id", tenant_id="your-tenant-id", required_scopes=["access_as_user"], ) auth = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[ AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") ], base_url="https://my-server.com", ) **Methods:** #### `scopes_supported` ```python scopes_supported(self) -> list[str] ``` Return scopes with Azure URI prefix for OAuth metadata. Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` claim, but clients must request full URI scopes (e.g., ``api://client-id/read``) from the Azure authorization endpoint. This property returns the full-URI form for OAuth metadata while ``required_scopes`` retains the short form for token validation. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-debug.mdx ================================================ --- title: debug sidebarTitle: debug --- # `fastmcp.server.auth.providers.debug` Debug token verifier for testing and special cases. This module provides a flexible token verifier that delegates validation to a custom callable. Useful for testing, development, or scenarios where standard verification isn't possible (like opaque tokens without introspection). Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.debug import DebugTokenVerifier # Accept all tokens (default - useful for testing) auth = DebugTokenVerifier() # Custom sync validation logic auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-")) # Custom async validation logic async def check_cache(token: str) -> bool: return await redis.exists(f"token:{token}") auth = DebugTokenVerifier(validate=check_cache) mcp = FastMCP("My Server", auth=auth) ``` ## Classes ### `DebugTokenVerifier` Token verifier with custom validation logic. This verifier delegates token validation to a user-provided callable. By default, it accepts all non-empty tokens (useful for testing). Use cases: - Testing: Accept any token without real verification - Development: Custom validation logic for prototyping - Opaque tokens: When you have tokens with no introspection endpoint WARNING: This bypasses standard security checks. Only use in controlled environments or when you understand the security implications. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify token using custom validation logic. **Args:** - `token`: The token string to validate **Returns:** - AccessToken if validation succeeds, None otherwise ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-descope.mdx ================================================ --- title: descope sidebarTitle: descope --- # `fastmcp.server.auth.providers.descope` Descope authentication provider for FastMCP. This module provides DescopeProvider - a complete authentication solution that integrates with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) for seamless MCP client authentication. ## Classes ### `DescopeProvider` Descope metadata provider for DCR (Dynamic Client Registration). This provider implements Descope integration using metadata forwarding. This is the recommended approach for Descope DCR as it allows Descope to handle the OAuth flow directly while FastMCP acts as a resource server. IMPORTANT SETUP REQUIREMENTS: 1. Create an MCP Server in Descope Console: - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console - Create a new MCP Server - Ensure that **Dynamic Client Registration (DCR)** is enabled - Note your Well-Known URL 2. Note your Well-Known URL: - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` For detailed setup instructions, see: https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr **Methods:** #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including Descope authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Descope's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-discord.mdx ================================================ --- title: discord sidebarTitle: discord --- # `fastmcp.server.auth.providers.discord` Discord OAuth provider for FastMCP. This module provides a complete Discord OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of Discord's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.discord import DiscordProvider # Simple Discord OAuth protection auth = DiscordProvider( client_id="your-discord-client-id", client_secret="your-discord-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `DiscordTokenVerifier` Token verifier for Discord OAuth tokens. Discord OAuth tokens are opaque (not JWTs), so we verify them by calling Discord's tokeninfo API to check if they're valid and get user info. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify Discord OAuth token by calling Discord's tokeninfo API. ### `DiscordProvider` Complete Discord OAuth provider for FastMCP. This provider makes it trivial to add Discord OAuth protection to any FastMCP server. Just provide your Discord OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to Discord - Automatic token validation via Discord's API - User information extraction from Discord APIs - Minimal configuration required ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-github.mdx ================================================ --- title: github sidebarTitle: github --- # `fastmcp.server.auth.providers.github` GitHub OAuth provider for FastMCP. This module provides a complete GitHub OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of GitHub's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider # Simple GitHub OAuth protection auth = GitHubProvider( client_id="your-github-client-id", client_secret="your-github-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `GitHubTokenVerifier` Token verifier for GitHub OAuth tokens. GitHub OAuth tokens are opaque (not JWTs), so we verify them by calling GitHub's API to check if they're valid and get user info. Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive integer to cache successful verification results and avoid repeated GitHub API calls for the same token. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify GitHub OAuth token by calling GitHub API. ### `GitHubProvider` Complete GitHub OAuth provider for FastMCP. This provider makes it trivial to add GitHub OAuth protection to any FastMCP server. Just provide your GitHub OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to GitHub - Automatic token validation via GitHub API - User information extraction - Minimal configuration required ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-google.mdx ================================================ --- title: google sidebarTitle: google --- # `fastmcp.server.auth.providers.google` Google OAuth provider for FastMCP. This module provides a complete Google OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of Google's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.google import GoogleProvider # Simple Google OAuth protection auth = GoogleProvider( client_id="your-google-client-id.apps.googleusercontent.com", client_secret="your-google-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `GoogleTokenVerifier` Token verifier for Google OAuth tokens. Google OAuth tokens are opaque (not JWTs), so we verify them by calling Google's tokeninfo API to check if they're valid and get user info. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify Google OAuth token by calling Google's tokeninfo API. ### `GoogleProvider` Complete Google OAuth provider for FastMCP. This provider makes it trivial to add Google OAuth protection to any FastMCP server. Just provide your Google OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to Google - Automatic token validation via Google's tokeninfo API - User information extraction from Google APIs - Minimal configuration required ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-in_memory.mdx ================================================ --- title: in_memory sidebarTitle: in_memory --- # `fastmcp.server.auth.providers.in_memory` ## Classes ### `InMemoryOAuthProvider` An in-memory OAuth provider for testing purposes. It simulates the OAuth 2.1 flow locally without external calls. **Methods:** #### `get_client` ```python get_client(self, client_id: str) -> OAuthClientInformationFull | None ``` #### `register_client` ```python register_client(self, client_info: OAuthClientInformationFull) -> None ``` #### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str ``` Simulates user authorization and generates an authorization code. Returns a redirect URI with the code and state. #### `load_authorization_code` ```python load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> AuthorizationCode | None ``` #### `exchange_authorization_code` ```python exchange_authorization_code(self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode) -> OAuthToken ``` #### `load_refresh_token` ```python load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None ``` #### `exchange_refresh_token` ```python exchange_refresh_token(self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]) -> OAuthToken ``` #### `load_access_token` ```python load_access_token(self, token: str) -> AccessToken | None ``` #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. **Args:** - `token`: The token string to validate **Returns:** - AccessToken object if valid, None if invalid or expired #### `revoke_token` ```python revoke_token(self, token: AccessToken | RefreshToken) -> None ``` Revokes an access or refresh token and its counterpart. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-introspection.mdx ================================================ --- title: introspection sidebarTitle: introspection --- # `fastmcp.server.auth.providers.introspection` OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP. This module provides token verification for opaque tokens using the OAuth 2.0 Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to validate tokens issued by authorization servers that don't use JWT format. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier # Verify opaque tokens via RFC 7662 introspection verifier = IntrospectionTokenVerifier( introspection_url="https://auth.example.com/oauth/introspect", client_id="your-client-id", client_secret="your-client-secret", required_scopes=["read", "write"] ) mcp = FastMCP("My Protected Server", auth=verifier) ``` ## Classes ### `IntrospectionTokenVerifier` OAuth 2.0 Token Introspection verifier (RFC 7662). This verifier validates opaque tokens by calling an OAuth 2.0 token introspection endpoint. Unlike JWT verification which is stateless, token introspection requires a network call to the authorization server for each token validation. The verifier authenticates to the introspection endpoint using either: - HTTP Basic Auth (client_secret_basic, default): credentials in Authorization header - POST body authentication (client_secret_post): credentials in request body Both methods are specified in RFC 6749 (OAuth 2.0) and RFC 7662 (Token Introspection). Use this when: - Your authorization server issues opaque (non-JWT) tokens - You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers - Your tokens require real-time revocation checking - Your authorization server supports RFC 7662 introspection Caching is disabled by default to preserve real-time revocation semantics. Set ``cache_ttl_seconds`` to enable caching and reduce load on the introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes). **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662). This method makes a POST request to the introspection endpoint with the token, authenticated using the configured client authentication method (client_secret_basic or client_secret_post). Results are cached in-memory to reduce load on the introspection endpoint. Cache TTL and size are configurable via constructor parameters. **Args:** - `token`: The opaque token string to validate **Returns:** - AccessToken object if valid and active, None if invalid, inactive, or expired ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-jwt.mdx ================================================ --- title: jwt sidebarTitle: jwt --- # `fastmcp.server.auth.providers.jwt` TokenVerifier implementations for FastMCP. ## Classes ### `JWKData` JSON Web Key data structure. ### `JWKSData` JSON Web Key Set data structure. ### `RSAKeyPair` RSA key pair for JWT testing. **Methods:** #### `generate` ```python generate(cls) -> RSAKeyPair ``` Generate an RSA key pair for testing. **Returns:** - Generated key pair #### `create_token` ```python 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, kid: str | None = None) -> str ``` Generate a test JWT token 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 - `kid`: Key ID to include in header ### `JWTVerifier` JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. 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 (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` ```python load_access_token(self, token: str) -> AccessToken | None ``` Validate a JWT bearer token and return an AccessToken when the token is valid. **Args:** - `token`: The JWT bearer token string to validate. **Returns:** - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. **Args:** - `token`: The JWT token string to validate **Returns:** - AccessToken object if valid, None if invalid or expired ### `StaticTokenVerifier` Simple static token verifier for testing and development. This verifier validates tokens against a predefined dictionary of valid token strings and their associated claims. When a token string matches a key in the dictionary, the verifier returns the corresponding claims as if the token was validated by a real authorization server. Use this when: - You're developing or testing locally without a real OAuth server - You need predictable tokens for automated testing - You want to simulate different users/scopes without complex setup - You're prototyping and need simple API key-style authentication WARNING: Never use this in production - tokens are stored in plain text! **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify token against static token dictionary. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-oci.mdx ================================================ --- title: oci sidebarTitle: oci --- # `fastmcp.server.auth.providers.oci` OCI OIDC provider for FastMCP. The pull request for the provider is submitted to fastmcp. This module provides OIDC Implementation to integrate MCP servers with OCI. You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. You can use the signer object to create OCI service object. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.oci import OCIProvider from fastmcp.server.dependencies import get_access_token from fastmcp.utilities.logging import get_logger import os import oci from oci.auth.signers import TokenExchangeSigner logger = get_logger(__name__) # Load configuration from environment config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain # Simple OCI OIDC protection auth = OCIProvider( config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application required_scopes=["openid", "profile", "email"], redirect_path="/auth/callback", base_url="http://localhost:8000", ) # NOTE: For production use, replace this with a thread-safe cache implementation # such as threading.Lock-protected dict or a proper caching library _global_token_cache = {} # In memory cache for OCI session token signer def get_oci_signer() -> TokenExchangeSigner: authntoken = get_access_token() tokenID = authntoken.claims.get("jti") token = authntoken.token # Check if the signer exists for the token ID in memory cache cached_signer = _global_token_cache.get(tokenID) logger.debug(f"Global cached signer: {cached_signer}") if cached_signer: logger.debug(f"Using globally cached signer for token ID: {tokenID}") return cached_signer # If the signer is not yet created for the token then create new OCI signer object logger.debug(f"Creating new signer for token ID: {tokenID}") signer = TokenExchangeSigner( jwt_or_func=token, oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application ) logger.debug(f"Signer {signer} created for token ID: {tokenID}") #Cache the signer object in memory cache _global_token_cache[tokenID] = signer logger.debug(f"Signer cached for token ID: {tokenID}") return signer mcp = FastMCP("My Protected Server", auth=auth) ``` ## Classes ### `OCIProvider` An OCI IAM Domain provider implementation for FastMCP. This provider is a complete OCI integration that's ready to use with just the configuration URL, client ID, client secret, and base URL. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-propelauth.mdx ================================================ --- title: propelauth sidebarTitle: propelauth --- # `fastmcp.server.auth.providers.propelauth` PropelAuth authentication provider for FastMCP. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider auth = PropelAuthProvider( auth_url="https://auth.yourdomain.com", introspection_client_id="your-client-id", introspection_client_secret="your-client-secret", base_url="https://your-fastmcp-server.com", required_scopes=["read:user_data"], ) mcp = FastMCP("My App", auth=auth) ``` ## Classes ### `PropelAuthTokenIntrospectionOverrides` ### `PropelAuthProvider` PropelAuth resource server provider using OAuth 2.1 token introspection. This provider validates access tokens via PropelAuth's introspection endpoint and forwards authorization server metadata for OAuth discovery. For detailed setup instructions, see: https://docs.propelauth.com/mcp-authentication/overview **Methods:** #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get routes for this provider. Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)), and creates an authorization server metadata route that forwards to PropelAuth's route **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify token and check the ``aud`` claim against the configured resource. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx ================================================ --- title: scalekit sidebarTitle: scalekit --- # `fastmcp.server.auth.providers.scalekit` Scalekit authentication provider for FastMCP. This module provides ScalekitProvider - a complete authentication solution that integrates with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server authentication for seamless MCP client authentication. ## Classes ### `ScalekitProvider` Scalekit resource server provider for OAuth 2.1 authentication. This provider implements Scalekit integration using resource server pattern. FastMCP acts as a protected resource server that validates access tokens issued by Scalekit's authorization server. IMPORTANT SETUP REQUIREMENTS: 1. Create an MCP Server in Scalekit Dashboard: - Go to your [Scalekit Dashboard](https://app.scalekit.com/) - Navigate to MCP Servers section - Register a new MCP Server with appropriate scopes - Ensure the Resource Identifier matches exactly what you configure as MCP URL - Note the Resource ID 2. Environment Configuration: - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - Set SCALEKIT_RESOURCE_ID from your created resource - Set BASE_URL to your FastMCP server's public URL For detailed setup instructions, see: https://docs.scalekit.com/mcp/overview/ **Methods:** #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including Scalekit authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Scalekit's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-supabase.mdx ================================================ --- title: supabase sidebarTitle: supabase --- # `fastmcp.server.auth.providers.supabase` Supabase authentication provider for FastMCP. This module provides SupabaseProvider - a complete authentication solution that integrates with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) for seamless MCP client authentication. ## Classes ### `SupabaseProvider` Supabase metadata provider for DCR (Dynamic Client Registration). This provider implements Supabase Auth integration using metadata forwarding. This approach allows Supabase to handle the OAuth flow directly while FastMCP acts as a resource server, verifying JWTs issued by Supabase Auth. IMPORTANT SETUP REQUIREMENTS: 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json - JWTs are issued by {project_url}{auth_route} - Default auth_route is "/auth/v1" (can be customized for self-hosted setups) - Tokens are cached for up to 10 minutes by Supabase's edge servers - Algorithm must match your Supabase Auth configuration 3. Authorization: - Supabase uses Row Level Security (RLS) policies for database authorization - OAuth-level scopes are an upcoming feature in Supabase Auth - Both approaches will be supported once scope handling is available For detailed setup instructions, see: https://supabase.com/docs/guides/auth/jwts **Methods:** #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including Supabase authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Supabase's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-providers-workos.mdx ================================================ --- title: workos sidebarTitle: workos --- # `fastmcp.server.auth.providers.workos` WorkOS authentication providers for FastMCP. This module provides two WorkOS authentication strategies: 1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) 2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit Choose based on your WorkOS setup and authentication requirements. ## Classes ### `WorkOSTokenVerifier` Token verifier for WorkOS OAuth tokens. WorkOS AuthKit tokens are opaque, so we verify them by calling the /oauth2/userinfo endpoint to check validity and get user info. **Methods:** #### `verify_token` ```python verify_token(self, token: str) -> AccessToken | None ``` Verify WorkOS OAuth token by calling userinfo endpoint. ### `WorkOSProvider` Complete WorkOS OAuth provider for FastMCP. This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. It provides OAuth2 authentication for users through WorkOS Connect applications. Features: - Transparent OAuth proxy to WorkOS AuthKit - Automatic token validation via userinfo endpoint - User information extraction from ID tokens - Support for standard OAuth scopes (openid, profile, email) Setup Requirements: 1. Create a WorkOS Connect application in your dashboard 2. Note your AuthKit domain (e.g., "https://your-app.authkit.app") 3. Configure redirect URI as: http://localhost:8000/auth/callback 4. Note your Client ID and Client Secret ### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). This provider implements AuthKit integration using metadata forwarding instead of OAuth proxying. This is the recommended approach for WorkOS DCR as it allows WorkOS to handle the OAuth flow directly while FastMCP acts as a resource server. IMPORTANT SETUP REQUIREMENTS: 1. Enable Dynamic Client Registration in WorkOS Dashboard: - Go to Applications → Configuration - Toggle "Dynamic Client Registration" to enabled 2. Configure your FastMCP server URL as a callback: - Add your server URL to the Redirects tab in WorkOS dashboard - Example: https://your-fastmcp-server.com/oauth2/callback For detailed setup instructions, see: https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** #### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] ``` Get OAuth routes including AuthKit authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards AuthKit's OAuth metadata to clients. **Args:** - `mcp_path`: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. ================================================ FILE: docs/python-sdk/fastmcp-server-auth-redirect_validation.mdx ================================================ --- title: redirect_validation sidebarTitle: redirect_validation --- # `fastmcp.server.auth.redirect_validation` Utilities for validating client redirect URIs in OAuth flows. This module provides secure redirect URI validation with wildcard support, protecting against userinfo-based bypass attacks like http://localhost@evil.com. ## Functions ### `matches_allowed_pattern` ```python matches_allowed_pattern(uri: str, pattern: str) -> bool ``` Securely check if a URI matches an allowed pattern with wildcard support. This function parses both the URI and pattern as URLs, comparing each component separately to prevent bypass attacks like userinfo injection. Patterns support wildcards: - 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/ Security: Rejects URIs with userinfo (user:pass@host) which could bypass naive string matching (e.g., http://localhost@evil.com). **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, all URIs are allowed (for DCR compatibility). If empty list, no URIs are allowed. To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS. **Returns:** - True if the redirect URI is allowed ================================================ FILE: docs/python-sdk/fastmcp-server-auth-ssrf.mdx ================================================ --- title: ssrf sidebarTitle: ssrf --- # `fastmcp.server.auth.ssrf` SSRF-safe HTTP utilities for FastMCP. This module provides SSRF-protected HTTP fetching with: - DNS resolution and IP validation before requests - DNS pinning to prevent rebinding TOCTOU attacks - Support for both CIMD and JWKS fetches ## Functions ### `format_ip_for_url` ```python format_ip_for_url(ip_str: str) -> str ``` Format IP address for use in URL (bracket IPv6 addresses). IPv6 addresses must be bracketed in URLs to distinguish the address from the port separator. For example: https://[2001:db8::1]:443/path **Args:** - `ip_str`: IP address string **Returns:** - IP string suitable for URL (IPv6 addresses are bracketed) ### `is_ip_allowed` ```python is_ip_allowed(ip_str: str) -> bool ``` Check if an IP address is allowed (must be globally routable unicast). Uses ip.is_global which catches: - Private (10.x, 172.16-31.x, 192.168.x) - Loopback (127.x, ::1) - Link-local (169.254.x, fe80::) - includes AWS metadata! - Reserved, unspecified - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks Additionally blocks multicast addresses (not caught by is_global). **Args:** - `ip_str`: IP address string to check **Returns:** - True if the IP is allowed (public unicast internet), False if blocked ### `resolve_hostname` ```python resolve_hostname(hostname: str, port: int = 443) -> list[str] ``` Resolve hostname to IP addresses using DNS. **Args:** - `hostname`: Hostname to resolve - `port`: Port number (used for getaddrinfo) **Returns:** - List of resolved IP addresses **Raises:** - `SSRFError`: If resolution fails ### `validate_url` ```python validate_url(url: str, require_path: bool = False) -> ValidatedURL ``` Validate URL for SSRF and resolve to IPs. **Args:** - `url`: URL to validate - `require_path`: If True, require non-root path (for CIMD) **Returns:** - ValidatedURL with resolved IPs **Raises:** - `SSRFError`: If URL is invalid or resolves to blocked IPs ### `ssrf_safe_fetch` ```python ssrf_safe_fetch(url: str) -> bytes ``` Fetch URL with comprehensive SSRF protection and DNS pinning. Security measures: 1. HTTPS only 2. DNS resolution with IP validation 3. Connects to validated IP directly (DNS pinning prevents rebinding) 4. Response size limit 5. Redirects disabled 6. Overall timeout **Args:** - `url`: URL to fetch - `require_path`: If True, require non-root path - `max_size`: Maximum response size in bytes (default 5KB) - `timeout`: Per-operation timeout in seconds - `overall_timeout`: Overall timeout for entire operation **Returns:** - Response body as bytes **Raises:** - `SSRFError`: If SSRF validation fails - `SSRFFetchError`: If fetch fails ### `ssrf_safe_fetch_response` ```python ssrf_safe_fetch_response(url: str) -> SSRFFetchResponse ``` Fetch URL with SSRF protection and return response metadata. This is equivalent to :func:`ssrf_safe_fetch` but returns response headers and status code, and supports conditional request headers. ## Classes ### `SSRFError` Raised when an SSRF protection check fails. ### `SSRFFetchError` Raised when SSRF-safe fetch fails. ### `ValidatedURL` A URL that has been validated for SSRF with resolved IPs. ### `SSRFFetchResponse` Response payload from an SSRF-safe fetch. ================================================ FILE: docs/python-sdk/fastmcp-server-context.mdx ================================================ --- title: context sidebarTitle: context --- # `fastmcp.server.context` ## Functions ### `set_transport` ```python set_transport(transport: TransportType) -> Token[TransportType | None] ``` Set the current transport type. Returns token for reset. ### `reset_transport` ```python reset_transport(token: Token[TransportType | None]) -> None ``` Reset transport to previous value. ### `set_context` ```python set_context(context: Context) -> Generator[Context, None, None] ``` ## Classes ### `LogData` Data object for passing log arguments to client-side handlers. This provides an interface to match the Python standard library logging, for compatibility with structured logging. ### `Context` Context object providing access to MCP capabilities. This provides a cleaner interface to MCP's RequestContext functionality. It gets injected into tool and resource functions that request it via type hints. To use context in a tool function, add a parameter with the Context type annotation: ```python @server.tool async def my_tool(x: int, ctx: Context) -> str: # Log messages to the client await ctx.info(f"Processing {x}") await ctx.debug("Debug info") await ctx.warning("Warning message") await ctx.error("Error message") # Report progress await ctx.report_progress(50, 100, "Processing") # Access resources data = await ctx.read_resource("resource://data") # Get request info request_id = ctx.request_id client_id = ctx.client_id # Manage state across the session (persists across requests) await ctx.set_state("key", "value") value = await ctx.get_state("key") # Store non-serializable values for the current request only await ctx.set_state("client", http_client, serializable=False) return str(x) ``` State Management: Context provides session-scoped state that persists across requests within the same MCP session. State is automatically keyed by session, ensuring isolation between different clients. State set during `on_initialize` middleware will persist to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle the init and tool calls, state is isolated by the mcp-session-id header. The context parameter name can be anything as long as it's annotated with Context. The context is optional - tools that don't need it can omit the parameter. **Methods:** #### `is_background_task` ```python is_background_task(self) -> bool ``` True when this context is running in a background task (Docket worker). When True, certain operations like elicit() and sample() will use task-aware implementations that can pause the task and wait for client input. #### `task_id` ```python task_id(self) -> str | None ``` Get the background task ID if running in a background task. Returns None if not running in a background task context. #### `origin_request_id` ```python origin_request_id(self) -> str | None ``` Get the request ID that originated this execution, if available. In foreground request mode, this is the current request_id. In background task mode, this is the request_id captured when the task was submitted, if one was available. #### `fastmcp` ```python fastmcp(self) -> FastMCP ``` Get the FastMCP instance. #### `request_context` ```python request_context(self) -> RequestContext[ServerSession, Any, Request] | None ``` Access to the underlying request context. Returns None when the MCP session has not been established yet. Returns the full RequestContext once the MCP session is available. For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies, which works whether or not the MCP session is available. Example in middleware: ```python async def on_request(self, context, call_next): ctx = context.fastmcp_context if ctx.request_context: # MCP session available - can access session_id, request_id, etc. session_id = ctx.session_id else: # MCP session not available yet - use HTTP helpers from fastmcp.server.dependencies import get_http_request request = get_http_request() return await call_next(context) ``` #### `lifespan_context` ```python lifespan_context(self) -> dict[str, Any] ``` Access the server's lifespan context. Returns the context dict yielded by the server's lifespan function. Returns an empty dict if no lifespan was configured or if the MCP session is not yet established. In background tasks (Docket workers), where request_context is not available, falls back to reading from the FastMCP server's lifespan result directly. Example: ```python @server.tool def my_tool(ctx: Context) -> str: db = ctx.lifespan_context.get("db") if db: return db.query("SELECT 1") return "No database connection" ``` #### `report_progress` ```python report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None ``` Report progress for the current operation. Works in both foreground (MCP progress notifications) and background (Docket task execution) contexts. **Args:** - `progress`: Current progress value e.g. 24 - `total`: Optional total value e.g. 100 - `message`: Optional status message describing current progress #### `list_resources` ```python list_resources(self) -> list[SDKResource] ``` List all available resources from the server. **Returns:** - List of Resource objects available on the server #### `list_prompts` ```python list_prompts(self) -> list[SDKPrompt] ``` List all available prompts from the server. **Returns:** - List of Prompt objects available on the server #### `get_prompt` ```python get_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult ``` Get a prompt by name with optional arguments. **Args:** - `name`: The name of the prompt to get - `arguments`: Optional arguments to pass to the prompt **Returns:** - The prompt result #### `read_resource` ```python read_resource(self, uri: str | AnyUrl) -> ResourceResult ``` Read a resource by URI. **Args:** - `uri`: Resource URI to read **Returns:** - ResourceResult with contents #### `log` ```python log(self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` Send a log message to the client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. **Args:** - `message`: Log message - `level`: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", "alert", or "emergency". Default is "info". - `logger_name`: Optional logger name - `extra`: Optional mapping for additional arguments #### `transport` ```python transport(self) -> TransportType | None ``` Get the current transport type. Returns the transport type used to run this server: "stdio", "sse", or "streamable-http". Returns None if called outside of a server context. #### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool ``` Check whether the connected client supports a given MCP extension. Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by the client during initialization. Returns ``False`` when no session is available (e.g., outside a request context) or when the client did not advertise the extension. Example:: from fastmcp.server.apps import UI_EXTENSION_ID @mcp.tool async def my_tool(ctx: Context) -> str: if ctx.client_supports_extension(UI_EXTENSION_ID): return "UI-capable client" return "text-only client" #### `client_id` ```python client_id(self) -> str | None ``` Get the client ID if available. #### `request_id` ```python request_id(self) -> str ``` Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. #### `session_id` ```python session_id(self) -> str ``` Get the MCP session ID for ALL transports. Returns the session ID that can be used as a key for session-based data storage (e.g., Redis) to share data between tool calls within the same client session. **Returns:** - The session ID for StreamableHTTP transports, or a generated ID - for other transports. #### `session` ```python session(self) -> ServerSession ``` Access to the underlying session for advanced usage. In request mode: Returns the session from the active request context. In background task mode: Returns the session stored at Context creation. Raises RuntimeError if no session is available. #### `debug` ```python debug(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. #### `info` ```python info(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. #### `warning` ```python warning(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. #### `error` ```python error(self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None) -> None ``` Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. #### `list_roots` ```python list_roots(self) -> list[Root] ``` List the roots available to the server, as indicated by the client. #### `send_notification` ```python send_notification(self, notification: mcp.types.ServerNotificationType) -> None ``` Send a notification to the client immediately. **Args:** - `notification`: An MCP notification instance (e.g., ToolListChangedNotification()) #### `close_sse_stream` ```python close_sse_stream(self) -> None ``` Close the current response stream to trigger client reconnection. When using StreamableHTTP transport with an EventStore configured, this method gracefully closes the HTTP connection for the current request. The client will automatically reconnect (after `retry_interval` milliseconds) and resume receiving events from where it left off via the EventStore. This is useful for long-running operations to avoid load balancer timeouts. Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. #### `sample_step` ```python sample_step(self, messages: str | Sequence[str | SamplingMessage]) -> SampleStep ``` Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. Use this for fine-grained control over the sampling loop. **Args:** - `messages`: The message(s) to send. Can be a string, list of strings, or list of SamplingMessage objects. - `system_prompt`: Optional system prompt for the LLM. - `temperature`: Optional sampling temperature. - `max_tokens`: Maximum tokens to generate. Defaults to 512. - `model_preferences`: Optional model preferences. - `tools`: Optional list of tools the LLM can use. - `tool_choice`: Tool choice mode ("auto", "required", or "none"). - `execute_tools`: If True (default), execute tool calls and append results to history. If False, return immediately with tool_calls available in the step for manual execution. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. - `tool_concurrency`: Controls parallel execution of tools\: - None (default)\: Sequential execution (one at a time) - 0\: Unlimited parallel execution - N > 0\: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. **Returns:** - SampleStep containing: - - .response: The raw LLM response - - .history: Messages including input, assistant response, and tool results - - .is_tool_use: True if the LLM requested tool execution - - .tool_calls: List of tool calls (if any) - - .text: The text content (if any) #### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] ``` Overload: With result_type, returns SamplingResult[ResultT]. #### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[str] ``` Overload: Without result_type, returns SamplingResult[str]. #### `sample` ```python sample(self, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] | SamplingResult[str] ``` Send a sampling request to the client and await the response. This method runs to completion automatically. When tools are provided, it executes a tool loop: if the LLM returns a tool use request, the tools are executed and the results are sent back to the LLM. This continues until the LLM provides a final text response. When result_type is specified, a synthetic `final_response` tool is created. The LLM calls this tool to provide the structured response, which is validated against the result_type and returned as `.result`. For fine-grained control over the sampling loop, use sample_step() instead. **Args:** - `messages`: The message(s) to send. Can be a string, list of strings, or list of SamplingMessage objects. - `system_prompt`: Optional system prompt for the LLM. - `temperature`: Optional sampling temperature. - `max_tokens`: Maximum tokens to generate. Defaults to 512. - `model_preferences`: Optional model preferences. - `tools`: Optional list of tools the LLM can use. Accepts plain functions or SamplingTools. - `result_type`: Optional type for structured output. When specified, a synthetic `final_response` tool is created and the LLM's response is validated against this type. - `mask_error_details`: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. - `tool_concurrency`: Controls parallel execution of tools\: - None (default)\: Sequential execution (one at a time) - 0\: Unlimited parallel execution - N > 0\: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. **Returns:** - SamplingResult[T] containing: - - .text: The text representation (raw text or JSON for structured) - - .result: The typed result (str for text, parsed object for structured) - - .history: All messages exchanged during sampling #### `elicit` ```python elicit(self, message: str, response_type: None) -> AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: type[T]) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: list[str]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: dict[str, dict[str, str]]) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: list[list[str]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: list[dict[str, dict[str, str]]]) -> AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` #### `elicit` ```python elicit(self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None) -> AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ``` Send an elicitation request to the client and await the response. Call this method at any time to request additional information from the user through the client. The client must support elicitation, or the request will error. Note that the MCP protocol only supports simple object schemas with primitive types. You can provide a dataclass, TypedDict, or BaseModel to comply. If you provide a primitive type, an object schema with a single "value" field will be generated for the MCP interaction and automatically deconstructed into the primitive type upon response. If the response_type is None, the generated schema will be that of an empty object in order to comply with the MCP protocol requirements. Clients must send an empty object ("{}")in response. **Args:** - `message`: A human-readable message explaining what information is needed - `response_type`: The type of the response, which should be a primitive type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. #### `set_state` ```python set_state(self, key: str, value: Any) -> None ``` Set a value in the state store. By default, values are stored in the session-scoped state store and persist across requests within the same MCP session. Values must be JSON-serializable (dicts, lists, strings, numbers, etc.). For non-serializable values (e.g., HTTP clients, database connections), pass ``serializable=False``. These values are stored in a request-scoped dict and only live for the current MCP request (tool call, resource read, or prompt render). They will not be available in subsequent requests. The key is automatically prefixed with the session identifier. #### `get_state` ```python get_state(self, key: str) -> Any ``` Get a value from the state store. Checks request-scoped state first (set with ``serializable=False``), then falls back to the session-scoped state store. Returns None if the key is not found. #### `delete_state` ```python delete_state(self, key: str) -> None ``` Delete a value from the state store. Removes from both request-scoped and session-scoped stores. #### `enable_components` ```python enable_components(self) -> None ``` Enable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. **Args:** - `names`: Component names or URIs to match. - `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to match. - `tags`: Tags to match (component must have at least one). - `components`: Component types to match (e.g., {"tool", "prompt"}). - `match_all`: If True, matches all components regardless of other criteria. #### `disable_components` ```python disable_components(self) -> None ``` Disable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. **Args:** - `names`: Component names or URIs to match. - `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to match. - `tags`: Tags to match (component must have at least one). - `components`: Component types to match (e.g., {"tool", "prompt"}). - `match_all`: If True, matches all components regardless of other criteria. #### `reset_visibility` ```python reset_visibility(self) -> None ``` Clear all session visibility rules. Use this to reset session visibility back to global defaults. Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. ================================================ FILE: docs/python-sdk/fastmcp-server-dependencies.mdx ================================================ --- title: dependencies sidebarTitle: dependencies --- # `fastmcp.server.dependencies` Dependency injection for FastMCP. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, CurrentWorker) and background task execution require fastmcp[tasks]. ## Functions ### `get_task_context` ```python get_task_context() -> TaskContextInfo | None ``` Get the current task context if running inside a background task worker. This function extracts task information from the Docket execution context. Returns None if not running in a task context (e.g., foreground execution). **Returns:** - TaskContextInfo with task_id and session_id, or None if not in a task. ### `register_task_session` ```python register_task_session(session_id: str, session: ServerSession) -> None ``` Register a session for Context access in background tasks. Called automatically when a task is submitted to Docket. The session is stored as a weakref so it doesn't prevent garbage collection when the client disconnects. **Args:** - `session_id`: The session identifier - `session`: The ServerSession instance ### `get_task_session` ```python get_task_session(session_id: str) -> ServerSession | None ``` Get a registered session by ID if still alive. **Args:** - `session_id`: The session identifier **Returns:** - The ServerSession if found and alive, None otherwise ### `is_docket_available` ```python is_docket_available() -> bool ``` Check if pydocket is installed. ### `require_docket` ```python require_docket(feature: str) -> None ``` Raise ImportError with install instructions if docket not available. **Args:** - `feature`: Description of what requires docket (e.g., "`task=True`", "CurrentDocket()"). Will be included in the error message. ### `transform_context_annotations` ```python transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any] ``` Transform ctx: Context into ctx: Context = CurrentContext(). Transforms ALL params typed as Context to use Docket's DI system, unless they already have a Dependency-based default (like CurrentContext()). This unifies the legacy type annotation DI with Docket's Depends() system, allowing both patterns to work through a single resolution path. Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults after those without). KEYWORD_ONLY parameters keep their position since Python allows them to have defaults in any order. **Args:** - `fn`: Function to transform **Returns:** - Function with modified signature (same function object, updated __signature__) ### `get_context` ```python get_context() -> Context ``` Get the current FastMCP Context instance directly. ### `get_server` ```python get_server() -> FastMCP ``` Get the current FastMCP server instance directly. **Returns:** - The active FastMCP server **Raises:** - `RuntimeError`: If no server in context ### `get_http_request` ```python get_http_request() -> Request ``` Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. ### `get_http_headers` ```python get_http_headers(include_all: bool = False, include: set[str] | None = None) -> dict[str, str] ``` Extract headers from the current HTTP request if available. Never raises an exception, even if there is no active HTTP request (in which case an empty dict is returned). By default, strips problematic headers like `content-length` and `authorization` that cause issues if forwarded to downstream services. If `include_all` is True, all headers are returned. The `include` parameter allows specific headers to be included even if they would normally be excluded. This is useful for proxy transports that need to forward authorization headers to upstream MCP servers. ### `get_access_token` ```python get_access_token() -> AccessToken | None ``` Get the FastMCP access token from the current context. This function first tries to get the token from the current HTTP request's scope, which is more reliable for long-lived connections where the SDK's auth_context_var may become stale after token refresh. Falls back to the SDK's context var if no request is available. In background tasks (Docket workers), falls back to the token snapshot stored in Redis at task submission time. **Returns:** - The access token if an authenticated user is available, None otherwise. ### `without_injected_parameters` ```python without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any] ``` Create a wrapper function without injected parameters. Returns a wrapper that excludes Context and Docket dependency parameters, making it safe to use with Pydantic TypeAdapter for schema generation and validation. The wrapper internally handles all dependency resolution and Context injection when called. Handles: - Legacy Context injection (always works) - Depends() injection (always works - uses docket or vendored DI engine) **Args:** - `fn`: Original function with Context and/or dependencies **Returns:** - Async wrapper function without injected parameters ### `resolve_dependencies` ```python resolve_dependencies(fn: Callable[..., Any], arguments: dict[str, Any]) -> AsyncGenerator[dict[str, Any], None] ``` Resolve dependencies for a FastMCP function. This function: 1. Filters out any dependency parameter names from user arguments (security) 2. Resolves Depends() parameters via the DI system The filtering prevents external callers from overriding injected parameters by providing values for dependency parameter names. This is a security feature. Note: Context injection is handled via transform_context_annotations() which converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration time, so all injection goes through the unified DI system. **Args:** - `fn`: The function to resolve dependencies for - `arguments`: User arguments (may contain keys that match dependency names, which will be filtered out) ### `CurrentContext` ```python CurrentContext() -> Context ``` Get the current FastMCP Context instance. This dependency provides access to the active FastMCP Context for the current MCP operation (tool/resource/prompt call). **Returns:** - A dependency that resolves to the active Context instance **Raises:** - `RuntimeError`: If no active context found (during resolution) ### `OptionalCurrentContext` ```python OptionalCurrentContext() -> Context | None ``` Get the current FastMCP Context, or None when no context is active. ### `CurrentDocket` ```python CurrentDocket() -> Docket ``` Get the current Docket instance managed by FastMCP. This dependency provides access to the Docket instance that FastMCP automatically creates for background task scheduling. **Returns:** - A dependency that resolves to the active Docket instance **Raises:** - `RuntimeError`: If not within a FastMCP server context - `ImportError`: If fastmcp[tasks] not installed ### `CurrentWorker` ```python CurrentWorker() -> Worker ``` Get the current Docket Worker instance managed by FastMCP. This dependency provides access to the Worker instance that FastMCP automatically creates for background task processing. **Returns:** - A dependency that resolves to the active Worker instance **Raises:** - `RuntimeError`: If not within a FastMCP server context - `ImportError`: If fastmcp[tasks] not installed ### `CurrentFastMCP` ```python CurrentFastMCP() -> FastMCP ``` Get the current FastMCP server instance. This dependency provides access to the active FastMCP server. **Returns:** - A dependency that resolves to the active FastMCP server **Raises:** - `RuntimeError`: If no server in context (during resolution) ### `CurrentRequest` ```python CurrentRequest() -> Request ``` Get the current HTTP request. This dependency provides access to the Starlette Request object for the current HTTP request. Only available when running over HTTP transports (SSE or Streamable HTTP). **Returns:** - A dependency that resolves to the active Starlette Request **Raises:** - `RuntimeError`: If no HTTP request in context (e.g., STDIO transport) ### `CurrentHeaders` ```python CurrentHeaders() -> dict[str, str] ``` Get the current HTTP request headers. This dependency provides access to the HTTP headers for the current request, including the authorization header. Returns an empty dictionary when no HTTP request is available, making it safe to use in code that might run over any transport. **Returns:** - A dependency that resolves to a dictionary of header name -> value ### `CurrentAccessToken` ```python CurrentAccessToken() -> AccessToken ``` Get the current access token for the authenticated user. This dependency provides access to the AccessToken for the current authenticated request. Raises an error if no authentication is present. **Returns:** - A dependency that resolves to the active AccessToken **Raises:** - `RuntimeError`: If no authenticated user (use get_access_token() for optional) ### `TokenClaim` ```python TokenClaim(name: str) -> str ``` Get a specific claim from the access token. This dependency extracts a single claim value from the current access token. It's useful for getting user identifiers, roles, or other token claims without needing the full token object. **Args:** - `name`: The name of the claim to extract (e.g., "oid", "sub", "email") **Returns:** - A dependency that resolves to the claim value as a string **Raises:** - `RuntimeError`: If no access token is available or claim is missing ## Classes ### `TaskContextInfo` Information about the current background task context. Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. ### `ProgressLike` Protocol for progress tracking interface. Defines the common interface between InMemoryProgress (server context) and Docket's Progress (worker context). **Methods:** #### `current` ```python current(self) -> int | None ``` Current progress value. #### `total` ```python total(self) -> int ``` Total/target progress value. #### `message` ```python message(self) -> str | None ``` Current progress message. #### `set_total` ```python set_total(self, total: int) -> None ``` Set the total/target value for progress tracking. #### `increment` ```python increment(self, amount: int = 1) -> None ``` Atomically increment the current progress value. #### `set_message` ```python set_message(self, message: str | None) -> None ``` Update the progress status message. ### `InMemoryProgress` In-memory progress tracker for immediate tool execution. Provides the same interface as Docket's Progress but stores state in memory instead of Redis. Useful for testing and immediate execution where progress doesn't need to be observable across processes. **Methods:** #### `current` ```python current(self) -> int | None ``` #### `total` ```python total(self) -> int ``` #### `message` ```python message(self) -> str | None ``` #### `set_total` ```python set_total(self, total: int) -> None ``` Set the total/target value for progress tracking. #### `increment` ```python increment(self, amount: int = 1) -> None ``` Atomically increment the current progress value. #### `set_message` ```python set_message(self, message: str | None) -> None ``` Update the progress status message. ### `Progress` FastMCP Progress dependency that works in both server and worker contexts. Handles three execution modes: - In Docket worker: Uses the execution's progress (observable via Redis) - In FastMCP server with Docket: Falls back to in-memory progress - In FastMCP server without Docket: Uses in-memory progress This allows tools to use Progress() regardless of whether they're called immediately or as background tasks, and regardless of whether pydocket is installed. **Methods:** #### `current` ```python current(self) -> int | None ``` Current progress value. #### `total` ```python total(self) -> int ``` Total/target progress value. #### `message` ```python message(self) -> str | None ``` Current progress message. #### `set_total` ```python set_total(self, total: int) -> None ``` Set the total/target value for progress tracking. #### `increment` ```python increment(self, amount: int = 1) -> None ``` Atomically increment the current progress value. #### `set_message` ```python set_message(self, message: str | None) -> None ``` Update the progress status message. ================================================ FILE: docs/python-sdk/fastmcp-server-elicitation.mdx ================================================ --- title: elicitation sidebarTitle: elicitation --- # `fastmcp.server.elicitation` ## Functions ### `parse_elicit_response_type` ```python parse_elicit_response_type(response_type: Any) -> ElicitConfig ``` Parse response_type into schema and handling configuration. Supports multiple syntaxes: - None: Empty object schema, expect empty response - dict: `{"low": {"title": "..."}}` -> single-select titled enum - list patterns: - `[["a", "b"]]` -> multi-select untitled - `[{"low": {...}}]` -> multi-select titled - `["a", "b"]` -> single-select untitled - `list[X]` type annotation: multi-select with type - Scalar types (bool, int, float, str, Literal, Enum): single value - Other types (dataclass, BaseModel): use directly ### `handle_elicit_accept` ```python handle_elicit_accept(config: ElicitConfig, content: Any) -> AcceptedElicitation[Any] ``` Handle an accepted elicitation response. **Args:** - `config`: The elicitation configuration from parse_elicit_response_type - `content`: The response content from the client **Returns:** - AcceptedElicitation with the extracted/validated data ### `get_elicitation_schema` ```python get_elicitation_schema(response_type: type[T]) -> dict[str, Any] ``` Get the schema for an elicitation response. **Args:** - `response_type`: The type of the response ### `validate_elicitation_json_schema` ```python validate_elicitation_json_schema(schema: dict[str, Any]) -> None ``` Validate that a JSON schema follows MCP elicitation requirements. This ensures the schema is compatible with MCP elicitation requirements: - Must be an object schema - Must only contain primitive field types (string, number, integer, boolean) - Must be flat (no nested objects or arrays of objects) - Allows const fields (for Literal types) and enum fields (for Enum types) - Only primitive types and their nullable variants are allowed **Args:** - `schema`: The JSON schema to validate **Raises:** - `TypeError`: If the schema doesn't meet MCP elicitation requirements ## Classes ### `ElicitationJsonSchema` Custom JSON schema generator for MCP elicitation that always inlines enums. MCP elicitation requires inline enum schemas without $ref/$defs references. This generator ensures enums are always generated inline for compatibility. Optionally adds enumNames for better UI display when available. **Methods:** #### `generate_inner` ```python generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue ``` Override to prevent ref generation for enums and handle list schemas. #### `list_schema` ```python list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue ``` Generate schema for list types, detecting enum items for multi-select. #### `enum_schema` ```python enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue ``` Generate inline enum schema. Always generates enum pattern: `{"enum": [value, ...]}` Titled enums are handled separately via dict-based syntax in ctx.elicit(). ### `AcceptedElicitation` Result when user accepts the elicitation. ### `ScalarElicitationType` ### `ElicitConfig` Configuration for an elicitation request. **Attributes:** - `schema`: The JSON schema to send to the client - `response_type`: The type to validate responses with (None for raw schemas) - `is_raw`: True if schema was built directly (extract "value" from response) ================================================ FILE: docs/python-sdk/fastmcp-server-event_store.mdx ================================================ --- title: event_store sidebarTitle: event_store --- # `fastmcp.server.event_store` EventStore implementation backed by AsyncKeyValue. This module provides an EventStore implementation that enables SSE polling/resumability for Streamable HTTP transports. Events are stored using the key_value package's AsyncKeyValue protocol, allowing users to configure any compatible backend (in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware. ## Classes ### `EventEntry` Stored event entry. ### `StreamEventList` List of event IDs for a stream. ### `EventStore` EventStore implementation backed by AsyncKeyValue. Enables SSE polling/resumability by storing events that can be replayed when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware and OAuthProxy. **Args:** - `storage`: AsyncKeyValue backend. Defaults to MemoryStore. - `max_events_per_stream`: Maximum events to retain per stream. Default 100. - `ttl`: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration. **Methods:** #### `store_event` ```python store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId ``` Store an event and return its ID. **Args:** - `stream_id`: ID of the stream the event belongs to - `message`: The JSON-RPC message to store, or None for priming events **Returns:** - The generated event ID for the stored event #### `replay_events_after` ```python replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None ``` Replay events that occurred after the specified event ID. **Args:** - `last_event_id`: The ID of the last event the client received - `send_callback`: A callback function to send events to the client **Returns:** - The stream ID of the replayed events, or None if the event ID was not found ================================================ FILE: docs/python-sdk/fastmcp-server-http.mdx ================================================ --- title: http sidebarTitle: http --- # `fastmcp.server.http` ## Functions ### `set_http_request` ```python set_http_request(request: Request) -> Generator[Request, None, None] ``` ### `create_base_app` ```python create_base_app(routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None) -> StarletteWithLifespan ``` Create a base Starlette app with common middleware and routes. **Args:** - `routes`: List of routes to include in the app - `middleware`: List of middleware to include in the app - `debug`: Whether to enable debug mode - `lifespan`: Optional lifespan manager for the app **Returns:** - A Starlette application ### `create_sse_app` ```python create_sse_app(server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan ``` Return an instance of the SSE server app. **Args:** - `server`: The FastMCP server instance - `message_path`: Path for SSE messages - `sse_path`: Path for SSE connections - `auth`: Optional authentication provider (AuthProvider) - `debug`: Whether to enable debug mode - `routes`: Optional list of custom routes - `middleware`: Optional list of middleware Returns: A Starlette application with RequestContextMiddleware ### `create_streamable_http_app` ```python create_streamable_http_app(server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None) -> StarletteWithLifespan ``` Return an instance of the StreamableHTTP server app. **Args:** - `server`: The FastMCP server instance - `streamable_http_path`: Path for StreamableHTTP connections - `event_store`: Optional event store for SSE polling/resumability - `retry_interval`: Optional retry interval in milliseconds for SSE polling. Controls how quickly clients should reconnect after server-initiated disconnections. Requires event_store to be set. Defaults to SDK default. - `auth`: Optional authentication provider (AuthProvider) - `json_response`: Whether to use JSON response format - `stateless_http`: Whether to use stateless mode (new transport per request) - `debug`: Whether to enable debug mode - `routes`: Optional list of custom routes - `middleware`: Optional list of middleware **Returns:** - A Starlette application with StreamableHTTP support ## Classes ### `StreamableHTTPASGIApp` ASGI application wrapper for Streamable HTTP server transport. ### `StarletteWithLifespan` **Methods:** #### `lifespan` ```python lifespan(self) -> Lifespan[Starlette] ``` ### `RequestContextMiddleware` Middleware that stores each request in a ContextVar and sets transport type. ================================================ FILE: docs/python-sdk/fastmcp-server-lifespan.mdx ================================================ --- title: lifespan sidebarTitle: lifespan --- # `fastmcp.server.lifespan` Composable lifespans for FastMCP servers. This module provides a `@lifespan` decorator for creating composable server lifespans that can be combined using the `|` operator. Example: ```python from fastmcp import FastMCP from fastmcp.server.lifespan import lifespan @lifespan async def db_lifespan(server): conn = await connect_db() yield {"db": conn} await conn.close() @lifespan async def cache_lifespan(server): cache = await connect_cache() yield {"cache": cache} await cache.close() mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) ``` To compose with existing `@asynccontextmanager` lifespans, wrap them explicitly: ```python from contextlib import asynccontextmanager from fastmcp.server.lifespan import lifespan, ContextManagerLifespan @asynccontextmanager async def legacy_lifespan(server): yield {"legacy": True} @lifespan async def new_lifespan(server): yield {"new": True} # Wrap the legacy lifespan explicitly combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan ``` ## Functions ### `lifespan` ```python lifespan(fn: LifespanFn) -> Lifespan ``` Decorator to create a composable lifespan. Use this decorator on an async generator function to make it composable with other lifespans using the `|` operator. **Args:** - `fn`: An async generator function that takes a FastMCP server and yields a dict for the lifespan context. **Returns:** - A composable Lifespan wrapper. ## Classes ### `Lifespan` Composable lifespan wrapper. Wraps an async generator function and enables composition via the `|` operator. The wrapped function should yield a dict that becomes part of the lifespan context. ### `ContextManagerLifespan` Lifespan wrapper for already-wrapped context manager functions. Use this for functions already decorated with @asynccontextmanager. ### `ComposedLifespan` Two lifespans composed together. Enters the left lifespan first, then the right. Exits in reverse order. Results are shallow-merged into a single dict. ================================================ FILE: docs/python-sdk/fastmcp-server-low_level.mdx ================================================ --- title: low_level sidebarTitle: low_level --- # `fastmcp.server.low_level` ## Classes ### `MiddlewareServerSession` ServerSession that routes initialization requests through FastMCP middleware. **Methods:** #### `fastmcp` ```python fastmcp(self) -> FastMCP ``` Get the FastMCP instance. #### `client_supports_extension` ```python client_supports_extension(self, extension_id: str) -> bool ``` Check if the connected client supports a given MCP extension. Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by the client during initialization. ### `LowLevelServer` **Methods:** #### `fastmcp` ```python fastmcp(self) -> FastMCP ``` Get the FastMCP instance. #### `create_initialization_options` ```python create_initialization_options(self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> InitializationOptions ``` #### `get_capabilities` ```python get_capabilities(self, notification_options: NotificationOptions, experimental_capabilities: dict[str, dict[str, Any]]) -> mcp.types.ServerCapabilities ``` Override to set capabilities.tasks as a first-class field per SEP-1686. This ensures task capabilities appear in capabilities.tasks instead of capabilities.experimental.tasks, which is required by the MCP spec and enables proper task detection by clients like VS Code Copilot 1.107+. #### `run` ```python run(self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, raise_exceptions: bool = False, stateless: bool = False) ``` Overrides the run method to use the MiddlewareServerSession. #### `read_resource` ```python read_resource(self) -> Callable[[Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]], Callable[[AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult]]] ``` Decorator for registering a read_resource handler with CreateTaskResult support. The MCP SDK's read_resource decorator does not support returning CreateTaskResult for background task execution. This decorator wraps the result in ServerResult. This decorator can be removed once the MCP SDK adds native CreateTaskResult support for resources. #### `get_prompt` ```python get_prompt(self) -> Callable[[Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]], Callable[[str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult]]] ``` Decorator for registering a get_prompt handler with CreateTaskResult support. The MCP SDK's get_prompt decorator does not support returning CreateTaskResult for background task execution. This decorator wraps the result in ServerResult. This decorator can be removed once the MCP SDK adds native CreateTaskResult support for prompts. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.middleware` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-authorization.mdx ================================================ --- title: authorization sidebarTitle: authorization --- # `fastmcp.server.middleware.authorization` Authorization middleware for FastMCP. This module provides middleware-based authorization using callable auth checks. AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware # Require specific scope for all components mcp = FastMCP(middleware=[ AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope mcp = FastMCP(middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ]) ``` ## Classes ### `AuthMiddleware` Global authorization middleware using callable checks. This middleware applies auth checks to all components (tools, resources, prompts) on the server. It uses the same callable API as component-level auth checks. The middleware: - Filters tools/resources/prompts from list responses based on auth checks - Checks auth before tool execution, resource read, and prompt render - Skips all auth checks for STDIO transport (no OAuth concept) **Args:** - `auth`: A single auth check function or list of check functions. All checks must pass for authorization to succeed (AND logic). **Methods:** #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` Filter tools/list response based on auth checks. #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult ``` Check auth before tool execution. #### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] ``` Filter resources/list response based on auth checks. #### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult]) -> ResourceResult ``` Check auth before resource read. #### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] ``` Filter resource templates/list response based on auth checks. #### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] ``` Filter prompts/list response based on auth checks. #### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult ``` Check auth before prompt render. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-caching.mdx ================================================ --- title: caching sidebarTitle: caching --- # `fastmcp.server.middleware.caching` A middleware for response caching. ## Classes ### `CachableResourceContent` A wrapper for ResourceContent that can be cached. ### `CachableResourceResult` A wrapper for ResourceResult that can be cached. **Methods:** #### `get_size` ```python get_size(self) -> int ``` #### `wrap` ```python wrap(cls, value: ResourceResult) -> Self ``` #### `unwrap` ```python unwrap(self) -> ResourceResult ``` ### `CachableToolResult` **Methods:** #### `wrap` ```python wrap(cls, value: ToolResult) -> Self ``` #### `unwrap` ```python unwrap(self) -> ToolResult ``` ### `CachableMessage` A wrapper for Message that can be cached. ### `CachablePromptResult` A wrapper for PromptResult that can be cached. **Methods:** #### `get_size` ```python get_size(self) -> int ``` #### `wrap` ```python wrap(cls, value: PromptResult) -> Self ``` #### `unwrap` ```python unwrap(self) -> PromptResult ``` ### `SharedMethodSettings` Shared config for a cache method. ### `ListToolsSettings` Configuration options for Tool-related caching. ### `ListResourcesSettings` Configuration options for Resource-related caching. ### `ListPromptsSettings` Configuration options for Prompt-related caching. ### `CallToolSettings` Configuration options for Tool-related caching. ### `ReadResourceSettings` Configuration options for Resource-related caching. ### `GetPromptSettings` Configuration options for Prompt-related caching. ### `ResponseCachingStatistics` ### `ResponseCachingMiddleware` The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching but cache implementations may offer additional features like LRU eviction, size limits, and more. When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects this means that response caching may not be compatible with other middleware that expects original subclasses. Notes: - Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests. - Cache keys are derived from method name and arguments. **Methods:** #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mcp.types.ListResourcesRequest], call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] ``` List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mcp.types.ListPromptsRequest], call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] ``` List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult ``` Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult]) -> ResourceResult ``` Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult]) -> PromptResult ``` Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled. #### `statistics` ```python statistics(self) -> ResponseCachingStatistics ``` Get the statistics for the cache. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-dereference.mdx ================================================ --- title: dereference sidebarTitle: dereference --- # `fastmcp.server.middleware.dereference` Middleware that dereferences $ref in JSON schemas before sending to clients. ## Classes ### `DereferenceRefsMiddleware` Dereferences $ref in component schemas before sending to clients. Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref properly. This middleware inlines all $ref definitions so schemas are self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. **Methods:** #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` #### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] ``` ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-error_handling.mdx ================================================ --- title: error_handling sidebarTitle: error_handling --- # `fastmcp.server.middleware.error_handling` Error handling middleware for consistent error responses and tracking. ## Classes ### `ErrorHandlingMiddleware` Middleware that provides consistent error handling and logging. Catches exceptions, logs them appropriately, and converts them to proper MCP error responses. Also tracks error patterns for monitoring. **Methods:** #### `on_message` ```python on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Handle errors for all messages. #### `get_error_stats` ```python get_error_stats(self) -> dict[str, int] ``` Get error statistics for monitoring. ### `RetryMiddleware` Middleware that implements automatic retry logic for failed requests. Retries requests that fail with transient errors, using exponential backoff to avoid overwhelming the server or external dependencies. **Methods:** #### `on_request` ```python on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Implement retry logic for requests. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-logging.mdx ================================================ --- title: logging sidebarTitle: logging --- # `fastmcp.server.middleware.logging` Comprehensive logging middleware for FastMCP servers. ## Functions ### `default_serializer` ```python default_serializer(data: Any) -> str ``` The default serializer for Payloads in the logging middleware. ## Classes ### `BaseLoggingMiddleware` Base class for logging middleware. **Methods:** #### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` Log messages for configured methods. ### `LoggingMiddleware` Middleware that provides comprehensive request and response logging. Logs all MCP messages with configurable detail levels. Useful for debugging, monitoring, and understanding server usage patterns. ### `StructuredLoggingMiddleware` Middleware that provides structured JSON logging for better log analysis. Outputs structured logs that are easier to parse and analyze with log aggregation tools like ELK stack, Splunk, or cloud logging services. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-middleware.mdx ================================================ --- title: middleware sidebarTitle: middleware --- # `fastmcp.server.middleware.middleware` ## Functions ### `make_middleware_wrapper` ```python make_middleware_wrapper(middleware: Middleware, call_next: CallNext[T, R]) -> CallNext[T, R] ``` Create a wrapper that applies a single middleware to a context. The closure bakes in the middleware and call_next function, so it can be passed to other functions that expect a call_next function. ## Classes ### `CallNext` ### `MiddlewareContext` Unified context for all middleware operations. **Methods:** #### `copy` ```python copy(self, **kwargs: Any) -> MiddlewareContext[T] ``` ### `Middleware` Base class for FastMCP middleware with dispatching hooks. **Methods:** #### `on_message` ```python on_message(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any ``` #### `on_request` ```python on_request(self, context: MiddlewareContext[mt.Request[Any, Any]], call_next: CallNext[mt.Request[Any, Any], Any]) -> Any ``` #### `on_notification` ```python on_notification(self, context: MiddlewareContext[mt.Notification[Any, Any]], call_next: CallNext[mt.Notification[Any, Any], Any]) -> Any ``` #### `on_initialize` ```python on_initialize(self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None]) -> mt.InitializeResult | None ``` #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult ``` #### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult]) -> ResourceResult ``` #### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult]) -> PromptResult ``` #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` #### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]]) -> Sequence[Resource] ``` #### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate]]) -> Sequence[ResourceTemplate] ``` #### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]]) -> Sequence[Prompt] ``` ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-ping.mdx ================================================ --- title: ping sidebarTitle: ping --- # `fastmcp.server.middleware.ping` Ping middleware for keeping client connections alive. ## Classes ### `PingMiddleware` Middleware that sends periodic pings to keep client connections alive. Starts a background ping task on first message from each session. The task sends server-to-client pings at the configured interval until the session ends. **Methods:** #### `on_message` ```python on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Start ping task on first message from a session. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-rate_limiting.mdx ================================================ --- title: rate_limiting sidebarTitle: rate_limiting --- # `fastmcp.server.middleware.rate_limiting` Rate limiting middleware for protecting FastMCP servers from abuse. ## Classes ### `RateLimitError` Error raised when rate limit is exceeded. ### `TokenBucketRateLimiter` Token bucket implementation for rate limiting. **Methods:** #### `consume` ```python consume(self, tokens: int = 1) -> bool ``` Try to consume tokens from the bucket. **Args:** - `tokens`: Number of tokens to consume **Returns:** - True if tokens were available and consumed, False otherwise ### `SlidingWindowRateLimiter` Sliding window rate limiter implementation. **Methods:** #### `is_allowed` ```python is_allowed(self) -> bool ``` Check if a request is allowed. ### `RateLimitingMiddleware` Middleware that implements rate limiting to prevent server abuse. Uses a token bucket algorithm by default, allowing for burst traffic while maintaining a sustainable long-term rate. **Methods:** #### `on_request` ```python on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Apply rate limiting to requests. ### `SlidingWindowRateLimitingMiddleware` Middleware that implements sliding window rate limiting. Uses a sliding window approach which provides more precise rate limiting but uses more memory to track individual request timestamps. **Methods:** #### `on_request` ```python on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Apply sliding window rate limiting to requests. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-response_limiting.mdx ================================================ --- title: response_limiting sidebarTitle: response_limiting --- # `fastmcp.server.middleware.response_limiting` Response limiting middleware for controlling tool response sizes. ## Classes ### `ResponseLimitingMiddleware` Middleware that limits the response size of tool calls. Intercepts tool call responses and enforces size limits. If a response exceeds the limit, it extracts text content, truncates it, and returns a single TextContent block. **Methods:** #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult]) -> ToolResult ``` Intercept tool calls and limit response size. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-timing.mdx ================================================ --- title: timing sidebarTitle: timing --- # `fastmcp.server.middleware.timing` Timing middleware for measuring and logging request performance. ## Classes ### `TimingMiddleware` Middleware that logs the execution time of requests. Only measures and logs timing for request messages (not notifications). Provides insights into performance characteristics of your MCP server. **Methods:** #### `on_request` ```python on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time request execution and log the results. ### `DetailedTimingMiddleware` Enhanced timing middleware with per-operation breakdowns. Provides detailed timing information for different types of MCP operations, allowing you to identify performance bottlenecks in specific operations. **Methods:** #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time tool execution. #### `on_read_resource` ```python on_read_resource(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time resource reading. #### `on_get_prompt` ```python on_get_prompt(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time prompt retrieval. #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time tool listing. #### `on_list_resources` ```python on_list_resources(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time resource listing. #### `on_list_resource_templates` ```python on_list_resource_templates(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time resource template listing. #### `on_list_prompts` ```python on_list_prompts(self, context: MiddlewareContext, call_next: CallNext) -> Any ``` Time prompt listing. ================================================ FILE: docs/python-sdk/fastmcp-server-middleware-tool_injection.mdx ================================================ --- title: tool_injection sidebarTitle: tool_injection --- # `fastmcp.server.middleware.tool_injection` A middleware for injecting tools into the MCP server context. ## Functions ### `list_prompts` ```python list_prompts(context: Context) -> list[Prompt] ``` List prompts available on the server. ### `get_prompt` ```python get_prompt(context: Context, name: Annotated[str, 'The name of the prompt to render.'], arguments: Annotated[dict[str, Any] | None, 'The arguments to pass to the prompt.'] = None) -> mcp.types.GetPromptResult ``` Render a prompt available on the server. ### `list_resources` ```python list_resources(context: Context) -> list[mcp.types.Resource] ``` List resources available on the server. ### `read_resource` ```python read_resource(context: Context, uri: Annotated[AnyUrl | str, 'The URI of the resource to read.']) -> ResourceResult ``` Read a resource available on the server. ## Classes ### `ToolInjectionMiddleware` A middleware for injecting tools into the context. **Methods:** #### `on_list_tools` ```python on_list_tools(self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]]) -> Sequence[Tool] ``` Inject tools into the response. #### `on_call_tool` ```python on_call_tool(self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult]) -> ToolResult ``` Intercept tool calls to injected tools. ### `PromptToolMiddleware` A middleware for injecting prompts as tools into the context. .. deprecated:: Use ``fastmcp.server.transforms.PromptsAsTools`` instead. ### `ResourceToolMiddleware` A middleware for injecting resources as tools into the context. .. deprecated:: Use ``fastmcp.server.transforms.ResourcesAsTools`` instead. ================================================ FILE: docs/python-sdk/fastmcp-server-mixins-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.mixins` Server mixins for FastMCP. ================================================ FILE: docs/python-sdk/fastmcp-server-mixins-lifespan.mdx ================================================ --- title: lifespan sidebarTitle: lifespan --- # `fastmcp.server.mixins.lifespan` Lifespan and Docket task infrastructure for FastMCP Server. ## Classes ### `LifespanMixin` Mixin providing lifespan and Docket task infrastructure for FastMCP. **Methods:** #### `docket` ```python docket(self: FastMCP) -> Docket | None ``` Get the Docket instance if Docket support is enabled. Returns None if Docket is not enabled or server hasn't been started yet. ================================================ FILE: docs/python-sdk/fastmcp-server-mixins-mcp_operations.mdx ================================================ --- title: mcp_operations sidebarTitle: mcp_operations --- # `fastmcp.server.mixins.mcp_operations` MCP protocol handler setup and wire-format handlers for FastMCP Server. ## Classes ### `MCPOperationsMixin` Mixin providing MCP protocol handler setup and wire-format handlers. Note: Methods registered with SDK decorators (e.g., _list_tools_mcp, _call_tool_mcp) cannot use `self: FastMCP` type hints because the SDK's `get_type_hints()` fails to resolve FastMCP at runtime (it's only available under TYPE_CHECKING). When type hints fail to resolve, the SDK falls back to calling handlers with no arguments. These methods use untyped `self` to avoid this issue. ================================================ FILE: docs/python-sdk/fastmcp-server-mixins-transport.mdx ================================================ --- title: transport sidebarTitle: transport --- # `fastmcp.server.mixins.transport` Transport-related methods for FastMCP Server. ## Classes ### `TransportMixin` Mixin providing transport-related methods for FastMCP. Includes HTTP/stdio/SSE transport handling and custom HTTP routes. **Methods:** #### `run_async` ```python run_async(self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any) -> None ``` Run the FastMCP server asynchronously. **Args:** - `transport`: Transport protocol to use ("stdio", "http", "sse", or "streamable-http") - `show_banner`: Whether to display the server banner. If None, uses the FASTMCP_SHOW_SERVER_BANNER setting (default\: True). #### `run` ```python run(self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any) -> None ``` Run the FastMCP server. Note this is a synchronous function. **Args:** - `transport`: Transport protocol to use ("http", "stdio", "sse", or "streamable-http") - `show_banner`: Whether to display the server banner. If None, uses the FASTMCP_SHOW_SERVER_BANNER setting (default\: True). #### `custom_route` ```python custom_route(self: FastMCP, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True) -> Callable[[Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]]] ``` Decorator to register a custom HTTP route on the FastMCP server. Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, which can be useful for OAuth callbacks, health checks, or admin APIs. The handler function must be an async function that accepts a Starlette Request and returns a Response. **Args:** - `path`: URL path for the route (e.g., "/auth/callback") - `methods`: List of HTTP methods to support (e.g., ["GET", "POST"]) - `name`: Optional name for the route (to reference this route with Starlette's reverse URL lookup feature) - `include_in_schema`: Whether to include in OpenAPI schema, defaults to True #### `run_stdio_async` ```python run_stdio_async(self: FastMCP, show_banner: bool = True, log_level: str | None = None, stateless: bool = False) -> None ``` Run the server using stdio transport. **Args:** - `show_banner`: Whether to display the server banner - `log_level`: Log level for the server - `stateless`: Whether to run in stateless mode (no session initialization) #### `run_http_async` ```python run_http_async(self: FastMCP, 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, json_response: bool | None = None, stateless_http: bool | None = None, stateless: bool | None = None) -> None ``` Run the server using HTTP transport. **Args:** - `transport`: Transport protocol to use - "http" (default), "streamable-http", or "sse" - `host`: Host address to bind to (defaults to settings.host) - `port`: Port to bind to (defaults to settings.port) - `log_level`: Log level for the server (defaults to settings.log_level) - `path`: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) - `uvicorn_config`: Additional configuration for the Uvicorn server - `middleware`: A list of middleware to apply to the app - `json_response`: Whether to use JSON response format (defaults to settings.json_response) - `stateless_http`: Whether to use stateless HTTP (defaults to settings.stateless_http) - `stateless`: Alias for stateless_http for CLI consistency #### `http_app` ```python http_app(self: FastMCP, 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', event_store: EventStore | None = None, retry_interval: int | None = None) -> StarletteWithLifespan ``` Create a Starlette app using the specified HTTP transport. **Args:** - `path`: The path for the HTTP endpoint - `middleware`: A list of middleware to apply to the app - `json_response`: Whether to use JSON response format - `stateless_http`: Whether to use stateless mode (new transport per request) - `transport`: Transport protocol to use - "http", "streamable-http", or "sse" - `event_store`: Optional event store for SSE polling/resumability. When set, enables clients to reconnect and resume receiving events after server-initiated disconnections. Only used with streamable-http transport. - `retry_interval`: Optional retry interval in milliseconds for SSE polling. Controls how quickly clients should reconnect after server-initiated disconnections. Requires event_store to be set. Only used with streamable-http transport. **Returns:** - A Starlette application configured with the specified transport ================================================ FILE: docs/python-sdk/fastmcp-server-openapi-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.openapi` OpenAPI server implementation for FastMCP. .. deprecated:: This module is deprecated. Import from fastmcp.server.providers.openapi instead. The recommended approach is to use OpenAPIProvider with FastMCP: from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("My API Server") mcp.add_provider(provider) FastMCPOpenAPI is still available but deprecated. ================================================ FILE: docs/python-sdk/fastmcp-server-openapi-components.mdx ================================================ --- title: components sidebarTitle: components --- # `fastmcp.server.openapi.components` OpenAPI component implementations - backwards compatibility stub. This module is deprecated. Import from fastmcp.server.providers.openapi instead. ================================================ FILE: docs/python-sdk/fastmcp-server-openapi-routing.mdx ================================================ --- title: routing sidebarTitle: routing --- # `fastmcp.server.openapi.routing` Route mapping logic for OpenAPI operations. .. deprecated:: This module is deprecated. Import from fastmcp.server.providers.openapi instead. ================================================ FILE: docs/python-sdk/fastmcp-server-openapi-server.mdx ================================================ --- title: server sidebarTitle: server --- # `fastmcp.server.openapi.server` FastMCPOpenAPI - backwards compatibility wrapper. This class is deprecated. Use FastMCP with OpenAPIProvider instead: from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("My API Server", providers=[provider]) ## Classes ### `FastMCPOpenAPI` FastMCP server implementation that creates components from an OpenAPI schema. .. deprecated:: Use FastMCP with OpenAPIProvider instead. This class will be removed in a future version. Example (deprecated): ```python from fastmcp.server.openapi import FastMCPOpenAPI import httpx server = FastMCPOpenAPI( openapi_spec=spec, client=httpx.AsyncClient(), ) ``` ================================================ FILE: docs/python-sdk/fastmcp-server-providers-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.providers` Providers for dynamic MCP components. This module provides the `Provider` abstraction for providing tools, resources, and prompts dynamically at runtime. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): def __init__(self, db_url: str): self.db = Database(db_url) async def _list_tools(self) -> list[Tool]: rows = await self.db.fetch("SELECT * FROM tools") return [self._make_tool(row) for row in rows] async def _get_tool(self, name: str) -> Tool | None: row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) return self._make_tool(row) if row else None mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) ``` ================================================ FILE: docs/python-sdk/fastmcp-server-providers-aggregate.mdx ================================================ --- title: aggregate sidebarTitle: aggregate --- # `fastmcp.server.providers.aggregate` AggregateProvider for combining multiple providers into one. This module provides `AggregateProvider`, a utility class that presents multiple providers as a single unified provider. Useful when you want to combine custom providers without creating a full FastMCP server. Example: ```python from fastmcp.server.providers import AggregateProvider # Combine multiple providers into one combined = AggregateProvider() combined.add_provider(provider1) combined.add_provider(provider2, namespace="api") # Tools become "api_foo" # Use like any other provider tools = await combined.list_tools() ``` ## Classes ### `AggregateProvider` Utility provider that combines multiple providers into one. Components are aggregated from all providers. For get_* operations, providers are queried in parallel and the highest version is returned. When adding providers with a namespace, wrap_transform() is used to apply the Namespace transform. This means namespace transformation is handled by the wrapped provider, not by AggregateProvider. Errors from individual providers are logged and skipped (graceful degradation). **Methods:** #### `add_provider` ```python add_provider(self, provider: Provider) -> None ``` Add a provider with optional namespace. If the provider is a FastMCP server, it's automatically wrapped in FastMCPProvider to ensure middleware is invoked correctly. **Args:** - `provider`: The provider to add. - `namespace`: Optional namespace prefix. When set\: - Tools become "namespace_toolname" - Resources become "protocol\://namespace/path" - Prompts become "namespace_promptname" #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Get all task-eligible components from all providers. #### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` Combine lifespans of all providers. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.server.providers.base` Base Provider class for dynamic MCP components. This module provides the `Provider` abstraction for providing tools, resources, and prompts dynamically at runtime. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): def __init__(self, db_url: str): super().__init__() self.db = Database(db_url) async def _list_tools(self) -> list[Tool]: rows = await self.db.fetch("SELECT * FROM tools") return [self._make_tool(row) for row in rows] async def _get_tool(self, name: str) -> Tool | None: row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) return self._make_tool(row) if row else None mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) ``` ## Classes ### `Provider` Base class for dynamic component providers. Subclass and override whichever methods you need. Default implementations return empty lists / None, so you only need to implement what your provider supports. **Methods:** #### `transforms` ```python transforms(self) -> list[Transform] ``` All transforms applied to components from this provider. #### `add_transform` ```python add_transform(self, transform: Transform) -> None ``` Add a transform to this provider. Transforms modify components (tools, resources, prompts) as they flow through the provider. They're applied in order - first added is innermost. **Args:** - `transform`: The transform to add. #### `wrap_transform` ```python wrap_transform(self, transform: Transform) -> Provider ``` Return a new provider with this transform applied (immutable). Unlike add_transform() which mutates this provider, wrap_transform() returns a new provider that wraps this one. The original provider is unchanged. This is useful when you want to apply transforms without side effects, such as adding the same provider to multiple aggregators with different namespaces. **Args:** - `transform`: The transform to apply. **Returns:** - A new provider that wraps this one with the transform applied. #### `list_tools` ```python list_tools(self) -> Sequence[Tool] ``` List tools with all transforms applied. Applies transforms sequentially: base → transforms (in order). Each transform receives the result from the previous transform. Components may be marked as disabled but are NOT filtered here - filtering happens at the server level to allow session transforms to override. **Returns:** - Transformed sequence of tools (including disabled ones). #### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None ``` Get tool by transformed name with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete, allowing session-level transforms to override provider-level disables. **Args:** - `name`: The transformed tool name to look up. - `version`: Optional version filter. If None, returns highest version. **Returns:** - The tool if found (may be marked disabled), None if not found. #### `list_resources` ```python list_resources(self) -> Sequence[Resource] ``` List resources with all transforms applied. Components may be marked as disabled but are NOT filtered here. #### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None ``` Get resource by transformed URI with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. **Args:** - `uri`: The transformed resource URI to look up. - `version`: Optional version filter. If None, returns highest version. **Returns:** - The resource if found (may be marked disabled), None if not found. #### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] ``` List resource templates with all transforms applied. Components may be marked as disabled but are NOT filtered here. #### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None ``` Get resource template by transformed URI with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. **Args:** - `uri`: The transformed template URI to look up. - `version`: Optional version filter. If None, returns highest version. **Returns:** - The template if found (may be marked disabled), None if not found. #### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] ``` List prompts with all transforms applied. Components may be marked as disabled but are NOT filtered here. #### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None ``` Get prompt by transformed name with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. **Args:** - `name`: The transformed prompt name to look up. - `version`: Optional version filter. If None, returns highest version. **Returns:** - The prompt if found (may be marked disabled), None if not found. #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Return components that should be registered as background tasks. Override to customize which components are task-eligible. Default calls list_* methods, applies provider transforms, and filters for components with task_config.mode != 'forbidden'. Used by the server during startup to register functions with Docket. #### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` User-overridable lifespan for custom setup and teardown. Override this method to perform provider-specific initialization like opening database connections, setting up external resources, or other state management needed for the provider's lifetime. The lifespan scope matches the server's lifespan - code before yield runs at startup, code after yield runs at shutdown. #### `enable` ```python enable(self) -> Self ``` Enable components matching all specified criteria. Adds a visibility transform that marks matching components as enabled. Later transforms override earlier ones, so enable after disable makes the component enabled. With only=True, switches to allowlist mode - first disables everything, then enables matching components. **Args:** - `names`: Component names or URIs to enable. - `keys`: Component keys to enable (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to enable (e.g., VersionSpec(eq="v1") or VersionSpec(gte="v2")). Unversioned components will not match. - `tags`: Enable components with these tags. - `components`: Component types to include (e.g., {"tool", "prompt"}). - `only`: If True, ONLY enable matching components (allowlist mode). **Returns:** - Self for method chaining. #### `disable` ```python disable(self) -> Self ``` Disable components matching all specified criteria. Adds a visibility transform that marks matching components as disabled. Components can be re-enabled by calling enable() with matching criteria (the later transform wins). **Args:** - `names`: Component names or URIs to disable. - `keys`: Component keys to disable (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to disable (e.g., VersionSpec(eq="v1") or VersionSpec(gte="v2")). Unversioned components will not match. - `tags`: Disable components with these tags. - `components`: Component types to include (e.g., {"tool", "prompt"}). **Returns:** - Self for method chaining. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx ================================================ --- title: fastmcp_provider sidebarTitle: fastmcp_provider --- # `fastmcp.server.providers.fastmcp_provider` FastMCPProvider for wrapping FastMCP servers as providers. This module provides the `FastMCPProvider` class that wraps a FastMCP server and exposes its components through the Provider interface. It also provides FastMCPProvider* component classes that delegate execution to the wrapped server's middleware, ensuring middleware runs when components are executed. ## Classes ### `FastMCPProviderTool` Tool that delegates execution to a wrapped server's middleware. When `run()` is called, this tool invokes the wrapped server's `_call_tool_middleware()` method, ensuring the server's middleware chain is executed. **Methods:** #### `wrap` ```python wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool ``` Wrap a Tool to delegate execution to the server's middleware. #### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult ``` Delegate to child server's call_tool() without task_meta. This is called when the tool is used within a TransformedTool forwarding function or other contexts where task_meta is not available. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `FastMCPProviderResource` Resource that delegates reading to a wrapped server's read_resource(). When `read()` is called, this resource invokes the wrapped server's `read_resource()` method, ensuring the server's middleware chain is executed. **Methods:** #### `wrap` ```python wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource ``` Wrap a Resource to delegate reading to the server's middleware. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `FastMCPProviderPrompt` Prompt that delegates rendering to a wrapped server's render_prompt(). When `render()` is called, this prompt invokes the wrapped server's `render_prompt()` method, ensuring the server's middleware chain is executed. **Methods:** #### `wrap` ```python wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt ``` Wrap a Prompt to delegate rendering to the server's middleware. #### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult ``` Delegate to child server's render_prompt() without task_meta. This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `FastMCPProviderResourceTemplate` Resource template that creates FastMCPProviderResources. When `create_resource()` is called, this template creates a FastMCPProviderResource that will invoke the wrapped server's middleware when read. **Methods:** #### `wrap` ```python wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTemplate ``` Wrap a ResourceTemplate to create FastMCPProviderResources. #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource ``` Create a FastMCPProviderResource for the given URI. The `uri` is the external/transformed URI (e.g., with namespace prefix). We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. #### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult ``` Read the resource content for background task execution. Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` No-op: the child's actual template is registered via get_tasks(). #### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution ``` Schedule this template for background execution via docket. The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `FastMCPProvider` Provider that wraps a FastMCP server. This provider enables mounting one FastMCP server onto another, exposing the mounted server's tools, resources, and prompts through the parent server. Components returned by this provider are wrapped in FastMCPProvider* classes that delegate execution to the wrapped server's middleware chain. This ensures middleware runs when components are executed. **Methods:** #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Return task-eligible components from the mounted server. Returns the child's ACTUAL components (not wrapped) so their actual functions get registered with Docket. Gets components with child server's transforms applied, then applies this provider's transforms for correct registration keys. #### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` Start the mounted server's user lifespan. This starts only the wrapped server's user-defined lifespan, NOT its full _lifespan_manager() (which includes Docket). The parent server's Docket handles all background tasks. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-filesystem.mdx ================================================ --- title: filesystem sidebarTitle: filesystem --- # `fastmcp.server.providers.filesystem` FileSystemProvider for filesystem-based component discovery. FileSystemProvider scans a directory for Python files, imports them, and registers any Tool, Resource, ResourceTemplate, or Prompt objects found. Components are created using the standalone decorators from fastmcp.tools, fastmcp.resources, and fastmcp.prompts: Example: ```python # In mcp/tools.py from fastmcp.tools import tool @tool def greet(name: str) -> str: return f"Hello, {name}!" # In main.py from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) ``` ## Classes ### `FileSystemProvider` Provider that discovers components from the filesystem. Scans a directory for Python files and registers any Tool, Resource, ResourceTemplate, or Prompt objects found. Components are created using the standalone decorators: - @tool from fastmcp.tools - @resource from fastmcp.resources - @prompt from fastmcp.prompts **Args:** - `root`: Root directory to scan. Defaults to current directory. - `reload`: If True, re-scan files on every request (dev mode). Defaults to False (scan once at init, cache results). ================================================ FILE: docs/python-sdk/fastmcp-server-providers-filesystem_discovery.mdx ================================================ --- title: filesystem_discovery sidebarTitle: filesystem_discovery --- # `fastmcp.server.providers.filesystem_discovery` File discovery and module import utilities for filesystem-based routing. This module provides functions to: 1. Discover Python files in a directory tree 2. Import modules (as packages if __init__.py exists, else directly) 3. Extract decorated components (Tool, Resource, Prompt objects) from imported modules ## Functions ### `discover_files` ```python discover_files(root: Path) -> list[Path] ``` Recursively discover all Python files under a directory. Excludes __init__.py files (they're for package structure, not components). **Args:** - `root`: Root directory to scan. **Returns:** - List of .py file paths, sorted for deterministic order. ### `import_module_from_file` ```python import_module_from_file(file_path: Path) -> ModuleType ``` Import a Python file as a module. If the file is part of a package (directory has __init__.py), imports it as a proper package member (relative imports work). Otherwise, imports directly using spec_from_file_location. **Args:** - `file_path`: Path to the Python file. **Returns:** - The imported module. **Raises:** - `ImportError`: If the module cannot be imported. ### `extract_components` ```python extract_components(module: ModuleType) -> list[FastMCPComponent] ``` Extract all MCP components from a module. Scans all module attributes for instances of Tool, Resource, ResourceTemplate, or Prompt objects created by standalone decorators, or functions decorated with @tool/@resource/@prompt that have __fastmcp__ metadata. **Args:** - `module`: The imported module to scan. **Returns:** - List of component objects (Tool, Resource, ResourceTemplate, Prompt). ### `discover_and_import` ```python discover_and_import(root: Path) -> DiscoveryResult ``` Discover files, import modules, and extract components. This is the main entry point for filesystem-based discovery. **Args:** - `root`: Root directory to scan. **Returns:** - DiscoveryResult with components and any failed files. ## Classes ### `DiscoveryResult` Result of filesystem discovery. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.providers.local_provider` LocalProvider for locally-defined MCP components. This module provides the `LocalProvider` class that manages tools, resources, templates, and prompts registered via decorators or direct methods. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-decorators-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.providers.local_provider.decorators` Decorator mixins for LocalProvider. This module provides mixin classes that add decorator functionality to LocalProvider for tools, resources, templates, and prompts. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-decorators-prompts.mdx ================================================ --- title: prompts sidebarTitle: prompts --- # `fastmcp.server.providers.local_provider.decorators.prompts` Prompt decorator mixin for LocalProvider. This module provides the PromptDecoratorMixin class that adds prompt registration functionality to LocalProvider. ## Classes ### `PromptDecoratorMixin` Mixin class providing prompt decorator functionality for LocalProvider. This mixin contains all methods related to: - Prompt registration via add_prompt() - Prompt decorator (@provider.prompt) **Methods:** #### `add_prompt` ```python add_prompt(self: LocalProvider, prompt: Prompt | Callable[..., Any]) -> Prompt ``` Add a prompt to this provider's storage. Accepts either a Prompt object or a decorated function with __fastmcp__ metadata. #### `prompt` ```python prompt(self: LocalProvider, name_or_fn: F) -> F ``` #### `prompt` ```python prompt(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `prompt` ```python prompt(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] ``` Decorator to register a prompt. This decorator supports multiple calling patterns: - @provider.prompt (without parentheses) - @provider.prompt() (with empty parentheses) - @provider.prompt("custom_name") (with name as first argument) - @provider.prompt(name="custom_name") (with name as keyword argument) - provider.prompt(function, name="custom_name") (direct function call) **Args:** - `name_or_fn`: Either a function (when used as @prompt), a string name, or None - `name`: Optional name for the prompt (keyword-only, alternative to name_or_fn) - `title`: Optional title for the prompt - `description`: Optional description of what the prompt does - `icons`: Optional icons for the prompt - `tags`: Optional set of tags for categorizing the prompt - `enabled`: Whether the prompt is enabled (default True). If False, adds to blocklist. - `meta`: Optional meta information about the prompt - `task`: Optional task configuration for background execution - `auth`: Optional authorization checks for the prompt **Returns:** - The registered FunctionPrompt or a decorator function. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-decorators-resources.mdx ================================================ --- title: resources sidebarTitle: resources --- # `fastmcp.server.providers.local_provider.decorators.resources` Resource decorator mixin for LocalProvider. This module provides the ResourceDecoratorMixin class that adds resource and template registration functionality to LocalProvider. ## Classes ### `ResourceDecoratorMixin` Mixin class providing resource decorator functionality for LocalProvider. This mixin contains all methods related to: - Resource registration via add_resource() - Resource template registration via add_template() - Resource decorator (@provider.resource) **Methods:** #### `add_resource` ```python add_resource(self: LocalProvider, resource: Resource | ResourceTemplate | Callable[..., Any]) -> Resource | ResourceTemplate ``` Add a resource to this provider's storage. Accepts either a Resource/ResourceTemplate object or a decorated function with __fastmcp__ metadata. #### `add_template` ```python add_template(self: LocalProvider, template: ResourceTemplate) -> ResourceTemplate ``` Add a resource template to this provider's storage. #### `resource` ```python resource(self: LocalProvider, uri: str) -> Callable[[F], F] ``` Decorator to register a function as a resource. If the URI contains parameters (e.g. "resource://{param}") or the function has parameters, it will be registered as a template resource. **Args:** - `uri`: URI for the resource (e.g. "resource\://my-resource" or "resource\://{param}") - `name`: Optional name for the resource - `title`: Optional title for the resource - `description`: Optional description of the resource - `icons`: Optional icons for the resource - `mime_type`: Optional MIME type for the resource - `tags`: Optional set of tags for categorizing the resource - `enabled`: Whether the resource is enabled (default True). If False, adds to blocklist. - `annotations`: Optional annotations about the resource's behavior - `meta`: Optional meta information about the resource - `task`: Optional task configuration for background execution - `auth`: Optional authorization checks for the resource **Returns:** - A decorator function. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-decorators-tools.mdx ================================================ --- title: tools sidebarTitle: tools --- # `fastmcp.server.providers.local_provider.decorators.tools` Tool decorator mixin for LocalProvider. This module provides the ToolDecoratorMixin class that adds tool registration functionality to LocalProvider. ## Classes ### `ToolDecoratorMixin` Mixin class providing tool decorator functionality for LocalProvider. This mixin contains all methods related to: - Tool registration via add_tool() - Tool decorator (@provider.tool) **Methods:** #### `add_tool` ```python add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool ``` Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. #### `tool` ```python tool(self: LocalProvider, name_or_fn: F) -> F ``` #### `tool` ```python tool(self: LocalProvider, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `tool` ```python tool(self: LocalProvider, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] ``` Decorator to register a tool. This decorator supports multiple calling patterns: - @provider.tool (without parentheses) - @provider.tool() (with empty parentheses) - @provider.tool("custom_name") (with name as first argument) - @provider.tool(name="custom_name") (with name as keyword argument) - provider.tool(function, name="custom_name") (direct function call) **Args:** - `name_or_fn`: Either a function (when used as @tool), a string name, or None - `name`: Optional name for the tool (keyword-only, alternative to name_or_fn) - `title`: Optional title for the tool - `description`: Optional description of what the tool does - `icons`: Optional icons for the tool - `tags`: Optional set of tags for categorizing the tool - `output_schema`: Optional JSON schema for the tool's output - `annotations`: Optional annotations about the tool's behavior - `exclude_args`: Optional list of argument names to exclude from the tool schema - `meta`: Optional meta information about the tool - `enabled`: Whether the tool is enabled (default True). If False, adds to blocklist. - `task`: Optional task configuration for background execution - `serializer`: Deprecated. Return ToolResult from your tools for full control over serialization. **Returns:** - The registered FunctionTool or a decorator function. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-local_provider-local_provider.mdx ================================================ --- title: local_provider sidebarTitle: local_provider --- # `fastmcp.server.providers.local_provider.local_provider` LocalProvider for locally-defined MCP components. This module provides the `LocalProvider` class that manages tools, resources, templates, and prompts registered via decorators or direct methods. LocalProvider can be used standalone and attached to multiple servers: ```python from fastmcp.server.providers import LocalProvider # Create a reusable provider with tools provider = LocalProvider() @provider.tool def greet(name: str) -> str: return f"Hello, {name}!" # Attach to any server from fastmcp import FastMCP server1 = FastMCP("Server1", providers=[provider]) server2 = FastMCP("Server2", providers=[provider]) ``` ## Classes ### `LocalProvider` Provider for locally-defined components. Supports decorator-based registration (`@provider.tool`, `@provider.resource`, `@provider.prompt`) and direct object registration methods. When used standalone, LocalProvider uses default settings. When attached to a FastMCP server via the server's decorators, server-level settings like `_tool_serializer` and `_support_tasks_by_default` are injected. **Methods:** #### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None ``` Remove tool(s) from this provider's storage. **Args:** - `name`: The tool name. - `version`: If None, removes ALL versions. If specified, removes only that version. **Raises:** - `KeyError`: If no matching tool is found. #### `remove_resource` ```python remove_resource(self, uri: str, version: str | None = None) -> None ``` Remove resource(s) from this provider's storage. **Args:** - `uri`: The resource URI. - `version`: If None, removes ALL versions. If specified, removes only that version. **Raises:** - `KeyError`: If no matching resource is found. #### `remove_template` ```python remove_template(self, uri_template: str, version: str | None = None) -> None ``` Remove resource template(s) from this provider's storage. **Args:** - `uri_template`: The template URI pattern. - `version`: If None, removes ALL versions. If specified, removes only that version. **Raises:** - `KeyError`: If no matching template is found. #### `remove_prompt` ```python remove_prompt(self, name: str, version: str | None = None) -> None ``` Remove prompt(s) from this provider's storage. **Args:** - `name`: The prompt name. - `version`: If None, removes ALL versions. If specified, removes only that version. **Raises:** - `KeyError`: If no matching prompt is found. #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Return components eligible for background task execution. Returns components that have task_config.mode != 'forbidden'. This includes both FunctionTool/Resource/Prompt instances created via decorators and custom Tool/Resource/Prompt subclasses. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-openapi-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.providers.openapi` OpenAPI provider for FastMCP. This module provides OpenAPI integration for FastMCP through the Provider pattern. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("API Server", providers=[provider]) ``` ================================================ FILE: docs/python-sdk/fastmcp-server-providers-openapi-components.mdx ================================================ --- title: components sidebarTitle: components --- # `fastmcp.server.providers.openapi.components` OpenAPI component classes: Tool, Resource, and ResourceTemplate. ## Classes ### `OpenAPITool` Tool implementation for OpenAPI endpoints. **Methods:** #### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult ``` Execute the HTTP request using RequestDirector. ### `OpenAPIResource` Resource implementation for OpenAPI endpoints. **Methods:** #### `read` ```python read(self) -> ResourceResult ``` Fetch the resource data by making an HTTP request. ### `OpenAPIResourceTemplate` Resource template implementation for OpenAPI endpoints. **Methods:** #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> Resource ``` Create a resource with the given parameters. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-openapi-provider.mdx ================================================ --- title: provider sidebarTitle: provider --- # `fastmcp.server.providers.openapi.provider` OpenAPIProvider for creating MCP components from OpenAPI specifications. ## Classes ### `OpenAPIProvider` Provider that creates MCP components from an OpenAPI specification. Components are created eagerly during initialization by parsing the OpenAPI spec. Each component makes HTTP calls to the described API endpoints. **Methods:** #### `lifespan` ```python lifespan(self) -> AsyncIterator[None] ``` Manage the lifecycle of the auto-created httpx client. #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Return empty list - OpenAPI components don't support tasks. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-openapi-routing.mdx ================================================ --- title: routing sidebarTitle: routing --- # `fastmcp.server.providers.openapi.routing` Route mapping logic for OpenAPI operations. ## Classes ### `MCPType` Type of FastMCP component to create from a route. ### `RouteMap` Mapping configuration for HTTP routes to FastMCP component types. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-proxy.mdx ================================================ --- title: proxy sidebarTitle: proxy --- # `fastmcp.server.providers.proxy` ProxyProvider for proxying to remote MCP servers. This module provides the `ProxyProvider` class that proxies components from a remote MCP server via a client factory. It also provides proxy component classes that forward execution to remote servers. ## Functions ### `default_proxy_roots_handler` ```python default_proxy_roots_handler(context: RequestContext[ClientSession, LifespanContextT]) -> RootsList ``` Forward list roots request from remote server to proxy's connected clients. ### `default_proxy_sampling_handler` ```python default_proxy_sampling_handler(messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> mcp.types.CreateMessageResult ``` Forward sampling request from remote server to proxy's connected clients. ### `default_proxy_elicitation_handler` ```python default_proxy_elicitation_handler(message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT]) -> ElicitResult ``` Forward elicitation request from remote server to proxy's connected clients. ### `default_proxy_log_handler` ```python default_proxy_log_handler(message: LogMessage) -> None ``` Forward log notification from remote server to proxy's connected clients. ### `default_proxy_progress_handler` ```python default_proxy_progress_handler(progress: float, total: float | None, message: str | None) -> None ``` Forward progress notification from remote server to proxy's connected clients. ## Classes ### `ProxyTool` A Tool that represents and executes a tool on a remote server. **Methods:** #### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyTool ``` Override to preserve _backend_name when name changes. #### `from_mcp_tool` ```python from_mcp_tool(cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool) -> ProxyTool ``` Factory method to create a ProxyTool from a raw MCP tool schema. #### `run` ```python run(self, arguments: dict[str, Any], context: Context | None = None) -> ToolResult ``` Executes the tool by making a call through the client. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `ProxyResource` A Resource that represents and reads a resource from a remote server. **Methods:** #### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyResource ``` Override to preserve _backend_uri when uri changes. #### `from_mcp_resource` ```python from_mcp_resource(cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.Resource) -> ProxyResource ``` Factory method to create a ProxyResource from a raw MCP resource schema. #### `read` ```python read(self) -> ResourceResult ``` Read the resource content from the remote server. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `ProxyTemplate` A ResourceTemplate that represents and creates resources from a remote server template. **Methods:** #### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyTemplate ``` Override to preserve _backend_uri_template when uri_template changes. #### `from_mcp_template` ```python from_mcp_template(cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate) -> ProxyTemplate ``` Factory method to create a ProxyTemplate from a raw MCP template schema. #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any], context: Context | None = None) -> ProxyResource ``` Create a resource from the template by calling the remote server. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `ProxyPrompt` A Prompt that represents and renders a prompt from a remote server. **Methods:** #### `model_copy` ```python model_copy(self, **kwargs: Any) -> ProxyPrompt ``` Override to preserve _backend_name when name changes. #### `from_mcp_prompt` ```python from_mcp_prompt(cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt) -> ProxyPrompt ``` Factory method to create a ProxyPrompt from a raw MCP prompt schema. #### `render` ```python render(self, arguments: dict[str, Any]) -> PromptResult ``` Render the prompt by making a call through the client. #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ### `ProxyProvider` Provider that proxies to a remote MCP server via a client factory. This provider fetches components from a remote server and returns Proxy* component instances that forward execution to the remote server. All components returned by this provider have task_config.mode="forbidden" because tasks cannot be executed through a proxy. Component lists (tools, resources, templates, prompts) are cached so that individual lookups (e.g. during ``call_tool``) can resolve from the cache instead of opening a new backend connection. The cache stores the backend's raw component metadata and is shared across all sessions; per-session visibility and auth filtering are applied after cache lookup by the server layer. The cache is refreshed whenever a ``list_*`` call is made, and entries expire after ``cache_ttl`` seconds (default 300). Set ``cache_ttl=0`` to disable caching. Disabling is recommended for backends whose component lists change dynamically. **Methods:** #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Return empty list since proxy components don't support tasks. Override the base implementation to avoid calling list_tools() during server lifespan initialization, which would open the client before any context is set. All Proxy* components have task_config.mode="forbidden". ### `FastMCPProxy` A FastMCP server that acts as a proxy to a remote MCP-compliant server. This is a convenience wrapper that creates a FastMCP server with a ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)). ### `ProxyClient` A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. Supports forwarding roots, sampling, elicitation, logging, and progress. ### `StatefulProxyClient` A proxy client that provides a stateful client factory for the proxy server. The stateful proxy client bound its copy to the server session. And it will be disconnected when the session is exited. This is useful to proxy a stateful mcp server such as the Playwright MCP server. Note that it is essential to ensure that the proxy server itself is also stateful. Because session reuse means the receive-loop task inherits a stale ``request_ctx`` ContextVar snapshot, the default proxy handlers are replaced with versions that restore the ContextVar before forwarding. ``ProxyTool.run`` stashes the current ``RequestContext`` in ``_proxy_rc_ref`` before each backend call, and the handlers consult it to detect (and correct) staleness. **Methods:** #### `clear` ```python clear(self) ``` Clear all cached clients and force disconnect them. #### `new_stateful` ```python new_stateful(self) -> Client[ClientTransportT] ``` Create a new stateful proxy client instance with the same configuration. Use this method as the client factory for stateful proxy server. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-skills-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.providers.skills` Skills providers for exposing agent skills as MCP resources. This module provides a two-layer architecture for skill discovery: - **SkillProvider**: Handles a single skill folder, exposing its files as resources. - **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder. - **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex, Gemini, Goose, Copilot, and OpenCode. Example: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider mcp = FastMCP("Skills Server") # Load a single skill mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing")) # Or load all skills in a directory mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ ``` ================================================ FILE: docs/python-sdk/fastmcp-server-providers-skills-claude_provider.mdx ================================================ --- title: claude_provider sidebarTitle: claude_provider --- # `fastmcp.server.providers.skills.claude_provider` Claude-specific skills provider for Claude Code skills. ## Classes ### `ClaudeSkillsProvider` Provider for Claude Code skills from ~/.claude/skills/. A convenience subclass that sets the default root to Claude's skills location. **Args:** - `reload`: If True, re-scan on every request. Defaults to False. - `supporting_files`: How supporting files are exposed\: - "template"\: Accessed via ResourceTemplate, hidden from list_resources(). - "resources"\: Each file exposed as individual Resource in list_resources(). ================================================ FILE: docs/python-sdk/fastmcp-server-providers-skills-directory_provider.mdx ================================================ --- title: directory_provider sidebarTitle: directory_provider --- # `fastmcp.server.providers.skills.directory_provider` Directory scanning provider for discovering multiple skills. ## Classes ### `SkillsDirectoryProvider` Provider that scans directories and creates a SkillProvider per skill folder. This extends AggregateProvider to combine multiple SkillProviders into one. Each subdirectory containing a main file (default: SKILL.md) becomes a skill. Can scan multiple root directories - if a skill name appears in multiple roots, the first one found wins. **Args:** - `roots`: Root directory(ies) containing skill folders. Can be a single path or a sequence of paths. - `reload`: If True, re-discover skills on each request. Defaults to False. - `main_file_name`: Name of the main skill file. Defaults to "SKILL.md". - `supporting_files`: How supporting files are exposed in child SkillProviders\: - "template"\: Accessed via ResourceTemplate, hidden from list_resources(). - "resources"\: Each file exposed as individual Resource in list_resources(). ================================================ FILE: docs/python-sdk/fastmcp-server-providers-skills-skill_provider.mdx ================================================ --- title: skill_provider sidebarTitle: skill_provider --- # `fastmcp.server.providers.skills.skill_provider` Basic skill provider for handling a single skill folder. ## Classes ### `SkillResource` A resource representing a skill's main file or manifest. **Methods:** #### `get_meta` ```python get_meta(self) -> dict[str, Any] ``` #### `read` ```python read(self) -> str | bytes | ResourceResult ``` Read the resource content. ### `SkillFileTemplate` A template for accessing files within a skill. **Methods:** #### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult ``` Read a file from the skill directory. #### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource ``` Create a resource for the given URI and parameters. Note: This is not typically used since _read() handles file reading directly. Provided for compatibility with the ResourceTemplate interface. ### `SkillFileResource` A resource representing a specific file within a skill. **Methods:** #### `get_meta` ```python get_meta(self) -> dict[str, Any] ``` #### `read` ```python read(self) -> str | bytes | ResourceResult ``` Read the file content. ### `SkillProvider` Provider that exposes a single skill folder as MCP resources. Each skill folder must contain a main file (default: SKILL.md) and may contain additional supporting files. Exposes: - A Resource for the main file (skill://{name}/SKILL.md) - A Resource for the synthetic manifest (skill://{name}/_manifest) - Supporting files via ResourceTemplate or Resources (configurable) **Args:** - `skill_path`: Path to the skill directory. - `main_file_name`: Name of the main skill file. Defaults to "SKILL.md". - `supporting_files`: How supporting files (everything except main file and manifest) are exposed to clients\: - "template"\: Accessed via ResourceTemplate, hidden from list_resources(). Clients discover files by reading the manifest first. - "resources"\: Each file exposed as individual Resource in list_resources(). Full enumeration upfront. **Methods:** #### `skill_info` ```python skill_info(self) -> SkillInfo ``` Get the loaded skill info. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-skills-vendor_providers.mdx ================================================ --- title: vendor_providers sidebarTitle: vendor_providers --- # `fastmcp.server.providers.skills.vendor_providers` Vendor-specific skills providers for various AI coding platforms. ## Classes ### `CursorSkillsProvider` Cursor skills from ~/.cursor/skills/. ### `VSCodeSkillsProvider` VS Code skills from ~/.copilot/skills/. ### `CodexSkillsProvider` Codex skills from /etc/codex/skills/ and ~/.codex/skills/. Scans both system-level and user-level directories. System skills take precedence if duplicates exist. ### `GeminiSkillsProvider` Gemini skills from ~/.gemini/skills/. ### `GooseSkillsProvider` Goose skills from ~/.config/agents/skills/. ### `CopilotSkillsProvider` GitHub Copilot skills from ~/.copilot/skills/. ### `OpenCodeSkillsProvider` OpenCode skills from ~/.config/opencode/skills/. ================================================ FILE: docs/python-sdk/fastmcp-server-providers-wrapped_provider.mdx ================================================ --- title: wrapped_provider sidebarTitle: wrapped_provider --- # `fastmcp.server.providers.wrapped_provider` WrappedProvider for immutable transform composition. This module provides `_WrappedProvider`, an internal class that wraps a provider with an additional transform. Created by `Provider.wrap_transform()`. ================================================ FILE: docs/python-sdk/fastmcp-server-proxy.mdx ================================================ --- title: proxy sidebarTitle: proxy --- # `fastmcp.server.proxy` Backwards compatibility - import from fastmcp.server.providers.proxy instead. This module re-exports all proxy-related classes from their new location at fastmcp.server.providers.proxy. Direct imports from this module are deprecated and will be removed in a future version. ================================================ FILE: docs/python-sdk/fastmcp-server-sampling-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.sampling` Sampling module for FastMCP servers. ================================================ FILE: docs/python-sdk/fastmcp-server-sampling-run.mdx ================================================ --- title: run sidebarTitle: run --- # `fastmcp.server.sampling.run` Sampling types and helper functions for FastMCP servers. ## Functions ### `determine_handler_mode` ```python determine_handler_mode(context: Context, needs_tools: bool) -> bool ``` Determine whether to use fallback handler or client for sampling. **Args:** - `context`: The MCP context. - `needs_tools`: Whether the sampling request requires tool support. **Returns:** - True if fallback handler should be used, False to use client. **Raises:** - `ValueError`: If client lacks required capability and no fallback configured. ### `call_sampling_handler` ```python call_sampling_handler(context: Context, messages: list[SamplingMessage]) -> CreateMessageResult | CreateMessageResultWithTools ``` Make LLM call using the fallback handler. Note: This function expects the caller (sample_step) to have validated that sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. ### `execute_tools` ```python execute_tools(tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None) -> list[ToolResultContent] ``` Execute tool calls and return results. **Args:** - `tool_calls`: List of tool use requests from the LLM. - `tool_map`: Mapping from tool name to SamplingTool. - `mask_error_details`: If True, mask detailed error messages from tool execution. When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. - `tool_concurrency`: Controls parallel execution of tools\: - None (default)\: Sequential execution (one at a time) - 0\: Unlimited parallel execution - N > 0\: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. **Returns:** - List of tool result content blocks in the same order as tool_calls. ### `prepare_messages` ```python prepare_messages(messages: str | Sequence[str | SamplingMessage]) -> list[SamplingMessage] ``` Convert various message formats to a list of SamplingMessage objects. ### `prepare_tools` ```python prepare_tools(tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None) -> list[SamplingTool] | None ``` Convert tools to SamplingTool objects. Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances, or plain callable functions. FunctionTool and TransformedTool are converted using from_callable_tool(), while plain functions use from_function(). **Args:** - `tools`: Sequence of tools to prepare. Can be SamplingTool, FunctionTool, TransformedTool, or plain callable functions. **Returns:** - List of SamplingTool instances, or None if tools is None. ### `extract_tool_calls` ```python extract_tool_calls(response: CreateMessageResult | CreateMessageResultWithTools) -> list[ToolUseContent] ``` Extract tool calls from a response. ### `create_final_response_tool` ```python create_final_response_tool(result_type: type) -> SamplingTool ``` Create a synthetic 'final_response' tool for structured output. This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. ### `sample_step_impl` ```python sample_step_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SampleStep ``` Implementation of Context.sample_step(). Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. ### `sample_impl` ```python sample_impl(context: Context, messages: str | Sequence[str | SamplingMessage]) -> SamplingResult[ResultT] ``` Implementation of Context.sample(). Send a sampling request to the client and await the response. This method runs to completion automatically, executing a tool loop until the LLM provides a final text response. ## Classes ### `SamplingResult` Result of a sampling operation. **Attributes:** - `text`: The text representation of the result (raw text or JSON for structured). - `result`: The typed result (str for text, parsed object for structured output). - `history`: All messages exchanged during sampling. ### `SampleStep` Result of a single sampling call. Represents what the LLM returned in this step plus the message history. **Methods:** #### `is_tool_use` ```python is_tool_use(self) -> bool ``` True if the LLM is requesting tool execution. #### `text` ```python text(self) -> str | None ``` Extract text from the response, if available. #### `tool_calls` ```python tool_calls(self) -> list[ToolUseContent] ``` Get the list of tool calls from the response. ================================================ FILE: docs/python-sdk/fastmcp-server-sampling-sampling_tool.mdx ================================================ --- title: sampling_tool sidebarTitle: sampling_tool --- # `fastmcp.server.sampling.sampling_tool` SamplingTool for use during LLM sampling requests. ## Classes ### `SamplingTool` A tool that can be used during LLM sampling. SamplingTools bundle a tool's schema (name, description, parameters) with an executor function, enabling servers to execute agentic workflows where the LLM can request tool calls during sampling. In most cases, pass functions directly to ctx.sample(): def search(query: str) -> str: '''Search the web.''' return web_search(query) result = await context.sample( messages="Find info about Python", tools=[search], # Plain functions work directly ) Create a SamplingTool explicitly when you need custom name/description: tool = SamplingTool.from_function(search, name="web_search") **Methods:** #### `run` ```python run(self, arguments: dict[str, Any] | None = None) -> Any ``` Execute the tool with the given arguments. **Args:** - `arguments`: Dictionary of arguments to pass to the tool function. **Returns:** - The result of executing the tool function. #### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> SamplingTool ``` Create a SamplingTool from a function. The function's signature is analyzed to generate a JSON schema for the tool's parameters. Type hints are used to determine parameter types. **Args:** - `fn`: The function to create a tool from. - `name`: Optional name override. Defaults to the function's name. - `description`: Optional description override. Defaults to the function's docstring. - `sequential`: If True, this tool requires sequential execution and prevents parallel execution of all tools in the batch. Set to True for tools with shared state, file writes, or other operations that cannot run concurrently. Defaults to False. **Returns:** - A SamplingTool wrapping the function. **Raises:** - `ValueError`: If the function is a lambda without a name override. #### `from_callable_tool` ```python from_callable_tool(cls, tool: FunctionTool | TransformedTool) -> SamplingTool ``` Create a SamplingTool from a FunctionTool or TransformedTool. Reuses existing server tools in sampling contexts. For TransformedTool, the tool's .run() method is used to ensure proper argument transformation, and the ToolResult is automatically unwrapped. **Args:** - `tool`: A FunctionTool or TransformedTool to convert. - `name`: Optional name override. Defaults to tool.name. - `description`: Optional description override. Defaults to tool.description. **Raises:** - `TypeError`: If the tool is not a FunctionTool or TransformedTool. ================================================ FILE: docs/python-sdk/fastmcp-server-server.mdx ================================================ --- title: server sidebarTitle: server --- # `fastmcp.server.server` FastMCP - A more ergonomic interface for MCP servers. ## Functions ### `default_lifespan` ```python default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any] ``` Default lifespan context manager that does nothing. **Args:** - `server`: The server instance this lifespan is managing **Returns:** - An empty dictionary as the lifespan result. ### `create_proxy` ```python create_proxy(target: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy ``` Create a FastMCP proxy server for the given target. This is the recommended way to create a proxy server. For lower-level control, use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.proxy`. **Args:** - `target`: The backend to proxy to. Can be\: - A Client instance (connected or disconnected) - A ClientTransport - A FastMCP server instance - A URL string or AnyUrl - A Path to a server script - An MCPConfig or dict - `**settings`: Additional settings passed to FastMCPProxy (name, etc.) **Returns:** - A FastMCPProxy server that proxies to the target. ## Classes ### `StateValue` Wrapper for stored context state values. ### `FastMCP` **Methods:** #### `name` ```python name(self) -> str ``` #### `instructions` ```python instructions(self) -> str | None ``` #### `instructions` ```python instructions(self, value: str | None) -> None ``` #### `version` ```python version(self) -> str | None ``` #### `website_url` ```python website_url(self) -> str | None ``` #### `icons` ```python icons(self) -> list[mcp.types.Icon] ``` #### `local_provider` ```python local_provider(self) -> LocalProvider ``` The server's local provider, which stores directly-registered components. Use this to remove components: mcp.local_provider.remove_tool("my_tool") mcp.local_provider.remove_resource("data://info") mcp.local_provider.remove_prompt("my_prompt") #### `add_middleware` ```python add_middleware(self, middleware: Middleware) -> None ``` #### `add_provider` ```python add_provider(self, provider: Provider) -> None ``` Add a provider for dynamic tools, resources, and prompts. Providers are queried in registration order. The first provider to return a non-None result wins. Static components (registered via decorators) always take precedence over providers. **Args:** - `provider`: A Provider instance that will provide components dynamically. - `namespace`: Optional namespace prefix. When set\: - Tools become "namespace_toolname" - Resources become "protocol\://namespace/path" - Prompts become "namespace_promptname" #### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] ``` Get task-eligible components with all transforms applied. Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. #### `add_transform` ```python add_transform(self, transform: Transform) -> None ``` Add a server-level transform. Server-level transforms are applied after all providers are aggregated. They transform tools, resources, and prompts from ALL providers. **Args:** - `transform`: The transform to add. #### `add_tool_transformation` ```python add_tool_transformation(self, tool_name: str, transformation: ToolTransformConfig) -> None ``` Add a tool transformation. .. deprecated:: Use ``add_transform(ToolTransform({...}))`` instead. #### `remove_tool_transformation` ```python remove_tool_transformation(self, _tool_name: str) -> None ``` Remove a tool transformation. .. deprecated:: Tool transformations are now immutable. Use enable/disable controls instead. #### `list_tools` ```python list_tools(self) -> Sequence[Tool] ``` List all enabled tools from providers. Overrides Provider.list_tools() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. #### `get_tool` ```python get_tool(self, name: str, version: VersionSpec | None = None) -> Tool | None ``` Get a tool by name, filtering disabled tools. Overrides Provider.get_tool() to add visibility filtering after all transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. **Args:** - `name`: The tool name. - `version`: Version filter (None returns highest version). **Returns:** - The tool if found and enabled, None otherwise. #### `list_resources` ```python list_resources(self) -> Sequence[Resource] ``` List all enabled resources from providers. Overrides Provider.list_resources() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. #### `get_resource` ```python get_resource(self, uri: str, version: VersionSpec | None = None) -> Resource | None ``` Get a resource by URI, filtering disabled resources. Overrides Provider.get_resource() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. **Args:** - `uri`: The resource URI. - `version`: Version filter (None returns highest version). **Returns:** - The resource if found and enabled, None otherwise. #### `list_resource_templates` ```python list_resource_templates(self) -> Sequence[ResourceTemplate] ``` List all enabled resource templates from providers. Overrides Provider.list_resource_templates() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. #### `get_resource_template` ```python get_resource_template(self, uri: str, version: VersionSpec | None = None) -> ResourceTemplate | None ``` Get a resource template by URI, filtering disabled templates. Overrides Provider.get_resource_template() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. **Args:** - `uri`: The template URI. - `version`: Version filter (None returns highest version). **Returns:** - The template if found and enabled, None otherwise. #### `list_prompts` ```python list_prompts(self) -> Sequence[Prompt] ``` List all enabled prompts from providers. Overrides Provider.list_prompts() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. #### `get_prompt` ```python get_prompt(self, name: str, version: VersionSpec | None = None) -> Prompt | None ``` Get a prompt by name, filtering disabled prompts. Overrides Provider.get_prompt() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. **Args:** - `name`: The prompt name. - `version`: Version filter (None returns highest version). **Returns:** - The prompt if found and enabled, None otherwise. #### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult ``` #### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` #### `call_tool` ```python call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> ToolResult | mcp.types.CreateTaskResult ``` Call a tool by name. This is the public API for executing tools. By default, middleware is applied. **Args:** - `name`: The tool name - `arguments`: Tool arguments (optional) - `version`: Specific version to call. If None, calls highest version. - `run_middleware`: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. - `task_meta`: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ToolResult. **Returns:** - ToolResult when task_meta is None. - CreateTaskResult when task_meta is provided. **Raises:** - `NotFoundError`: If tool not found or disabled - `ToolError`: If tool execution fails - `ValidationError`: If arguments fail validation #### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult ``` #### `read_resource` ```python read_resource(self, uri: str) -> mcp.types.CreateTaskResult ``` #### `read_resource` ```python read_resource(self, uri: str) -> ResourceResult | mcp.types.CreateTaskResult ``` Read a resource by URI. This is the public API for reading resources. By default, middleware is applied. Checks concrete resources first, then templates. **Args:** - `uri`: The resource URI - `version`: Specific version to read. If None, reads highest version. - `run_middleware`: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. - `task_meta`: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ResourceResult. **Returns:** - ResourceResult when task_meta is None. - CreateTaskResult when task_meta is provided. **Raises:** - `NotFoundError`: If resource not found or disabled - `ResourceError`: If resource read fails #### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult ``` #### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> mcp.types.CreateTaskResult ``` #### `render_prompt` ```python render_prompt(self, name: str, arguments: dict[str, Any] | None = None) -> PromptResult | mcp.types.CreateTaskResult ``` Render a prompt by name. This is the public API for rendering prompts. By default, middleware is applied. Use get_prompt() to retrieve the prompt definition without rendering. **Args:** - `name`: The prompt name - `arguments`: Prompt arguments (optional) - `version`: Specific version to render. If None, renders highest version. - `run_middleware`: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. - `task_meta`: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return PromptResult. **Returns:** - PromptResult when task_meta is None. - CreateTaskResult when task_meta is provided. **Raises:** - `NotFoundError`: If prompt not found or disabled - `PromptError`: If prompt rendering fails #### `add_tool` ```python add_tool(self, tool: Tool | Callable[..., Any]) -> Tool ``` Add a tool to the server. The tool function can optionally request a Context object by adding a parameter with the Context type annotation. See the @tool decorator for examples. **Args:** - `tool`: The Tool instance or @tool-decorated function to register **Returns:** - The tool instance that was added to the server. #### `remove_tool` ```python remove_tool(self, name: str, version: str | None = None) -> None ``` Remove tool(s) from the server. .. deprecated:: Use ``mcp.local_provider.remove_tool(name)`` instead. **Args:** - `name`: The name of the tool to remove. - `version`: If None, removes ALL versions. If specified, removes only that version. **Raises:** - `NotFoundError`: If no matching tool is found. #### `tool` ```python tool(self, name_or_fn: F) -> F ``` #### `tool` ```python tool(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `tool` ```python tool(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] ``` Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and resource access. This decorator supports multiple calling patterns: - @server.tool (without parentheses) - @server.tool (with empty parentheses) - @server.tool("custom_name") (with name as first argument) - @server.tool(name="custom_name") (with name as keyword argument) - server.tool(function, name="custom_name") (direct function call) **Args:** - `name_or_fn`: Either a function (when used as @tool), a string name, or None - `name`: Optional name for the tool (keyword-only, alternative to name_or_fn) - `description`: Optional description of what the tool does - `tags`: Optional set of tags for categorizing the tool - `output_schema`: Optional JSON schema for the tool's output - `annotations`: Optional annotations about the tool's behavior - `exclude_args`: Optional list of argument names to exclude from the tool schema. Deprecated\: Use `Depends()` for dependency injection instead. - `meta`: Optional meta information about the tool **Examples:** Register a tool with a custom name: ```python @server.tool def my_tool(x: int) -> str: return str(x) # Register a tool with a custom name @server.tool def my_tool(x: int) -> str: return str(x) @server.tool("custom_name") def my_tool(x: int) -> str: return str(x) @server.tool(name="custom_name") def my_tool(x: int) -> str: return str(x) # Direct function call server.tool(my_function, name="custom_name") ``` #### `add_resource` ```python add_resource(self, resource: Resource | Callable[..., Any]) -> Resource | ResourceTemplate ``` Add a resource to the server. **Args:** - `resource`: A Resource instance or @resource-decorated function to add **Returns:** - The resource instance that was added to the server. #### `add_template` ```python add_template(self, template: ResourceTemplate) -> ResourceTemplate ``` Add a resource template to the server. **Args:** - `template`: A ResourceTemplate instance to add **Returns:** - The template instance that was added to the server. #### `resource` ```python resource(self, uri: str) -> Callable[[F], F] ``` Decorator to register a function as a resource. The function will be called when the resource is read to generate its content. The function can return: - str for text content - bytes for binary content - other types will be converted to JSON Resources can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and session information. If the URI contains parameters (e.g. "resource://{param}") or the function has parameters, it will be registered as a template resource. **Args:** - `uri`: URI for the resource (e.g. "resource\://my-resource" or "resource\://{param}") - `name`: Optional name for the resource - `description`: Optional description of the resource - `mime_type`: Optional MIME type for the resource - `tags`: Optional set of tags for categorizing the resource - `annotations`: Optional annotations about the resource's behavior - `meta`: Optional meta information about the resource **Examples:** Register a resource with a custom name: ```python @server.resource("resource://my-resource") def get_data() -> str: return "Hello, world!" @server.resource("resource://my-resource") async get_data() -> str: data = await fetch_data() return f"Hello, world! {data}" @server.resource("resource://{city}/weather") def get_weather(city: str) -> str: return f"Weather for {city}" @server.resource("resource://{city}/weather") async def get_weather_with_context(city: str, ctx: Context) -> str: await ctx.info(f"Fetching weather for {city}") return f"Weather for {city}" @server.resource("resource://{city}/weather") async def get_weather(city: str) -> str: data = await fetch_weather(city) return f"Weather for {city}: {data}" ``` #### `add_prompt` ```python add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt ``` Add a prompt to the server. **Args:** - `prompt`: A Prompt instance or @prompt-decorated function to add **Returns:** - The prompt instance that was added to the server. #### `prompt` ```python prompt(self, name_or_fn: F) -> F ``` #### `prompt` ```python prompt(self, name_or_fn: str | None = None) -> Callable[[F], F] ``` #### `prompt` ```python prompt(self, name_or_fn: str | AnyFunction | None = None) -> Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] ``` Decorator to register a prompt. Prompts can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and session information. This decorator supports multiple calling patterns: - @server.prompt (without parentheses) - @server.prompt() (with empty parentheses) - @server.prompt("custom_name") (with name as first argument) - @server.prompt(name="custom_name") (with name as keyword argument) - server.prompt(function, name="custom_name") (direct function call) Args: name_or_fn: Either a function (when used as @prompt), a string name, or None name: Optional name for the prompt (keyword-only, alternative to name_or_fn) description: Optional description of what the prompt does tags: Optional set of tags for categorizing the prompt meta: Optional meta information about the prompt Examples: ```python @server.prompt def analyze_table(table_name: str) -> list[Message]: schema = read_table_schema(table_name) return [ { "role": "user", "content": f"Analyze this schema: {schema}" } ] @server.prompt() async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: await ctx.info(f"Analyzing table {table_name}") schema = read_table_schema(table_name) return [ { "role": "user", "content": f"Analyze this schema: {schema}" } ] @server.prompt("custom_name") async def analyze_file(path: str) -> list[Message]: content = await read_file(path) return [ { "role": "user", "content": { "type": "resource", "resource": { "uri": f"file://{path}", "text": content } } } ] @server.prompt(name="custom_name") def another_prompt(data: str) -> list[Message]: return [{"role": "user", "content": data}] # Direct function call server.prompt(my_function, name="custom_name") ``` #### `mount` ```python mount(self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None) -> None ``` Mount another FastMCP server on this server with an optional namespace. Unlike importing (with import_server), mounting establishes a dynamic connection between servers. When a client interacts with a mounted server's objects through the parent server, requests are forwarded to the mounted server in real-time. This means changes to the mounted server are immediately reflected when accessed through the parent. When a server is mounted with a namespace: - Tools from the mounted server are accessible with namespaced names. Example: If server has a tool named "get_weather", it will be available as "namespace_get_weather". - Resources are accessible with namespaced URIs. Example: If server has a resource with URI "weather://forecast", it will be available as "weather://namespace/forecast". - Templates are accessible with namespaced URI templates. Example: If server has a template with URI "weather://location/{id}", it will be available as "weather://namespace/location/{id}". - Prompts are accessible with namespaced names. Example: If server has a prompt named "weather_prompt", it will be available as "namespace_weather_prompt". When a server is mounted without a namespace (namespace=None), its tools, resources, templates, and prompts are accessible with their original names. Multiple servers can be mounted without namespaces, and they will be tried in order until a match is found. The mounted server's lifespan is executed when the parent server starts, and its middleware chain is invoked for all operations (tool calls, resource reads, prompts). **Args:** - `server`: The FastMCP server to mount. - `namespace`: Optional namespace to use for the mounted server's objects. If None, the server's objects are accessible with their original names. - `as_proxy`: Deprecated. Mounted servers now always have their lifespan and middleware invoked. To create a proxy server, use create_proxy() explicitly before mounting. - `tool_names`: Optional mapping of original tool names to custom names. Use this to override namespaced names. Keys are the original tool names from the mounted server. - `prefix`: Deprecated. Use namespace instead. #### `import_server` ```python import_server(self, server: FastMCP[LifespanResultT], prefix: str | None = None) -> None ``` Import the MCP objects from another FastMCP server into this one, optionally with a given prefix. .. deprecated:: Use :meth:`mount` instead. ``import_server`` will be removed in a future version. Note that when a server is *imported*, its objects are immediately registered to the importing server. This is a one-time operation and future changes to the imported server will not be reflected in the importing server. Server-level configurations and lifespans are not imported. When a server is imported with a prefix: - The tools are imported with prefixed names Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather" - The resources are imported with prefixed URIs using the new format Example: If server has a resource with URI "weather://forecast", it will be available as "weather://prefix/forecast" - The templates are imported with prefixed URI templates using the new format Example: If server has a template with URI "weather://location/{id}", it will be available as "weather://prefix/location/{id}" - The prompts are imported with prefixed names Example: If server has a prompt named "weather_prompt", it will be available as "prefix_weather_prompt" When a server is imported without a prefix (prefix=None), its tools, resources, templates, and prompts are imported with their original names. **Args:** - `server`: The FastMCP server to import - `prefix`: Optional prefix to use for the imported server's objects. If None, objects are imported with their original names. #### `from_openapi` ```python from_openapi(cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = 'OpenAPI Server', route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any) -> Self ``` Create a FastMCP server from an OpenAPI specification. **Args:** - `openapi_spec`: OpenAPI schema as a dictionary - `client`: Optional httpx AsyncClient for making HTTP requests. If not provided, a default client is created using the first server URL from the OpenAPI spec with a 30-second timeout. - `name`: Name for the MCP server - `route_maps`: Optional list of RouteMap objects defining route mappings - `route_map_fn`: Optional callable for advanced route type mapping - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names - `tags`: Optional set of tags to add to all components - `validate_output`: If True (default), tools use the output schema extracted from the OpenAPI spec for response validation. If False, a permissive schema is used instead, allowing any response structure while still returning structured JSON. - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. #### `from_fastapi` ```python from_fastapi(cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any) -> Self ``` Create a FastMCP server from a FastAPI application. **Args:** - `app`: FastAPI application instance - `name`: Name for the MCP server (defaults to app.title) - `route_maps`: Optional list of RouteMap objects defining route mappings - `route_map_fn`: Optional callable for advanced route type mapping - `mcp_component_fn`: Optional callable for component customization - `mcp_names`: Optional dictionary mapping operationId to component names - `httpx_client_kwargs`: Optional kwargs passed to httpx.AsyncClient. Use this to configure timeout and other client settings. - `tags`: Optional set of tags to add to all components - `**settings`: Additional settings passed to FastMCP **Returns:** - A FastMCP server with an OpenAPIProvider attached. #### `as_proxy` ```python as_proxy(cls, backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, **settings: Any) -> FastMCPProxy ``` Create a FastMCP proxy server for the given backend. .. deprecated:: Use :func:`fastmcp.server.create_proxy` instead. This method will be removed in a future version. The `backend` argument can be either an existing `fastmcp.client.Client` instance or any value accepted as the `transport` argument of `fastmcp.client.Client`. This mirrors the convenience of the `fastmcp.client.Client` constructor. #### `generate_name` ```python generate_name(cls, name: str | None = None) -> str ``` ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.tasks` MCP SEP-1686 background tasks support. This module implements protocol-level background task execution for MCP servers. ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-capabilities.mdx ================================================ --- title: capabilities sidebarTitle: capabilities --- # `fastmcp.server.tasks.capabilities` SEP-1686 task capabilities declaration. ## Functions ### `get_task_capabilities` ```python get_task_capabilities() -> ServerTasksCapability | None ``` Return the SEP-1686 task capabilities. Returns task capabilities as a first-class ServerCapabilities field, declaring support for list, cancel, and request operations per SEP-1686. Returns None if pydocket is not installed (no task support). Note: prompts/resources are passed via extra_data since the SDK types don't include them yet (FastMCP supports them ahead of the spec). ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-config.mdx ================================================ --- title: config sidebarTitle: config --- # `fastmcp.server.tasks.config` TaskConfig for MCP SEP-1686 background task execution modes. This module defines the configuration for how tools, resources, and prompts handle task-augmented execution as specified in SEP-1686. ## Classes ### `TaskMeta` Metadata for task-augmented execution requests. When passed to call_tool/read_resource/get_prompt, signals that the operation should be submitted as a background task. **Attributes:** - `ttl`: Client-requested TTL in milliseconds. If None, uses server default. - `fn_key`: Docket routing key. Auto-derived from component name if None. ### `TaskConfig` Configuration for MCP background task execution (SEP-1686). Controls how a component handles task-augmented requests: - "forbidden": Component does not support task execution. Clients must not request task augmentation; server returns -32601 if they do. - "optional": Component supports both synchronous and task execution. Client may request task augmentation or call normally. - "required": Component requires task execution. Clients must request task augmentation; server returns -32601 if they don't. **Methods:** #### `from_bool` ```python from_bool(cls, value: bool) -> TaskConfig ``` Convert boolean task flag to TaskConfig. **Args:** - `value`: True for "optional" mode, False for "forbidden" mode. **Returns:** - TaskConfig with appropriate mode. #### `supports_tasks` ```python supports_tasks(self) -> bool ``` Check if this component supports task execution. **Returns:** - True if mode is "optional" or "required", False if "forbidden". #### `validate_function` ```python validate_function(self, fn: Callable[..., Any], name: str) -> None ``` Validate that function is compatible with this task config. Task execution requires: 1. fastmcp[tasks] to be installed (pydocket) 2. Async functions Raises ImportError if mode is "optional" or "required" but pydocket is not installed. Raises ValueError if function is synchronous. **Args:** - `fn`: The function to validate (handles callable classes and staticmethods). - `name`: Name for error messages. **Raises:** - `ImportError`: If task execution is enabled but pydocket not installed. - `ValueError`: If task execution is enabled but function is sync. ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-elicitation.mdx ================================================ --- title: elicitation sidebarTitle: elicitation --- # `fastmcp.server.tasks.elicitation` Background task elicitation support (SEP-1686). This module provides elicitation capabilities for background tasks running in Docket workers. Unlike regular MCP requests, background tasks don't have an active request context, so elicitation requires special handling: 1. Set task status to "input_required" via Redis 2. Send notifications/tasks/status with elicitation metadata 3. Wait for client to send input via tasks/sendInput 4. Resume task execution with the provided input This uses the public MCP SDK APIs where possible, with minimal use of internal APIs for background task coordination. ## Functions ### `elicit_for_task` ```python elicit_for_task(task_id: str, session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP) -> mcp.types.ElicitResult ``` Send an elicitation request from a background task. This function handles the complexity of eliciting user input when running in a Docket worker context where there's no active MCP request. **Args:** - `task_id`: The background task ID - `session`: The MCP ServerSession for this task - `message`: The message to display to the user - `schema`: The JSON schema for the expected response - `fastmcp`: The FastMCP server instance **Returns:** - ElicitResult containing the user's response **Raises:** - `RuntimeError`: If Docket is not available - `McpError`: If the elicitation request fails ### `relay_elicitation` ```python relay_elicitation(session: ServerSession, session_id: str, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP) -> None ``` Relay elicitation from a background task worker to the client. Called by the notification subscriber when it detects an input_required notification with elicitation metadata. Sends a standard elicitation/create request to the client session, then uses handle_task_input() to push the response to Redis so the blocked worker can resume. **Args:** - `session`: MCP ServerSession - `session_id`: Session identifier - `task_id`: Background task ID - `elicitation`: Elicitation metadata (message, requestedSchema) - `fastmcp`: FastMCP server instance ### `handle_task_input` ```python handle_task_input(task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP) -> bool ``` Handle input sent to a background task via tasks/sendInput. This is called when a client sends input in response to an elicitation request from a background task. **Args:** - `task_id`: The background task ID - `session_id`: The MCP session ID - `action`: The elicitation action ("accept", "decline", "cancel") - `content`: The response content (for "accept" action) - `fastmcp`: The FastMCP server instance **Returns:** - True if the input was successfully stored, False otherwise ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-handlers.mdx ================================================ --- title: handlers sidebarTitle: handlers --- # `fastmcp.server.tasks.handlers` SEP-1686 task execution handlers. Handles queuing tool/prompt/resource executions to Docket as background tasks. ## Functions ### `submit_to_docket` ```python submit_to_docket(task_type: Literal['tool', 'resource', 'template', 'prompt'], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult ``` Submit any component to Docket for background execution (SEP-1686). Unified handler for all component types. Called by component's internal methods (_run, _read, _render) when task metadata is present and mode allows. Queues the component's method to Docket, stores raw return values, and converts to MCP types on retrieval. **Args:** - `task_type`: Component type for task key construction - `key`: The component key as seen by MCP layer (with namespace prefix) - `component`: The component instance (Tool, Resource, ResourceTemplate, Prompt) - `arguments`: Arguments/params (None for Resource which has no args) - `task_meta`: Task execution metadata. If task_meta.ttl is provided, it overrides the server default (docket.execution_ttl). **Returns:** - Task stub with proper Task object ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-keys.mdx ================================================ --- title: keys sidebarTitle: keys --- # `fastmcp.server.tasks.keys` Task key management for SEP-1686 background tasks. Task keys encode security scoping and metadata in the Docket key format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` This format provides: - Session-based security scoping (prevents cross-session access) - Task type identification (tool/prompt/resource) - Component identification (name or URI for result conversion) ## Functions ### `build_task_key` ```python build_task_key(session_id: str, client_task_id: str, task_type: str, component_identifier: str) -> str ``` Build Docket task key with embedded metadata. Format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.). **Args:** - `session_id`: Session ID for security scoping - `client_task_id`: Client-provided task ID - `task_type`: Type of task ("tool", "prompt", "resource") - `component_identifier`: Tool name, prompt name, or resource URI **Returns:** - Encoded task key for Docket **Examples:** >>> build_task_key("session123", "task456", "tool", "my_tool") 'session123:task456:tool:my_tool' >>> build_task_key("session123", "task456", "resource", "file://data.txt") 'session123:task456:resource:file%3A%2F%2Fdata.txt' ### `parse_task_key` ```python parse_task_key(task_key: str) -> dict[str, str] ``` Parse Docket task key to extract metadata. **Args:** - `task_key`: Encoded task key from Docket **Returns:** - Dict with keys: session_id, client_task_id, task_type, component_identifier **Examples:** >>> parse_task_key("session123:task456:tool:my_tool") `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` >>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt") `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'resource', 'component_identifier': 'file://data.txt'}` ### `get_client_task_id_from_key` ```python get_client_task_id_from_key(task_key: str) -> str ``` Extract just the client task ID from a task key. **Args:** - `task_key`: Full encoded task key **Returns:** - Client-provided task ID (second segment) ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-notifications.mdx ================================================ --- title: notifications sidebarTitle: notifications --- # `fastmcp.server.tasks.notifications` Distributed notification queue for background task events (SEP-1686). Enables distributed Docket workers to send MCP notifications to clients without holding session references. Workers push to a Redis queue, the MCP server process subscribes and forwards to the client's session. Pattern: Fire-and-forward with retry - One queue per session_id - LPUSH/BRPOP for reliable ordered delivery - Retry up to 3 times on delivery failure, then discard - TTL-based expiration for stale messages Note: Docket's execution.subscribe() handles task state/progress events via Redis Pub/Sub. This module handles elicitation-specific notifications that require reliable delivery (input_required prompts, cancel signals). ## Functions ### `push_notification` ```python push_notification(session_id: str, notification: dict[str, Any], docket: Docket) -> None ``` Push notification to session's queue (called from Docket worker). Used for elicitation-specific notifications (input_required, cancel) that need reliable delivery across distributed processes. **Args:** - `session_id`: Target session's identifier - `notification`: MCP notification dict (method, params, _meta) - `docket`: Docket instance for Redis access ### `notification_subscriber_loop` ```python notification_subscriber_loop(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None ``` Subscribe to notification queue and forward to session. Runs in the MCP server process. Bridges distributed workers to clients. This loop: 1. Maintains a heartbeat (active subscriber marker for debugging) 2. Blocks on BRPOP waiting for notifications 3. Forwards notifications to the client's session 4. Retries failed deliveries, then discards (no dead-letter queue) **Args:** - `session_id`: Session identifier to subscribe to - `session`: MCP ServerSession for sending notifications - `docket`: Docket instance for Redis access - `fastmcp`: FastMCP server instance (for elicitation relay) ### `ensure_subscriber_running` ```python ensure_subscriber_running(session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP) -> None ``` Start notification subscriber if not already running (idempotent). Subscriber is created on first task submission and cleaned up on disconnect. Safe to call multiple times for the same session. **Args:** - `session_id`: Session identifier - `session`: MCP ServerSession - `docket`: Docket instance - `fastmcp`: FastMCP server instance (for elicitation relay) ### `stop_subscriber` ```python stop_subscriber(session_id: str) -> None ``` Stop notification subscriber for a session. Called when session disconnects. Pending messages remain in queue for delivery if client reconnects (with TTL expiration). **Args:** - `session_id`: Session identifier ### `get_subscriber_count` ```python get_subscriber_count() -> int ``` Get number of active subscribers (for monitoring). ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-requests.mdx ================================================ --- title: requests sidebarTitle: requests --- # `fastmcp.server.tasks.requests` SEP-1686 task request handlers. Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel. These handlers query and manage existing tasks (contrast with handlers.py which creates tasks). This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. ## Functions ### `tasks_get_handler` ```python tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult ``` Handle MCP 'tasks/get' request (SEP-1686). **Args:** - `server`: FastMCP server instance - `params`: Request params containing taskId **Returns:** - Task status response with spec-compliant fields ### `tasks_result_handler` ```python tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any ``` Handle MCP 'tasks/result' request (SEP-1686). Converts raw task return values to MCP types based on task type. **Args:** - `server`: FastMCP server instance - `params`: Request params containing taskId **Returns:** - MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) ### `tasks_list_handler` ```python tasks_list_handler(server: FastMCP, params: dict[str, Any]) -> ListTasksResult ``` Handle MCP 'tasks/list' request (SEP-1686). Note: With client-side tracking, this returns minimal info. **Args:** - `server`: FastMCP server instance - `params`: Request params (cursor, limit) **Returns:** - Response with tasks list and pagination ### `tasks_cancel_handler` ```python tasks_cancel_handler(server: FastMCP, params: dict[str, Any]) -> CancelTaskResult ``` Handle MCP 'tasks/cancel' request (SEP-1686). Cancels a running task, transitioning it to cancelled state. **Args:** - `server`: FastMCP server instance - `params`: Request params containing taskId **Returns:** - Task status response showing cancelled state ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-routing.mdx ================================================ --- title: routing sidebarTitle: routing --- # `fastmcp.server.tasks.routing` Task routing helper for MCP components. Provides unified task mode enforcement and docket routing logic. ## Functions ### `check_background_task` ```python check_background_task(component: Tool | Resource | ResourceTemplate | Prompt, task_type: TaskType, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None) -> mcp.types.CreateTaskResult | None ``` Check task mode and submit to background if requested. **Args:** - `component`: The MCP component - `task_type`: Type of task ("tool", "resource", "template", "prompt") - `arguments`: Arguments for tool/prompt/template execution - `task_meta`: Task execution metadata. If provided, execute as background task. **Returns:** - CreateTaskResult if submitted to docket, None for sync execution **Raises:** - `McpError`: If mode="required" but no task metadata, or mode="forbidden" but task metadata is present ================================================ FILE: docs/python-sdk/fastmcp-server-tasks-subscriptions.mdx ================================================ --- title: subscriptions sidebarTitle: subscriptions --- # `fastmcp.server.tasks.subscriptions` Task subscription helpers for sending MCP notifications (SEP-1686). Subscribes to Docket execution state changes and sends notifications/tasks/status to clients when their tasks change state. This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. ## Functions ### `subscribe_to_task_updates` ```python subscribe_to_task_updates(task_id: str, task_key: str, session: ServerSession, docket: Docket, poll_interval_ms: int = 5000) -> None ``` Subscribe to Docket execution events and send MCP notifications. Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status when task state changes. This is an optional optimization that reduces client polling frequency. **Args:** - `task_id`: Client-visible task ID (server-generated UUID) - `task_key`: Internal Docket execution key (includes session, type, component) - `session`: MCP ServerSession for sending notifications - `docket`: Docket instance for subscribing to execution events - `poll_interval_ms`: Poll interval in milliseconds to include in notifications ================================================ FILE: docs/python-sdk/fastmcp-server-telemetry.mdx ================================================ --- title: telemetry sidebarTitle: telemetry --- # `fastmcp.server.telemetry` Server-side telemetry helpers. ## Functions ### `get_auth_span_attributes` ```python get_auth_span_attributes() -> dict[str, str] ``` Get auth attributes for the current request, if authenticated. ### `get_session_span_attributes` ```python get_session_span_attributes() -> dict[str, str] ``` Get session attributes for the current request. ### `server_span` ```python server_span(name: str, method: str, server_name: str, component_type: str, component_key: str, resource_uri: str | None = None) -> Generator[Span, None, None] ``` Create a SERVER span with standard MCP attributes and auth context. Automatically records any exception on the span and sets error status. ### `delegate_span` ```python delegate_span(name: str, provider_type: str, component_key: str) -> Generator[Span, None, None] ``` Create an INTERNAL span for provider delegation. Used by FastMCPProvider when delegating to mounted servers. Automatically records any exception on the span and sets error status. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.transforms` Transform system for component transformations. Transforms modify components (tools, resources, prompts). List operations use a pure function pattern where transforms receive sequences and return transformed sequences. Get operations use a middleware pattern with `call_next` to chain lookups. Unlike middleware (which operates on requests), transforms are observable by the system for task registration, tag filtering, and component introspection. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import Namespace server = FastMCP("Server") mount = server.mount(other_server) mount.add_transform(Namespace("api")) # Tools become api_toolname ``` ## Classes ### `GetToolNext` Protocol for get_tool call_next functions. ### `GetResourceNext` Protocol for get_resource call_next functions. ### `GetResourceTemplateNext` Protocol for get_resource_template call_next functions. ### `GetPromptNext` Protocol for get_prompt call_next functions. ### `Transform` Base class for component transformations. List operations use a pure function pattern: transforms receive sequences and return transformed sequences. Get operations use a middleware pattern with `call_next` to chain lookups. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` List tools with transformation applied. **Args:** - `tools`: Sequence of tools to transform. **Returns:** - Transformed sequence of tools. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Get a tool by name. **Args:** - `name`: The requested tool name (may be transformed). - `call_next`: Callable to get tool from downstream. - `version`: Optional version filter to apply. **Returns:** - The tool if found, None otherwise. #### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` List resources with transformation applied. **Args:** - `resources`: Sequence of resources to transform. **Returns:** - Transformed sequence of resources. #### `get_resource` ```python get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None ``` Get a resource by URI. **Args:** - `uri`: The requested resource URI (may be transformed). - `call_next`: Callable to get resource from downstream. - `version`: Optional version filter to apply. **Returns:** - The resource if found, None otherwise. #### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` List resource templates with transformation applied. **Args:** - `templates`: Sequence of resource templates to transform. **Returns:** - Transformed sequence of resource templates. #### `get_resource_template` ```python get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None ``` Get a resource template by URI. **Args:** - `uri`: The requested template URI (may be transformed). - `call_next`: Callable to get template from downstream. - `version`: Optional version filter to apply. **Returns:** - The resource template if found, None otherwise. #### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` List prompts with transformation applied. **Args:** - `prompts`: Sequence of prompts to transform. **Returns:** - Transformed sequence of prompts. #### `get_prompt` ```python get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None ``` Get a prompt by name. **Args:** - `name`: The requested prompt name (may be transformed). - `call_next`: Callable to get prompt from downstream. - `version`: Optional version filter to apply. **Returns:** - The prompt if found, None otherwise. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-catalog.mdx ================================================ --- title: catalog sidebarTitle: catalog --- # `fastmcp.server.transforms.catalog` Base class for transforms that need to read the real component catalog. Some transforms replace ``list_tools()`` output with synthetic components (e.g. a search interface) while still needing access to the *real* (auth-filtered) catalog at call time. ``CatalogTransform`` provides the bypass machinery so subclasses can call ``get_tool_catalog()`` without triggering their own replacement logic. Re-entrancy problem ------------------- When a synthetic tool handler calls ``get_tool_catalog()``, that calls ``ctx.fastmcp.list_tools()`` which re-enters the transform pipeline — including *this* transform's ``list_tools()``. If the subclass overrides ``list_tools()`` directly, the re-entrant call would hit the subclass's replacement logic again (returning synthetic tools instead of the real catalog). A ``super()`` call can't prevent this because Python can't short-circuit a method after ``super()`` returns. Solution: ``CatalogTransform`` owns ``list_tools()`` and uses a per-instance ``ContextVar`` to detect re-entrant calls. During bypass, it passes through to the base ``Transform.list_tools()`` (a no-op). Otherwise, it delegates to ``transform_tools()`` — the subclass hook where replacement logic lives. Same pattern for resources, prompts, and resource templates. This is *not* the same as the ``Provider._list_tools()`` convention (which produces raw components with no arguments). ``transform_tools()`` receives the current catalog and returns a transformed version. The distinct name avoids confusion between the two patterns. Usage:: class MyTransform(CatalogTransform): async def transform_tools(self, tools): return [self._make_search_tool()] def _make_search_tool(self): async def search(ctx: Context = None): real_tools = await self.get_tool_catalog(ctx) ... return Tool.from_function(fn=search, name="search") ## Classes ### `CatalogTransform` Transform that needs access to the real component catalog. Subclasses override ``transform_tools()`` / ``transform_resources()`` / ``transform_prompts()`` / ``transform_resource_templates()`` instead of the ``list_*()`` methods. The base class owns ``list_*()`` and handles re-entrant bypass automatically — subclasses never see re-entrant calls from ``get_*_catalog()``. The ``get_*_catalog()`` methods fetch the real (auth-filtered) catalog by temporarily setting a bypass flag so that this transform's ``list_*()`` passes through without calling the subclass hook. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` #### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` #### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` #### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` #### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Transform the tool catalog. Override this method to replace, filter, or augment the tool listing. The default implementation passes through unchanged. Do NOT override ``list_tools()`` directly — the base class uses it to handle re-entrant bypass when ``get_tool_catalog()`` reads the real catalog. #### `transform_resources` ```python transform_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` Transform the resource catalog. Override this method to replace, filter, or augment the resource listing. The default implementation passes through unchanged. Do NOT override ``list_resources()`` directly — the base class uses it to handle re-entrant bypass when ``get_resource_catalog()`` reads the real catalog. #### `transform_resource_templates` ```python transform_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` Transform the resource template catalog. Override this method to replace, filter, or augment the template listing. The default implementation passes through unchanged. Do NOT override ``list_resource_templates()`` directly — the base class uses it to handle re-entrant bypass when ``get_resource_template_catalog()`` reads the real catalog. #### `transform_prompts` ```python transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` Transform the prompt catalog. Override this method to replace, filter, or augment the prompt listing. The default implementation passes through unchanged. Do NOT override ``list_prompts()`` directly — the base class uses it to handle re-entrant bypass when ``get_prompt_catalog()`` reads the real catalog. #### `get_tool_catalog` ```python get_tool_catalog(self, ctx: Context) -> Sequence[Tool] ``` Fetch the real tool catalog, bypassing this transform. The result is deduplicated by name so that only the highest version of each tool is returned — matching what protocol handlers expose on the wire. **Args:** - `ctx`: The current request context. - `run_middleware`: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_tools middleware has not yet run. #### `get_resource_catalog` ```python get_resource_catalog(self, ctx: Context) -> Sequence[Resource] ``` Fetch the real resource catalog, bypassing this transform. **Args:** - `ctx`: The current request context. - `run_middleware`: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_resources middleware has not yet run. #### `get_prompt_catalog` ```python get_prompt_catalog(self, ctx: Context) -> Sequence[Prompt] ``` Fetch the real prompt catalog, bypassing this transform. **Args:** - `ctx`: The current request context. - `run_middleware`: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_prompts middleware has not yet run. #### `get_resource_template_catalog` ```python get_resource_template_catalog(self, ctx: Context) -> Sequence[ResourceTemplate] ``` Fetch the real resource template catalog, bypassing this transform. **Args:** - `ctx`: The current request context. - `run_middleware`: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_resource_templates middleware has not yet run. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-namespace.mdx ================================================ --- title: namespace sidebarTitle: namespace --- # `fastmcp.server.transforms.namespace` Namespace transform for prefixing component names. ## Classes ### `Namespace` Prefixes component names with a namespace. - Tools: name → namespace_name - Prompts: name → namespace_name - Resources: protocol://path → protocol://namespace/path - Resource Templates: same as resources **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Prefix tool names with namespace. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Get tool by namespaced name. #### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` Add namespace path segment to resource URIs. #### `get_resource` ```python get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None ``` Get resource by namespaced URI. #### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` Add namespace path segment to template URIs. #### `get_resource_template` ```python get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None ``` Get resource template by namespaced URI. #### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` Prefix prompt names with namespace. #### `get_prompt` ```python get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None ``` Get prompt by namespaced name. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-prompts_as_tools.mdx ================================================ --- title: prompts_as_tools sidebarTitle: prompts_as_tools --- # `fastmcp.server.transforms.prompts_as_tools` Transform that exposes prompts as tools. This transform generates tools for listing and getting prompts, enabling clients that only support tools to access prompt functionality. The generated tools route through `ctx.fastmcp` at runtime, so all server middleware (auth, visibility, rate limiting, etc.) applies to prompt operations exactly as it would for direct `prompts/get` calls. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import PromptsAsTools mcp = FastMCP("Server") mcp.add_transform(PromptsAsTools(mcp)) # Now has list_prompts and get_prompt tools ``` ## Classes ### `PromptsAsTools` Transform that adds tools for listing and getting prompts. Generates two tools: - `list_prompts`: Lists all prompts - `get_prompt`: Gets a specific prompt with optional arguments The generated tools route through the server at runtime, so auth, middleware, and visibility apply automatically. This transform should be applied to a FastMCP server instance, not a raw Provider, because the generated tools need the server's middleware chain for auth and visibility filtering. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Add prompt tools to the tool list. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Get a tool by name, including generated prompt tools. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-resources_as_tools.mdx ================================================ --- title: resources_as_tools sidebarTitle: resources_as_tools --- # `fastmcp.server.transforms.resources_as_tools` Transform that exposes resources as tools. This transform generates tools for listing and reading resources, enabling clients that only support tools to access resource functionality. The generated tools route through `ctx.fastmcp` at runtime, so all server middleware (auth, visibility, rate limiting, etc.) applies to resource operations exactly as it would for direct `resources/read` calls. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import ResourcesAsTools mcp = FastMCP("Server") mcp.add_transform(ResourcesAsTools(mcp)) # Now has list_resources and read_resource tools ``` ## Classes ### `ResourcesAsTools` Transform that adds tools for listing and reading resources. Generates two tools: - `list_resources`: Lists all resources and templates - `read_resource`: Reads a resource by URI The generated tools route through the server at runtime, so auth, middleware, and visibility apply automatically. This transform should be applied to a FastMCP server instance, not a raw Provider, because the generated tools need the server's middleware chain for auth and visibility filtering. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Add resource tools to the tool list. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Get a tool by name, including generated resource tools. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-search-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.server.transforms.search` Search transforms for tool discovery. Search transforms collapse a large tool catalog into a search interface, letting LLMs discover tools on demand instead of seeing the full list. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("Server") mcp.add_transform(RegexSearchTransform()) # list_tools now returns only search_tools + call_tool ``` ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-search-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.server.transforms.search.base` Base class for search transforms. Search transforms replace ``list_tools()`` output with a small set of synthetic tools — a search tool and a call-tool proxy — so LLMs can discover tools on demand instead of receiving the full catalog. All concrete search transforms (``RegexSearchTransform``, ``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and implement ``_make_search_tool()`` and ``_search()`` to provide their specific search strategy. Example:: from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("Server") @mcp.tool def add(a: int, b: int) -> int: ... @mcp.tool def multiply(x: float, y: float) -> float: ... # Clients now see only ``search_tools`` and ``call_tool``. # The original tools are discoverable via search. mcp.add_transform(RegexSearchTransform()) ## Functions ### `serialize_tools_for_output_json` ```python serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]] ``` Serialize tools to the same dict format as ``list_tools`` output. ### `serialize_tools_for_output_markdown` ```python serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str ``` Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON. ## Classes ### `BaseSearchTransform` Replace the tool listing with a search interface. When this transform is active, ``list_tools()`` returns only: * Any tools listed in ``always_visible`` (pinned). * A **search tool** that finds tools matching a query. * A **call_tool** proxy that executes tools discovered via search. Hidden tools remain callable — ``get_tool()`` delegates unknown names downstream, so direct calls and the call-tool proxy both work. Search results respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply. **Args:** - `max_results`: Maximum number of tools returned per search. - `always_visible`: Tool names that stay in the ``list_tools`` output alongside the synthetic search/call tools. - `search_tool_name`: Name of the generated search tool. - `call_tool_name`: Name of the generated call-tool proxy. **Methods:** #### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Replace the catalog with pinned + synthetic search/call tools. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Intercept synthetic tool names; delegate everything else. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-search-bm25.mdx ================================================ --- title: bm25 sidebarTitle: bm25 --- # `fastmcp.server.transforms.search.bm25` BM25-based search transform. ## Classes ### `BM25SearchTransform` Search transform using BM25 Okapi relevance ranking. Maintains an in-memory index that is lazily rebuilt when the tool catalog changes (detected via a hash of tool names). ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-search-regex.mdx ================================================ --- title: regex sidebarTitle: regex --- # `fastmcp.server.transforms.search.regex` Regex-based search transform. ## Classes ### `RegexSearchTransform` Search transform using regex pattern matching. Tools are matched against their name, description, and parameter information using ``re.search`` with ``re.IGNORECASE``. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-tool_transform.mdx ================================================ --- title: tool_transform sidebarTitle: tool_transform --- # `fastmcp.server.transforms.tool_transform` Transform for applying tool transformations. ## Classes ### `ToolTransform` Applies tool transformations to modify tool schemas. Wraps ToolTransformConfig to apply argument renames, schema changes, hidden arguments, and other transformations at the transform level. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Apply transforms to matching tools. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Get tool by transformed name. ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-version_filter.mdx ================================================ --- title: version_filter sidebarTitle: version_filter --- # `fastmcp.server.transforms.version_filter` Version filter transform for filtering components by version range. ## Classes ### `VersionFilter` Filters components by version range. When applied to a provider or server, components within the version range are visible, and unversioned components are included by default. Within that filtered set, the highest version of each component is exposed to clients (standard deduplication behavior). Set ``include_unversioned=False`` to exclude unversioned components. Parameters mirror comparison operators for clarity: # Versions < 3.0 (v1 and v2) server.add_transform(VersionFilter(version_lt="3.0")) # Versions >= 2.0 and < 3.0 (only v2.x) server.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0")) Works with any version string - PEP 440 (1.0, 2.0) or dates (2025-01-01). **Args:** - `version_gte`: Versions >= this value pass through. - `version_lt`: Versions < this value pass through. - `include_unversioned`: Whether unversioned components (``version=None``) should pass through the filter. Defaults to True. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` #### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` #### `get_resource` ```python get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None ``` #### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` #### `get_resource_template` ```python get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None ``` #### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` #### `get_prompt` ```python get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None ``` ================================================ FILE: docs/python-sdk/fastmcp-server-transforms-visibility.mdx ================================================ --- title: visibility sidebarTitle: visibility --- # `fastmcp.server.transforms.visibility` Visibility transform for marking component visibility state. Each Visibility instance marks components via internal metadata. Multiple visibility transforms can be stacked - later transforms override earlier ones. Final filtering happens at the Provider level. ## Functions ### `is_enabled` ```python is_enabled(component: FastMCPComponent) -> bool ``` Check if component is enabled. Returns True if: - No visibility mark exists (default is enabled) - Visibility mark is True Returns False if visibility mark is False. **Args:** - `component`: Component to check. **Returns:** - True if component should be enabled/visible to clients. ### `get_visibility_rules` ```python get_visibility_rules(context: Context) -> list[dict[str, Any]] ``` Load visibility rule dicts from session state. ### `save_visibility_rules` ```python save_visibility_rules(context: Context, rules: list[dict[str, Any]]) -> None ``` Save visibility rule dicts to session state and send notifications. **Args:** - `context`: The context to save rules for. - `rules`: The visibility rules to save. - `components`: Optional hint about which component types are affected. If None, sends notifications for all types (safe default). If provided, only sends notifications for specified types. ### `create_visibility_transforms` ```python create_visibility_transforms(rules: list[dict[str, Any]]) -> list[Visibility] ``` Convert rule dicts to Visibility transforms. ### `get_session_transforms` ```python get_session_transforms(context: Context) -> list[Visibility] ``` Get session-specific Visibility transforms from state store. ### `enable_components` ```python enable_components(context: Context) -> None ``` Enable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. **Args:** - `context`: The context for this session. - `names`: Component names or URIs to match. - `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to match. - `tags`: Tags to match (component must have at least one). - `components`: Component types to match (e.g., {"tool", "prompt"}). - `match_all`: If True, matches all components regardless of other criteria. ### `disable_components` ```python disable_components(context: Context) -> None ``` Disable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. **Args:** - `context`: The context for this session. - `names`: Component names or URIs to match. - `keys`: Component keys to match (e.g., {"tool\:my_tool@v1"}). - `version`: Component version spec to match. - `tags`: Tags to match (component must have at least one). - `components`: Component types to match (e.g., {"tool", "prompt"}). - `match_all`: If True, matches all components regardless of other criteria. ### `reset_visibility` ```python reset_visibility(context: Context) -> None ``` Clear all session visibility rules. Use this to reset session visibility back to global defaults. Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. **Args:** - `context`: The context for this session. ### `apply_session_transforms` ```python apply_session_transforms(components: Sequence[ComponentT]) -> Sequence[ComponentT] ``` Apply session-specific visibility transforms to components. This helper applies session-level enable/disable rules by marking components with their visibility state. Session transforms override global transforms due to mark-based semantics (later marks win). **Args:** - `components`: The components to apply session transforms to. **Returns:** - The components with session transforms applied. ## Classes ### `Visibility` Sets visibility state on matching components. Does NOT filter inline - just marks components with visibility state. Later transforms in the chain can override earlier marks. Final filtering happens at the Provider level after all transforms run. **Methods:** #### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` Mark tools by visibility state. #### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` Mark tool if found. #### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` Mark resources by visibility state. #### `get_resource` ```python get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None ``` Mark resource if found. #### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` Mark resource templates by visibility state. #### `get_resource_template` ```python get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None ``` Mark resource template if found. #### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` Mark prompts by visibility state. #### `get_prompt` ```python get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None ``` Mark prompt if found. ================================================ FILE: docs/python-sdk/fastmcp-settings.mdx ================================================ --- title: settings sidebarTitle: settings --- # `fastmcp.settings` ## Classes ### `DocketSettings` Docket worker configuration. ### `Settings` FastMCP settings. **Methods:** #### `get_setting` ```python get_setting(self, attr: str) -> Any ``` Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. #### `set_setting` ```python set_setting(self, attr: str, value: Any) -> None ``` Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. #### `normalize_log_level` ```python normalize_log_level(cls, v) ``` ================================================ FILE: docs/python-sdk/fastmcp-telemetry.mdx ================================================ --- title: telemetry sidebarTitle: telemetry --- # `fastmcp.telemetry` OpenTelemetry instrumentation for FastMCP. This module provides native OpenTelemetry integration for FastMCP servers and clients. It uses only the opentelemetry-api package, so telemetry is a no-op unless the user installs an OpenTelemetry SDK and configures exporters. Example usage with SDK: ```python from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor # Configure the SDK (user responsibility) provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) trace.set_tracer_provider(provider) # Now FastMCP will emit traces from fastmcp import FastMCP mcp = FastMCP("my-server") ``` ## Functions ### `get_tracer` ```python get_tracer(version: str | None = None) -> Tracer ``` Get the FastMCP tracer for creating spans. **Args:** - `version`: Optional version string for the instrumentation **Returns:** - A tracer instance. Returns a no-op tracer if no SDK is configured. ### `inject_trace_context` ```python inject_trace_context(meta: dict[str, Any] | None = None) -> dict[str, Any] | None ``` Inject current trace context into a meta dict for MCP request propagation. **Args:** - `meta`: Optional existing meta dict to merge with trace context **Returns:** - A new dict containing the original meta (if any) plus trace context keys, - or None if no trace context to inject and meta was None ### `record_span_error` ```python record_span_error(span: Span, exception: BaseException) -> None ``` Record an exception on a span and set error status. ### `extract_trace_context` ```python extract_trace_context(meta: dict[str, Any] | None) -> Context ``` Extract trace context from an MCP request meta dict. If already in a valid trace (e.g., from HTTP propagation), the existing trace context is preserved and meta is not used. **Args:** - `meta`: The meta dict from an MCP request (ctx.request_context.meta) **Returns:** - An OpenTelemetry Context with the extracted trace context, - or the current context if no trace context found or already in a trace ================================================ FILE: docs/python-sdk/fastmcp-tools-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.tools` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-tools-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.tools.base` ## Functions ### `default_serializer` ```python default_serializer(data: Any) -> str ``` ## Classes ### `ToolResult` **Methods:** #### `to_mcp_result` ```python to_mcp_result(self) -> list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ``` ### `Tool` Internal tool registration info. **Methods:** #### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> MCPTool ``` Convert the FastMCP tool to an MCP tool. #### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool ``` Create a Tool from a function. #### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult ``` Run the tool with arguments. This method is not implemented in the base Tool class and must be implemented by subclasses. `run()` can EITHER return a list of ContentBlocks, or a tuple of (list of ContentBlocks, dict of structured output). #### `convert_result` ```python convert_result(self, raw_value: Any) -> ToolResult ``` Convert a raw result to ToolResult. Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this tool with docket for background execution. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution ``` Schedule this tool for background execution via docket. **Args:** - `docket`: The Docket instance - `arguments`: Tool arguments - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() #### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any]) -> TransformedTool ``` #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` ================================================ FILE: docs/python-sdk/fastmcp-tools-function_parsing.mdx ================================================ --- title: function_parsing sidebarTitle: function_parsing --- # `fastmcp.tools.function_parsing` Function introspection and schema generation for FastMCP tools. ## Classes ### `ParsedFunction` **Methods:** #### `from_function` ```python from_function(cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True) -> ParsedFunction ``` ================================================ FILE: docs/python-sdk/fastmcp-tools-function_tool.mdx ================================================ --- title: function_tool sidebarTitle: function_tool --- # `fastmcp.tools.function_tool` Standalone @tool decorator for FastMCP. ## Functions ### `tool` ```python tool(name_or_fn: str | Callable[..., Any] | None = None) -> Any ``` Standalone decorator to mark a function as an MCP tool. Returns the original function with metadata attached. Register with a server using mcp.add_tool(). ## Classes ### `DecoratedTool` Protocol for functions decorated with @tool. ### `ToolMeta` Metadata attached to functions by the @tool decorator. ### `FunctionTool` **Methods:** #### `to_mcp_tool` ```python to_mcp_tool(self, **overrides: Any) -> mcp.types.Tool ``` Convert the FastMCP tool to an MCP tool. Extends the base implementation to add task execution mode if enabled. #### `from_function` ```python from_function(cls, fn: Callable[..., Any]) -> FunctionTool ``` Create a FunctionTool from a function. **Args:** - `fn`: The function to wrap - `metadata`: ToolMeta object with all configuration. If provided, individual parameters must not be passed. - `name, title, etc.`: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. #### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult ``` Run the tool with arguments. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this tool with docket for background execution. FunctionTool registers the underlying function, which has the user's Depends parameters for docket to resolve. #### `add_to_docket` ```python add_to_docket(self, docket: Docket, arguments: dict[str, Any], **kwargs: Any) -> Execution ``` Schedule this tool for background execution via docket. FunctionTool splats the arguments dict since .fn expects **kwargs. **Args:** - `docket`: The Docket instance - `arguments`: Tool arguments - `fn_key`: Function lookup key in Docket registry (defaults to self.key) - `task_key`: Redis storage key for the result - `**kwargs`: Additional kwargs passed to docket.add() ================================================ FILE: docs/python-sdk/fastmcp-tools-tool_transform.mdx ================================================ --- title: tool_transform sidebarTitle: tool_transform --- # `fastmcp.tools.tool_transform` ## Functions ### `forward` ```python forward(**kwargs: Any) -> ToolResult ``` Forward to parent tool with argument transformation applied. This function can only be called from within a transformed tool's custom function. It applies argument transformation (renaming, validation) before calling the parent tool. For example, if the parent tool has args `x` and `y`, but the transformed tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to `a` and `y` to `b`, then `forward(a=1, b=2)` will call the parent tool with `x=1` and `y=2`. **Args:** - `**kwargs`: Arguments to forward to the parent tool (using transformed names). **Returns:** - The ToolResult from the parent tool execution. **Raises:** - `RuntimeError`: If called outside a transformed tool context. - `TypeError`: If provided arguments don't match the transformed schema. ### `forward_raw` ```python forward_raw(**kwargs: Any) -> ToolResult ``` Forward directly to parent tool without transformation. This function bypasses all argument transformation and validation, calling the parent tool directly with the provided arguments. Use this when you need to call the parent with its original parameter names and structure. For example, if the parent tool has args `x` and `y`, then `forward_raw(x=1, y=2)` will call the parent tool with `x=1` and `y=2`. **Args:** - `**kwargs`: Arguments to pass directly to the parent tool (using original names). **Returns:** - The ToolResult from the parent tool execution. **Raises:** - `RuntimeError`: If called outside a transformed tool context. ### `apply_transformations_to_tools` ```python apply_transformations_to_tools(tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig]) -> dict[str, Tool] ``` Apply a list of transformations to a list of tools. Tools that do not have any transformations are left unchanged. Note: tools dict is keyed by prefixed key (e.g., "tool:my_tool"), but transformations are keyed by tool name (e.g., "my_tool"). ## Classes ### `ArgTransform` Configuration for transforming a parent tool's argument. This class allows fine-grained control over how individual arguments are transformed when creating a new tool from an existing one. You can rename arguments, change their descriptions, add default values, or hide them from clients while passing constants. **Attributes:** - `name`: New name for the argument. Use None to keep original name, or ... for no change. - `description`: New description for the argument. Use None to remove description, or ... for no change. - `default`: New default value for the argument. Use ... for no change. - `default_factory`: Callable that returns a default value. Cannot be used with default. - `type`: New type for the argument. Use ... for no change. - `hide`: If True, hide this argument from clients but pass a constant value to parent. - `required`: If True, make argument required (remove default). Use ... for no change. - `examples`: Examples for the argument. Use ... for no change. **Examples:** Rename argument 'old_name' to 'new_name' ```python ArgTransform(name="new_name") ``` Change description only ```python ArgTransform(description="Updated description") ``` Add a default value (makes argument optional) ```python ArgTransform(default=42) ``` Add a default factory (makes argument optional) ```python ArgTransform(default_factory=lambda: time.time()) ``` Change the type ```python ArgTransform(type=str) ``` Hide the argument entirely from clients ```python ArgTransform(hide=True) ``` Hide argument but pass a constant value to parent ```python ArgTransform(hide=True, default="constant_value") ``` Hide argument but pass a factory-generated value to parent ```python ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex) ``` Make an optional parameter required (removes any default) ```python ArgTransform(required=True) ``` Combine multiple transformations ```python ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` ### `ArgTransformConfig` A model for requesting a single argument transform. **Methods:** #### `to_arg_transform` ```python to_arg_transform(self) -> ArgTransform ``` Convert the argument transform to a FastMCP argument transform. ### `TransformedTool` A tool that is transformed from another tool. This class represents a tool that has been created by transforming another tool. It supports argument renaming, schema modification, custom function injection, structured output control, and provides context for the forward() and forward_raw() functions. The transformation can be purely schema-based (argument renaming, dropping, etc.) or can include a custom function that uses forward() to call the parent tool with transformed arguments. Output schemas and structured outputs are automatically inherited from the parent tool but can be overridden or disabled. **Attributes:** - `parent_tool`: The original tool that this tool was transformed from. - `fn`: The function to execute when this tool is called (either the forwarding function for pure transformations or a custom user function). - `forwarding_fn`: Internal function that handles argument transformation and validation when forward() is called from custom functions. **Methods:** #### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult ``` Run the tool with context set for forward() functions. This method executes the tool's function while setting up the context that allows forward() and forward_raw() to work correctly within custom functions. **Args:** - `arguments`: Dictionary of arguments to pass to the tool's function. **Returns:** - ToolResult object containing content and optional structured output. #### `from_tool` ```python from_tool(cls, tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, meta: dict[str, Any] | NotSetT | None = NotSet) -> TransformedTool ``` Create a transformed tool from a parent tool. **Args:** - `tool`: The parent tool to transform. - `transform_fn`: Optional custom function. Can use forward() and forward_raw() to call the parent tool. Functions with **kwargs receive transformed argument names. - `name`: New name for the tool. Defaults to parent tool's name. - `version`: New version for the tool. Defaults to parent tool's version. - `title`: New title for the tool. Defaults to parent tool's title. - `transform_args`: Optional transformations for parent tool arguments. Only specified arguments are transformed, others pass through unchanged\: - Simple rename (str) - Complex transformation (rename/description/default/drop) (ArgTransform) - Drop the argument (None) - `description`: New description. Defaults to parent's description. - `tags`: New tags. Defaults to parent's tags. - `annotations`: New annotations. Defaults to parent's annotations. - `output_schema`: Control output schema for structured outputs\: - None (default)\: Inherit from transform_fn if available, then parent tool - dict\: Use custom output schema - False\: Disable output schema and structured outputs - `serializer`: Deprecated. Return ToolResult from your tools for full control over serialization. - `meta`: Control meta information\: - NotSet (default)\: Inherit from parent tool - dict\: Use custom meta information - None\: Remove meta information **Returns:** - TransformedTool with the specified transformations. **Examples:** # Transform specific arguments only ```python Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged ``` # Custom function with partial transforms ```python async def custom(x: int, y: int) -> str: result = await forward(x=x, y=y) return f"Custom: {result}" Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"}) ``` # Using **kwargs (gets all args, transformed and untransformed) ```python async def flexible(**kwargs) -> str: result = await forward(**kwargs) return f"Got: {kwargs}" Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"}) ``` # Control structured outputs and schemas ```python # Custom output schema Tool.from_tool(parent, output_schema={ "type": "object", "properties": {"status": {"type": "string"}} }) # Disable structured outputs Tool.from_tool(parent, output_schema=None) # Return ToolResult for full control async def custom_output(**kwargs) -> ToolResult: result = await forward(**kwargs) return ToolResult( content=[TextContent(text="Summary")], structured_content={"processed": True} ) ``` ### `ToolTransformConfig` Provides a way to transform a tool. **Methods:** #### `apply` ```python apply(self, tool: Tool) -> TransformedTool ``` Create a TransformedTool from a provided tool and this transformation configuration. ================================================ FILE: docs/python-sdk/fastmcp-utilities-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities` FastMCP utility modules. ================================================ FILE: docs/python-sdk/fastmcp-utilities-async_utils.mdx ================================================ --- title: async_utils sidebarTitle: async_utils --- # `fastmcp.utilities.async_utils` Async utilities for FastMCP. ## Functions ### `is_coroutine_function` ```python is_coroutine_function(fn: Any) -> bool ``` Check if a callable is a coroutine function, unwrapping functools.partial. ``inspect.iscoroutinefunction`` returns ``False`` for ``functools.partial`` objects wrapping an async function on Python < 3.12. This helper unwraps any layers of ``partial`` before checking. ### `call_sync_fn_in_threadpool` ```python call_sync_fn_in_threadpool(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any ``` Call a sync function in a threadpool to avoid blocking the event loop. Uses anyio.to_thread.run_sync which properly propagates contextvars, making this safe for functions that depend on context (like dependency injection). ### `gather` ```python gather(*awaitables: Awaitable[T]) -> list[T] | list[T | BaseException] ``` Run awaitables concurrently and return results in order. Uses anyio TaskGroup for structured concurrency. **Args:** - `*awaitables`: Awaitables to run concurrently - `return_exceptions`: If True, exceptions are returned in results. If False, first exception cancels all and raises. **Returns:** - List of results in the same order as input awaitables. ================================================ FILE: docs/python-sdk/fastmcp-utilities-auth.mdx ================================================ --- title: auth sidebarTitle: auth --- # `fastmcp.utilities.auth` Authentication utility helpers. ## Functions ### `decode_jwt_header` ```python decode_jwt_header(token: str) -> dict[str, Any] ``` Decode JWT header without signature verification. Useful for extracting the key ID (kid) for JWKS lookup. **Args:** - `token`: JWT token string (header.payload.signature) **Returns:** - Decoded header as a dictionary **Raises:** - `ValueError`: If token is not a valid JWT format ### `decode_jwt_payload` ```python decode_jwt_payload(token: str) -> dict[str, Any] ``` Decode JWT payload without signature verification. Use only for tokens received directly from trusted sources (e.g., IdP token endpoints). **Args:** - `token`: JWT token string (header.payload.signature) **Returns:** - Decoded payload as a dictionary **Raises:** - `ValueError`: If token is not a valid JWT format ### `parse_scopes` ```python parse_scopes(value: Any) -> list[str] | None ``` Parse scopes from environment variables or settings values. Accepts either a JSON array string, a comma- or space-separated string, a list of strings, or ``None``. Returns a list of scopes or ``None`` if no value is provided. ================================================ FILE: docs/python-sdk/fastmcp-utilities-cli.mdx ================================================ --- title: cli sidebarTitle: cli --- # `fastmcp.utilities.cli` ## Functions ### `is_already_in_uv_subprocess` ```python is_already_in_uv_subprocess() -> bool ``` Check if we're already running in a FastMCP uv subprocess. ### `load_and_merge_config` ```python load_and_merge_config(server_spec: str | None, **cli_overrides) -> tuple[MCPServerConfig, str] ``` Load config from server_spec and apply CLI overrides. This consolidates the config parsing logic that was duplicated across run, inspect, and dev commands. **Args:** - `server_spec`: Python file, config file, URL, or None to auto-detect - `cli_overrides`: CLI arguments that override config values **Returns:** - Tuple of (MCPServerConfig, resolved_server_spec) ### `log_server_banner` ```python log_server_banner(server: FastMCP[Any]) -> None ``` Creates and logs a formatted banner with server information and logo. ================================================ FILE: docs/python-sdk/fastmcp-utilities-components.mdx ================================================ --- title: components sidebarTitle: components --- # `fastmcp.utilities.components` ## Functions ### `get_fastmcp_metadata` ```python get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta ``` Extract FastMCP metadata from a component's meta dict. Handles both the current `fastmcp` namespace and the legacy `_fastmcp` namespace for compatibility with older FastMCP servers. ## Classes ### `FastMCPMeta` ### `FastMCPComponent` Base class for FastMCP tools, prompts, resources, and resource templates. **Methods:** #### `make_key` ```python make_key(cls, identifier: str) -> str ``` Construct the lookup key for this component type. **Args:** - `identifier`: The raw identifier (name for tools/prompts, uri for resources) **Returns:** - A prefixed key like "tool:name" or "resource:uri" #### `key` ```python key(self) -> str ``` The globally unique lookup key for this component. Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@" e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@" The @ suffix is ALWAYS present to enable unambiguous parsing of keys (URIs may contain @ characters, so we always include the delimiter). Subclasses should override this to use their specific identifier. Base implementation uses name. #### `get_meta` ```python get_meta(self) -> dict[str, Any] ``` Get the meta information about the component. Returns a dict that always includes a `fastmcp` key containing: - `tags`: sorted list of component tags - `version`: component version (only if set) Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. #### `enable` ```python enable(self) -> None ``` Removed in 3.0. Use server.enable(keys=[...]) instead. #### `disable` ```python disable(self) -> None ``` Removed in 3.0. Use server.disable(keys=[...]) instead. #### `copy` ```python copy(self) -> Self ``` Create a copy of the component. #### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None ``` Register this component with docket for background execution. No-ops if task_config.mode is "forbidden". Subclasses override to register their callable (self.run, self.read, self.render, or self.fn). #### `add_to_docket` ```python add_to_docket(self, docket: Docket, *args: Any, **kwargs: Any) -> Execution ``` Schedule this component for background execution via docket. Subclasses override this to handle their specific calling conventions: - Tool: add_to_docket(docket, arguments: dict, **kwargs) - Resource: add_to_docket(docket, **kwargs) - ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) - Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) The **kwargs are passed through to docket.add() (e.g., key=task_key). #### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` Return span attributes for telemetry. Subclasses should call super() and merge their specific attributes. ================================================ FILE: docs/python-sdk/fastmcp-utilities-exceptions.mdx ================================================ --- title: exceptions sidebarTitle: exceptions --- # `fastmcp.utilities.exceptions` ## Functions ### `iter_exc` ```python iter_exc(group: BaseExceptionGroup) ``` ### `get_catch_handlers` ```python get_catch_handlers() -> Mapping[type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any]] ``` ================================================ FILE: docs/python-sdk/fastmcp-utilities-http.mdx ================================================ --- title: http sidebarTitle: http --- # `fastmcp.utilities.http` ## Functions ### `find_available_port` ```python find_available_port() -> int ``` Find an available port by letting the OS assign one. ================================================ FILE: docs/python-sdk/fastmcp-utilities-inspect.mdx ================================================ --- title: inspect sidebarTitle: inspect --- # `fastmcp.utilities.inspect` Utilities for inspecting FastMCP instances. ## Functions ### `inspect_fastmcp_v2` ```python inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo ``` Extract information from a FastMCP v2.x instance. **Args:** - `mcp`: The FastMCP v2.x instance to inspect **Returns:** - FastMCPInfo dataclass containing the extracted information ### `inspect_fastmcp_v1` ```python inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo ``` Extract information from a FastMCP v1.x instance using a Client. **Args:** - `mcp`: The FastMCP v1.x instance to inspect **Returns:** - FastMCPInfo dataclass containing the extracted information ### `inspect_fastmcp` ```python inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo ``` Extract information from a FastMCP instance into a dataclass. This function automatically detects whether the instance is FastMCP v1.x or v2.x and uses the appropriate extraction method. **Args:** - `mcp`: The FastMCP instance to inspect (v1.x or v2.x) **Returns:** - FastMCPInfo dataclass containing the extracted information ### `format_fastmcp_info` ```python format_fastmcp_info(info: FastMCPInfo) -> bytes ``` Format FastMCPInfo as FastMCP-specific JSON. This includes FastMCP-specific fields like tags, enabled, annotations, etc. ### `format_mcp_info` ```python format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes ``` Format server info as standard MCP protocol JSON. Uses Client to get the standard MCP protocol format with camelCase fields. Includes version metadata at the top level. ### `format_info` ```python format_info(mcp: FastMCP[Any] | FastMCP1x, format: InspectFormat | Literal['fastmcp', 'mcp'], info: FastMCPInfo | None = None) -> bytes ``` Format server information according to the specified format. **Args:** - `mcp`: The FastMCP instance - `format`: Output format ("fastmcp" or "mcp") - `info`: Pre-extracted FastMCPInfo (optional, will be extracted if not provided) **Returns:** - JSON bytes in the requested format ## Classes ### `ToolInfo` Information about a tool. ### `PromptInfo` Information about a prompt. ### `ResourceInfo` Information about a resource. ### `TemplateInfo` Information about a resource template. ### `FastMCPInfo` Information extracted from a FastMCP instance. ### `InspectFormat` Output format for inspect command. ================================================ FILE: docs/python-sdk/fastmcp-utilities-json_schema.mdx ================================================ --- title: json_schema sidebarTitle: json_schema --- # `fastmcp.utilities.json_schema` ## Functions ### `dereference_refs` ```python dereference_refs(schema: dict[str, Any]) -> dict[str, Any] ``` Resolve all $ref references in a JSON schema by inlining definitions. This function resolves $ref references that point to $defs, replacing them with the actual definition content while preserving sibling keywords (like description, default, examples) that Pydantic places alongside $ref. This is necessary because some MCP clients (e.g., VS Code Copilot) don't properly handle $ref in tool input schemas. For self-referencing/circular schemas where full dereferencing is not possible, this function falls back to resolving only the root-level $ref while preserving $defs for nested references. Only local ``$ref`` values (those starting with ``#``) are resolved. Remote URIs (``http://``, ``file://``, etc.) are stripped before resolution to prevent SSRF / local-file-inclusion attacks when proxying schemas from untrusted servers. **Args:** - `schema`: JSON schema dict that may contain $ref references **Returns:** - A new schema dict with $ref resolved where possible and $defs removed - when no longer needed ### `resolve_root_ref` ```python resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any] ``` Resolve $ref at root level to meet MCP spec requirements. MCP specification requires outputSchema to have "type": "object" at the root level. When Pydantic generates schemas for self-referential models, it uses $ref at the root level pointing to $defs. This function resolves such references by inlining the referenced definition while preserving $defs for nested references. **Args:** - `schema`: JSON schema dict that may have $ref at root level **Returns:** - A new schema dict with root-level $ref resolved, or the original schema - if no resolution is needed ### `compress_schema` ```python compress_schema(schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False) -> dict[str, Any] ``` Compress and optimize a JSON schema for MCP compatibility. **Args:** - `schema`: The schema to compress - `prune_params`: List of parameter names to remove from properties - `prune_additional_properties`: Whether to remove additionalProperties\: false. Defaults to False to maintain MCP client compatibility, as some clients (e.g., Claude) require additionalProperties\: false for strict validation. - `prune_titles`: Whether to remove title fields from the schema - `dereference`: Whether to dereference $ref by inlining definitions. Defaults to False; dereferencing is typically handled by middleware at serve-time instead. ================================================ FILE: docs/python-sdk/fastmcp-utilities-json_schema_type.mdx ================================================ --- title: json_schema_type sidebarTitle: json_schema_type --- # `fastmcp.utilities.json_schema_type` Convert JSON Schema to Python types with validation. The json_schema_to_type function converts a JSON Schema into a Python type that can be used for validation with Pydantic. It supports: - Basic types (string, number, integer, boolean, null) - Complex types (arrays, objects) - Format constraints (date-time, email, uri) - Numeric constraints (minimum, maximum, multipleOf) - String constraints (minLength, maxLength, pattern) - Array constraints (minItems, maxItems, uniqueItems) - Object properties with defaults - References and recursive schemas - Enums and constants - Union types Example: ```python schema = { "type": "object", "properties": { "name": {"type": "string", "minLength": 1}, "age": {"type": "integer", "minimum": 0}, "email": {"type": "string", "format": "email"} }, "required": ["name", "age"] } # Name is optional and will be inferred from schema's "title" property if not provided Person = json_schema_to_type(schema) # Creates a validated dataclass with name, age, and optional email fields ``` ## Functions ### `json_schema_to_type` ```python json_schema_to_type(schema: Mapping[str, Any], name: str | None = None) -> type ``` Convert JSON schema to appropriate Python type with validation. **Args:** - `schema`: A JSON Schema dictionary defining the type structure and validation rules - `name`: Optional name for object schemas. Only allowed when schema type is "object". If not provided for objects, name will be inferred from schema's "title" property or default to "Root". **Returns:** - A Python type (typically a dataclass for objects) with Pydantic validation **Raises:** - `ValueError`: If a name is provided for a non-object schema **Examples:** Create a dataclass from an object schema: ```python schema = { "type": "object", "title": "Person", "properties": { "name": {"type": "string", "minLength": 1}, "age": {"type": "integer", "minimum": 0}, "email": {"type": "string", "format": "email"} }, "required": ["name", "age"] } Person = json_schema_to_type(schema) # Creates a dataclass with name, age, and optional email fields: # @dataclass # class Person: # name: str # age: int # email: str | None = None ``` Person(name="John", age=30) Create a scalar type with constraints: ```python schema = { "type": "string", "minLength": 3, "pattern": "^[A-Z][a-z]+$" } NameType = json_schema_to_type(schema) # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")] @dataclass class Name: name: NameType ``` ## Classes ### `JSONSchema` ================================================ FILE: docs/python-sdk/fastmcp-utilities-lifespan.mdx ================================================ --- title: lifespan sidebarTitle: lifespan --- # `fastmcp.utilities.lifespan` Lifespan utilities for combining async context manager lifespans. ## Functions ### `combine_lifespans` ```python combine_lifespans(*lifespans: Callable[[AppT], AbstractAsyncContextManager[Mapping[str, Any] | None]]) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]] ``` Combine multiple lifespans into a single lifespan. Useful when mounting FastMCP into FastAPI and you need to run both your app's lifespan and the MCP server's lifespan. Works with both FastAPI-style lifespans (yield None) and FastMCP-style lifespans (yield dict). Results are merged; later lifespans override earlier ones on key conflicts. Lifespans are entered in order and exited in reverse order (LIFO). **Args:** - `*lifespans`: Lifespan context manager factories to combine. **Returns:** - A combined lifespan context manager factory. ================================================ FILE: docs/python-sdk/fastmcp-utilities-logging.mdx ================================================ --- title: logging sidebarTitle: logging --- # `fastmcp.utilities.logging` Logging utilities for FastMCP. ## Functions ### `get_logger` ```python get_logger(name: str) -> logging.Logger ``` Get a logger nested under FastMCP namespace. **Args:** - `name`: the name of the logger, which will be prefixed with 'FastMCP.' **Returns:** - a configured logger instance ### `configure_logging` ```python configure_logging(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | int = 'INFO', logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) -> None ``` Configure logging for FastMCP. **Args:** - `logger`: the logger to configure - `level`: the log level to use - `rich_kwargs`: the parameters to use for creating RichHandler ### `temporary_log_level` ```python temporary_log_level(level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any) ``` Context manager to temporarily set log level and restore it afterwards. **Args:** - `level`: The temporary log level to set (e.g., "DEBUG", "INFO") - `logger`: Optional logger to configure (defaults to FastMCP logger) - `enable_rich_tracebacks`: Whether to enable rich tracebacks - `**rich_kwargs`: Additional parameters for RichHandler ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities.mcp_server_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. ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities.mcp_server_config.v1` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities.mcp_server_config.v1.environments` Environment configuration for MCP servers. ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.utilities.mcp_server_config.v1.environments.base` ## Classes ### `Environment` Base class for environment configuration. **Methods:** #### `build_command` ```python build_command(self, command: list[str]) -> list[str] ``` Build the full command with environment setup. **Args:** - `command`: Base command to wrap with environment setup **Returns:** - Full command ready for subprocess execution #### `prepare` ```python prepare(self, output_dir: Path | None = None) -> None ``` Prepare the environment (optional, can be no-op). **Args:** - `output_dir`: Directory for persistent environment setup ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-environments-uv.mdx ================================================ --- title: uv sidebarTitle: uv --- # `fastmcp.utilities.mcp_server_config.v1.environments.uv` ## Classes ### `UVEnvironment` Configuration for Python environment setup. **Methods:** #### `build_command` ```python build_command(self, command: list[str]) -> list[str] ``` Build complete uv run command with environment args and command to execute. **Args:** - `command`: Command to execute (e.g., ["fastmcp", "run", "server.py"]) **Returns:** - Complete command ready for subprocess.run, including "uv" prefix if needed. - If no environment configuration is set, returns the command unchanged. #### `prepare` ```python prepare(self, output_dir: Path | None = None) -> None ``` Prepare the Python environment using uv. **Args:** - `output_dir`: Directory where the persistent uv project will be created. If None, creates a temporary directory for ephemeral use. ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-mcp_server_config.mdx ================================================ --- title: mcp_server_config sidebarTitle: mcp_server_config --- # `fastmcp.utilities.mcp_server_config.v1.mcp_server_config` FastMCP Configuration File Support. This module provides support for fastmcp.json configuration files that allow users to specify server settings in a declarative format instead of using command-line arguments. ## Functions ### `generate_schema` ```python 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 if output_path is None, otherwise None ## Classes ### `Deployment` Configuration for server deployment and runtime settings. **Methods:** #### `apply_runtime_settings` ```python apply_runtime_settings(self, config_path: Path | None = None) -> None ``` Apply runtime settings like environment variables and working directory. **Args:** - `config_path`: Path to config file for resolving relative paths Environment variables support interpolation with ${VAR_NAME} syntax. For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" will substitute the value of the ENVIRONMENT variable at runtime. ### `MCPServerConfig` Configuration for a FastMCP server. This configuration file allows you to specify all settings needed to run a FastMCP server in a declarative format. **Methods:** #### `validate_source` ```python validate_source(cls, v: dict | Source) -> SourceType ``` Validate and convert source to proper format. Supports: - Dict format: `{"path": "server.py", "entrypoint": "app"}` - FileSystemSource instance (passed through) No string parsing happens here - that's only at CLI boundaries. MCPServerConfig works only with properly typed objects. #### `validate_environment` ```python validate_environment(cls, v: dict | Any) -> EnvironmentType ``` Ensure environment has a type field for discrimination. For backward compatibility, if no type is specified, default to "uv". #### `validate_deployment` ```python validate_deployment(cls, v: dict | Deployment) -> Deployment ``` Validate and convert deployment to Deployment. Accepts: - Deployment instance - dict that can be converted to Deployment #### `from_file` ```python from_file(cls, file_path: Path) -> MCPServerConfig ``` Load configuration from a JSON file. **Args:** - `file_path`: Path to the configuration file **Returns:** - MCPServerConfig instance **Raises:** - `FileNotFoundError`: If the file doesn't exist - `json.JSONDecodeError`: If the file is not valid JSON - `pydantic.ValidationError`: If the configuration is invalid #### `from_cli_args` ```python from_cli_args(cls, source: FileSystemSource, transport: Literal['stdio', 'http', 'sse', 'streamable-http'] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None) -> MCPServerConfig ``` Create a config from CLI arguments. This allows us to have a single code path where everything goes through a config object. **Args:** - `source`: Server source (FileSystemSource instance) - `transport`: Transport protocol - `host`: Host for HTTP transport - `port`: Port for HTTP transport - `path`: URL path for server - `log_level`: Logging level - `python`: Python version - `dependencies`: Python packages to install - `requirements`: Path to requirements file - `project`: Path to project directory - `editable`: Path to install in editable mode - `env`: Environment variables - `cwd`: Working directory - `args`: Server arguments **Returns:** - MCPServerConfig instance #### `find_config` ```python find_config(cls, start_path: Path | None = None) -> Path | None ``` Find a fastmcp.json file in the specified directory. **Args:** - `start_path`: Directory to look in (defaults to current directory) **Returns:** - Path to the configuration file, or None if not found #### `prepare` ```python prepare(self, skip_source: bool = False, output_dir: Path | None = None) -> None ``` Prepare environment and source for execution. When output_dir is provided, creates a persistent uv project. When output_dir is None, does ephemeral caching (for backwards compatibility). **Args:** - `skip_source`: Skip source preparation if True - `output_dir`: Directory to create the persistent uv project in (optional) #### `prepare_environment` ```python prepare_environment(self, output_dir: Path | None = None) -> None ``` Prepare the Python environment. **Args:** - `output_dir`: If provided, creates a persistent uv project in this directory. If None, just populates uv's cache for ephemeral use. Delegates to the environment's prepare() method #### `prepare_source` ```python prepare_source(self) -> None ``` Prepare the source for loading. Delegates to the source's prepare() method. #### `run_server` ```python run_server(self, **kwargs: Any) -> None ``` Load and run the server with this configuration. **Args:** - `**kwargs`: Additional arguments to pass to server.run_async() These override config settings ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities.mcp_server_config.v1.sources` *This module is empty or contains only private/internal implementations.* ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-base.mdx ================================================ --- title: base sidebarTitle: base --- # `fastmcp.utilities.mcp_server_config.v1.sources.base` ## Classes ### `Source` Abstract base class for all source types. **Methods:** #### `prepare` ```python 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. #### `load_server` ```python 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. ================================================ FILE: docs/python-sdk/fastmcp-utilities-mcp_server_config-v1-sources-filesystem.mdx ================================================ --- title: filesystem sidebarTitle: filesystem --- # `fastmcp.utilities.mcp_server_config.v1.sources.filesystem` ## Classes ### `FileSystemSource` Source for local Python files. **Methods:** #### `parse_path_with_object` ```python 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. #### `load_server` ```python load_server(self) -> Any ``` Load server from filesystem. ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-__init__.mdx ================================================ --- title: __init__ sidebarTitle: __init__ --- # `fastmcp.utilities.openapi` OpenAPI utilities for FastMCP - refactored for better maintainability. ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-director.mdx ================================================ --- title: director sidebarTitle: director --- # `fastmcp.utilities.openapi.director` Request director using openapi-core for stateless HTTP request building. ## Classes ### `RequestDirector` Builds httpx.Request objects from HTTPRoute and arguments using openapi-core. **Methods:** #### `build` ```python build(self, route: HTTPRoute, flat_args: dict[str, Any], base_url: str = 'http://localhost') -> httpx.Request ``` Constructs a final httpx.Request object, handling all OpenAPI serialization. **Args:** - `route`: HTTPRoute containing OpenAPI operation details - `flat_args`: Flattened arguments from LLM (may include suffixed parameters) - `base_url`: Base URL for the request **Returns:** - httpx.Request: Properly formatted HTTP request ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-formatters.mdx ================================================ --- title: formatters sidebarTitle: formatters --- # `fastmcp.utilities.openapi.formatters` Parameter formatting functions for OpenAPI operations. ## Functions ### `format_array_parameter` ```python format_array_parameter(values: list, parameter_name: str, is_query_parameter: bool = False) -> str | list ``` Format an array parameter according to OpenAPI specifications. **Args:** - `values`: List of values to format - `parameter_name`: Name of the parameter (for error messages) - `is_query_parameter`: If True, can return list for explode=True behavior **Returns:** - String (comma-separated) or list (for query params with explode=True) ### `format_deep_object_parameter` ```python format_deep_object_parameter(param_value: dict, parameter_name: str) -> dict[str, str] ``` Format a dictionary parameter for deep-object style serialization. According to OpenAPI 3.0 spec, deepObject style with explode=true serializes object properties as separate query parameters with bracket notation. For example, `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`. **Args:** - `param_value`: Dictionary value to format - `parameter_name`: Name of the parameter **Returns:** - Dictionary with bracketed parameter names as keys ### `generate_example_from_schema` ```python generate_example_from_schema(schema: JsonSchema | None) -> Any ``` Generate a simple example value from a JSON schema dictionary. Very basic implementation focusing on types. ### `format_json_for_description` ```python format_json_for_description(data: Any, indent: int = 2) -> str ``` Formats Python data as a JSON string block for Markdown. ### `format_description_with_responses` ```python format_description_with_responses(base_description: str, responses: dict[str, Any], parameters: list[ParameterInfo] | None = None, request_body: RequestBodyInfo | None = None) -> str ``` Formats the base description string with response, parameter, and request body information. **Args:** - `base_description`: The initial description to be formatted. - `responses`: A dictionary of response information, keyed by status code. - `parameters`: A list of parameter information, including path and query parameters. Each parameter includes details such as name, location, whether it is required, and a description. - `request_body`: Information about the request body, including its description, whether it is required, and its content schema. **Returns:** - The formatted description string with additional details about responses, parameters, - and the request body. ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-json_schema_converter.mdx ================================================ --- title: json_schema_converter sidebarTitle: json_schema_converter --- # `fastmcp.utilities.openapi.json_schema_converter` Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser. This module provides a systematic approach to converting OpenAPI 3.0 schemas to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized for our specific use case. ## Functions ### `convert_openapi_schema_to_json_schema` ```python convert_openapi_schema_to_json_schema(schema: dict[str, Any], openapi_version: str | None = None, remove_read_only: bool = False, remove_write_only: bool = False, convert_one_of_to_any_of: bool = True) -> dict[str, Any] ``` Convert an OpenAPI schema to JSON Schema format. This is a clean, systematic approach that: 1. Removes OpenAPI-specific fields 2. Converts nullable fields to type arrays (for OpenAPI 3.0 only) 3. Converts oneOf to anyOf for overlapping union handling 4. Recursively processes nested schemas 5. Optionally removes readOnly/writeOnly properties **Args:** - `schema`: OpenAPI schema dictionary - `openapi_version`: OpenAPI version for optimization - `remove_read_only`: Whether to remove readOnly properties - `remove_write_only`: Whether to remove writeOnly properties - `convert_one_of_to_any_of`: Whether to convert oneOf to anyOf **Returns:** - JSON Schema-compatible dictionary ### `convert_schema_definitions` ```python convert_schema_definitions(schema_definitions: dict[str, Any] | None, openapi_version: str | None = None, **kwargs) -> dict[str, Any] ``` Convert a dictionary of OpenAPI schema definitions to JSON Schema. **Args:** - `schema_definitions`: Dictionary of schema definitions - `openapi_version`: OpenAPI version for optimization - `**kwargs`: Additional arguments passed to convert_openapi_schema_to_json_schema **Returns:** - Dictionary of converted schema definitions ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-models.mdx ================================================ --- title: models sidebarTitle: models --- # `fastmcp.utilities.openapi.models` Intermediate Representation (IR) models for OpenAPI operations. ## Classes ### `ParameterInfo` Represents a single parameter for an HTTP operation in our IR. ### `RequestBodyInfo` Represents the request body for an HTTP operation in our IR. ### `ResponseInfo` Represents response information in our IR. ### `HTTPRoute` Intermediate Representation for a single OpenAPI operation. ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-parser.mdx ================================================ --- title: parser sidebarTitle: parser --- # `fastmcp.utilities.openapi.parser` OpenAPI parsing logic for converting OpenAPI specs to HTTPRoute objects. ## Functions ### `parse_openapi_to_http_routes` ```python parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute] ``` Parses an OpenAPI schema dictionary into a list of HTTPRoute objects using the openapi-pydantic library. Supports both OpenAPI 3.0.x and 3.1.x versions. ## Classes ### `OpenAPIParser` Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1. **Methods:** #### `parse` ```python parse(self) -> list[HTTPRoute] ``` Parse the OpenAPI schema into HTTP routes. ================================================ FILE: docs/python-sdk/fastmcp-utilities-openapi-schemas.mdx ================================================ --- title: schemas sidebarTitle: schemas --- # `fastmcp.utilities.openapi.schemas` Schema manipulation utilities for OpenAPI operations. ## Functions ### `clean_schema_for_display` ```python clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None ``` Clean up a schema dictionary for display by removing internal/complex fields. ### `extract_output_schema_from_responses` ```python extract_output_schema_from_responses(responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None) -> dict[str, Any] | None ``` Extract output schema from OpenAPI responses for use as MCP tool output schema. This function finds the first successful response (200, 201, 202, 204) with a JSON-compatible content type and extracts its schema. If the schema is not an object type, it wraps it to comply with MCP requirements. **Args:** - `responses`: Dictionary of ResponseInfo objects keyed by status code - `schema_definitions`: Optional schema definitions to include in the output schema - `openapi_version`: OpenAPI version string, used to optimize nullable field handling **Returns:** - MCP-compliant output schema with potential wrapping, or None if no suitable schema found ================================================ FILE: docs/python-sdk/fastmcp-utilities-pagination.mdx ================================================ --- title: pagination sidebarTitle: pagination --- # `fastmcp.utilities.pagination` Pagination utilities for MCP list operations. ## Functions ### `paginate_sequence` ```python paginate_sequence(items: Sequence[T], cursor: str | None, page_size: int) -> tuple[list[T], str | None] ``` Paginate a sequence of items. **Args:** - `items`: The full sequence to paginate. - `cursor`: Optional cursor from a previous request. None for first page. - `page_size`: Maximum number of items per page. **Returns:** - Tuple of (page_items, next_cursor). next_cursor is None if no more pages. **Raises:** - `ValueError`: If the cursor is invalid. ## Classes ### `CursorState` Internal representation of pagination cursor state. The cursor encodes the offset into the result set. This is opaque to clients per the MCP spec - they should not parse or modify cursors. **Methods:** #### `encode` ```python encode(self) -> str ``` Encode cursor state to an opaque string. #### `decode` ```python decode(cls, cursor: str) -> CursorState ``` Decode cursor from an opaque string. **Raises:** - `ValueError`: If the cursor is invalid or malformed. ================================================ FILE: docs/python-sdk/fastmcp-utilities-skills.mdx ================================================ --- title: skills sidebarTitle: skills --- # `fastmcp.utilities.skills` Client utilities for discovering and downloading skills from MCP servers. ## Functions ### `list_skills` ```python list_skills(client: Client) -> list[SkillSummary] ``` List all available skills from an MCP server. Discovers skills by finding resources with URIs matching the `skill://{name}/SKILL.md` pattern. **Args:** - `client`: Connected FastMCP client **Returns:** - List of SkillSummary objects with name, description, and URI ### `get_skill_manifest` ```python get_skill_manifest(client: Client, skill_name: str) -> SkillManifest ``` Get the manifest for a specific skill. **Args:** - `client`: Connected FastMCP client - `skill_name`: Name of the skill **Returns:** - SkillManifest with file listing **Raises:** - `ValueError`: If manifest cannot be read or parsed ### `download_skill` ```python download_skill(client: Client, skill_name: str, target_dir: str | Path) -> Path ``` Download a skill and all its files to a local directory. Creates a subdirectory named after the skill containing all files. **Args:** - `client`: Connected FastMCP client - `skill_name`: Name of the skill to download - `target_dir`: Directory where skill folder will be created - `overwrite`: If True, overwrite existing skill directory. If False (default), raise FileExistsError if directory exists. **Returns:** - Path to the downloaded skill directory **Raises:** - `ValueError`: If skill cannot be found or downloaded - `FileExistsError`: If skill directory exists and overwrite=False ### `sync_skills` ```python sync_skills(client: Client, target_dir: str | Path) -> list[Path] ``` Download all available skills from a server. **Args:** - `client`: Connected FastMCP client - `target_dir`: Directory where skill folders will be created - `overwrite`: If True, overwrite existing files **Returns:** - List of paths to downloaded skill directories ## Classes ### `SkillSummary` Summary information about a skill available on a server. ### `SkillFile` Information about a file within a skill. ### `SkillManifest` Full manifest of a skill including all files. ================================================ FILE: docs/python-sdk/fastmcp-utilities-tests.mdx ================================================ --- title: tests sidebarTitle: tests --- # `fastmcp.utilities.tests` ## Functions ### `temporary_settings` ```python temporary_settings(**kwargs: Any) ``` Temporarily override FastMCP setting values. **Args:** - `**kwargs`: The settings to override, including nested settings. ### `run_server_in_process` ```python run_server_in_process(server_fn: Callable[..., None], *args: Any, **kwargs: Any) -> Generator[str, None, None] ``` Context manager that runs a FastMCP server in a separate process and returns the server URL. When the context manager is exited, the server process is killed. **Args:** - `server_fn`: The function that runs a FastMCP server. FastMCP servers are not pickleable, so we need a function that creates and runs one. - `*args`: Arguments to pass to the server function. - `provide_host_and_port`: Whether to provide the host and port to the server function as kwargs. - `host`: Host to bind the server to (default\: "127.0.0.1"). - `port`: Port to bind the server to (default\: find available port). - `**kwargs`: Keyword arguments to pass to the server function. **Returns:** - The server URL. ### `run_server_async` ```python run_server_async(server: FastMCP, port: int | None = None, transport: Literal['http', 'streamable-http', 'sse'] = 'http', path: str = '/mcp', host: str = '127.0.0.1') -> AsyncGenerator[str, None] ``` Start a FastMCP server as an asyncio task for in-process async testing. This is the recommended way to test FastMCP servers. It runs the server as an async task in the same process, eliminating subprocess coordination, sleeps, and cleanup issues. **Args:** - `server`: FastMCP server instance - `port`: Port to bind to (default\: find available port) - `transport`: Transport type ("http", "streamable-http", or "sse") - `path`: URL path for the server (default\: "/mcp") - `host`: Host to bind to (default\: "127.0.0.1") ## Classes ### `HeadlessOAuth` OAuth provider that bypasses browser interaction for testing. This simulates the complete OAuth flow programmatically by making HTTP requests instead of opening a browser and running a callback server. Useful for automated testing. **Methods:** #### `redirect_handler` ```python redirect_handler(self, authorization_url: str) -> None ``` Make HTTP request to authorization URL and store response for callback handler. #### `callback_handler` ```python callback_handler(self) -> tuple[str, str | None] ``` Parse stored response and return (auth_code, state). ================================================ FILE: docs/python-sdk/fastmcp-utilities-timeout.mdx ================================================ --- title: timeout sidebarTitle: timeout --- # `fastmcp.utilities.timeout` Timeout normalization utilities. ## Functions ### `normalize_timeout_to_timedelta` ```python normalize_timeout_to_timedelta(value: int | float | datetime.timedelta | None) -> datetime.timedelta | None ``` Normalize a timeout value to a timedelta. **Args:** - `value`: Timeout value as int/float (seconds), timedelta, or None **Returns:** - timedelta if value provided, None otherwise ### `normalize_timeout_to_seconds` ```python normalize_timeout_to_seconds(value: int | float | datetime.timedelta | None) -> float | None ``` Normalize a timeout value to seconds (float). **Args:** - `value`: Timeout value as int/float (seconds), timedelta, or None. Zero values are treated as "disabled" and return None. **Returns:** - float seconds if value provided and non-zero, None otherwise ================================================ FILE: docs/python-sdk/fastmcp-utilities-token_cache.mdx ================================================ --- title: token_cache sidebarTitle: token_cache --- # `fastmcp.utilities.token_cache` In-memory cache for token verification results. Provides a generic TTL-based cache for ``AccessToken`` objects, designed to reduce repeated network calls during opaque-token verification. Only *successful* verifications should be cached; errors and failures must be retried on every request. Example: ```python from fastmcp.utilities.token_cache import TokenCache cache = TokenCache(ttl_seconds=300, max_size=10000) # On cache miss, call the upstream verifier and store the result. hit, token = cache.get(raw_token) if not hit: token = await _call_upstream(raw_token) if token is not None: cache.set(raw_token, token) ``` ## Classes ### `TokenCache` TTL-based in-memory cache for ``AccessToken`` objects. Features: - SHA-256 hashed cache keys (fixed size, regardless of token length). - Per-entry TTL that respects both the configured ``ttl_seconds`` and the token's own ``expires_at`` claim (whichever is sooner). - Bounded size with FIFO eviction when the cache is full. - Periodic cleanup of expired entries to prevent unbounded growth. - Defensive deep copies on both store and retrieve to prevent callers from mutating cached values. Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or when ``max_size`` is ``0``. Negative values raise ``ValueError``. **Methods:** #### `enabled` ```python enabled(self) -> bool ``` Return whether caching is active. #### `get` ```python get(self, token: str) -> tuple[bool, AccessToken | None] ``` Look up a cached verification result. **Returns:** - ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss - or when caching is disabled. The returned ``AccessToken`` is a deep - copy that is safe to mutate. #### `set` ```python set(self, token: str, result: AccessToken) -> None ``` Store a *successful* verification result. Only successful verifications should be cached. Failures (inactive tokens, missing scopes, HTTP errors, timeouts) must **not** be cached so that transient problems do not produce sticky false negatives. ================================================ FILE: docs/python-sdk/fastmcp-utilities-types.mdx ================================================ --- title: types sidebarTitle: types --- # `fastmcp.utilities.types` Common types used across FastMCP. ## Functions ### `get_fn_name` ```python get_fn_name(fn: Callable[..., Any]) -> str ``` ### `get_cached_typeadapter` ```python get_cached_typeadapter(cls: T) -> TypeAdapter[T] ``` TypeAdapters are heavy objects, and in an application context we'd typically create them once in a global scope and reuse them as often as possible. 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` ```python 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` ```python 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. 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` ```python find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None ``` 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. ### `create_function_without_params` ```python create_function_without_params(fn: Callable[..., Any], exclude_params: list[str]) -> Callable[..., Any] ``` Create a new function with the same code but without the specified parameters in annotations. This is used to exclude parameters from type adapter processing when they can't be serialized. The excluded parameters are removed from the function's __annotations__ dictionary. ### `replace_type` ```python replace_type(type_, type_map: dict[type, type]) ``` Given a (possibly generic, nested, or otherwise complex) type, replaces all instances of old_type with new_type. This is useful for transforming types when creating tools. **Args:** - `type_`: The type to replace instances of old_type with new_type. - `old_type`: The type to replace. - `new_type`: The type to replace old_type with. Examples: ```python >>> replace_type(list[int | bool], {int: str}) list[str | bool] >>> replace_type(list[list[int]], {int: str}) list[list[str]] ``` ## Classes ### `FastMCPBaseModel` Base model for FastMCP models. ### `Image` Helper class for returning images from tools. **Methods:** #### `to_image_content` ```python to_image_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.ImageContent ``` Convert to MCP ImageContent. #### `to_data_uri` ```python to_data_uri(self, mime_type: str | None = None) -> str ``` Get image as a data URI. ### `Audio` Helper class for returning audio from tools. **Methods:** #### `to_audio_content` ```python to_audio_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.AudioContent ``` ### `File` Helper class for returning file data from tools. **Methods:** #### `to_resource_content` ```python to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource ``` ### `ContextSamplingFallbackProtocol` ================================================ FILE: docs/python-sdk/fastmcp-utilities-ui.mdx ================================================ --- title: ui sidebarTitle: ui --- # `fastmcp.utilities.ui` Shared UI utilities for FastMCP HTML pages. This module provides reusable HTML/CSS components for OAuth callbacks, consent pages, and other user-facing interfaces. ## Functions ### `create_page` ```python create_page(content: str, title: str = 'FastMCP', additional_styles: str = '', csp_policy: str = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'") -> str ``` Create a complete HTML page with FastMCP styling. **Args:** - `content`: HTML content to place inside the page - `title`: Page title - `additional_styles`: Extra CSS to include - `csp_policy`: Content Security Policy header value. If empty string "", the CSP meta tag is omitted entirely. **Returns:** - Complete HTML page as string ### `create_logo` ```python create_logo(icon_url: str | None = None, alt_text: str = 'FastMCP') -> str ``` Create logo HTML. **Args:** - `icon_url`: Optional custom icon URL. If not provided, uses the FastMCP logo. - `alt_text`: Alt text for the logo image. **Returns:** - HTML for logo image tag. ### `create_status_message` ```python create_status_message(message: str, is_success: bool = True) -> str ``` Create a status message with icon. **Args:** - `message`: Status message text - `is_success`: True for success (✓), False for error (✕) **Returns:** - HTML for status message ### `create_info_box` ```python create_info_box(content: str, is_error: bool = False, centered: bool = False, monospace: bool = False) -> str ``` Create an info box. **Args:** - `content`: HTML content for the info box - `is_error`: True for error styling, False for normal - `centered`: True to center the text, False for left-aligned - `monospace`: True to use gray monospace font styling instead of blue **Returns:** - HTML for info box ### `create_detail_box` ```python create_detail_box(rows: list[tuple[str, str]]) -> str ``` Create a detail box with key-value pairs. **Args:** - `rows`: List of (label, value) tuples **Returns:** - HTML for detail box ### `create_button_group` ```python create_button_group(buttons: list[tuple[str, str, str]]) -> str ``` Create a group of buttons. **Args:** - `buttons`: List of (text, value, css_class) tuples **Returns:** - HTML for button group ### `create_secure_html_response` ```python create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse ``` Create an HTMLResponse with security headers. Adds X-Frame-Options: DENY to prevent clickjacking attacks per MCP security best practices. **Args:** - `html`: HTML content to return - `status_code`: HTTP status code **Returns:** - HTMLResponse with security headers ================================================ FILE: docs/python-sdk/fastmcp-utilities-version_check.mdx ================================================ --- title: version_check sidebarTitle: version_check --- # `fastmcp.utilities.version_check` Version checking utilities for FastMCP. ## Functions ### `get_latest_version` ```python get_latest_version(include_prereleases: bool = False) -> str | None ``` Get the latest version of FastMCP from PyPI, using cache when available. **Args:** - `include_prereleases`: If True, include pre-release versions. **Returns:** - The latest version string, or None if unavailable. ### `check_for_newer_version` ```python check_for_newer_version() -> str | None ``` Check if a newer version of FastMCP is available. **Returns:** - The latest version string if newer than current, None otherwise. ================================================ FILE: docs/python-sdk/fastmcp-utilities-versions.mdx ================================================ --- title: versions sidebarTitle: versions --- # `fastmcp.utilities.versions` Version comparison utilities for component versioning. This module provides utilities for comparing component versions. Versions are strings that are first attempted to be parsed as PEP 440 versions (using the `packaging` library), falling back to lexicographic string comparison. Examples: - "1", "2", "10" → parsed as PEP 440, compared semantically (1 < 2 < 10) - "1.0", "2.0" → parsed as PEP 440 - "v1.0" → 'v' prefix stripped, parsed as "1.0" - "2025-01-15" → not valid PEP 440, compared as strings - None → sorts lowest (unversioned components) ## Functions ### `parse_version_key` ```python parse_version_key(version: str | None) -> VersionKey ``` Parse a version string into a sortable key. **Args:** - `version`: The version string, or None for unversioned. **Returns:** - A VersionKey suitable for sorting. ### `version_sort_key` ```python version_sort_key(component: FastMCPComponent) -> VersionKey ``` Get a sort key for a component based on its version. Use with sorted() or max() to order components by version. **Args:** - `component`: The component to get a sort key for. **Returns:** - A sortable VersionKey. ### `compare_versions` ```python compare_versions(a: str | None, b: str | None) -> int ``` Compare two version strings. **Args:** - `a`: First version string (or None). - `b`: Second version string (or None). **Returns:** - -1 if a < b, 0 if a == b, 1 if a > b. ### `is_version_greater` ```python is_version_greater(a: str | None, b: str | None) -> bool ``` Check if version a is greater than version b. **Args:** - `a`: First version string (or None). - `b`: Second version string (or None). **Returns:** - True if a > b, False otherwise. ### `max_version` ```python max_version(a: str | None, b: str | None) -> str | None ``` Return the greater of two versions. **Args:** - `a`: First version string (or None). - `b`: Second version string (or None). **Returns:** - The greater version, or None if both are None. ### `min_version` ```python min_version(a: str | None, b: str | None) -> str | None ``` Return the lesser of two versions. **Args:** - `a`: First version string (or None). - `b`: Second version string (or None). **Returns:** - The lesser version, or None if both are None. ### `dedupe_with_versions` ```python dedupe_with_versions(components: Sequence[C], key_fn: Callable[[C], str]) -> list[C] ``` Deduplicate components by key, keeping highest version. Groups components by key, selects the highest version from each group, and injects available versions into meta if any component is versioned. **Args:** - `components`: Sequence of components to deduplicate. - `key_fn`: Function to extract the grouping key from a component. **Returns:** - Deduplicated list with versions injected into meta. ## Classes ### `VersionSpec` Specification for filtering components by version. Used by transforms and providers to filter components to a specific version or version range. Unversioned components (version=None) always match any spec. **Args:** - `gte`: If set, only versions >= this value match. - `lt`: If set, only versions < this value match. - `eq`: If set, only this exact version matches (gte/lt ignored). **Methods:** #### `matches` ```python matches(self, version: str | None) -> bool ``` Check if a version matches this spec. **Args:** - `version`: The version to check, or None for unversioned. - `match_none`: Whether unversioned (None) components match. Defaults to True for backward compatibility with retrieval operations. Set to False when filtering (e.g., enable/disable) to exclude unversioned components from version-specific rules. **Returns:** - True if the version matches the spec. #### `intersect` ```python intersect(self, other: VersionSpec | None) -> VersionSpec ``` Return a spec that satisfies both this spec and other. Used by transforms to combine caller constraints with filter constraints. For example, if a VersionFilter has lt="3.0" and caller requests eq="1.0", the intersection validates "1.0" is in range and returns the exact spec. **Args:** - `other`: Another spec to intersect with, or None. **Returns:** - A VersionSpec that matches only versions satisfying both specs. ### `VersionKey` A comparable version key that handles None, PEP 440 versions, and strings. Comparison order: 1. None (unversioned) sorts lowest 2. PEP 440 versions sort by semantic version order 3. Invalid versions (strings) sort lexicographically 4. When comparing PEP 440 vs string, PEP 440 comes first ================================================ FILE: docs/servers/auth/authentication.mdx ================================================ --- title: Authentication sidebarTitle: Overview description: Secure your FastMCP server with flexible authentication patterns, from simple API keys to full OAuth 2.1 integration with external identity providers. icon: user-shield --- import { VersionBadge } from "/snippets/version-badge.mdx" Authentication in MCP presents unique challenges that differ from traditional web applications. MCP clients need to discover authentication requirements automatically, negotiate OAuth flows without user intervention, and work seamlessly across different identity providers. FastMCP addresses these challenges by providing authentication patterns that integrate with the MCP protocol while remaining simple to implement and deploy. Authentication applies only to FastMCP's HTTP-based transports (`http` and `sse`). The STDIO transport inherits security from its local execution environment. **Authentication is rapidly evolving in MCP.** The specification and best practices are changing quickly. FastMCP aims to provide stable, secure patterns that adapt to these changes while keeping your code simple and maintainable. ## MCP Authentication Challenges Traditional web authentication assumes a human user with a browser who can interact with login forms and consent screens. MCP clients are often automated systems that need to authenticate without human intervention. This creates several unique requirements: **Automatic Discovery**: MCP clients must discover authentication requirements by examining server metadata rather than encountering login redirects. **Programmatic OAuth**: OAuth flows must work without human interaction, relying on pre-configured credentials or Dynamic Client Registration. **Token Management**: Clients need to obtain, refresh, and manage tokens automatically across multiple MCP servers. **Protocol Integration**: Authentication must integrate cleanly with MCP's transport mechanisms and error handling. These challenges mean that not all authentication approaches work well with MCP. The patterns that do work fall into three categories based on the level of authentication responsibility your server assumes. ## Authentication Responsibility Authentication responsibility exists on a spectrum. Your MCP server can validate tokens created elsewhere, coordinate with external identity providers, or handle the complete authentication lifecycle internally. Each approach involves different trade-offs between simplicity, security, and control. ### Token Validation Your server validates tokens but delegates their creation to external systems. This approach treats your MCP server as a pure resource server that trusts tokens signed by known issuers. Token validation works well when you already have authentication infrastructure that can issue structured tokens like JWTs. Your existing API gateway, microservices platform, or enterprise SSO system becomes the source of truth for user identity, while your MCP server focuses on its core functionality. The key insight is that token validation separates authentication (proving who you are) from authorization (determining what you can do). Your MCP server receives proof of identity in the form of a signed token and makes access decisions based on the claims within that token. This pattern excels in microservices architectures where multiple services need to validate the same tokens, or when integrating MCP servers into existing systems that already handle user authentication. ### External Identity Providers Your server coordinates with established identity providers to create seamless authentication experiences for MCP clients. This approach leverages OAuth 2.0 and OpenID Connect protocols to delegate user authentication while maintaining control over authorization decisions. External identity providers handle the complex aspects of authentication: user credential verification, multi-factor authentication, account recovery, and security monitoring. Your MCP server receives tokens from these trusted providers and validates them using the provider's public keys. The MCP protocol's support for Dynamic Client Registration makes this pattern particularly powerful. MCP clients can automatically discover your authentication requirements and register themselves with your identity provider without manual configuration. This approach works best for production applications that need enterprise-grade authentication features without the complexity of building them from scratch. It scales well across multiple applications and provides consistent user experiences. ### Full OAuth Implementation Your server implements a complete OAuth 2.0 authorization server, handling everything from user credential verification to token lifecycle management. This approach provides maximum control at the cost of significant complexity. Full OAuth implementation means building user interfaces for login and consent, implementing secure credential storage, managing token lifecycles, and maintaining ongoing security updates. The complexity extends beyond initial implementation to include threat monitoring, compliance requirements, and keeping pace with evolving security best practices. This pattern makes sense only when you need complete control over the authentication process, operate in air-gapped environments, or have specialized requirements that external providers cannot meet. ## FastMCP Authentication Providers FastMCP translates these authentication responsibility levels into a variety of concrete classes that handle the complexities of MCP protocol integration. You can build on these classes to handle the complexities of MCP protocol integration. ### TokenVerifier `TokenVerifier` provides pure token validation without OAuth metadata endpoints. This class focuses on the essential task of determining whether a token is valid and extracting authorization information from its claims. The implementation handles JWT signature verification, expiration checking, and claim extraction. It validates tokens against known issuers and audiences, ensuring that tokens intended for your server are not accepted by other systems. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import JWTVerifier auth = JWTVerifier( jwks_uri="https://your-auth-system.com/.well-known/jwks.json", issuer="https://your-auth-system.com", audience="your-mcp-server" ) mcp = FastMCP(name="Protected Server", auth=auth) ``` This example configures token validation against a JWT issuer. The `JWTVerifier` will fetch public keys from the JWKS endpoint and validate incoming tokens against those keys. Only tokens with the correct issuer and audience claims will be accepted. `TokenVerifier` works well when you control both the token issuer and your MCP server, or when integrating with existing JWT-based infrastructure. → **Complete guide**: [Token Verification](/servers/auth/token-verification) ### RemoteAuthProvider `RemoteAuthProvider` enables authentication with identity providers that **support Dynamic Client Registration (DCR)**, such as Descope and WorkOS AuthKit. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. This class combines token validation with OAuth discovery metadata. It extends `TokenVerifier` functionality by adding OAuth 2.0 protected resource endpoints that advertise your authentication requirements. MCP clients examine these endpoints to understand which identity providers you trust and how to obtain valid tokens. The key requirement is that your identity provider must support DCR - the ability for clients to dynamically register and obtain credentials. This is what enables the seamless, automated authentication flow that MCP requires. For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully supports DCR: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", base_url="https://your-fastmcp-server.com" ) mcp = FastMCP(name="Enterprise Server", auth=auth) ``` This example uses WorkOS AuthKit as the external identity provider. The `AuthKitProvider` automatically configures token validation against WorkOS and provides the OAuth metadata that MCP clients need for automatic authentication. `RemoteAuthProvider` is ideal for production applications when your identity provider supports Dynamic Client Registration (DCR). This enables fully automated authentication without manual client configuration. → **Complete guide**: [Remote OAuth](/servers/auth/remote-oauth) ### OAuthProxy `OAuthProxy` enables authentication with OAuth providers that **don't support Dynamic Client Registration (DCR)**, such as GitHub, Google, Azure, AWS, and most traditional enterprise identity systems. When identity providers require manual app registration and fixed credentials, `OAuthProxy` bridges the gap. It presents a DCR-compliant interface to MCP clients (accepting any registration request) while using your pre-registered credentials with the upstream provider. The proxy handles the complexity of callback forwarding, enabling dynamic client callbacks to work with providers that require fixed redirect URIs. This class solves the fundamental incompatibility between MCP's expectation of dynamic registration and traditional OAuth providers' requirement for manual app registration. For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with GitHub's OAuth system: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id="Ov23li...", # Your GitHub OAuth App ID client_secret="abc123...", # Your GitHub OAuth App Secret base_url="https://your-server.com" ) mcp = FastMCP(name="GitHub-Protected Server", auth=auth) ``` This example uses the GitHub provider, which extends `OAuthProxy` with GitHub-specific token validation. The proxy handles the complete OAuth flow while making GitHub's non-DCR authentication work seamlessly with MCP clients. `OAuthProxy` is essential when integrating with OAuth providers that don't support DCR. This includes most established providers like GitHub, Google, and Azure, which require manual app registration through their developer consoles. → **Complete guide**: [OAuth Proxy](/servers/auth/oauth-proxy) ### OAuthProvider `OAuthProvider` implements a complete OAuth 2.0 authorization server within your MCP server. This class handles the full authentication lifecycle from user credential verification to token management. The implementation provides all required OAuth endpoints including authorization, token, and discovery endpoints. It manages client registration, user consent, and token lifecycle while integrating with your user storage and authentication logic. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.oauth import MyOAuthProvider auth = MyOAuthProvider( user_store=your_user_database, client_store=your_client_registry, # Additional configuration... ) mcp = FastMCP(name="Auth Server", auth=auth) ``` This example shows the basic structure of a custom OAuth provider. The actual implementation requires significant additional configuration for user management, client registration, and security policies. `OAuthProvider` should be used only when you have specific requirements that external providers cannot meet and the expertise to implement OAuth securely. → **Complete guide**: [Full OAuth Server](/servers/auth/full-oauth-server) ### MultiAuth `MultiAuth` composes multiple authentication sources into a single `auth` provider. When a server needs to accept tokens from different issuers — for example, an OAuth proxy for interactive clients alongside JWT verification for machine-to-machine tokens — `MultiAuth` tries each source in order and accepts the first successful verification. ```python from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier auth = MultiAuth( server=OAuthProxy( issuer_url="https://login.example.com/...", client_id="my-app", client_secret="secret", base_url="https://my-server.com", ), verifiers=[ JWTVerifier( jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json", issuer="https://internal-issuer.example.com", audience="my-mcp-server", ), ], ) mcp = FastMCP("My Server", auth=auth) ``` The server (if provided) owns all OAuth routes and metadata. Verifiers contribute only token verification logic. This keeps the MCP discovery surface clean while supporting multiple token sources. → **Complete guide**: [Multiple Auth Sources](/servers/auth/multi-auth) ## Configuration Authentication providers are configured programmatically by instantiating them directly in your code with their required parameters. This makes dependencies explicit and allows your IDE to provide helpful autocompletion and type checking. For production deployments, load sensitive values like client secrets from environment variables: ```python import os from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider # Load secrets from environment variables auth = GitHubProvider( client_id=os.environ.get("GITHUB_CLIENT_ID"), client_secret=os.environ.get("GITHUB_CLIENT_SECRET"), base_url=os.environ.get("BASE_URL", "http://localhost:8000") ) mcp = FastMCP(name="My Server", auth=auth) ``` This approach keeps secrets out of your codebase while maintaining explicit configuration. You can use any environment variable names you prefer - there are no special prefixes required. ## Choosing Your Implementation The authentication approach you choose depends on your existing infrastructure, security requirements, and operational constraints. **For OAuth providers without DCR support (GitHub, Google, Azure, AWS, most enterprise systems), use OAuth Proxy.** These providers require manual app registration through their developer consoles. OAuth Proxy bridges the gap by presenting a DCR-compliant interface to MCP clients while using your fixed credentials with the provider. The proxy's callback forwarding pattern enables dynamic client ports to work with providers that require fixed redirect URIs. **For identity providers with DCR support (Descope, WorkOS AuthKit, modern auth platforms), use RemoteAuthProvider.** These providers allow clients to dynamically register and obtain credentials without manual configuration. This enables the fully automated authentication flow that MCP is designed for, providing the best user experience and simplest implementation. **Token validation works well when you already have authentication infrastructure that issues structured tokens.** If your organization already uses JWT-based systems, API gateways, or enterprise SSO that can generate tokens, this approach integrates seamlessly while keeping your MCP server focused on its core functionality. The simplicity comes from leveraging existing investment in authentication infrastructure. **When you need tokens from multiple sources, use MultiAuth.** This is common in hybrid architectures where interactive clients authenticate through an OAuth proxy while backend services send JWT tokens directly. `MultiAuth` composes an optional auth server with additional token verifiers, trying each source in order until one succeeds. **Full OAuth implementation should be avoided unless you have compelling reasons that external providers cannot address.** Air-gapped environments, specialized compliance requirements, or unique organizational constraints might justify this approach, but it requires significant security expertise and ongoing maintenance commitment. The complexity extends far beyond initial implementation to include threat monitoring, security updates, and keeping pace with evolving attack vectors. FastMCP's architecture supports migration between these approaches as your requirements evolve. You can integrate with existing token systems initially and migrate to external identity providers as your application scales, or implement custom solutions when your requirements outgrow standard patterns. ================================================ FILE: docs/servers/auth/full-oauth-server.mdx ================================================ --- title: Full OAuth Server sidebarTitle: Full OAuth Server description: Build a self-contained authentication system where your FastMCP server manages users, issues tokens, and validates them. icon: users-between-lines --- import { VersionBadge } from "/snippets/version-badge.mdx" **This is an extremely advanced pattern that most users should avoid.** Building a secure OAuth 2.1 server requires deep expertise in authentication protocols, cryptography, and security best practices. The complexity extends far beyond initial implementation to include ongoing security monitoring, threat response, and compliance maintenance. **Use [Remote OAuth](/servers/auth/remote-oauth) instead** unless you have compelling requirements that external identity providers cannot meet, such as air-gapped environments or specialized compliance needs. The Full OAuth Server pattern exists to support the MCP protocol specification's requirements. Your FastMCP server becomes both an Authorization Server and Resource Server, handling the complete authentication lifecycle from user login to token validation. This documentation exists for completeness - the vast majority of applications should use external identity providers instead. ## OAuthProvider FastMCP provides the `OAuthProvider` abstract class that implements the OAuth 2.1 specification. To use this pattern, you must subclass `OAuthProvider` and implement all required abstract methods. `OAuthProvider` handles OAuth endpoints, protocol flows, and security requirements, but delegates all storage, user management, and business logic to your implementation of the abstract methods. ## Required Implementation You must implement these abstract methods to create a functioning OAuth server: ### Client Management Retrieve client information by ID from your database. Client identifier to look up Client information object or `None` if client not found Store new client registration information in your database. Complete client registration information to store No return value ### Authorization Flow Handle authorization request and return redirect URL. Must implement user authentication and consent collection. OAuth client making the authorization request Authorization request parameters from the client Redirect URL to send the client to Load authorization code from storage by code string. Return `None` if code is invalid or expired. OAuth client attempting to use the authorization code Authorization code string to look up Authorization code object or `None` if not found ### Token Management Exchange authorization code for access and refresh tokens. Must validate code and create new tokens. OAuth client exchanging the authorization code Valid authorization code object to exchange New OAuth token containing access and refresh tokens Load refresh token from storage by token string. Return `None` if token is invalid or expired. OAuth client attempting to use the refresh token Refresh token string to look up Refresh token object or `None` if not found Exchange refresh token for new access/refresh token pair. Must validate scopes and token. OAuth client using the refresh token Valid refresh token object to exchange Requested scopes for the new access token New OAuth token with updated access and refresh tokens Load an access token by its token string. The access token to verify The access token object, or `None` if the token is invalid Revoke access or refresh token, marking it as invalid in storage. Token object to revoke and mark invalid No return value Verify bearer token for incoming requests. Return `AccessToken` if valid, `None` if invalid. Bearer token string from incoming request Access token object if valid, `None` if invalid or expired Each method must handle storage, validation, security, and error cases according to the OAuth 2.1 specification. The implementation complexity is substantial and requires expertise in OAuth security considerations. **Security Notice:** OAuth server implementation involves numerous security considerations including PKCE, state parameters, redirect URI validation, token binding, replay attack prevention, and secure storage requirements. Mistakes can lead to serious security vulnerabilities. ================================================ FILE: docs/servers/auth/multi-auth.mdx ================================================ --- title: Multiple Auth Sources sidebarTitle: Multiple Auth Sources description: Accept tokens from multiple authentication sources with a single server. icon: layer-group --- import { VersionBadge } from "/snippets/version-badge.mdx" Production servers often need to accept tokens from multiple authentication sources. An interactive application might authenticate through an OAuth proxy, while a backend service sends machine-to-machine JWT tokens directly. `MultiAuth` composes these sources into a single `auth` provider so every valid token is accepted regardless of where it was issued. ## Understanding MultiAuth `MultiAuth` wraps an optional auth server (like `OAuthProxy`) together with one or more token verifiers (like `JWTVerifier`). When a request arrives with a bearer token, `MultiAuth` tries each source in order and accepts the first successful verification. The auth server, if provided, is tried first. It owns all OAuth routes and metadata — the verifiers contribute only token verification logic. This keeps the MCP discovery surface clean: one set of routes, one set of metadata, multiple verification paths. ```python from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth, OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier auth = MultiAuth( server=OAuthProxy( issuer_url="https://login.example.com/...", client_id="my-app", client_secret="secret", base_url="https://my-server.com", ), verifiers=[ JWTVerifier( jwks_uri="https://internal-issuer.example.com/.well-known/jwks.json", issuer="https://internal-issuer.example.com", audience="my-mcp-server", ), ], ) mcp = FastMCP("My Server", auth=auth) ``` Interactive MCP clients authenticate through the OAuth proxy as usual. Backend services skip OAuth entirely and send a JWT signed by the internal issuer. Both paths are validated, and the first match wins. ## Verification Order `MultiAuth` checks sources in a deterministic order: 1. **Server** (if provided) — the full auth provider's `verify_token` runs first 2. **Verifiers** — each `TokenVerifier` is tried in list order The first source that returns a valid `AccessToken` wins. If every source returns `None`, the request receives a 401 response. This ordering means the server acts as the "primary" authentication path, with verifiers as fallbacks for tokens the server doesn't recognize. ## Verifiers Only You don't always need a full OAuth server. If your server only needs to accept tokens from multiple issuers, pass verifiers without a server: ```python from fastmcp import FastMCP from fastmcp.server.auth import MultiAuth from fastmcp.server.auth.providers.jwt import JWTVerifier, StaticTokenVerifier auth = MultiAuth( verifiers=[ JWTVerifier( jwks_uri="https://issuer-a.example.com/.well-known/jwks.json", issuer="https://issuer-a.example.com", audience="my-server", ), JWTVerifier( jwks_uri="https://issuer-b.example.com/.well-known/jwks.json", issuer="https://issuer-b.example.com", audience="my-server", ), ], ) mcp = FastMCP("Multi-Issuer Server", auth=auth) ``` Without a server, no OAuth routes or metadata are served. This is appropriate for internal systems where clients already know how to obtain tokens. ## API Reference ### MultiAuth | Parameter | Type | Description | | --- | --- | --- | | `server` | `AuthProvider \| None` | Optional auth provider that owns routes and OAuth metadata. Also tried first for token verification. | | `verifiers` | `list[TokenVerifier] \| TokenVerifier` | One or more token verifiers tried after the server. | | `base_url` | `str \| None` | Override the base URL. Defaults to the server's `base_url`. | | `required_scopes` | `list[str] \| None` | Override required scopes. Defaults to the server's scopes. | ================================================ FILE: docs/servers/auth/oauth-proxy.mdx ================================================ --- title: OAuth Proxy sidebarTitle: OAuth Proxy description: Bridge traditional OAuth providers to work seamlessly with MCP's authentication flow. icon: share tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx"; The OAuth proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, AWS, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like Descope and WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. The OAuth proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange. This approach enables any MCP client (whether using random localhost ports or fixed URLs like Claude.ai) to authenticate with any traditional OAuth provider, all while maintaining full OAuth 2.1 and PKCE security. For providers that support OIDC discovery (Auth0, Google with OIDC configuration, Azure AD), consider using [`OIDC Proxy`](/servers/auth/oidc-proxy) for automatic configuration. OIDC Proxy extends the OAuth proxy to automatically discover endpoints from the provider's `/.well-known/openid-configuration` URL, simplifying setup. ## Implementation ### Provider Setup Requirements Before using the OAuth proxy, you need to register your application with your OAuth provider: 1. **Register your application** in the provider's developer console (GitHub Settings, Google Cloud Console, Azure Portal, etc.) 2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: - Default: `https://your-server.com/auth/callback` - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`) - Development: `http://localhost:8000/auth/callback` 3. **Obtain your credentials**: Client ID and Client Secret 4. **Note the OAuth endpoints**: Authorization URL and Token URL (usually found in the provider's OAuth documentation) The redirect URI you configure with your provider must exactly match your FastMCP server's URL plus the callback path. If you customize `redirect_path` in the OAuth proxy, update your provider's redirect URI accordingly. ### Basic Setup Here's how to implement the OAuth proxy with any provider: ```python from fastmcp import FastMCP from fastmcp.server.auth import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier # Configure token verification for your provider # See the Token Verification guide for provider-specific setups token_verifier = JWTVerifier( jwks_uri="https://your-provider.com/.well-known/jwks.json", issuer="https://your-provider.com", audience="your-app-id" ) # Create the OAuth proxy auth = OAuthProxy( # Provider's OAuth endpoints (from their documentation) upstream_authorization_endpoint="https://provider.com/oauth/authorize", upstream_token_endpoint="https://provider.com/oauth/token", # Your registered app credentials upstream_client_id="your-client-id", upstream_client_secret="your-client-secret", # Token validation (see Token Verification guide) token_verifier=token_verifier, # Your FastMCP server's public URL base_url="https://your-server.com", # Optional: customize the callback path (default is "/auth/callback") # redirect_path="/custom/callback", ) mcp = FastMCP(name="My Server", auth=auth) ``` ### Configuration Parameters URL of your OAuth provider's authorization endpoint (e.g., `https://github.com/login/oauth/authorize`) URL of your OAuth provider's token endpoint (e.g., `https://github.com/login/oauth/access_token`) Client ID from your registered OAuth application Client secret from your registered OAuth application. Optional for PKCE public clients or when using alternative credentials (e.g., managed identity client assertions via a subclass). When omitted, `jwt_signing_key` must be provided explicitly since it cannot be derived from the secret. A [`TokenVerifier`](/servers/auth/token-verification) instance to validate the provider's tokens Public URL where OAuth endpoints will be accessible, **including any mount path** (e.g., `https://your-server.com/api`). This URL is used to construct OAuth callback URLs and operational endpoints. When mounting under a path prefix, include that prefix in `base_url`. Use `issuer_url` separately to specify where auth server metadata is located (typically at root level). Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application Optional URL of provider's token revocation endpoint Issuer URL for OAuth authorization server metadata (defaults to `base_url`). When `issuer_url` has a path component (either explicitly or by defaulting from `base_url`), FastMCP creates path-aware discovery routes per RFC 8414. For example, if `base_url` is `http://localhost:8000/api`, the authorization server metadata will be at `/.well-known/oauth-authorization-server/api`. **Default behavior (recommended for most cases):** ```python auth = GitHubProvider( base_url="http://localhost:8000/api", # OAuth endpoints under /api # issuer_url defaults to base_url - path-aware discovery works automatically ) ``` **When to set explicitly:** Set `issuer_url` to root level only if you want multiple MCP servers to share a single discovery endpoint: ```python auth = GitHubProvider( base_url="http://localhost:8000/api", issuer_url="http://localhost:8000" # Shared root-level discovery ) ``` See the [HTTP Deployment guide](/deployment/http#mounting-authenticated-servers) for complete mounting examples. Optional URL to your service documentation Whether to forward PKCE (Proof Key for Code Exchange) to the upstream OAuth provider. When enabled and the client uses PKCE, the proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE. This ensures end-to-end PKCE security at both layers (client-to-proxy and proxy-to-upstream). - `True` (default): Forward PKCE for providers that support it (Google, Azure, AWS, GitHub, etc.) - `False`: Disable only if upstream provider doesn't support PKCE Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - `"client_secret_basic"`: Send credentials in Authorization header (most common) - `"client_secret_post"`: Send credentials in request body (required by some providers) - `"none"`: No authentication (for public clients) - `None` (default): Uses authlib's default (typically `"client_secret_basic"`) Set this if your provider requires a specific authentication method and the default doesn't work. List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - `None` (default): All redirect URIs allowed (for MCP/DCR compatibility) - Empty list `[]`: No redirect URIs allowed - Custom list: Only matching patterns allowed These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. List of all possible valid scopes for the OAuth provider. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` from your TokenVerifier if not specified. Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters that aren't part of the standard OAuth2 flow. For example, Auth0 requires an `audience` parameter to issue JWT tokens: ```python extra_authorize_params={"audience": "https://api.example.com"} ``` These parameters are added to every authorization request sent to the upstream provider. Additional parameters to forward to the upstream token endpoint during code exchange and token refresh. Useful for provider-specific requirements during token operations. For example, some providers require additional context during token exchange: ```python extra_token_params={"audience": "https://api.example.com"} ``` These parameters are included in all token requests to the upstream provider. Storage backend for persisting OAuth client registrations and upstream tokens. **Default behavior:** By default, clients are automatically persisted to an encrypted disk store, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. The disk store is encrypted using a key derived from the JWT Signing Key (which is derived from the upstream client secret by default). For client registrations to survive upstream client secret rotation, you should provide a JWT Signing Key or your own client_storage. For production deployments with multiple servers or cloud deployments, see [Storage Backends](/servers/storage-backends) for available options. **When providing custom storage**, wrap it in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest: ```python from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet import os auth = OAuthProxy( ..., jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore(host="redis.example.com", port=6379), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) ``` Without encryption, upstream OAuth tokens are stored in plaintext. Testing with in-memory storage (unencrypted): ```python from key_value.aio.stores.memory import MemoryStore # Use in-memory storage for testing (clients lost on restart) auth = OAuthProxy(..., client_storage=MemoryStore()) ``` Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. **Default behavior (`None`):** Derives a 32-byte key using PBKDF2 from the upstream client secret. **For production:** Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the key derived from the upstream client secret. This allows you to manage keys securely in cloud environments, allows keys to work across multiple instances, and allows you to rotate keys without losing client registrations. ```python import os auth = OAuthProxy( ..., jwt_signing_key=os.environ["JWT_SIGNING_KEY"], # Any sufficiently complex string! client_storage=RedisStore(...) # Persistent storage ) ``` See [HTTP Deployment - OAuth Token Security](/deployment/http#oauth-token-security) for complete production setup. Whether to require user consent before authorizing MCP clients. When enabled (default), users see a consent screen that displays which client is requesting access, preventing [confused deputy attacks](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) by ensuring users explicitly approve new clients. **Default behavior (True):** Users see a consent screen on first authorization. Consent choices are remembered via signed cookies, so users only need to approve each client once. This protects against malicious clients impersonating the user. **Disabling consent (False):** Authorization proceeds directly to the upstream provider without user confirmation. Only use this for local development or testing environments where the security trade-off is acceptable. ```python # Development/testing only - skip consent screen auth = OAuthProxy( ..., require_authorization_consent=False # ⚠️ Security warning: only for local/testing ) ``` Disabling consent removes an important security layer. Only disable for local development or testing environments where you fully control all connecting clients. Content Security Policy for the consent page. - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission - Empty string `""`: Disables CSP entirely (no meta tag rendered) - Custom string: Uses the provided value as the CSP policy This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. ```python # Disable CSP entirely (let org CSP policies apply) auth = OAuthProxy(..., consent_csp_policy="") # Use custom CSP policy auth = OAuthProxy(..., consent_csp_policy="default-src 'self'; style-src 'unsafe-inline'") ``` ### Using Built-in Providers FastMCP includes pre-configured providers for common services: ```python from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id="your-github-app-id", client_secret="your-github-app-secret", base_url="https://your-server.com" ) mcp = FastMCP(name="My Server", auth=auth) ``` Available providers include `GitHubProvider`, `GoogleProvider`, and others. These handle token verification automatically. ### Token Verification The OAuth proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats: - **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint - **Opaque tokens with RFC 7662 introspection** (Auth0, Okta, WorkOS): Use `IntrospectionTokenVerifier` - **Opaque tokens (provider-specific)** (GitHub, Discord): Use provider-specific verifiers like `GitHubTokenVerifier` See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider. ### Scope Configuration OAuth scopes control what permissions your application requests from users. They're configured through your `TokenVerifier` (required for the OAuth proxy to validate tokens from your provider). Set `required_scopes` to automatically request the permissions your application needs: ```python JWTVerifier(..., required_scopes = ["read:user", "write:data"]) ``` Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. See the [Token Verification](#token-verification) section below for detailed setup. ### Custom Parameters Some OAuth providers require additional parameters beyond the standard OAuth2 flow. Use `extra_authorize_params` and `extra_token_params` to pass provider-specific requirements. For example, Auth0 requires an `audience` parameter to issue JWT tokens instead of opaque tokens: ```python auth = OAuthProxy( upstream_authorization_endpoint="https://your-domain.auth0.com/authorize", upstream_token_endpoint="https://your-domain.auth0.com/oauth/token", upstream_client_id="your-auth0-client-id", upstream_client_secret="your-auth0-client-secret", # Auth0-specific audience parameter extra_authorize_params={"audience": "https://your-api-identifier.com"}, extra_token_params={"audience": "https://your-api-identifier.com"}, token_verifier=JWTVerifier( jwks_uri="https://your-domain.auth0.com/.well-known/jwks.json", issuer="https://your-domain.auth0.com/", audience="https://your-api-identifier.com" ), base_url="https://your-server.com" ) ``` The proxy also automatically forwards RFC 8707 `resource` parameters from MCP clients to upstream providers that support them. ## OAuth Flow ```mermaid sequenceDiagram participant Client as MCP Client
(localhost:random) participant User as User participant Proxy as FastMCP OAuth Proxy
(server:8000) participant Provider as OAuth Provider
(GitHub, etc.) Note over Client, Proxy: Dynamic Registration (Local) Client->>Proxy: 1. POST /register
redirect_uri: localhost:54321/callback Proxy-->>Client: 2. Returns fixed upstream credentials Note over Client, User: Authorization with User Consent Client->>Proxy: 3. GET /authorize
redirect_uri=localhost:54321/callback
code_challenge=CLIENT_CHALLENGE Note over Proxy: Store transaction with client PKCE
Generate proxy PKCE pair Proxy->>User: 4. Show consent page
(client details, redirect URI, scopes) User->>Proxy: 5. Approve/deny consent Note over Proxy: Set consent binding cookie
(binds browser to this flow) Proxy->>Provider: 6. Redirect to provider
redirect_uri=server:8000/auth/callback
code_challenge=PROXY_CHALLENGE Note over Provider, Proxy: Provider Callback Provider->>Proxy: 7. GET /auth/callback
with authorization code Note over Proxy: Verify consent binding cookie
(reject if missing or mismatched) Proxy->>Provider: 8. Exchange code for tokens
code_verifier=PROXY_VERIFIER Provider-->>Proxy: 9. Access & refresh tokens Note over Proxy, Client: Client Callback Forwarding Proxy->>Client: 10. Redirect to localhost:54321/callback
with new authorization code Note over Client, Proxy: Token Exchange Client->>Proxy: 11. POST /token with code
code_verifier=CLIENT_VERIFIER Proxy-->>Client: 12. Returns FastMCP JWT tokens ``` The flow diagram above illustrates the complete OAuth proxy pattern. Let's understand each phase: ### Registration Phase When an MCP client calls `/register` with its dynamic callback URL, the proxy responds with your pre-configured upstream credentials. The client stores these credentials believing it has registered a new app. Meanwhile, the proxy records the client's callback URL for later use. ### Authorization Phase The client initiates OAuth by redirecting to the proxy's `/authorize` endpoint. The proxy: 1. Stores the client's transaction with its PKCE challenge 2. Generates its own PKCE parameters for upstream security 3. Shows the user a consent page with the client's details, redirect URI, and requested scopes 4. If the user approves (or the client was previously approved), sets a consent binding cookie and redirects to the upstream provider using the fixed callback URL This dual-PKCE approach maintains end-to-end security at both the client-to-proxy and proxy-to-provider layers. The consent step protects against confused deputy attacks by ensuring you explicitly approve each client before it can complete authorization, and the consent binding cookie ensures that only the browser that approved consent can complete the callback. ### Callback Phase After user authorization, the provider redirects back to the proxy's fixed callback URL. The proxy: 1. Verifies the consent binding cookie matches the transaction (rejecting requests from a different browser) 2. Exchanges the authorization code for tokens with the provider 3. Stores these tokens temporarily 4. Generates a new authorization code for the client 5. Redirects to the client's original dynamic callback URL ### Token Exchange Phase Finally, the client exchanges its authorization code with the proxy. The proxy validates the client's PKCE verifier, then issues its own FastMCP JWT tokens (rather than forwarding the upstream provider's tokens). See [Token Architecture](#token-architecture) for details on this design. This entire flow is transparent to the MCP client—it experiences a standard OAuth flow with dynamic registration, unaware that a proxy is managing the complexity behind the scenes. ### Token Architecture The OAuth proxy implements a **token factory pattern**: instead of directly forwarding tokens from the upstream OAuth provider, it issues its own JWT tokens to MCP clients. This maintains proper OAuth 2.0 token audience boundaries and enables better security controls. **How it works:** When an MCP client completes authorization, the proxy: 1. **Receives upstream tokens** from the OAuth provider (GitHub, Google, etc.) 2. **Encrypts and stores** these tokens using Fernet encryption (AES-128-CBC + HMAC-SHA256) 3. **Issues FastMCP JWT tokens** to the client, signed with HS256 The FastMCP JWT contains minimal claims: issuer, audience, client ID, scopes, expiration, and a unique token identifier (JTI). The JTI acts as a reference linking to the encrypted upstream token. **Token validation:** When a client makes an MCP request with its FastMCP token: 1. **FastMCP validates the JWT** signature, expiration, issuer, and audience 2. **Looks up the upstream token** using the JTI from the validated JWT 3. **Decrypts and validates** the upstream token with the provider This two-tier validation ensures that FastMCP tokens can only be used with this server (via audience validation) while maintaining full upstream token security. This architecture also prevents [token passthrough](#token-passthrough) — see the [Security](#security) section for details. **Token expiry alignment:** FastMCP token lifetimes match the upstream token lifetimes. When the upstream token expires, the FastMCP token also expires, maintaining consistent security boundaries. **Refresh tokens:** The proxy issues its own refresh tokens that map to upstream refresh tokens. When a client uses a FastMCP refresh token, the proxy refreshes the upstream token and issues a new FastMCP access token. ### PKCE Forwarding The OAuth proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers. This is enabled by default via the `forward_pkce` parameter and works seamlessly with providers like Google, Azure AD, and GitHub. Only disable it for legacy providers that don't support PKCE: ```python # Disable PKCE forwarding only if upstream doesn't support it auth = OAuthProxy( ..., forward_pkce=False # Default is True ) ``` ### Redirect URI Validation While the OAuth proxy accepts all redirect URIs by default (for DCR compatibility), you can restrict which clients can connect by specifying allowed patterns: ```python # Allow only localhost clients (common for development) auth = OAuthProxy( # ... other parameters ... allowed_client_redirect_uris=[ "http://localhost:*", "http://127.0.0.1:*" ] ) # Allow specific known clients auth = OAuthProxy( # ... other parameters ... allowed_client_redirect_uris=[ "http://localhost:*", "https://claude.ai/api/mcp/auth_callback", "https://*.mycompany.com/auth/*" # Wildcard patterns supported ] ) ``` Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use. ## CIMD Support The OAuth proxy supports **Client ID Metadata Documents (CIMD)**, an alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL. Instead of registering dynamically, clients simply provide their CIMD URL as their `client_id`, and the server fetches and validates the metadata. CIMD clients appear in the consent screen with a verified domain badge, giving users confidence about which application is requesting access. This provides stronger identity verification than DCR, where any client can claim any name. ### How CIMD Works When a client presents an HTTPS URL as its `client_id` (for example, `https://myapp.example.com/oauth/client.json`), the OAuth proxy recognizes it as a CIMD client and: 1. Fetches the JSON document from that URL 2. Validates that the document's `client_id` field matches the URL 3. Extracts client metadata (name, redirect URIs, scopes, etc.) 4. Stores the client persistently alongside DCR clients 5. Shows the verified domain in the consent screen This flow happens transparently. MCP clients that support CIMD simply provide their metadata URL instead of registering, and the OAuth proxy handles the rest. ### CIMD Configuration CIMD support is enabled by default for `OAuthProxy`. Whether to accept CIMD URLs as client identifiers. When enabled, clients can use HTTPS URLs pointing to metadata documents as their `client_id` instead of registering via DCR. ### Private Key JWT Authentication CIMD clients can authenticate using `private_key_jwt` instead of the default `none` authentication method. This provides cryptographic proof of client identity by signing JWT assertions with a private key, while the server verifies using the client's public key from their CIMD document. To use `private_key_jwt`, the CIMD document must include either a `jwks_uri` (URL to fetch the public key set) or inline `jwks` (the key set directly in the document): ```json { "client_id": "https://myapp.example.com/oauth/client.json", "client_name": "My Secure App", "redirect_uris": ["http://localhost:*/callback"], "token_endpoint_auth_method": "private_key_jwt", "jwks_uri": "https://myapp.example.com/.well-known/jwks.json" } ``` The OAuth proxy validates JWT assertions according to RFC 7523, checking the signature, issuer, audience, subject claims, and preventing replay attacks via JTI tracking. ### Security Considerations CIMD provides several security advantages over DCR: - **Verified identity**: The domain in the `client_id` URL is verified by HTTPS, so users know which organization is requesting access - **No registration required**: Clients don't need to store or manage dynamically-issued credentials - **Redirect URI enforcement**: CIMD documents must declare `redirect_uris`, which are enforced by the proxy (wildcard patterns supported) - **SSRF protection**: The OAuth proxy blocks fetches to localhost, private IPs, and reserved addresses - **Replay prevention**: For `private_key_jwt` clients, JTI claims are tracked to prevent assertion replay - **Cache-aware fetching**: CIMD documents are cached according to HTTP cache headers and revalidated when required CIMD is enabled by default. To disable it entirely (for example, to require all clients to register via DCR), set `enable_cimd=False` explicitly: ```python auth = OAuthProxy( ..., enable_cimd=False, ) ``` ## Security ### Key and Storage Management The OAuth proxy requires cryptographic keys for JWT signing and storage encryption, plus persistent storage to maintain valid tokens across server restarts. **Default behavior (appropriate for development only):** - **Mac/Windows**: FastMCP automatically generates keys and stores them in your system keyring. Storage defaults to disk. Tokens survive server restarts. This is **only** suitable for development and local testing. - **Linux**: Keys are ephemeral (random salt at startup). Storage defaults to memory. Tokens become invalid on server restart. **For production:** Configure the following parameters together: provide a unique `jwt_signing_key` (for signing FastMCP JWTs), and a shared `client_storage` backend (for storing tokens). Both are required for production deployments. Use a network-accessible storage backend like Redis or DynamoDB rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** (see the `client_storage` parameter documentation above for examples). The keys accept any secret string and derive proper cryptographic keys using HKDF. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Storage Backends](/servers/storage-backends) for complete production setup. ### Confused Deputy Attacks A confused deputy attack allows a malicious client to steal your authorization by tricking you into granting it access under your identity. The OAuth proxy works by bridging DCR clients to traditional auth providers, which means that multiple MCP clients connect through a single upstream OAuth application. An attacker can exploit this shared application by registering a malicious client with their own redirect URI, then sending you an authorization link. When you click it, your browser goes through the OAuth flow—but since you may have already authorized this OAuth app before, the provider might auto-approve the request. The authorization code then gets sent to the attacker's redirect URI instead of a legitimate client, giving them access under your credentials. #### Mitigation FastMCP's OAuth proxy defends against confused deputy attacks with two layers of protection: **Consent screen.** Before any authorization happens, you see a consent page showing the client's details, redirect URI, and requested scopes. This gives you the opportunity to review and deny suspicious requests. Once you approve a client, it's remembered so you don't see the consent page again for that client. The consent mechanism is implemented with CSRF tokens and cryptographically signed cookies to prevent tampering. ![](/assets/images/oauth-proxy-consent-screen.png) The consent page automatically displays your server's name, icon, and website URL, if available. These visual identifiers help users confirm they're authorizing the correct server. **Browser-session binding.** When you approve consent (or when a previously-approved client auto-approves), the proxy sets a cryptographically signed cookie that binds your browser session to the authorization flow. When the identity provider redirects back to the proxy's callback, the proxy verifies that this cookie is present and matches the expected transaction. A different browser — such as a victim who was sent the authorization URL by an attacker — won't have this cookie, and the callback will be rejected with a 403 error. This prevents the attack even when the identity provider skips the consent page for previously-authorized applications. **Learn more:** - [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#confused-deputy-problem) - Official specification guidance - [Confused Deputy Attacks Explained](https://den.dev/blog/mcp-confused-deputy-api-management/) - Detailed walkthrough by Den Delimarsky ### Token Passthrough [Token passthrough](https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices#token-passthrough) occurs when an intermediary exposes upstream tokens to downstream clients, allowing those clients to impersonate the intermediary or access services they shouldn't reach. #### Client-facing mitigation The OAuth proxy's [token factory architecture](#token-architecture) prevents this by design. MCP clients only ever receive FastMCP-issued JWTs — the upstream provider token is never sent to the client. A FastMCP JWT is scoped to your server and cannot be used to access the upstream provider directly, even if intercepted. #### Calling downstream services When your MCP server needs to call other APIs on behalf of the authenticated user, avoid forwarding the upstream token directly — this reintroduces the token passthrough problem in the other direction. Instead, use a token exchange flow like [OAuth 2.0 Token Exchange (RFC 8693)](https://datatracker.ietf.org/doc/html/rfc8693) or your provider's equivalent (such as Azure's [On-Behalf-Of flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)) to obtain a new token scoped to the downstream service. The upstream token is available in your tool functions via `get_access_token()` or the `CurrentAccessToken` dependency, which you can use as the assertion for a token exchange. The exchanged token will be scoped to the specific downstream service and identify your MCP server as the authorized intermediary, maintaining proper audience boundaries throughout the chain. ## Production Configuration For production deployments, load sensitive credentials from environment variables: ```python import os from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider # Load secrets from environment variables auth = GitHubProvider( client_id=os.environ.get("GITHUB_CLIENT_ID"), client_secret=os.environ.get("GITHUB_CLIENT_SECRET"), base_url=os.environ.get("BASE_URL", "https://your-production-server.com") ) mcp = FastMCP(name="My Server", auth=auth) @mcp.tool def protected_tool(data: str) -> str: """This tool is now protected by OAuth.""" return f"Processed: {data}" if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` This keeps secrets out of your codebase while maintaining explicit configuration. ================================================ FILE: docs/servers/auth/oidc-proxy.mdx ================================================ --- title: OIDC Proxy sidebarTitle: OIDC Proxy description: Bridge OIDC providers to work seamlessly with MCP's authentication flow. icon: share --- import { VersionBadge } from "/snippets/version-badge.mdx"; The OIDC proxy enables FastMCP servers to authenticate with OIDC providers that **don't support Dynamic Client Registration (DCR)** out of the box. This includes OAuth providers like: Auth0, Google, Azure, AWS, etc. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead. The OIDC proxy is built upon [`OAuthProxy`](/servers/auth/oauth-proxy) so it has all the same functionality under the covers. ## Implementation ### Provider Setup Requirements Before using the OIDC proxy, you need to register your application with your OAuth provider: 1. **Register your application** in the provider's developer console (Auth0 Applications, Google Cloud Console, Azure Portal, etc.) 2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path: - Default: `https://your-server.com/auth/callback` - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`) - Development: `http://localhost:8000/auth/callback` 3. **Obtain your credentials**: Client ID and Client Secret The redirect URI you configure with your provider must exactly match your FastMCP server's URL plus the callback path. If you customize `redirect_path` in the OIDC proxy, update your provider's redirect URI accordingly. ### Basic Setup Here's how to implement the OIDC proxy with any provider: ```python from fastmcp import FastMCP from fastmcp.server.auth.oidc_proxy import OIDCProxy # Create the OIDC proxy auth = OIDCProxy( # Provider's configuration URL config_url="https://provider.com/.well-known/openid-configuration", # Your registered app credentials client_id="your-client-id", client_secret="your-client-secret", # Your FastMCP server's public URL base_url="https://your-server.com", # Optional: customize the callback path (default is "/auth/callback") # redirect_path="/custom/callback", ) mcp = FastMCP(name="My Server", auth=auth) ``` ### Configuration Parameters URL of your OAuth provider's OIDC configuration Client ID from your registered OAuth application Client secret from your registered OAuth application. Optional for PKCE public clients. When omitted, `jwt_signing_key` must be provided. Public URL of your FastMCP server (e.g., `https://your-server.com`) Strict flag for configuration validation. When True, requires all OIDC mandatory fields. Audience parameter for OIDC providers that require it (e.g., Auth0). This is typically your API identifier. HTTP request timeout in seconds for fetching OIDC configuration Custom token verifier for validating tokens. When provided, FastMCP uses your custom verifier instead of creating a default `JWTVerifier`. Cannot be used with `algorithm` or `required_scopes` parameters - configure these on your verifier instead. The verifier's `required_scopes` are automatically loaded and advertised. JWT algorithm to use for token verification (e.g., "RS256"). If not specified, uses the provider's default. Only used when `token_verifier` is not provided. List of OAuth scopes for token validation. These are automatically included in authorization requests. Only used when `token_verifier` is not provided. Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`). - `None` (default): All redirect URIs allowed (for MCP/DCR compatibility) - Empty list `[]`: No redirect URIs allowed - Custom list: Only matching patterns allowed These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI. Token endpoint authentication method for the upstream OAuth server. Controls how the proxy authenticates when exchanging authorization codes and refresh tokens with the upstream provider. - `"client_secret_basic"`: Send credentials in Authorization header (most common) - `"client_secret_post"`: Send credentials in request body (required by some providers) - `"none"`: No authentication (for public clients) - `None` (default): Uses authlib's default (typically `"client_secret_basic"`) Set this if your provider requires a specific authentication method and the default doesn't work. Secret used to sign FastMCP JWT tokens issued to clients. Accepts any string or bytes - will be derived into a proper 32-byte cryptographic key using HKDF. **Default behavior (`None`):** - **Mac/Windows**: Auto-managed via system keyring. Keys are generated once and persisted, surviving server restarts with zero configuration. Keys are automatically derived from server attributes, so this approach, while convenient, is **only** suitable for development and local testing. For production, you must provide an explicit secret. - **Linux**: Ephemeral (random salt at startup). Tokens become invalid on server restart, triggering client re-authentication. **For production:** Provide an explicit secret (e.g., from environment variable) to use a fixed key instead of the auto-generated one. Storage backend for persisting OAuth client registrations and upstream tokens. **Default behavior:** - **Mac/Windows**: Encrypted DiskStore in your platform's data directory (derived from `platformdirs`) - **Linux**: MemoryStore (ephemeral - clients lost on restart) By default on Mac/Windows, clients are automatically persisted to encrypted disk storage, allowing them to survive server restarts as long as the filesystem remains accessible. This means MCP clients only need to register once and can reconnect seamlessly. On Linux where keyring isn't available, ephemeral storage is used to match the ephemeral key strategy. For production deployments with multiple servers or cloud deployments, use a network-accessible storage backend rather than local disk storage. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest.** See [Storage Backends](/servers/storage-backends) for available options. Testing with in-memory storage (unencrypted): ```python from key_value.aio.stores.memory import MemoryStore # Use in-memory storage for testing (clients lost on restart) auth = OIDCProxy(..., client_storage=MemoryStore()) ``` Production with encrypted Redis storage: ```python from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet import os auth = OIDCProxy( ..., jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=FernetEncryptionWrapper( key_value=RedisStore(host="redis.example.com", port=6379), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) ``` Whether to require user consent before authorizing MCP clients. When enabled (default), users see a consent screen that displays which client is requesting access. See [OAuthProxy documentation](/servers/auth/oauth-proxy#confused-deputy-attacks) for details on confused deputy attack protection. Content Security Policy for the consent page. - `None` (default): Uses the built-in CSP policy with appropriate directives for form submission - Empty string `""`: Disables CSP entirely (no meta tag rendered) - Custom string: Uses the provided value as the CSP policy This is useful for organizations that have their own CSP policies and need to override or disable FastMCP's built-in CSP directives. ### Using Built-in Providers FastMCP includes pre-configured OIDC providers for common services: ```python from fastmcp.server.auth.providers.auth0 import Auth0Provider auth = Auth0Provider( config_url="https://.../.well-known/openid-configuration", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", audience="https://...", base_url="https://localhost:8000" ) mcp = FastMCP(name="My Server", auth=auth) ``` Available providers include `Auth0Provider` at present. ### Scope Configuration OAuth scopes are configured with `required_scopes` to automatically request the permissions your application needs. Dynamic clients created by the proxy will automatically include these scopes in their authorization requests. ## CIMD Support The OIDC proxy inherits full CIMD (Client ID Metadata Document) support from `OAuthProxy`. Clients can use HTTPS URLs as their `client_id` instead of registering dynamically, and the proxy will fetch and validate their metadata document. See the [OAuth Proxy CIMD documentation](/servers/auth/oauth-proxy#cimd-support) for complete details on how CIMD works, including private key JWT authentication and security considerations. The CIMD-related parameters available on `OIDCProxy` are: Whether to accept CIMD URLs as client identifiers. ## Production Configuration For production deployments, load sensitive credentials from environment variables: ```python import os from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider # Load secrets from environment variables auth = Auth0Provider( config_url=os.environ.get("AUTH0_CONFIG_URL"), client_id=os.environ.get("AUTH0_CLIENT_ID"), client_secret=os.environ.get("AUTH0_CLIENT_SECRET"), audience=os.environ.get("AUTH0_AUDIENCE"), base_url=os.environ.get("BASE_URL", "https://localhost:8000") ) mcp = FastMCP(name="My Server", auth=auth) @mcp.tool def protected_tool(data: str) -> str: """This tool is now protected by OAuth.""" return f"Processed: {data}" if __name__ == "__main__": mcp.run(transport="http", port=8000) ``` This keeps secrets out of your codebase while maintaining explicit configuration. ================================================ FILE: docs/servers/auth/remote-oauth.mdx ================================================ --- title: Remote OAuth sidebarTitle: Remote OAuth description: Integrate your FastMCP server with external identity providers like Descope, WorkOS, Auth0, and corporate SSO systems. icon: camera-cctv --- import { VersionBadge } from "/snippets/version-badge.mdx" Remote OAuth integration allows your FastMCP server to leverage external identity providers that **support Dynamic Client Registration (DCR)**. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. This provides enterprise-grade authentication with fully automated flows, making it ideal for production applications with modern identity providers. **When to use RemoteAuthProvider vs OAuth Proxy:** - **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (Descope, WorkOS AuthKit, modern OIDC providers) - **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, AWS, Discord, etc.) RemoteAuthProvider requires DCR support for fully automated client registration and authentication. ## DCR-Enabled Providers RemoteAuthProvider works with identity providers that support **Dynamic Client Registration (DCR)** - a critical capability that enables automated authentication flows: | Feature | DCR Providers (RemoteAuth) | Non-DCR Providers (OAuth Proxy) | |---------|---------------------------|--------------------------------| | **Client Registration** | Automatic via API | Manual in provider console | | **Credentials** | Dynamic per client | Fixed app credentials | | **Configuration** | Zero client config | Pre-shared credentials | | **Examples** | Descope, WorkOS AuthKit, modern OIDC | GitHub, Google, Azure | | **FastMCP Class** | `RemoteAuthProvider` | [`OAuthProxy`](/servers/auth/oauth-proxy) | If your provider doesn't support DCR (most traditional OAuth providers), you'll need to use [`OAuth Proxy`](/servers/auth/oauth-proxy) instead, which bridges the gap between MCP's DCR expectations and fixed OAuth credentials. ## The Remote OAuth Challenge Traditional OAuth flows assume human users with web browsers who can interact with login forms, consent screens, and redirects. MCP clients operate differently - they're often automated systems that need to authenticate programmatically without human intervention. This creates several unique requirements that standard OAuth implementations don't address well: **Automatic Discovery**: MCP clients must discover authentication requirements by examining server metadata rather than encountering HTTP redirects. They need to know which identity provider to use and how to reach it before making any authenticated requests. **Programmatic Registration**: Clients need to register themselves with identity providers automatically. Manual client registration doesn't work when clients might be dynamically created tools or services. **Seamless Token Management**: Clients must obtain, store, and refresh tokens without user interaction. The authentication flow needs to work in headless environments where no human is available to complete OAuth consent flows. **Protocol Integration**: The authentication process must integrate cleanly with MCP's JSON-RPC transport layer and error handling mechanisms. These requirements mean that your MCP server needs to do more than just validate tokens - it needs to provide discovery metadata that enables MCP clients to understand and navigate your authentication requirements automatically. ## MCP Authentication Discovery MCP authentication discovery relies on well-known endpoints that clients can examine to understand your authentication requirements. Your server becomes a bridge between MCP clients and your chosen identity provider. The core discovery endpoint is `/.well-known/oauth-protected-resource`, which tells clients that your server requires OAuth authentication and identifies the authorization servers you trust. This endpoint contains static metadata that points clients to your identity provider without requiring any dynamic lookups. ```mermaid sequenceDiagram participant Client participant FastMCPServer as FastMCP Server participant ExternalIdP as Identity Provider Client->>FastMCPServer: 1. GET /.well-known/oauth-protected-resource FastMCPServer-->>Client: 2. "Use https://my-idp.com for auth" note over Client, ExternalIdP: Client goes directly to the IdP Client->>ExternalIdP: 3. Authenticate & get token via DCR ExternalIdP-->>Client: 4. Access token Client->>FastMCPServer: 5. MCP request with Bearer token FastMCPServer->>FastMCPServer: 6. Verify token signature FastMCPServer-->>Client: 7. MCP response ``` This flow separates concerns cleanly: your MCP server handles resource protection and token validation, while your identity provider handles user authentication and token issuance. The client coordinates between these systems using standardized OAuth discovery mechanisms. ## FastMCP Remote Authentication FastMCP provides `RemoteAuthProvider` to handle the complexities of remote OAuth integration. This class combines token validation capabilities with the OAuth discovery metadata that MCP clients require. ### RemoteAuthProvider `RemoteAuthProvider` works by composing a [`TokenVerifier`](/servers/auth/token-verification) with authorization server information. A `TokenVerifier` is another FastMCP authentication class that focuses solely on token validation - signature verification, expiration checking, and claim extraction. The `RemoteAuthProvider` takes that token validation capability and adds the OAuth discovery endpoints that enable MCP clients to automatically find and authenticate with your identity provider. This composition pattern means you can use any token validation strategy while maintaining consistent OAuth discovery behavior: - **JWT tokens**: Use `JWTVerifier` for self-contained tokens - **Opaque tokens**: Use `IntrospectionTokenVerifier` for RFC 7662 introspection - **Custom validation**: Implement your own `TokenVerifier` subclass The separation allows you to change token validation approaches without affecting the client discovery experience. The class automatically generates the required OAuth metadata endpoints using the MCP SDK's standardized route creation functions. This ensures compatibility with MCP clients while reducing the implementation complexity for server developers. ### Basic Implementation Most applications can use `RemoteAuthProvider` directly without subclassing. The implementation requires a `TokenVerifier` instance, a list of trusted authorization servers, and your server's URL for metadata generation. ```python from fastmcp import FastMCP from fastmcp.server.auth import RemoteAuthProvider from fastmcp.server.auth.providers.jwt import JWTVerifier from pydantic import AnyHttpUrl # Configure token validation for your identity provider token_verifier = JWTVerifier( jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", issuer="https://auth.yourcompany.com", audience="mcp-production-api" ) # Create the remote auth provider auth = RemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], base_url="https://api.yourcompany.com", # Your server base URL # Optional: restrict allowed client redirect URIs (defaults to all for DCR compatibility) allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"] ) mcp = FastMCP(name="Company API", auth=auth) ``` This configuration creates a server that accepts tokens issued by `auth.yourcompany.com` and provides the OAuth discovery metadata that MCP clients need. The `JWTVerifier` handles token validation using your identity provider's public keys, while the `RemoteAuthProvider` generates the required OAuth endpoints. The `authorization_servers` list tells MCP clients which identity providers you trust. The `base_url` identifies your server in OAuth metadata, enabling proper token audience validation. **Important**: The `base_url` should point to your server base URL - for example, if your MCP server is accessible at `https://api.yourcompany.com/mcp`, use `https://api.yourcompany.com` as the base URL. ### Overriding Advertised Scopes Some identity providers use different scope formats for authorization requests versus token claims. For example, Azure AD requires clients to request full URI scopes like `api://client-id/read`, but the token's `scp` claim contains just `read`. The `scopes_supported` parameter lets you advertise the full-form scopes in metadata while validating against the short form: ```python auth = RemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url="https://api.example.com", scopes_supported=["api://my-api/read", "api://my-api/write"], ) ``` When not set, `scopes_supported` defaults to the token verifier's `required_scopes`. For Azure AD specifically, see the [AzureJWTVerifier](/integrations/azure#token-verification-only-managed-identity) which handles this automatically. ### Custom Endpoints You can extend `RemoteAuthProvider` to add additional endpoints beyond the standard OAuth protected resource metadata. These don't have to be OAuth-specific - you can add any endpoints your authentication integration requires. ```python import httpx from starlette.responses import JSONResponse from starlette.routing import Route class CompanyAuthProvider(RemoteAuthProvider): def __init__(self): token_verifier = JWTVerifier( jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", issuer="https://auth.yourcompany.com", audience="mcp-production-api" ) super().__init__( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.yourcompany.com")], base_url="https://api.yourcompany.com" # Your server base URL ) def get_routes(self) -> list[Route]: """Add custom endpoints to the standard protected resource routes.""" # Get the standard OAuth protected resource routes routes = super().get_routes() # Add authorization server metadata forwarding for client convenience async def authorization_server_metadata(request): async with httpx.AsyncClient() as client: response = await client.get( "https://auth.yourcompany.com/.well-known/oauth-authorization-server" ) response.raise_for_status() return JSONResponse(response.json()) routes.append( Route("/.well-known/oauth-authorization-server", authorization_server_metadata) ) return routes mcp = FastMCP(name="Company API", auth=CompanyAuthProvider()) ``` This pattern uses `super().get_routes()` to get the standard protected resource routes, then adds additional endpoints as needed. A common use case is providing authorization server metadata forwarding, which allows MCP clients to discover your identity provider's capabilities through your MCP server rather than contacting the identity provider directly. ## WorkOS AuthKit Integration WorkOS AuthKit provides an excellent example of remote OAuth integration. The `AuthKitProvider` demonstrates how to implement both token validation and OAuth metadata forwarding in a production-ready package. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import AuthKitProvider auth = AuthKitProvider( authkit_domain="https://your-project.authkit.app", base_url="https://your-mcp-server.com" ) mcp = FastMCP(name="Protected Application", auth=auth) ``` The `AuthKitProvider` automatically configures JWT validation against WorkOS's public keys and provides both protected resource metadata and authorization server metadata forwarding. This implementation handles the complete remote OAuth integration with minimal configuration. WorkOS's support for Dynamic Client Registration makes it particularly well-suited for MCP applications. Clients can automatically register themselves with your WorkOS project and obtain the credentials needed for authentication without manual intervention. → **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): All redirect URIs allowed (for DCR compatibility) - Custom list: Specify allowed patterns with wildcard support - Empty list `[]`: No redirect URIs allowed 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. **Token Validation Performance**: Your server validates every incoming token by checking signatures against your identity provider's public keys. Consider implementing key caching and rotation handling to minimize latency while maintaining security. **Error Handling**: Network issues with your identity provider can affect token validation. Implement appropriate timeouts, retry logic, and graceful degradation to maintain service availability during identity provider outages. **Audience Validation**: Ensure that tokens intended for your server are not accepted by other applications. Proper audience validation prevents token misuse across different services in your ecosystem. **Scope Management**: Map token scopes to your application's permission model consistently. Consider how scope changes affect existing tokens and plan for smooth permission updates. The complexity of these considerations reinforces why external identity providers are recommended over custom OAuth implementations. Established providers handle these technical details with extensive testing and operational experience. ================================================ FILE: docs/servers/auth/token-verification.mdx ================================================ --- title: Token Verification sidebarTitle: Token Verification description: Protect your server by validating bearer tokens issued by external systems. icon: key --- import { VersionBadge } from "/snippets/version-badge.mdx" Token verification enables your FastMCP server to validate bearer tokens issued by external systems without participating in user authentication flows. Your server acts as a pure resource server, focusing on token validation and authorization decisions while delegating identity management to other systems in your infrastructure. Token verification operates somewhat outside the formal MCP authentication flow, which expects OAuth-style discovery. It's best suited for internal systems, microservices architectures, or when you have full control over token generation and distribution. ## Understanding Token Verification Token verification addresses scenarios where authentication responsibility is distributed across multiple systems. Your MCP server receives structured tokens containing identity and authorization information, validates their authenticity, and makes access control decisions based on their contents. This pattern emerges naturally in microservices architectures where a central authentication service issues tokens that multiple downstream services validate independently. It also works well when integrating MCP servers into existing systems that already have established token-based authentication mechanisms. ### The Token Verification Model Token verification treats your MCP server as a resource server in OAuth terminology. The key insight is that token validation and token issuance are separate concerns that can be handled by different systems. **Token Issuance**: Another system (API gateway, authentication service, or identity provider) handles user authentication and creates signed tokens containing identity and permission information. **Token Validation**: Your MCP server receives these tokens, verifies their authenticity using cryptographic signatures, and extracts authorization information from their claims. **Access Control**: Based on token contents, your server determines what resources, tools, and prompts the client can access. This separation allows your MCP server to focus on its core functionality while leveraging existing authentication infrastructure. The token acts as a portable proof of identity that travels with each request. ### Token Security Considerations Token-based authentication relies on cryptographic signatures to ensure token integrity. Your MCP server validates tokens using public keys corresponding to the private keys used for token creation. This asymmetric approach means your server never needs access to signing secrets. Token validation must address several security requirements: signature verification ensures tokens haven't been tampered with, expiration checking prevents use of stale tokens, and audience validation ensures tokens intended for your server aren't accepted by other systems. The challenge in MCP environments is that clients need to obtain valid tokens before making requests, but the MCP protocol doesn't provide built-in discovery mechanisms for token endpoints. Clients must obtain tokens through separate channels or prior configuration. ## TokenVerifier Class FastMCP provides the `TokenVerifier` class to handle token validation complexity while remaining flexible about token sources and validation strategies. `TokenVerifier` focuses exclusively on token validation without providing OAuth discovery metadata. This makes it ideal for internal systems where clients already know how to obtain tokens, or for microservices that trust tokens from known issuers. The class validates token signatures, checks expiration timestamps, and extracts authorization information from token claims. It supports various token formats and validation strategies while maintaining a consistent interface for authorization decisions. You can subclass `TokenVerifier` to implement custom validation logic for specialized token formats or validation requirements. The base class handles common patterns while allowing extension for unique use cases. ## JWT Token Verification JSON Web Tokens (JWTs) represent the most common token format for modern applications. FastMCP's `JWTVerifier` validates JWTs using industry-standard cryptographic techniques and claim validation. ### JWKS Endpoint Integration JWKS endpoint integration provides the most flexible approach for production systems. The verifier automatically fetches public keys from a JSON Web Key Set endpoint, enabling automatic key rotation without server configuration changes. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import JWTVerifier # Configure JWT verification against your identity provider verifier = JWTVerifier( jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", issuer="https://auth.yourcompany.com", audience="mcp-production-api" ) mcp = FastMCP(name="Protected API", auth=verifier) ``` This configuration creates a server that validates JWTs issued by `auth.yourcompany.com`. The verifier periodically fetches public keys from the JWKS endpoint and validates incoming tokens against those keys. Only tokens with the correct issuer and audience claims will be accepted. 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 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 from fastmcp.server.auth.providers.jwt import JWTVerifier # Use a static public key for token verification public_key_pem = """-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----""" verifier = JWTVerifier( public_key=public_key_pem, issuer="https://auth.yourcompany.com", audience="mcp-production-api" ) mcp = FastMCP(name="Protected API", auth=verifier) ``` 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. ## Opaque Token Verification Many authorization servers issue opaque tokens rather than self-contained JWTs. Opaque tokens are random strings that carry no information themselves - the authorization server maintains their state and validation requires querying the server. FastMCP supports opaque token validation through OAuth 2.0 Token Introspection (RFC 7662). ### Understanding Opaque Tokens Opaque tokens differ fundamentally from JWTs in their verification model. Where JWTs carry signed claims that can be validated locally, opaque tokens require network calls to the issuing authorization server for validation. The authorization server maintains token state and can revoke tokens immediately, providing stronger security guarantees for sensitive operations. This approach trades performance (network latency on each validation) for security and flexibility. Authorization servers can revoke opaque tokens instantly, implement complex authorization logic, and maintain detailed audit logs of token usage. Many enterprise OAuth providers default to opaque tokens for these security advantages. ### Token Introspection Protocol RFC 7662 standardizes how resource servers validate opaque tokens. The protocol defines an introspection endpoint where resource servers authenticate using client credentials and receive token metadata including active status, scopes, expiration, and subject identity. FastMCP implements this protocol through the `IntrospectionTokenVerifier` class, handling authentication, request formatting, and response parsing according to the specification. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier # Configure introspection with your OAuth provider verifier = IntrospectionTokenVerifier( introspection_url="https://auth.yourcompany.com/oauth/introspect", client_id="mcp-resource-server", client_secret="your-client-secret", required_scopes=["api:read", "api:write"] ) mcp = FastMCP(name="Protected API", auth=verifier) ``` The verifier authenticates to the introspection endpoint using client credentials and queries it whenever a bearer token arrives. FastMCP checks whether the token is active and has sufficient scopes before allowing access. Two standard client authentication methods are supported, both defined in RFC 6749: - **`client_secret_basic`** (default): Sends credentials via HTTP Basic Auth header - **`client_secret_post`**: Sends credentials in the POST request body Most OAuth providers support both methods, though some may require one specifically. Configure the authentication method with the `client_auth_method` parameter: ```python # Use POST body authentication instead of Basic Auth verifier = IntrospectionTokenVerifier( introspection_url="https://auth.yourcompany.com/oauth/introspect", client_id="mcp-resource-server", client_secret="your-client-secret", client_auth_method="client_secret_post", required_scopes=["api:read", "api:write"] ) ``` ## Development and Testing Development environments often need simpler token management without the complexity of full JWT infrastructure. FastMCP provides tools specifically designed for these scenarios. ### Static Token Verification Static token verification enables rapid development by accepting predefined tokens with associated claims. This approach eliminates the need for token generation infrastructure during development and testing. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import StaticTokenVerifier # Define development tokens and their associated claims verifier = StaticTokenVerifier( tokens={ "dev-alice-token": { "client_id": "alice@company.com", "scopes": ["read:data", "write:data", "admin:users"] }, "dev-guest-token": { "client_id": "guest-user", "scopes": ["read:data"] } }, required_scopes=["read:data"] ) mcp = FastMCP(name="Development Server", auth=verifier) ``` Clients can now authenticate using `Authorization: Bearer dev-alice-token` headers. The server will recognize the token and load the associated claims for authorization decisions. This approach enables immediate development without external dependencies. Static token verification stores tokens as plain text and should never be used in production environments. It's designed exclusively for development and testing scenarios. ### Debug/Custom Token Verification The `DebugTokenVerifier` provides maximum flexibility for testing and special cases where standard token verification isn't applicable. It delegates validation to a user-provided callable, making it useful for prototyping, testing scenarios, or handling opaque tokens without introspection endpoints. ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.debug import DebugTokenVerifier # Accept all tokens (useful for rapid development) verifier = DebugTokenVerifier() mcp = FastMCP(name="Development Server", auth=verifier) ``` By default, `DebugTokenVerifier` accepts any non-empty token as valid. This eliminates authentication barriers during early development, allowing you to focus on core functionality before adding security. For more controlled testing, provide custom validation logic: ```python from fastmcp.server.auth.providers.debug import DebugTokenVerifier # Synchronous validation - check token prefix verifier = DebugTokenVerifier( validate=lambda token: token.startswith("dev-"), client_id="development-client", scopes=["read", "write"] ) mcp = FastMCP(name="Development Server", auth=verifier) ``` The validation callable can also be async, enabling database lookups or external service calls: ```python from fastmcp.server.auth.providers.debug import DebugTokenVerifier # Asynchronous validation - check against cache async def validate_token(token: str) -> bool: # Check if token exists in Redis, database, etc. return await redis.exists(f"valid_tokens:{token}") verifier = DebugTokenVerifier( validate=validate_token, client_id="api-client", scopes=["api:access"] ) mcp = FastMCP(name="Custom API", auth=verifier) ``` **Use Cases:** - **Testing**: Accept any token during integration tests without setting up token infrastructure - **Prototyping**: Quickly validate concepts without authentication complexity - **Opaque tokens without introspection**: When you have tokens from an IDP that provides no introspection endpoint, and you're willing to accept tokens without validation (validation happens later at the upstream service) - **Custom token formats**: Implement validation for non-standard token formats or legacy systems `DebugTokenVerifier` bypasses standard security checks. Only use in controlled environments (development, testing) or when you fully understand the security implications. For production, use proper JWT or introspection-based verification. ### Test Token Generation Test token generation helps when you need to test JWT verification without setting up complete identity infrastructure. FastMCP includes utilities for generating test key pairs and signed tokens. ```python from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair # Generate a key pair for testing key_pair = RSAKeyPair.generate() # Configure your server with the public key verifier = JWTVerifier( public_key=key_pair.public_key, issuer="https://test.yourcompany.com", audience="test-mcp-server" ) # Generate a test token using the private key test_token = key_pair.create_token( subject="test-user-123", issuer="https://test.yourcompany.com", audience="test-mcp-server", scopes=["read", "write", "admin"] ) print(f"Test token: {test_token}") ``` This pattern enables comprehensive testing of JWT validation logic without depending on external token issuers. The generated tokens are cryptographically valid and will pass all standard JWT validation checks. ## HTTP Client Customization All token verifiers that make HTTP calls accept an optional `http_client` parameter. This lets you provide your own `httpx.AsyncClient` for connection pooling, custom TLS configuration, or proxy settings. ### Connection Pooling By default, each token verification call creates a fresh HTTP client. Under high load, this means repeated TCP connections and TLS handshakes. Providing a shared client enables connection pooling across calls: ```python import httpx from fastmcp import FastMCP from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier # Create a shared client with connection pooling http_client = httpx.AsyncClient( timeout=10, limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), ) verifier = IntrospectionTokenVerifier( introspection_url="https://auth.yourcompany.com/oauth/introspect", client_id="mcp-resource-server", client_secret="your-client-secret", http_client=http_client, ) mcp = FastMCP(name="Protected API", auth=verifier) ``` The same pattern works for `JWTVerifier` when using JWKS endpoints: ```python from fastmcp.server.auth.providers.jwt import JWTVerifier verifier = JWTVerifier( jwks_uri="https://auth.yourcompany.com/.well-known/jwks.json", issuer="https://auth.yourcompany.com", http_client=http_client, ) ``` `JWTVerifier` does not support `http_client` when `ssrf_safe=True`. SSRF-safe mode requires a hardened transport that validates DNS resolution and connection targets, which cannot be guaranteed with a user-provided client. Attempting to use both will raise a `ValueError`. When you provide an `http_client`, you are responsible for its lifecycle. The verifier will not close it. Use the server's `lifespan` to manage client cleanup: ```python from contextlib import asynccontextmanager from fastmcp import FastMCP from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier http_client = httpx.AsyncClient(timeout=10) verifier = IntrospectionTokenVerifier( introspection_url="https://auth.example.com/introspect", client_id="my-service", client_secret="secret", http_client=http_client, ) @asynccontextmanager async def lifespan(app): yield await http_client.aclose() mcp = FastMCP(name="My API", auth=verifier, lifespan=lifespan) ``` The convenience providers (`GitHubProvider`, `GoogleProvider`, `DiscordProvider`, `WorkOSProvider`, `AzureProvider`) also accept `http_client` and pass it through to their internal token verifier. ## Production Configuration For production deployments, load sensitive configuration from environment variables: ```python import os from fastmcp import FastMCP from fastmcp.server.auth.providers.jwt import JWTVerifier # Load configuration from environment variables # Parse comma-separated scopes if provided scopes_env = os.environ.get("JWT_REQUIRED_SCOPES") required_scopes = scopes_env.split(",") if scopes_env else None verifier = JWTVerifier( jwks_uri=os.environ.get("JWT_JWKS_URI"), issuer=os.environ.get("JWT_ISSUER"), audience=os.environ.get("JWT_AUDIENCE"), required_scopes=required_scopes, ) mcp = FastMCP(name="Production API", auth=verifier) ``` This keeps configuration out of your codebase while maintaining explicit setup. This approach enables the same codebase to run across development, staging, and production environments with different authentication requirements. Development might use static tokens while production uses JWT verification, all controlled through environment configuration. ================================================ FILE: docs/servers/authorization.mdx ================================================ --- title: Authorization sidebarTitle: Authorization description: Control access to components using callable-based authorization checks that filter visibility and enforce permissions. icon: shield-halved tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx" Authorization controls what authenticated users can do with your FastMCP server. While [authentication](/servers/auth/authentication) verifies identity (who you are), authorization determines access (what you can do). FastMCP provides a callable-based authorization system that works at both the component level and globally via middleware. The authorization model centers on a simple concept: callable functions that receive context about the current request and return `True` to allow access or `False` to deny it. Multiple checks combine with AND logic, meaning all checks must pass for access to be granted. Authorization relies on OAuth tokens which are only available with HTTP transports (SSE, Streamable HTTP). In STDIO mode, there's no OAuth mechanism, so `get_access_token()` returns `None` and all auth checks are skipped. When an `AuthProvider` is configured, all requests to the MCP endpoint must carry a valid token—unauthenticated requests are rejected at the transport level before any auth checks run. Authorization checks therefore differentiate between authenticated users based on their scopes and claims, not between authenticated and unauthenticated users. ## Auth Checks An auth check is any callable that accepts an `AuthContext` and returns a boolean. Auth checks can be synchronous or asynchronous, so checks that need to perform async operations (like reading server state or calling external services) work naturally. ```python from fastmcp.server.auth import AuthContext def my_custom_check(ctx: AuthContext) -> bool: # ctx.token is AccessToken | None # ctx.component is the Tool, Resource, or Prompt being accessed return ctx.token is not None and "special" in ctx.token.scopes ``` FastMCP provides two built-in auth checks that cover common authorization patterns. ### require_scopes Scope-based authorization checks that the token contains all specified OAuth scopes. When multiple scopes are provided, all must be present (AND logic). ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP("Scoped Server") @mcp.tool(auth=require_scopes("admin")) def admin_operation() -> str: """Requires the 'admin' scope.""" return "Admin action completed" @mcp.tool(auth=require_scopes("read", "write")) def read_write_operation() -> str: """Requires both 'read' AND 'write' scopes.""" return "Read/write action completed" ``` ### restrict_tag Tag-based restrictions apply scope requirements conditionally. If a component has the specified tag, the token must have the required scopes. Components without the tag are unaffected. ```python from fastmcp import FastMCP from fastmcp.server.auth import restrict_tag from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Tagged Server", middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ] ) @mcp.tool(tags={"admin"}) def admin_tool() -> str: """Tagged 'admin', so requires 'admin' scope.""" return "Admin only" @mcp.tool(tags={"public"}) def public_tool() -> str: """Not tagged 'admin', so no scope required by the restriction.""" return "Anyone can access" ``` ### Combining Checks Multiple auth checks can be combined by passing a list. All checks must pass for authorization to succeed (AND logic). ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP("Combined Auth Server") @mcp.tool(auth=[require_scopes("admin"), require_scopes("write")]) def secure_admin_action() -> str: """Requires both 'admin' AND 'write' scopes.""" return "Secure admin action" ``` ### Custom Auth Checks Any callable that accepts `AuthContext` and returns `bool` can serve as an auth check. This enables authorization logic based on token claims, component metadata, or external systems. ```python from fastmcp import FastMCP from fastmcp.server.auth import AuthContext mcp = FastMCP("Custom Auth Server") def require_premium_user(ctx: AuthContext) -> bool: """Check for premium user status in token claims.""" if ctx.token is None: return False return ctx.token.claims.get("premium", False) is True def require_access_level(minimum_level: int): """Factory function for level-based authorization.""" def check(ctx: AuthContext) -> bool: if ctx.token is None: return False user_level = ctx.token.claims.get("level", 0) return user_level >= minimum_level return check @mcp.tool(auth=require_premium_user) def premium_feature() -> str: """Only for premium users.""" return "Premium content" @mcp.tool(auth=require_access_level(5)) def advanced_feature() -> str: """Requires access level 5 or higher.""" return "Advanced feature" ``` ### Async Auth Checks Auth checks can be `async` functions, which is useful when the authorization decision depends on asynchronous operations like reading server state or querying external services. ```python from fastmcp import FastMCP from fastmcp.server.auth import AuthContext mcp = FastMCP("Async Auth Server") async def check_user_permissions(ctx: AuthContext) -> bool: """Async auth check that reads server state.""" if ctx.token is None: return False user_id = ctx.token.claims.get("sub") # Async operations work naturally in auth checks permissions = await fetch_user_permissions(user_id) return "admin" in permissions @mcp.tool(auth=check_user_permissions) def admin_tool() -> str: return "Admin action completed" ``` Sync and async checks can be freely combined in a list — each check is handled according to its type. ### Error Handling Auth checks can raise exceptions for explicit denial with custom messages: - **`AuthorizationError`**: Propagates with its custom message, useful for explaining why access was denied - **Other exceptions**: Masked for security (logged internally, treated as denial) ```python from fastmcp.server.auth import AuthContext from fastmcp.exceptions import AuthorizationError def require_verified_email(ctx: AuthContext) -> bool: """Require verified email with explicit denial message.""" if ctx.token is None: raise AuthorizationError("Authentication required") if not ctx.token.claims.get("email_verified"): raise AuthorizationError("Email verification required") return True ``` ## Component-Level Authorization The `auth` parameter on decorators controls visibility and access for individual components. When auth checks fail for the current request, the component is hidden from list responses and direct access returns not-found. ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP("Component Auth Server") @mcp.tool(auth=require_scopes("write")) def write_tool() -> str: """Only visible to users with 'write' scope.""" return "Written" @mcp.resource("secret://data", auth=require_scopes("read")) def secret_resource() -> str: """Only visible to users with 'read' scope.""" return "Secret data" @mcp.prompt(auth=require_scopes("admin")) def admin_prompt() -> str: """Only visible to users with 'admin' scope.""" return "Admin prompt content" ``` Component-level `auth` controls both visibility (list filtering) and access (direct lookups return not-found for unauthorized requests). Additionally use `AuthMiddleware` to apply server-wide authorization rules and get explicit `AuthorizationError` responses on unauthorized execution attempts. ## Server-Level Authorization For server-wide authorization enforcement, use `AuthMiddleware`. This middleware applies auth checks globally to all components—filtering list responses and blocking unauthorized execution with explicit `AuthorizationError` responses. ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Enforced Auth Server", middleware=[AuthMiddleware(auth=require_scopes("api"))] ) @mcp.tool def any_tool() -> str: """Requires 'api' scope to see AND call.""" return "Protected" ``` ### Component Auth + Middleware Component-level `auth` and `AuthMiddleware` work together as complementary layers. The middleware applies server-wide rules to all components, while component-level auth adds per-component requirements. Both layers are checked—all checks must pass. ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Layered Auth Server", middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ] ) # Requires "write" scope (component-level) # Also requires "admin" scope if tagged "admin" (middleware-level) @mcp.tool(auth=require_scopes("write"), tags={"admin"}) def admin_write() -> str: """Requires both 'write' AND 'admin' scopes.""" return "Admin write" # Requires "write" scope (component-level only) @mcp.tool(auth=require_scopes("write")) def user_write() -> str: """Requires 'write' scope.""" return "User write" ``` ### Tag-Based Global Authorization A common pattern uses `restrict_tag` with `AuthMiddleware` to apply scope requirements based on component tags. ```python from fastmcp import FastMCP from fastmcp.server.auth import restrict_tag from fastmcp.server.middleware import AuthMiddleware mcp = FastMCP( "Tag-Based Auth Server", middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])), AuthMiddleware(auth=restrict_tag("write", scopes=["write"])), ] ) @mcp.tool(tags={"admin"}) def delete_all_data() -> str: """Requires 'admin' scope.""" return "Deleted" @mcp.tool(tags={"write"}) def update_record(id: str, data: str) -> str: """Requires 'write' scope.""" return f"Updated {id}" @mcp.tool def read_record(id: str) -> str: """No tag restrictions, accessible to all.""" return f"Record {id}" ``` ## Accessing Tokens in Tools Tools can access the current authentication token using `get_access_token()` from `fastmcp.server.dependencies`. This enables tools to make decisions based on user identity or permissions beyond simple authorization checks. ```python from fastmcp import FastMCP from fastmcp.server.dependencies import get_access_token mcp = FastMCP("Token Access Server") @mcp.tool def personalized_greeting() -> str: """Greet the user based on their token claims.""" token = get_access_token() if token is None: return "Hello, guest!" name = token.claims.get("name", "user") return f"Hello, {name}!" @mcp.tool def user_dashboard() -> dict: """Return user-specific data based on token.""" token = get_access_token() if token is None: return {"error": "Not authenticated"} return { "client_id": token.client_id, "scopes": token.scopes, "claims": token.claims, } ``` ## Reference ### AccessToken The `AccessToken` object contains information extracted from the OAuth token. | Property | Type | Description | |----------|------|-------------| | `token` | `str` | The raw token string | | `client_id` | `str \| None` | OAuth client identifier | | `scopes` | `list[str]` | Granted OAuth scopes | | `expires_at` | `datetime \| None` | Token expiration time | | `claims` | `dict[str, Any]` | All JWT claims or custom token data | ### AuthContext The `AuthContext` dataclass is passed to all auth check functions. | Property | Type | Description | |----------|------|-------------| | `token` | `AccessToken \| None` | Current access token, or `None` if unauthenticated | | `component` | `Tool \| Resource \| Prompt` | The component being accessed | Access to the component object enables authorization decisions based on metadata like tags, name, or custom properties. ```python from fastmcp.server.auth import AuthContext def require_matching_tag(ctx: AuthContext) -> bool: """Require a scope matching each of the component's tags.""" if ctx.token is None: return False user_scopes = set(ctx.token.scopes) return ctx.component.tags.issubset(user_scopes) ``` ### Imports ```python from fastmcp.server.auth import ( AccessToken, # Token with .token, .client_id, .scopes, .expires_at, .claims AuthContext, # Context with .token, .component AuthCheck, # Type alias: sync or async Callable[[AuthContext], bool] require_scopes, # Built-in: requires specific scopes restrict_tag, # Built-in: tag-based scope requirements run_auth_checks, # Utility: run checks with AND logic ) from fastmcp.server.middleware import AuthMiddleware ``` ================================================ FILE: docs/servers/composition.mdx ================================================ --- title: Composing Servers sidebarTitle: Composition description: Combine multiple servers into one icon: puzzle-piece --- import { VersionBadge } from '/snippets/version-badge.mdx' As your application grows, you'll want to split it into focused servers — one for weather, one for calendar, one for admin — and combine them into a single server that clients connect to. That's what `mount()` does. When you mount a server, all its tools, resources, and prompts become available through the parent. The connection is live: add a tool to the child after mounting, and it's immediately visible through the parent. ```python from fastmcp import FastMCP weather = FastMCP("Weather") @weather.tool def get_forecast(city: str) -> str: """Get weather forecast for a city.""" return f"Sunny in {city}" @weather.resource("data://cities") def list_cities() -> list[str]: """List supported cities.""" return ["London", "Paris", "Tokyo"] main = FastMCP("MainApp") main.mount(weather) # main now serves get_forecast and data://cities ``` ## Mounting External Servers Mount remote HTTP servers or subprocess-based MCP servers using `create_proxy()`: ```python from fastmcp import FastMCP from fastmcp.server import create_proxy mcp = FastMCP("Orchestrator") # Mount a remote HTTP server (URLs work directly) mcp.mount(create_proxy("http://api.example.com/mcp"), namespace="api") # Mount local Python scripts (file paths work directly) mcp.mount(create_proxy("./my_server.py"), namespace="local") ``` ### Mounting npm/uvx Packages For npm packages or Python tools, use the config dict format: ```python from fastmcp import FastMCP from fastmcp.server import create_proxy mcp = FastMCP("Orchestrator") # Mount npm package via config github_config = { "mcpServers": { "default": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] } } } mcp.mount(create_proxy(github_config), namespace="github") # Mount Python tool via config sqlite_config = { "mcpServers": { "default": { "command": "uvx", "args": ["mcp-server-sqlite", "--db", "data.db"] } } } mcp.mount(create_proxy(sqlite_config), namespace="db") ``` Or use explicit transport classes: ```python from fastmcp import FastMCP from fastmcp.server import create_proxy from fastmcp.client.transports import NpxStdioTransport, UvxStdioTransport mcp = FastMCP("Orchestrator") mcp.mount( create_proxy(NpxStdioTransport(package="@modelcontextprotocol/server-github")), namespace="github" ) mcp.mount( create_proxy(UvxStdioTransport(tool_name="mcp-server-sqlite", tool_args=["--db", "data.db"])), namespace="db" ) ``` For advanced configuration, see [Proxying](/servers/providers/proxy). ## Namespacing When mounting multiple servers, use namespaces to avoid naming conflicts: ```python weather = FastMCP("Weather") calendar = FastMCP("Calendar") @weather.tool def get_data() -> str: return "Weather data" @calendar.tool def get_data() -> str: return "Calendar data" main = FastMCP("Main") main.mount(weather, namespace="weather") main.mount(calendar, namespace="calendar") # Tools are now: # - weather_get_data # - calendar_get_data ``` ### How Namespacing Works | Component Type | Without Namespace | With `namespace="api"` | |----------------|-------------------|------------------------| | Tool | `my_tool` | `api_my_tool` | | Prompt | `my_prompt` | `api_my_prompt` | | Resource | `data://info` | `data://api/info` | | Template | `data://{id}` | `data://api/{id}` | Namespacing uses [transforms](/servers/transforms/transforms) under the hood. ## Dynamic Composition Because `mount()` creates a live link, you can add components to a child server after mounting and they'll be immediately available through the parent: ```python main = FastMCP("Main") main.mount(dynamic_server, namespace="dynamic") # Add a tool AFTER mounting - it's accessible through main @dynamic_server.tool def added_later() -> str: return "Added after mounting!" ``` ## Tag Filtering Parent server tag filters apply recursively to mounted servers: ```python api_server = FastMCP("API") @api_server.tool(tags={"production"}) def prod_endpoint() -> str: return "Production data" @api_server.tool(tags={"development"}) def dev_endpoint() -> str: return "Debug data" # Mount with production filter prod_app = FastMCP("Production") prod_app.mount(api_server, namespace="api") prod_app.enable(tags={"production"}, only=True) # Only prod_endpoint (namespaced as api_prod_endpoint) is visible ``` ## Performance Considerations Operations like `list_tools()` on the parent are affected by the performance of all mounted servers. This is particularly noticeable with: - HTTP-based mounted servers (300-400ms vs 1-2ms for local tools) - Mounted servers with slow initialization - Deep mounting hierarchies If low latency is critical, consider implementing caching strategies or limiting mounting depth. ## Custom Routes Custom HTTP routes defined with `@server.custom_route()` are also forwarded when mounting: ```python subserver = FastMCP("Sub") @subserver.custom_route("/health", methods=["GET"]) async def health_check(): return {"status": "ok"} main = FastMCP("Main") main.mount(subserver, namespace="sub") # /health is now accessible through main's HTTP app ``` ## Conflict Resolution When mounting multiple servers with the same namespace (or no namespace), the **most recently mounted** server takes precedence for conflicting component names: ```python server_a = FastMCP("A") server_b = FastMCP("B") @server_a.tool def shared_tool() -> str: return "From A" @server_b.tool def shared_tool() -> str: return "From B" main = FastMCP("Main") main.mount(server_a) main.mount(server_b) # shared_tool returns "From B" (most recently mounted) ``` ================================================ FILE: docs/servers/context.mdx ================================================ --- title: MCP Context sidebarTitle: Context description: Access MCP capabilities like logging, progress, and resources within your MCP objects. icon: rectangle-code tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' When defining FastMCP [tools](/servers/tools), [resources](/servers/resources), resource templates, or [prompts](/servers/prompts), your functions might need to interact with the underlying MCP session or access advanced server capabilities. FastMCP provides the `Context` object for this purpose. You access Context through FastMCP's dependency injection system. For other injectable values like HTTP requests, access tokens, and custom dependencies, see [Dependency Injection](/servers/dependency-injection). ## What Is Context? The `Context` object provides a clean interface to access MCP features within your functions, including: - **Logging**: Send debug, info, warning, and error messages back to the client - **Progress Reporting**: Update the client on the progress of long-running operations - **Resource Access**: List and read data from resources registered with the server - **Prompt Access**: List and retrieve prompts registered with the server - **LLM Sampling**: Request the client's LLM to generate text based on provided messages - **User Elicitation**: Request structured input from users during tool execution - **Session State**: Store data that persists across requests within an MCP session - **Session Visibility**: [Control which components are visible](/servers/visibility#per-session-visibility) to the current session - **Request Information**: Access metadata about the current request - **Server Access**: When needed, access the underlying FastMCP server instance ## Accessing the Context The preferred way to access context is using the `CurrentContext()` dependency: ```python {1, 6} from fastmcp import FastMCP from fastmcp.dependencies import CurrentContext from fastmcp.server.context import Context mcp = FastMCP(name="Context Demo") @mcp.tool async def process_file(file_uri: str, ctx: Context = CurrentContext()) -> str: """Processes a file, using context for logging and resource access.""" await ctx.info(f"Processing {file_uri}") return "Processed file" ``` This works with tools, resources, and prompts: ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentContext from fastmcp.server.context import Context mcp = FastMCP(name="Context Demo") @mcp.resource("resource://user-data") async def get_user_data(ctx: Context = CurrentContext()) -> dict: await ctx.debug("Fetching user data") return {"user_id": "example"} @mcp.prompt async def data_analysis_request(dataset: str, ctx: Context = CurrentContext()) -> str: return f"Please analyze the following dataset: {dataset}" ``` **Key Points:** - Dependency parameters are automatically excluded from the MCP schema—clients never see them. - Context methods are async, so your function usually needs to be async as well. - **Each MCP request receives a new context object.** Context is scoped to a single request; state or data set in one request will not be available in subsequent requests. - Context is only available during a request; attempting to use context methods outside a request will raise errors. ### Legacy Type-Hint Injection For backwards compatibility, you can still access context by simply adding a parameter with the `Context` type hint. FastMCP will automatically inject the context instance: ```python {1, 6} from fastmcp import FastMCP, Context mcp = FastMCP(name="Context Demo") @mcp.tool async def process_file(file_uri: str, ctx: Context) -> str: """Processes a file, using context for logging and resource access.""" # Context is injected automatically based on the type hint return "Processed file" ``` This approach still works for tools, resources, and prompts. The parameter name doesn't matter—only the `Context` type hint is important. The type hint can also be a union (`Context | None`) or use `Annotated[]`. ### Via `get_context()` Function For code nested deeper within your function calls where passing context through parameters is inconvenient, use `get_context()` to retrieve the active context from anywhere within a request's execution flow: ```python {2,9} from fastmcp import FastMCP from fastmcp.server.dependencies import get_context mcp = FastMCP(name="Dependency Demo") # Utility function that needs context but doesn't receive it as a parameter async def process_data(data: list[float]) -> dict: # Get the active context - only works when called within a request ctx = get_context() await ctx.info(f"Processing {len(data)} data points") @mcp.tool async def analyze_dataset(dataset_name: str) -> dict: # Call utility function that uses context internally data = load_data(dataset_name) await process_data(data) ``` **Important Notes:** - The `get_context()` function should only be used within the context of a server request. Calling it outside of a request will raise a `RuntimeError`. - The `get_context()` function is server-only and should not be used in client code. ## Context Capabilities FastMCP provides several advanced capabilities through the context object. Each capability has dedicated documentation with comprehensive examples and best practices: ### Logging Send debug, info, warning, and error messages back to the MCP client for visibility into function execution. ```python await ctx.debug("Starting analysis") await ctx.info(f"Processing {len(data)} items") await ctx.warning("Deprecated parameter used") await ctx.error("Processing failed") ``` See [Server Logging](/servers/logging) for complete documentation and examples. ### Client Elicitation Request structured input from clients during tool execution, enabling interactive workflows and progressive disclosure. This is a new feature in the 6/18/2025 MCP spec. ```python result = await ctx.elicit("Enter your name:", response_type=str) if result.action == "accept": name = result.data ``` See [User Elicitation](/servers/elicitation) for detailed examples and supported response types. ### LLM Sampling Request the client's LLM to generate text based on provided messages, useful for leveraging AI capabilities within your tools. ```python response = await ctx.sample("Analyze this data", temperature=0.7) ``` See [LLM Sampling](/servers/sampling) for comprehensive usage and advanced techniques. ### Progress Reporting Update clients on the progress of long-running operations, enabling progress indicators and better user experience. ```python await ctx.report_progress(progress=50, total=100) # 50% complete ``` See [Progress Reporting](/servers/progress) for detailed patterns and examples. ### Resource Access List and read data from resources registered with your FastMCP server, allowing access to files, configuration, or dynamic content. ```python # List available resources resources = await ctx.list_resources() # Read a specific resource content_list = await ctx.read_resource("resource://config") content = content_list[0].content ``` **Method signatures:** - **`ctx.list_resources() -> list[MCPResource]`**: Returns list of all available resources - **`ctx.read_resource(uri: str | AnyUrl) -> list[ReadResourceContents]`**: Returns a list of resource content parts ### Prompt Access List and retrieve prompts registered with your FastMCP server, allowing tools and middleware to discover and use available prompts programmatically. ```python # List available prompts prompts = await ctx.list_prompts() # Get a specific prompt with arguments result = await ctx.get_prompt("analyze_data", {"dataset": "users"}) messages = result.messages ``` **Method signatures:** - **`ctx.list_prompts() -> list[MCPPrompt]`**: Returns list of all available prompts - **`ctx.get_prompt(name: str, arguments: dict[str, Any] | None = None) -> GetPromptResult`**: Get a specific prompt with optional arguments ### Session State Store data that persists across multiple requests within the same MCP session. Session state is automatically keyed by the client's session, ensuring isolation between different clients. ```python from fastmcp import FastMCP, Context mcp = FastMCP("stateful-app") @mcp.tool async def increment_counter(ctx: Context) -> int: """Increment a counter that persists across tool calls.""" count = await ctx.get_state("counter") or 0 await ctx.set_state("counter", count + 1) return count + 1 @mcp.tool async def get_counter(ctx: Context) -> int: """Get the current counter value.""" return await ctx.get_state("counter") or 0 ``` Each client session has its own isolated state—two different clients calling `increment_counter` will each have their own counter. **Method signatures:** - **`await ctx.set_state(key, value, *, serializable=True)`**: Store a value in session state - **`await ctx.get_state(key)`**: Retrieve a value (returns None if not found) - **`await ctx.delete_state(key)`**: Remove a value from session state State methods are async and require `await`. State expires after 1 day to prevent unbounded memory growth. #### Non-Serializable Values By default, state values must be JSON-serializable (dicts, lists, strings, numbers, etc.) so they can be persisted across requests. For non-serializable values like HTTP clients or database connections, pass `serializable=False`: ```python @mcp.tool async def my_tool(ctx: Context) -> str: # This object can't be JSON-serialized client = SomeHTTPClient(base_url="https://api.example.com") await ctx.set_state("client", client, serializable=False) # Retrieve it later in the same request client = await ctx.get_state("client") return await client.fetch("/data") ``` Values stored with `serializable=False` only live for the current MCP request (a single tool call, resource read, or prompt render). They will not be available in subsequent requests within the session. #### Custom Storage Backends By default, session state uses an in-memory store suitable for single-server deployments. For distributed or serverless deployments, provide a custom storage backend: ```python from key_value.aio.stores.redis import RedisStore # Use Redis for distributed state mcp = FastMCP("distributed-app", session_state_store=RedisStore(...)) ``` Any backend compatible with the [py-key-value-aio](https://github.com/strawgate/py-key-value) `AsyncKeyValue` protocol works. See [Storage Backends](/servers/storage-backends) for more options including Redis, DynamoDB, and MongoDB. #### State During Initialization State set during `on_initialize` middleware persists to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle init and tool calls, state is isolated by the `mcp-session-id` header. ### Session Visibility Tools can customize which components are visible to their current session using `ctx.enable_components()`, `ctx.disable_components()`, and `ctx.reset_visibility()`. These methods apply visibility rules that affect only the calling session, leaving other sessions unchanged. See [Per-Session Visibility](/servers/visibility#per-session-visibility) for complete documentation, filter criteria, and patterns like namespace activation. ### Change Notifications FastMCP automatically sends list change notifications when components (such as tools, resources, or prompts) are added, removed, enabled, or disabled. In rare cases where you need to manually trigger these notifications, you can use the context's notification methods: ```python import mcp.types @mcp.tool async def custom_tool_management(ctx: Context) -> str: """Example of manual notification after custom tool changes.""" await ctx.send_notification(mcp.types.ToolListChangedNotification()) await ctx.send_notification(mcp.types.ResourceListChangedNotification()) await ctx.send_notification(mcp.types.PromptListChangedNotification()) return "Notifications sent" ``` These methods are primarily used internally by FastMCP's automatic notification system and most users will not need to invoke them directly. ### FastMCP Server To access the underlying FastMCP server instance, you can use the `ctx.fastmcp` property: ```python @mcp.tool async def my_tool(ctx: Context) -> None: # Access the FastMCP server instance server_name = ctx.fastmcp.name ... ``` ### Transport The `ctx.transport` property indicates which transport is being used to run the server. This is useful when your tool needs to behave differently depending on whether the server is running over STDIO, SSE, or Streamable HTTP. For example, you might want to return shorter responses over STDIO or adjust timeout behavior based on transport characteristics. The transport type is set once when the server starts and remains constant for the server's lifetime. It returns `None` when called outside of a server context (for example, in unit tests or when running code outside of an MCP request). ```python from fastmcp import FastMCP, Context mcp = FastMCP("example") @mcp.tool def connection_info(ctx: Context) -> str: if ctx.transport == "stdio": return "Connected via STDIO" elif ctx.transport == "sse": return "Connected via SSE" elif ctx.transport == "streamable-http": return "Connected via Streamable HTTP" else: return "Transport unknown" ``` **Property signature:** `ctx.transport -> Literal["stdio", "sse", "streamable-http"] | None` ### MCP Request Access metadata about the current request and client. ```python @mcp.tool async def request_info(ctx: Context) -> dict: """Return information about the current request.""" return { "request_id": ctx.request_id, "client_id": ctx.client_id or "Unknown client" } ``` **Available Properties:** - **`ctx.request_id -> str`**: Get the unique ID for the current MCP request - **`ctx.client_id -> str | None`**: Get the ID of the client making the request, if provided during initialization - **`ctx.session_id -> str`**: Get the MCP session ID for session-based data sharing. Raises `RuntimeError` if the MCP session is not yet established. #### Request Context Availability The `ctx.request_context` property provides access to the underlying MCP request context, but returns `None` when the MCP session has not been established yet. This typically occurs: - During middleware execution in the `on_request` hook before the MCP handshake completes - During the initialization phase of client connections The MCP request context is distinct from the HTTP request. For HTTP transports, HTTP request data may be available even when the MCP session is not yet established. To safely access the request context in situations where it may not be available: ```python from fastmcp import FastMCP, Context from fastmcp.server.dependencies import get_http_request mcp = FastMCP(name="Session Aware Demo") @mcp.tool async def session_info(ctx: Context) -> dict: """Return session information when available.""" # Check if MCP session is available if ctx.request_context: # MCP session available - can access MCP-specific attributes return { "session_id": ctx.session_id, "request_id": ctx.request_id, "has_meta": ctx.request_context.meta is not None } else: # MCP session not available - use HTTP helpers for request data (if using HTTP transport) request = get_http_request() return { "message": "MCP session not available", "user_agent": request.headers.get("user-agent", "Unknown") } ``` For HTTP request access that works regardless of MCP session availability (when using HTTP transports), use the [HTTP request helpers](/servers/dependency-injection#http-request) like `get_http_request()` and `get_http_headers()`. #### Client Metadata Clients can send contextual information with their requests using the `meta` parameter. This metadata is accessible through `ctx.request_context.meta` and is available for all MCP operations (tools, resources, prompts). The `meta` field is `None` when clients don't provide metadata. When provided, metadata is accessible via attribute access (e.g., `meta.user_id`) rather than dictionary access. The structure of metadata is determined by the client making the request. ```python @mcp.tool def send_email(to: str, subject: str, body: str, ctx: Context) -> str: """Send an email, logging metadata about the request.""" # Access client-provided metadata meta = ctx.request_context.meta if meta: # Meta is accessed as an object with attribute access user_id = meta.user_id if hasattr(meta, 'user_id') else None trace_id = meta.trace_id if hasattr(meta, 'trace_id') else None # Use metadata for logging, observability, etc. if trace_id: log_with_trace(f"Sending email for user {user_id}", trace_id) # Send the email... return f"Email sent to {to}" ``` The MCP request is part of the low-level MCP SDK and intended for advanced use cases. Most users will not need to use it directly. ================================================ FILE: docs/servers/dependency-injection.mdx ================================================ --- title: Dependency Injection sidebarTitle: Dependencies description: Inject runtime values like HTTP requests, access tokens, and custom dependencies into your MCP components. icon: syringe tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx"; FastMCP uses dependency injection to provide runtime values to your tools, resources, and prompts. Instead of passing context through every layer of your code, you declare what you need as parameter defaults—FastMCP resolves them automatically when your function runs. The dependency injection system is powered by [Docket](https://github.com/chrisguidry/docket) and its dependency system [uncalled-for](https://github.com/chrisguidry/uncalled-for). Core DI features like `Depends()` and `CurrentContext()` work without installing Docket. For background tasks and advanced task-related dependencies, install `fastmcp[tasks]`. For comprehensive coverage of dependency patterns, see the [Docket dependency documentation](https://docket.lol/en/latest/dependency-injection/). Dependency parameters are automatically excluded from the MCP schema—clients never see them as callable parameters. This separation keeps your function signatures clean while giving you access to the runtime context you need. ## How Dependency Injection Works Dependency injection in FastMCP follows a simple pattern: declare a parameter with a recognized type annotation or a dependency default value, and FastMCP injects the resolved value at runtime. ```python from fastmcp import FastMCP from fastmcp.server.context import Context mcp = FastMCP("Demo") @mcp.tool async def my_tool(query: str, ctx: Context) -> str: await ctx.info(f"Processing: {query}") return f"Results for: {query}" ``` When a client calls `my_tool`, they only see `query` as a parameter. The `ctx` parameter is injected automatically because it has a `Context` type annotation—FastMCP recognizes this and provides the active context for the request. This works identically for tools, resources, resource templates, and prompts. ### Explicit Dependencies with CurrentContext For more explicit code, you can use `CurrentContext()` as a default value instead of relying on the type annotation: ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentContext from fastmcp.server.context import Context mcp = FastMCP("Demo") @mcp.tool async def my_tool(query: str, ctx: Context = CurrentContext()) -> str: await ctx.info(f"Processing: {query}") return f"Results for: {query}" ``` Both approaches work identically. The type-annotation approach is more concise; the explicit `CurrentContext()` approach makes the dependency injection visible in the signature. ## Built-in Dependencies ### MCP Context The MCP Context provides logging, progress reporting, resource access, and other request-scoped operations. See [MCP Context](/servers/context) for the full API. **Dependency injection:** Use a `Context` type annotation (FastMCP injects automatically) or `CurrentContext()`: ```python from fastmcp import FastMCP from fastmcp.server.context import Context mcp = FastMCP("Demo") @mcp.tool async def process_data(data: str, ctx: Context) -> str: await ctx.info(f"Processing: {data}") return "Done" # Or explicitly with CurrentContext() from fastmcp.dependencies import CurrentContext @mcp.tool async def process_data(data: str, ctx: Context = CurrentContext()) -> str: ... ``` **Function:** Use `get_context()` in helper functions or middleware: ```python from fastmcp.server.dependencies import get_context async def log_something(message: str): ctx = get_context() await ctx.info(message) ``` ### Server Instance Access the FastMCP server instance for introspection or server-level configuration. **Dependency injection:** Use `CurrentFastMCP()`: ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentFastMCP mcp = FastMCP("Demo") @mcp.tool async def server_info(server: FastMCP = CurrentFastMCP()) -> str: return f"Server: {server.name}" ``` **Function:** Use `get_server()`: ```python from fastmcp.server.dependencies import get_server def get_server_name() -> str: return get_server().name ``` ### HTTP Request Access the Starlette Request when running over HTTP transports (SSE or Streamable HTTP). **Dependency injection:** Use `CurrentRequest()`: ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentRequest from starlette.requests import Request mcp = FastMCP("Demo") @mcp.tool async def client_info(request: Request = CurrentRequest()) -> dict: return { "user_agent": request.headers.get("user-agent", "Unknown"), "client_ip": request.client.host if request.client else "Unknown", } ``` **Function:** Use `get_http_request()`: ```python from fastmcp.server.dependencies import get_http_request def get_client_ip() -> str: request = get_http_request() return request.client.host if request.client else "Unknown" ``` Both raise `RuntimeError` when called outside an HTTP context (e.g., STDIO transport). Use HTTP Headers if you need graceful fallback. ### HTTP Headers Access HTTP headers with graceful fallback—returns an empty dictionary when no HTTP request is available, making it safe for code that might run over any transport. **Dependency injection:** Use `CurrentHeaders()`: ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentHeaders mcp = FastMCP("Demo") @mcp.tool async def get_auth_type(headers: dict = CurrentHeaders()) -> str: auth = headers.get("authorization", "") return "Bearer" if auth.startswith("Bearer ") else "None" ``` **Function:** Use `get_http_headers()`: ```python from fastmcp.server.dependencies import get_http_headers def get_user_agent() -> str: headers = get_http_headers() return headers.get("user-agent", "Unknown") ``` By default, problematic headers like `host` and `content-length` are excluded. Use `get_http_headers(include_all=True)` to include all headers. ### Access Token Access the authenticated user's token when your server uses authentication. **Dependency injection:** Use `CurrentAccessToken()` (raises if not authenticated): ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentAccessToken from fastmcp.server.auth import AccessToken mcp = FastMCP("Demo") @mcp.tool async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: return token.claims.get("sub", "unknown") ``` **Function:** Use `get_access_token()` (returns `None` if not authenticated): ```python from fastmcp.server.dependencies import get_access_token @mcp.tool async def get_user_info() -> dict: token = get_access_token() if token is None: return {"authenticated": False} return {"authenticated": True, "user": token.claims.get("sub")} ``` The `AccessToken` object provides: - **`client_id`**: The OAuth client identifier - **`scopes`**: List of granted permission scopes - **`expires_at`**: Token expiration timestamp (if available) - **`claims`**: Dictionary of all token claims (JWT claims or provider-specific data) ### Token Claims When you need just one specific value from the token—like a user ID or tenant identifier—`TokenClaim()` extracts it directly without needing the full token object. ```python from fastmcp import FastMCP from fastmcp.server.dependencies import TokenClaim mcp = FastMCP("Demo") @mcp.tool async def add_expense( amount: float, user_id: str = TokenClaim("oid"), # Azure object ID ) -> dict: await db.insert({"user_id": user_id, "amount": amount}) return {"status": "created", "user_id": user_id} ``` `TokenClaim()` raises a `RuntimeError` if the claim doesn't exist, listing available claims to help with debugging. Common claims vary by identity provider: | Provider | User ID Claim | Email Claim | Name Claim | |----------|--------------|-------------|------------| | Azure/Entra | `oid` | `email` | `name` | | GitHub | `sub` | `email` | `name` | | Google | `sub` | `email` | `name` | | Auth0 | `sub` | `email` | `name` | ### Background Task Dependencies For background task execution, FastMCP provides dependencies that integrate with [Docket](https://github.com/chrisguidry/docket). These require installing `fastmcp[tasks]`. ```python from fastmcp import FastMCP from fastmcp.dependencies import CurrentDocket, CurrentWorker, Progress mcp = FastMCP("Task Demo") @mcp.tool(task=True) async def long_running_task( data: str, docket=CurrentDocket(), worker=CurrentWorker(), progress=Progress(), ) -> str: await progress.set_total(100) for i in range(100): # Process chunk... await progress.increment() await progress.set_message(f"Processing chunk {i + 1}") return "Complete" ``` - **`CurrentDocket()`**: Access the Docket instance for scheduling additional background work - **`CurrentWorker()`**: Access the worker processing tasks (name, concurrency settings) - **`Progress()`**: Track task progress with atomic updates Task dependencies require `pip install 'fastmcp[tasks]'`. They're only available within task-enabled components (`task=True`). For comprehensive task patterns, see the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/). ## Custom Dependencies Beyond the built-in dependencies, you can create your own to inject configuration, database connections, API clients, or any other values your functions need. ### Using Depends() The `Depends()` function wraps any callable and injects its return value. This works with synchronous functions, async functions, and async context managers. ```python from fastmcp import FastMCP from fastmcp.dependencies import Depends mcp = FastMCP("Custom Deps Demo") def get_config() -> dict: return {"api_url": "https://api.example.com", "timeout": 30} async def get_user_id() -> int: # Could fetch from database, external service, etc. return 42 @mcp.tool async def fetch_data( query: str, config: dict = Depends(get_config), user_id: int = Depends(get_user_id), ) -> str: return f"User {user_id} fetching '{query}' from {config['api_url']}" ``` ### Caching Dependencies are cached per-request. If multiple parameters use the same dependency, or if nested dependencies share a common dependency, it's resolved once and the same instance is reused. ```python from fastmcp import FastMCP from fastmcp.dependencies import Depends mcp = FastMCP("Caching Demo") def get_db_connection(): print("Connecting to database...") # Only printed once per request return {"connection": "active"} def get_user_repo(db=Depends(get_db_connection)): return {"db": db, "type": "user"} def get_order_repo(db=Depends(get_db_connection)): return {"db": db, "type": "order"} @mcp.tool async def process_order( order_id: str, users=Depends(get_user_repo), orders=Depends(get_order_repo), ) -> str: # Both repos share the same db connection return f"Processed order {order_id}" ``` ### Resource Management For dependencies that need cleanup—database connections, file handles, HTTP clients—use an async context manager. The cleanup code runs after your function completes, even if an error occurs. ```python from contextlib import asynccontextmanager from fastmcp import FastMCP from fastmcp.dependencies import Depends mcp = FastMCP("Resource Demo") @asynccontextmanager async def get_database(): db = await connect_to_database() try: yield db finally: await db.close() @mcp.tool async def query_users(sql: str, db=Depends(get_database)) -> list: return await db.execute(sql) ``` ### Nested Dependencies Dependencies can depend on other dependencies. FastMCP resolves them in the correct order and applies caching across the dependency tree. ```python from fastmcp import FastMCP from fastmcp.dependencies import Depends mcp = FastMCP("Nested Demo") def get_base_url() -> str: return "https://api.example.com" def get_api_client(base_url: str = Depends(get_base_url)) -> dict: return {"base_url": base_url, "version": "v1"} @mcp.tool async def call_api(endpoint: str, client: dict = Depends(get_api_client)) -> str: return f"Calling {client['base_url']}/{client['version']}/{endpoint}" ``` For advanced dependency patterns—like `TaskArgument()` for accessing task parameters, or custom `Dependency` subclasses—see the [Docket dependency documentation](https://chrisguidry.github.io/docket/dependencies/). ================================================ FILE: docs/servers/elicitation.mdx ================================================ --- title: User Elicitation sidebarTitle: Elicitation description: Request structured input from users during tool execution through the MCP context. icon: message-question --- import { VersionBadge } from '/snippets/version-badge.mdx' User elicitation allows MCP servers to request structured input from users during tool execution. Instead of requiring all inputs upfront, tools can interactively ask for missing parameters, clarification, or additional context as needed. Elicitation enables tools to pause execution and request specific information from users: - **Missing parameters**: Ask for required information not provided initially - **Clarification requests**: Get user confirmation or choices for ambiguous scenarios - **Progressive disclosure**: Collect complex information step-by-step - **Dynamic workflows**: Adapt tool behavior based on user responses For example, a file management tool might ask "Which directory should I create?" or a data analysis tool might request "What date range should I analyze?" ## Overview Use the `ctx.elicit()` method within any tool function to request user input. Specify the message to display and the type of response you expect. ```python from fastmcp import FastMCP, Context from dataclasses import dataclass mcp = FastMCP("Elicitation Server") @dataclass class UserInfo: name: str age: int @mcp.tool async def collect_user_info(ctx: Context) -> str: """Collect user information through interactive prompts.""" result = await ctx.elicit( message="Please provide your information", response_type=UserInfo ) if result.action == "accept": user = result.data return f"Hello {user.name}, you are {user.age} years old" elif result.action == "decline": return "Information not provided" else: # cancel return "Operation cancelled" ``` The elicitation result contains an `action` field indicating how the user responded: | Action | Description | |--------|-------------| | `accept` | User provided valid input—data is available in the `data` field | | `decline` | User chose not to provide the requested information | | `cancel` | User cancelled the entire operation | FastMCP also provides typed result classes for pattern matching: ```python from fastmcp.server.elicitation import ( AcceptedElicitation, DeclinedElicitation, CancelledElicitation, ) @mcp.tool async def pattern_example(ctx: Context) -> str: result = await ctx.elicit("Enter your name:", response_type=str) match result: case AcceptedElicitation(data=name): return f"Hello {name}!" case DeclinedElicitation(): return "No name provided" case CancelledElicitation(): return "Operation cancelled" ``` ### Multi-Turn Elicitation Tools can make multiple elicitation calls to gather information progressively: ```python @mcp.tool async def plan_meeting(ctx: Context) -> str: """Plan a meeting by gathering details step by step.""" title_result = await ctx.elicit("What's the meeting title?", response_type=str) if title_result.action != "accept": return "Meeting planning cancelled" duration_result = await ctx.elicit("Duration in minutes?", response_type=int) if duration_result.action != "accept": return "Meeting planning cancelled" priority_result = await ctx.elicit( "Is this urgent?", response_type=["yes", "no"] ) if priority_result.action != "accept": return "Meeting planning cancelled" urgent = priority_result.data == "yes" return f"Meeting '{title_result.data}' for {duration_result.data} minutes (Urgent: {urgent})" ``` ### Client Requirements Elicitation requires the client to implement an elicitation handler. If a client doesn't support elicitation, calls to `ctx.elicit()` will raise an error indicating that elicitation is not supported. See [Client Elicitation](/clients/elicitation) for details on how clients handle these requests. ## Schema and Response Types The server must send a schema to the client indicating the type of data it expects in response to the elicitation request. The MCP spec only supports a limited subset of JSON Schema types for elicitation responses—specifically JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean`, and `enum` fields. FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`) or no response at all, by automatically wrapping them in MCP-compatible object schemas. ### Scalar Types You can request simple scalar data types for basic input, such as a string, integer, or boolean. When you request a scalar type, FastMCP automatically wraps it in an object schema for MCP spec compatibility. Clients will see a schema requesting a single "value" field of the requested type. Once clients respond, the provided object is "unwrapped" and the scalar value is returned directly in the `data` field. ```python title="String" @mcp.tool async def get_user_name(ctx: Context) -> str: result = await ctx.elicit("What's your name?", response_type=str) if result.action == "accept": return f"Hello, {result.data}!" return "No name provided" ``` ```python title="Integer" @mcp.tool async def pick_a_number(ctx: Context) -> str: result = await ctx.elicit("Pick a number!", response_type=int) if result.action == "accept": return f"You picked {result.data}" return "No number provided" ``` ```python title="Boolean" @mcp.tool async def pick_a_boolean(ctx: Context) -> str: result = await ctx.elicit("True or false?", response_type=bool) if result.action == "accept": return f"You picked {result.data}" return "No boolean provided" ``` ### No Response Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. Pass `None` as the response type to indicate that no data is expected. The `data` field will be `None` when the user accepts. ```python @mcp.tool async def approve_action(ctx: Context) -> str: result = await ctx.elicit("Approve this action?", response_type=None) if result.action == "accept": return do_action() else: raise ValueError("Action rejected") ``` ### Constrained Options Constrain the user's response to a specific set of values using a `Literal` type, Python enum, or a list of strings as a convenient shortcut. ```python title="List of strings" @mcp.tool async def set_priority(ctx: Context) -> str: result = await ctx.elicit( "What priority level?", response_type=["low", "medium", "high"], ) if result.action == "accept": return f"Priority set to: {result.data}" ``` ```python title="Literal type" from typing import Literal @mcp.tool async def set_priority(ctx: Context) -> str: result = await ctx.elicit( "What priority level?", response_type=Literal["low", "medium", "high"] ) if result.action == "accept": return f"Priority set to: {result.data}" return "No priority set" ``` ```python title="Python enum" from enum import Enum class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" @mcp.tool async def set_priority(ctx: Context) -> str: result = await ctx.elicit("What priority level?", response_type=Priority) if result.action == "accept": return f"Priority set to: {result.data.value}" return "No priority set" ``` ### Multi-Select Enable multi-select by wrapping your choices in an additional list level. This allows users to select multiple values from the available options. ```python title="List of strings" @mcp.tool async def select_tags(ctx: Context) -> str: result = await ctx.elicit( "Choose tags", response_type=[["bug", "feature", "documentation"]] # Note: list of a list ) if result.action == "accept": tags = result.data return f"Selected tags: {', '.join(tags)}" ``` ```python title="list[Enum] type" from enum import Enum class Tag(Enum): BUG = "bug" FEATURE = "feature" DOCS = "documentation" @mcp.tool async def select_tags(ctx: Context) -> str: result = await ctx.elicit( "Choose tags", response_type=list[Tag] ) if result.action == "accept": tags = [tag.value for tag in result.data] return f"Selected: {', '.join(tags)}" ``` ### Titled Options For better UI display, provide human-readable titles for enum options. FastMCP generates SEP-1330 compliant schemas using the `oneOf` pattern with `const` and `title` fields. ```python @mcp.tool async def set_priority(ctx: Context) -> str: result = await ctx.elicit( "What priority level?", response_type={ "low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}, "high": {"title": "High Priority"} } ) if result.action == "accept": return f"Priority set to: {result.data}" ``` For multi-select with titles, wrap the dict in a list: ```python @mcp.tool async def select_priorities(ctx: Context) -> str: result = await ctx.elicit( "Choose priorities", response_type=[{ "low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}, "high": {"title": "High Priority"} }] ) if result.action == "accept": return f"Selected: {', '.join(result.data)}" ``` ### Structured Responses Request structured data with multiple fields by using a dataclass, typed dict, or Pydantic model as the response type. Note that the MCP spec only supports shallow objects with scalar (string, number, boolean) or enum properties. ```python from dataclasses import dataclass from typing import Literal @dataclass class TaskDetails: title: str description: str priority: Literal["low", "medium", "high"] due_date: str @mcp.tool async def create_task(ctx: Context) -> str: result = await ctx.elicit( "Please provide task details", response_type=TaskDetails ) if result.action == "accept": task = result.data return f"Created task: {task.title} (Priority: {task.priority})" return "Task creation cancelled" ``` ### Default Values Provide default values for elicitation fields using Pydantic's `Field(default=...)`. Clients will pre-populate form fields with these defaults. Fields with default values are automatically marked as optional. ```python from pydantic import BaseModel, Field from enum import Enum class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" class TaskDetails(BaseModel): title: str = Field(description="Task title") description: str = Field(default="", description="Task description") priority: Priority = Field(default=Priority.MEDIUM, description="Task priority") @mcp.tool async def create_task(ctx: Context) -> str: result = await ctx.elicit("Please provide task details", response_type=TaskDetails) if result.action == "accept": return f"Created: {result.data.title}" return "Task creation cancelled" ``` Default values are supported for strings, integers, numbers, booleans, and enums. ================================================ FILE: docs/servers/icons.mdx ================================================ --- title: Icons description: Add visual icons to your servers, tools, resources, and prompts icon: image --- import { VersionBadge } from '/snippets/version-badge.mdx' Icons provide visual representations for your MCP servers and components, helping client applications present better user interfaces. When displayed in MCP clients, icons help users quickly identify and navigate your server's capabilities. ## Icon Format Icons use the standard MCP Icon type from the MCP protocol specification. Each icon specifies a source URL or data URI, and optionally includes MIME type and size information. ```python from mcp.types import Icon icon = Icon( src="https://example.com/icon.png", mimeType="image/png", sizes=["48x48"] ) ``` The fields serve different purposes: - **src**: URL or data URI pointing to the icon image - **mimeType** (optional): MIME type of the image (e.g., "image/png", "image/svg+xml") - **sizes** (optional): Array of size descriptors (e.g., ["48x48"], ["any"]) ## Server Icons Add icons and a website URL to your server for display in client applications. Multiple icons at different sizes help clients choose the best resolution for their display context. ```python from fastmcp import FastMCP from mcp.types import Icon mcp = FastMCP( name="WeatherService", website_url="https://weather.example.com", icons=[ Icon( src="https://weather.example.com/icon-48.png", mimeType="image/png", sizes=["48x48"] ), Icon( src="https://weather.example.com/icon-96.png", mimeType="image/png", sizes=["96x96"] ), ] ) ``` Server icons appear in MCP client interfaces to help users identify your server among others they may have installed. ## Component Icons Icons can be added to individual tools, resources, resource templates, and prompts. This helps users visually distinguish between different component types and purposes. ### Tool Icons ```python from mcp.types import Icon @mcp.tool( icons=[Icon(src="https://example.com/calculator-icon.png")] ) def calculate_sum(a: int, b: int) -> int: """Add two numbers together.""" return a + b ``` ### Resource Icons ```python @mcp.resource( "config://settings", icons=[Icon(src="https://example.com/config-icon.png")] ) def get_settings() -> dict: """Retrieve application settings.""" return {"theme": "dark", "language": "en"} ``` ### Resource Template Icons ```python @mcp.resource( "user://{user_id}/profile", icons=[Icon(src="https://example.com/user-icon.png")] ) def get_user_profile(user_id: str) -> dict: """Get a user's profile.""" return {"id": user_id, "name": f"User {user_id}"} ``` ### Prompt Icons ```python @mcp.prompt( icons=[Icon(src="https://example.com/prompt-icon.png")] ) def analyze_code(code: str): """Create a prompt for code analysis.""" return f"Please analyze this code:\n\n{code}" ``` ## Using Data URIs For small icons or when you want to embed the icon directly without external dependencies, use data URIs. This approach eliminates the need for hosting and ensures the icon is always available. ```python from mcp.types import Icon from fastmcp.utilities.types import Image # SVG icon as data URI svg_icon = Icon( src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6Ii8+PC9zdmc+", mimeType="image/svg+xml" ) @mcp.tool(icons=[svg_icon]) def my_tool() -> str: """A tool with an embedded SVG icon.""" return "result" ``` ### Generating Data URIs from Files FastMCP provides the `Image` utility class to convert local image files into data URIs. ```python from mcp.types import Icon from fastmcp.utilities.types import Image # Generate a data URI from a local image file img = Image(path="./assets/brand/favicon.png") icon = Icon(src=img.to_data_uri()) @mcp.tool(icons=[icon]) def file_icon_tool() -> str: """A tool with an icon generated from a local file.""" return "result" ``` This approach is useful when you have local image assets and want to embed them directly in your server definition. ================================================ FILE: docs/servers/lifespan.mdx ================================================ --- title: Lifespans sidebarTitle: Lifespan description: Server-level setup and teardown with composable lifespans icon: heart-pulse tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Lifespans let you run code once when the server starts and clean up when it stops. Unlike per-session handlers, lifespans run exactly once regardless of how many clients connect. ## Basic Usage Use the `@lifespan` decorator to define a lifespan: ```python from fastmcp import FastMCP from fastmcp.server.lifespan import lifespan @lifespan async def app_lifespan(server): # Setup: runs once when server starts print("Starting up...") try: yield {"started_at": "2024-01-01"} finally: # Teardown: runs when server stops print("Shutting down...") mcp = FastMCP("MyServer", lifespan=app_lifespan) ``` The dict you yield becomes the **lifespan context**, accessible from tools. Always use `try/finally` for cleanup code to ensure it runs even if the server is cancelled. ## Accessing Lifespan Context Access the lifespan context in tools via `ctx.lifespan_context`: ```python from fastmcp import FastMCP, Context from fastmcp.server.lifespan import lifespan @lifespan async def app_lifespan(server): # Initialize shared state data = {"users": ["alice", "bob"]} yield {"data": data} mcp = FastMCP("MyServer", lifespan=app_lifespan) @mcp.tool def list_users(ctx: Context) -> list[str]: data = ctx.lifespan_context["data"] return data["users"] ``` ## Composing Lifespans Compose multiple lifespans with the `|` operator: ```python from fastmcp import FastMCP from fastmcp.server.lifespan import lifespan @lifespan async def config_lifespan(server): config = {"debug": True, "version": "1.0"} yield {"config": config} @lifespan async def data_lifespan(server): data = {"items": []} yield {"data": data} # Compose with | mcp = FastMCP("MyServer", lifespan=config_lifespan | data_lifespan) ``` Composed lifespans: - Enter in order (left to right) - Exit in reverse order (right to left) - Merge their context dicts (later values overwrite earlier on conflict) ## Backwards Compatibility Existing `@asynccontextmanager` lifespans still work when passed directly to FastMCP: ```python from contextlib import asynccontextmanager from fastmcp import FastMCP @asynccontextmanager async def legacy_lifespan(server): yield {"key": "value"} mcp = FastMCP("MyServer", lifespan=legacy_lifespan) ``` To compose an `@asynccontextmanager` function with `@lifespan` functions, wrap it with `ContextManagerLifespan`: ```python from contextlib import asynccontextmanager from fastmcp.server.lifespan import lifespan, ContextManagerLifespan @asynccontextmanager async def legacy_lifespan(server): yield {"legacy": True} @lifespan async def new_lifespan(server): yield {"new": True} # Wrap the legacy lifespan explicitly for composition combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan ``` ## With FastAPI When mounting FastMCP into FastAPI, use `combine_lifespans` to run both your app's lifespan and the MCP server's lifespan: ```python from contextlib import asynccontextmanager from fastapi import FastAPI from fastmcp import FastMCP from fastmcp.utilities.lifespan import combine_lifespans @asynccontextmanager async def app_lifespan(app): print("FastAPI starting...") yield print("FastAPI shutting down...") mcp = FastMCP("Tools") mcp_app = mcp.http_app() app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) app.mount("/mcp", mcp_app) ``` See the [FastAPI integration guide](/integrations/fastapi#combining-lifespans) for full details. ================================================ FILE: docs/servers/logging.mdx ================================================ --- title: Client Logging sidebarTitle: Logging description: Send log messages back to MCP clients through the context. icon: receipt --- import { VersionBadge } from '/snippets/version-badge.mdx' This documentation covers **MCP client logging**—sending messages from your server to MCP clients. For standard server-side logging (e.g., writing to files, console), use `fastmcp.utilities.logging.get_logger()` or Python's built-in `logging` module. Server logging allows MCP tools to send debug, info, warning, and error messages back to the client. Unlike standard Python logging, MCP server logging sends messages directly to the client, making them visible in the client's interface or logs. ## Basic Usage Use the context logging methods within any tool function: ```python from fastmcp import FastMCP, Context mcp = FastMCP("LoggingDemo") @mcp.tool async def analyze_data(data: list[float], ctx: Context) -> dict: """Analyze numerical data with comprehensive logging.""" await ctx.debug("Starting analysis of numerical data") await ctx.info(f"Analyzing {len(data)} data points") try: if not data: await ctx.warning("Empty data list provided") return {"error": "Empty data list"} result = sum(data) / len(data) await ctx.info(f"Analysis complete, average: {result}") return {"average": result, "count": len(data)} except Exception as e: await ctx.error(f"Analysis failed: {str(e)}") raise ``` ## Log Levels | Level | Use Case | |-------|----------| | `ctx.debug()` | Detailed execution information for diagnosing problems | | `ctx.info()` | General information about normal program execution | | `ctx.warning()` | Potentially harmful situations that don't prevent execution | | `ctx.error()` | Error events that might still allow the application to continue | ## Structured Logging All logging methods accept an `extra` parameter for sending structured data to the client. This is useful for creating rich, queryable logs. ```python @mcp.tool async def process_transaction(transaction_id: str, amount: float, ctx: Context): await ctx.info( f"Processing transaction {transaction_id}", extra={ "transaction_id": transaction_id, "amount": amount, "currency": "USD" } ) ``` ## Server-Side Logs Messages sent to clients via `ctx.log()` and its convenience methods are also logged to the server's log at `DEBUG` level. Enable debug logging on the `fastmcp.server.context.to_client` logger to see these messages: ```python import logging from fastmcp.utilities.logging import get_logger to_client_logger = get_logger(name="fastmcp.server.context.to_client") to_client_logger.setLevel(level=logging.DEBUG) ``` ## Client Handling Log messages are sent to the client through the MCP protocol. How clients handle these messages depends on their implementation—development clients may display logs in real-time, production clients may store them for analysis, and integration clients may forward them to external logging systems. See [Client Logging](/clients/logging) for details on how clients handle server log messages. ================================================ FILE: docs/servers/middleware.mdx ================================================ --- title: Middleware sidebarTitle: Middleware description: Add cross-cutting functionality to your MCP server with middleware that intercepts and modifies requests and responses. icon: layer-group --- import { VersionBadge } from "/snippets/version-badge.mdx" Middleware adds behavior that applies across multiple operations—authentication, logging, rate limiting, or request transformation—without modifying individual tools or resources. MCP middleware is a FastMCP-specific concept and is not part of the official MCP protocol specification. ## Overview MCP middleware forms a pipeline around your server's operations. When a request arrives, it flows through each middleware in order—each can inspect, modify, or reject the request before passing it along. After the operation completes, the response flows back through the same middleware in reverse order. ``` Request → Middleware A → Middleware B → Handler → Middleware B → Middleware A → Response ``` This bidirectional flow means middleware can: - **Pre-process**: Validate authentication, log incoming requests, check rate limits - **Post-process**: Transform responses, record timing metrics, handle errors consistently The key decision point is `call_next(context)`. Calling it continues the chain; not calling it stops processing entirely. ```python from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext class LoggingMiddleware(Middleware): async def on_message(self, context: MiddlewareContext, call_next): print(f"→ {context.method}") result = await call_next(context) print(f"← {context.method}") return result mcp = FastMCP("MyServer") mcp.add_middleware(LoggingMiddleware()) ``` ### Execution Order Middleware executes in the order added to the server. The first middleware runs first on the way in and last on the way out: ```python from fastmcp import FastMCP from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware from fastmcp.server.middleware.logging import LoggingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(ErrorHandlingMiddleware()) # 1st in, last out mcp.add_middleware(RateLimitingMiddleware()) # 2nd in, 2nd out mcp.add_middleware(LoggingMiddleware()) # 3rd in, first out ``` This ordering matters. Place error handling early so it catches exceptions from all subsequent middleware. Place logging late so it records the actual execution after other middleware has processed the request. ### Server Composition When using [mounted servers](/servers/composition), middleware behavior follows a clear hierarchy: - **Parent middleware** runs for all requests, including those routed to mounted servers - **Mounted server middleware** only runs for requests handled by that specific server ```python from fastmcp import FastMCP from fastmcp.server.middleware.logging import LoggingMiddleware parent = FastMCP("Parent") parent.add_middleware(AuthMiddleware()) # Runs for ALL requests child = FastMCP("Child") child.add_middleware(LoggingMiddleware()) # Only runs for child's tools parent.mount(child, namespace="child") ``` Requests to `child_tool` flow through the parent's `AuthMiddleware` first, then through the child's `LoggingMiddleware`. ## Hooks Rather than processing every message identically, FastMCP provides specialized hooks at different levels of specificity. Multiple hooks fire for a single request, going from general to specific: | Level | Hooks | Purpose | |-------|-------|---------| | Message | `on_message` | All MCP traffic (requests and notifications) | | Type | `on_request`, `on_notification` | Requests expecting responses vs fire-and-forget | | Operation | `on_call_tool`, `on_read_resource`, `on_get_prompt`, etc. | Specific MCP operations | When a client calls a tool, the middleware chain processes `on_message` first, then `on_request`, then `on_call_tool`. This hierarchy lets you target exactly the right scope—use `on_message` for logging everything, `on_request` for authentication, and `on_call_tool` for tool-specific behavior. ### Hook Signature Every hook follows the same pattern: ```python async def hook_name(self, context: MiddlewareContext, call_next) -> result_type: # Pre-processing result = await call_next(context) # Post-processing return result ``` **Parameters:** - `context` — `MiddlewareContext` containing request information - `call_next` — Async function to continue the middleware chain **Returns:** The appropriate result type for the hook (varies by operation). ### MiddlewareContext The `context` parameter provides access to request details: | Attribute | Type | Description | |-----------|------|-------------| | `method` | `str` | MCP method name (e.g., `"tools/call"`) | | `source` | `str` | Origin: `"client"` or `"server"` | | `type` | `str` | Message type: `"request"` or `"notification"` | | `message` | `object` | The MCP message data | | `timestamp` | `datetime` | When the request was received | | `fastmcp_context` | `Context` | FastMCP context object (if available) | ### Message Hooks #### on_message Called for every MCP message—both requests and notifications. ```python async def on_message(self, context: MiddlewareContext, call_next): result = await call_next(context) return result ``` Use for: Logging, metrics, or any cross-cutting concern that applies to all traffic. #### on_request Called for MCP requests that expect a response. ```python async def on_request(self, context: MiddlewareContext, call_next): result = await call_next(context) return result ``` Use for: Authentication, authorization, request validation. #### on_notification Called for fire-and-forget MCP notifications. ```python async def on_notification(self, context: MiddlewareContext, call_next): await call_next(context) # Notifications don't return values ``` Use for: Event logging, async side effects. ### Operation Hooks #### on_call_tool Called when a tool is executed. The `context.message` contains `name` (tool name) and `arguments` (dict). ```python async def on_call_tool(self, context: MiddlewareContext, call_next): tool_name = context.message.name args = context.message.arguments result = await call_next(context) return result ``` **Returns:** Tool execution result or raises `ToolError`. #### on_read_resource Called when a resource is read. The `context.message` contains `uri` (resource URI). ```python async def on_read_resource(self, context: MiddlewareContext, call_next): uri = context.message.uri result = await call_next(context) return result ``` **Returns:** Resource content. #### on_get_prompt Called when a prompt is retrieved. The `context.message` contains `name` (prompt name) and `arguments` (dict). ```python async def on_get_prompt(self, context: MiddlewareContext, call_next): prompt_name = context.message.name result = await call_next(context) return result ``` **Returns:** Prompt messages. #### on_list_tools Called when listing available tools. Returns a list of FastMCP `Tool` objects before MCP conversion. ```python async def on_list_tools(self, context: MiddlewareContext, call_next): tools = await call_next(context) # Filter or modify the tool list return tools ``` **Returns:** `list[Tool]` — Can be filtered before returning to client. #### on_list_resources Called when listing available resources. Returns FastMCP `Resource` objects. ```python async def on_list_resources(self, context: MiddlewareContext, call_next): resources = await call_next(context) return resources ``` **Returns:** `list[Resource]` #### on_list_resource_templates Called when listing resource templates. ```python async def on_list_resource_templates(self, context: MiddlewareContext, call_next): templates = await call_next(context) return templates ``` **Returns:** `list[ResourceTemplate]` #### on_list_prompts Called when listing available prompts. ```python async def on_list_prompts(self, context: MiddlewareContext, call_next): prompts = await call_next(context) return prompts ``` **Returns:** `list[Prompt]` #### on_initialize Called when a client connects and initializes the session. This hook cannot modify the initialization response. ```python from mcp import McpError from mcp.types import ErrorData async def on_initialize(self, context: MiddlewareContext, call_next): client_info = context.message.params.get("clientInfo", {}) client_name = client_info.get("name", "unknown") # Reject before call_next to send error to client if client_name == "blocked-client": raise McpError(ErrorData(code=-32000, message="Client not supported")) await call_next(context) print(f"Client {client_name} initialized") ``` **Returns:** `None` — The initialization response is handled internally by the MCP protocol. Raising `McpError` after `call_next()` will only log the error, not send it to the client. The response has already been sent. Always reject **before** `call_next()`. ### Raw Handler For complete control over all messages, override `__call__` instead of individual hooks: ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class RawMiddleware(Middleware): async def __call__(self, context: MiddlewareContext, call_next): print(f"Processing: {context.method}") result = await call_next(context) print(f"Completed: {context.method}") return result ``` This bypasses the hook dispatch system entirely. Use when you need uniform handling regardless of message type. ### Session Availability The MCP session may not be available during certain phases like initialization. Check before accessing session-specific attributes: ```python async def on_request(self, context: MiddlewareContext, call_next): ctx = context.fastmcp_context if ctx.request_context: # MCP session available session_id = ctx.session_id request_id = ctx.request_id else: # Session not yet established (e.g., during initialization) # Use HTTP helpers if needed from fastmcp.server.dependencies import get_http_headers headers = get_http_headers() return await call_next(context) ``` For HTTP-specific data (headers, client IP) when using HTTP transports, see [HTTP Requests](/servers/context#http-requests). ## Built-in Middleware FastMCP includes production-ready middleware for common server concerns. ### Logging ```python from fastmcp.server.middleware.logging import LoggingMiddleware, StructuredLoggingMiddleware ``` `LoggingMiddleware` provides human-readable request and response logging. `StructuredLoggingMiddleware` outputs JSON-formatted logs for aggregation tools like Datadog or Splunk. ```python from fastmcp import FastMCP from fastmcp.server.middleware.logging import LoggingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(LoggingMiddleware( include_payloads=True, max_payload_length=1000 )) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `include_payloads` | `bool` | `False` | Log request/response content | | `max_payload_length` | `int` | `500` | Truncate payloads beyond this length | | `logger` | `Logger` | module logger | Custom logger instance | ### Timing ```python from fastmcp.server.middleware.timing import TimingMiddleware, DetailedTimingMiddleware ``` `TimingMiddleware` logs execution duration for all requests. `DetailedTimingMiddleware` provides per-operation timing with separate tracking for tools, resources, and prompts. ```python from fastmcp import FastMCP from fastmcp.server.middleware.timing import TimingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(TimingMiddleware()) ``` ### Caching ```python from fastmcp.server.middleware.caching import ResponseCachingMiddleware ``` Caches tool calls, resource reads, and list operations with TTL-based expiration. ```python from fastmcp import FastMCP from fastmcp.server.middleware.caching import ResponseCachingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(ResponseCachingMiddleware()) ``` Each operation type can be configured independently using settings classes: ```python from fastmcp.server.middleware.caching import ( ResponseCachingMiddleware, CallToolSettings, ListToolsSettings, ReadResourceSettings ) mcp.add_middleware(ResponseCachingMiddleware( list_tools_settings=ListToolsSettings(ttl=30), call_tool_settings=CallToolSettings(included_tools=["expensive_tool"]), read_resource_settings=ReadResourceSettings(enabled=False) )) ``` | Settings Class | Configures | |----------------|------------| | `ListToolsSettings` | `on_list_tools` caching | | `CallToolSettings` | `on_call_tool` caching | | `ListResourcesSettings` | `on_list_resources` caching | | `ReadResourceSettings` | `on_read_resource` caching | | `ListPromptsSettings` | `on_list_prompts` caching | | `GetPromptSettings` | `on_get_prompt` caching | Each settings class accepts: - `enabled` — Enable/disable caching for this operation - `ttl` — Time-to-live in seconds - `included_*` / `excluded_*` — Whitelist or blacklist specific items For persistence or distributed deployments, configure a different storage backend: ```python from fastmcp.server.middleware.caching import ResponseCachingMiddleware from key_value.aio.stores.disk import DiskStore mcp.add_middleware(ResponseCachingMiddleware( cache_storage=DiskStore(directory="cache") )) ``` See [Storage Backends](/servers/storage-backends) for complete options. Cache keys are based on the operation name and arguments only — they do not include user or session identity. If your tools return user-specific data derived from auth context (e.g., headers or session state) rather than from the request arguments, you should either disable caching for those tools or ensure user identity is part of the tool arguments. ### Rate Limiting ```python from fastmcp.server.middleware.rate_limiting import ( RateLimitingMiddleware, SlidingWindowRateLimitingMiddleware ) ``` `RateLimitingMiddleware` uses a token bucket algorithm allowing controlled bursts. `SlidingWindowRateLimitingMiddleware` provides precise time-window rate limiting without burst allowance. ```python from fastmcp import FastMCP from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(RateLimitingMiddleware( max_requests_per_second=10.0, burst_capacity=20 )) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `max_requests_per_second` | `float` | `10.0` | Sustained request rate | | `burst_capacity` | `int` | `20` | Maximum burst size | | `client_id_func` | `Callable` | `None` | Custom client identification | For sliding window rate limiting: ```python from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware mcp.add_middleware(SlidingWindowRateLimitingMiddleware( max_requests=100, window_minutes=1 )) ``` ### Error Handling ```python from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware, RetryMiddleware ``` `ErrorHandlingMiddleware` provides centralized error logging and transformation. `RetryMiddleware` automatically retries with exponential backoff for transient failures. ```python from fastmcp import FastMCP from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(ErrorHandlingMiddleware( include_traceback=True, transform_errors=True, error_callback=my_error_callback )) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `include_traceback` | `bool` | `False` | Include stack traces in logs | | `transform_errors` | `bool` | `False` | Convert exceptions to MCP errors | | `error_callback` | `Callable` | `None` | Custom callback on errors | For automatic retries: ```python from fastmcp.server.middleware.error_handling import RetryMiddleware mcp.add_middleware(RetryMiddleware( max_retries=3, retry_exceptions=(ConnectionError, TimeoutError) )) ``` ### Ping ```python from fastmcp.server.middleware import PingMiddleware ``` Keeps long-lived connections alive by sending periodic pings. ```python from fastmcp import FastMCP from fastmcp.server.middleware import PingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(PingMiddleware(interval_ms=5000)) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `interval_ms` | `int` | `30000` | Ping interval in milliseconds | The ping task starts on the first message and stops automatically when the session ends. Most useful for stateful HTTP connections; has no effect on stateless connections. ### Response Limiting ```python from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware ``` Large tool responses can overwhelm LLM context windows or cause memory issues. You can add response-limiting middleware to enforce size constraints on tool outputs. ```python from fastmcp import FastMCP from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware mcp = FastMCP("MyServer") # Limit all tool responses to 500KB mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) @mcp.tool def search(query: str) -> str: # This could return a very large result return "x" * 1_000_000 # 1MB response # When called, the response will be truncated to ~500KB with: # "...\n\n[Response truncated due to size limit]" ``` When a response exceeds the limit, the middleware extracts all text content, joins it together, truncates to fit within the limit, and returns a single `TextContent` block. For non-text responses, the serialized JSON is used as the text source. If a tool defines an `output_schema`, truncated responses will no longer conform to that schema — the client will receive a plain `TextContent` block instead of the expected structured output. Keep this in mind when setting size limits for tools with structured responses. ```python # Limit only specific tools mcp.add_middleware(ResponseLimitingMiddleware( max_size=100_000, tools=["search", "fetch_data"], )) ``` | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `max_size` | `int` | `1_000_000` | Maximum response size in bytes (1MB default) | | `truncation_suffix` | `str` | `"\n\n[Response truncated due to size limit]"` | Suffix appended to truncated responses | | `tools` | `list[str] \| None` | `None` | Limit only these tools (None = all tools) | ### Combining Middleware Order matters. Place middleware that should run first (on the way in) earliest: ```python from fastmcp import FastMCP from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware from fastmcp.server.middleware.timing import TimingMiddleware from fastmcp.server.middleware.logging import LoggingMiddleware mcp = FastMCP("Production Server") mcp.add_middleware(ErrorHandlingMiddleware()) # Catch all errors mcp.add_middleware(RateLimitingMiddleware(max_requests_per_second=50)) mcp.add_middleware(TimingMiddleware()) mcp.add_middleware(LoggingMiddleware()) @mcp.tool def my_tool(data: str) -> str: return f"Processed: {data}" ``` ## Custom Middleware When the built-in middleware doesn't fit your needs—custom authentication schemes, domain-specific logging, or request transformation—subclass `Middleware` and override the hooks you need. ```python from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext class CustomMiddleware(Middleware): async def on_request(self, context: MiddlewareContext, call_next): # Pre-processing print(f"→ {context.method}") result = await call_next(context) # Post-processing print(f"← {context.method}") return result mcp = FastMCP("MyServer") mcp.add_middleware(CustomMiddleware()) ``` Override only the hooks relevant to your use case. Unoverridden hooks pass through automatically. ### Denying Requests Raise the appropriate error type to stop processing and return an error to the client. ```python from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.exceptions import ToolError class AuthMiddleware(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): tool_name = context.message.name if tool_name in ["delete_all", "admin_config"]: raise ToolError("Access denied: requires admin privileges") return await call_next(context) ``` | Operation | Error Type | |-----------|------------| | Tool calls | `ToolError` | | Resource reads | `ResourceError` | | Prompt retrieval | `PromptError` | | General requests | `McpError` | Do not return error values or skip `call_next()` to indicate errors—raise exceptions for proper error propagation. ### Modifying Requests Change the message before passing it down the chain. ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class InputSanitizer(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): if context.message.name == "search": # Normalize search query query = context.message.arguments.get("query", "") context.message.arguments["query"] = query.strip().lower() return await call_next(context) ``` ### Modifying Responses Transform results after the handler executes. ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class ResponseEnricher(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): result = await call_next(context) if context.message.name == "get_data" and result.structured_content: result.structured_content["processed_by"] = "enricher" return result ``` For more complex tool transformations, consider [Transforms](/servers/transforms/transforms) instead. ### Filtering Lists List operations return FastMCP objects that you can filter before they reach the client. When filtering list results, also block execution in the corresponding operation hook to maintain consistency: ```python from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.exceptions import ToolError class PrivateToolFilter(Middleware): async def on_list_tools(self, context: MiddlewareContext, call_next): tools = await call_next(context) return [tool for tool in tools if "private" not in tool.tags] async def on_call_tool(self, context: MiddlewareContext, call_next): if context.fastmcp_context: tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) if "private" in tool.tags: raise ToolError("Tool not found") return await call_next(context) ``` ### Accessing Component Metadata During execution hooks, component metadata (like tags) isn't directly available. Look up the component through the server: ```python from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.exceptions import ToolError class TagBasedAuth(Middleware): async def on_call_tool(self, context: MiddlewareContext, call_next): if context.fastmcp_context: try: tool = await context.fastmcp_context.fastmcp.get_tool(context.message.name) if "requires-auth" in tool.tags: # Check authentication here pass except Exception: pass # Let execution handle missing tools return await call_next(context) ``` The same pattern works for resources and prompts: ```python resource = await context.fastmcp_context.fastmcp.get_resource(context.message.uri) prompt = await context.fastmcp_context.fastmcp.get_prompt(context.message.name) ``` ### Storing State Middleware can store state that tools access later through the FastMCP context. ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class UserMiddleware(Middleware): async def on_request(self, context: MiddlewareContext, call_next): # Extract user from headers (HTTP transport) from fastmcp.server.dependencies import get_http_headers headers = get_http_headers() or {} user_id = headers.get("x-user-id", "anonymous") # Store for tools to access if context.fastmcp_context: context.fastmcp_context.set_state("user_id", user_id) return await call_next(context) ``` Tools retrieve the state: ```python from fastmcp import FastMCP, Context mcp = FastMCP("MyServer") @mcp.tool def get_user_data(ctx: Context) -> str: user_id = ctx.get_state("user_id") return f"Data for user: {user_id}" ``` See [Context State Management](/servers/context#state-management) for details. ### Constructor Parameters Initialize middleware with configuration: ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class ConfigurableMiddleware(Middleware): def __init__(self, api_key: str, rate_limit: int = 100): self.api_key = api_key self.rate_limit = rate_limit self.request_counts = {} async def on_request(self, context: MiddlewareContext, call_next): # Use self.api_key, self.rate_limit, etc. return await call_next(context) mcp.add_middleware(ConfigurableMiddleware( api_key="secret", rate_limit=50 )) ``` ### Error Handling in Custom Middleware Wrap `call_next()` to handle errors from downstream middleware and handlers. ```python from fastmcp.server.middleware import Middleware, MiddlewareContext class ErrorLogger(Middleware): async def on_request(self, context: MiddlewareContext, call_next): try: return await call_next(context) except Exception as e: print(f"Error in {context.method}: {type(e).__name__}: {e}") raise # Re-raise to let error propagate ``` Catching and not re-raising suppresses the error entirely. Usually you want to log and re-raise. ### Complete Example Authentication middleware checking API keys for specific tools: ```python from fastmcp import FastMCP from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.dependencies import get_http_headers from fastmcp.exceptions import ToolError class ApiKeyAuth(Middleware): def __init__(self, valid_keys: set[str], protected_tools: set[str]): self.valid_keys = valid_keys self.protected_tools = protected_tools async def on_call_tool(self, context: MiddlewareContext, call_next): tool_name = context.message.name if tool_name not in self.protected_tools: return await call_next(context) headers = get_http_headers() or {} api_key = headers.get("x-api-key") if api_key not in self.valid_keys: raise ToolError(f"Invalid API key for protected tool: {tool_name}") return await call_next(context) mcp = FastMCP("Secure Server") mcp.add_middleware(ApiKeyAuth( valid_keys={"key-1", "key-2"}, protected_tools={"delete_user", "admin_panel"} )) @mcp.tool def delete_user(user_id: str) -> str: return f"Deleted user {user_id}" @mcp.tool def get_user(user_id: str) -> str: return f"User {user_id}" # Not protected ``` ================================================ FILE: docs/servers/pagination.mdx ================================================ --- title: Pagination sidebarTitle: Pagination description: Control how servers return large lists of components to clients. icon: page tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' When a server exposes many tools, resources, or prompts, returning them all in a single response can be impractical. MCP supports pagination for list operations, allowing servers to return results in manageable chunks that clients can fetch incrementally. ## Server Configuration By default, FastMCP servers return all components in a single response for backward compatibility. To enable pagination, set the `list_page_size` parameter when creating your server. This value determines the maximum number of items returned per page across all list operations. ```python from fastmcp import FastMCP # Enable pagination with 50 items per page server = FastMCP("ComponentRegistry", list_page_size=50) # Register tools (in practice, these might come from a database or config) @server.tool def search(query: str) -> str: return f"Results for: {query}" @server.tool def analyze(data: str) -> dict: return {"status": "analyzed", "data": data} # ... many more tools, resources, prompts ``` When `list_page_size` is configured, the `tools/list`, `resources/list`, `resources/templates/list`, and `prompts/list` endpoints all paginate their responses. Each response includes a `nextCursor` field when more results exist, which clients use to fetch subsequent pages. ### Cursor Format Cursors are opaque base64-encoded strings per the MCP specification. Clients should treat them as black boxes, passing them unchanged between requests. The cursor encodes the offset into the result set, but this is an implementation detail that may change. ## Client Behavior The FastMCP Client handles pagination transparently. Convenience methods like `list_tools()`, `list_resources()`, `list_resource_templates()`, and `list_prompts()` automatically fetch all pages and return the complete list. Existing code continues to work without modification. ```python from fastmcp import Client async with Client(server) as client: # Returns all 200 tools, fetching pages automatically tools = await client.list_tools() print(f"Total tools: {len(tools)}") # 200 ``` ### Manual Pagination For scenarios where you want to process results incrementally (memory-constrained environments, progress reporting, or early termination), use the `_mcp` variants with explicit cursor handling. ```python from fastmcp import Client async with Client(server) as client: # Fetch first page result = await client.list_tools_mcp() print(f"Page 1: {len(result.tools)} tools") # Continue fetching while more pages exist while result.nextCursor: result = await client.list_tools_mcp(cursor=result.nextCursor) print(f"Next page: {len(result.tools)} tools") ``` The `_mcp` methods return the raw MCP protocol objects, which include both the items and the `nextCursor` for the next page. When `nextCursor` is `None`, you've reached the end of the result set. All four list operations support manual pagination: | Operation | Convenience Method | Manual Method | |-----------|-------------------|---------------| | Tools | `list_tools()` | `list_tools_mcp(cursor=...)` | | Resources | `list_resources()` | `list_resources_mcp(cursor=...)` | | Resource Templates | `list_resource_templates()` | `list_resource_templates_mcp(cursor=...)` | | Prompts | `list_prompts()` | `list_prompts_mcp(cursor=...)` | ## When to Use Pagination Pagination becomes valuable when your server exposes a large number of components. Consider enabling it when: - Your server dynamically generates many components (e.g., from a database or file system) - Memory usage is a concern for clients - You want to reduce initial response latency For servers with a fixed, modest number of components (fewer than 100), pagination adds complexity without meaningful benefit. The default behavior of returning everything in one response is simpler and efficient for typical use cases. ================================================ FILE: docs/servers/progress.mdx ================================================ --- title: Progress Reporting sidebarTitle: Progress description: Update clients on the progress of long-running operations through the MCP context. icon: chart-line --- import { VersionBadge } from '/snippets/version-badge.mdx' Progress reporting allows MCP tools to notify clients about the progress of long-running operations. Clients can display progress indicators and provide better user experience during time-consuming tasks. ## Basic Usage Use `ctx.report_progress()` to send progress updates to the client. The method accepts a `progress` value representing how much work is complete, and an optional `total` representing the full scope of work. ```python from fastmcp import FastMCP, Context import asyncio mcp = FastMCP("ProgressDemo") @mcp.tool async def process_items(items: list[str], ctx: Context) -> dict: """Process a list of items with progress updates.""" total = len(items) results = [] for i, item in enumerate(items): await ctx.report_progress(progress=i, total=total) await asyncio.sleep(0.1) results.append(item.upper()) await ctx.report_progress(progress=total, total=total) return {"processed": len(results), "results": results} ``` ## Progress Patterns | Pattern | Description | Example | |---------|-------------|---------| | Percentage | Progress as 0-100 percentage | `progress=75, total=100` | | Absolute | Completed items of a known count | `progress=3, total=10` | | Indeterminate | Progress without known endpoint | `progress=files_found` (no total) | For multi-stage operations, map each stage to a portion of the total progress range. A four-stage operation might allocate 0-25% to validation, 25-60% to export, 60-80% to transform, and 80-100% to import. ## Client Requirements Progress reporting requires clients to support progress handling. Clients must send a `progressToken` in the initial request to receive progress updates. If no progress token is provided, progress calls have no effect (they don't error). See [Client Progress](/clients/progress) for details on implementing client-side progress handling. ================================================ FILE: docs/servers/prompts.mdx ================================================ --- title: Prompts sidebarTitle: Prompts description: Create reusable, parameterized prompt templates for MCP clients. icon: message-lines --- import { VersionBadge } from "/snippets/version-badge.mdx" Prompts are reusable message templates that help LLMs generate structured, purposeful responses. FastMCP simplifies defining these templates, primarily using the `@mcp.prompt` decorator. ## What Are Prompts? Prompts provide parameterized message templates for LLMs. When a client requests a prompt: 1. FastMCP finds the corresponding prompt definition. 2. If it has parameters, they are validated against your function signature. 3. Your function executes with the validated inputs. 4. The generated message(s) are returned to the LLM to guide its response. This allows you to define consistent, reusable templates that LLMs can use across different clients and contexts. ## Prompts ### The `@prompt` Decorator The most common way to define a prompt is by decorating a Python function. The decorator uses the function name as the prompt's identifier. ```python from fastmcp import FastMCP from fastmcp.prompts import Message mcp = FastMCP(name="PromptServer") # Basic prompt returning a string (converted to user message automatically) @mcp.prompt def ask_about_topic(topic: str) -> str: """Generates a user message asking for an explanation of a topic.""" return f"Can you please explain the concept of '{topic}'?" # Prompt returning multiple messages @mcp.prompt def generate_code_request(language: str, task_description: str) -> list[Message]: """Generates a conversation for code generation.""" return [ Message(f"Write a {language} function that performs the following task: {task_description}"), Message("I'll help you write that function.", role="assistant"), ] ``` **Key Concepts:** * **Name:** By default, the prompt name is taken from the function name. * **Parameters:** The function parameters define the inputs needed to generate the prompt. * **Inferred Metadata:** By default: * Prompt Name: Taken from the function name (`ask_about_topic`). * Prompt Description: Taken from the function's docstring. Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists. #### Decorator Arguments While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.prompt` decorator: ```python @mcp.prompt( name="analyze_data_request", # Custom prompt name description="Creates a request to analyze data with specific parameters", # Custom description tags={"analysis", "data"}, # Optional categorization tags meta={"version": "1.1", "author": "data-team"} # Custom metadata ) def data_analysis_prompt( data_uri: str = Field(description="The URI of the resource containing the data."), analysis_type: str = Field(default="summary", description="Type of analysis.") ) -> str: """This docstring is ignored when description is provided.""" return f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}." ``` Sets the explicit prompt name exposed via MCP. If not provided, uses the function name A human-readable title for the prompt Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose A set of strings used to categorize the prompt. These can be used by the server and, in some cases, by clients to filter or group available prompts. Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. A boolean to enable or disable the prompt. See [Component Visibility](#component-visibility) for the recommended approach. Optional list of icon representations for this prompt. See [Icons](/servers/icons) for detailed examples Optional meta information about the prompt. This data is passed through to the MCP client as the `meta` field of the client-side prompt object and can be used for custom metadata, versioning, or other application-specific purposes. Optional version identifier for this prompt. See [Versioning](/servers/versioning) for details. #### Using with Methods For decorating instance or class methods, use the standalone `@prompt` decorator and register the bound method. See [Tools: Using with Methods](/servers/tools#using-with-methods) for the pattern. ### Argument Types The MCP specification requires that all prompt arguments be passed as strings, but FastMCP allows you to use typed annotations for better developer experience. When you use complex types like `list[int]` or `dict[str, str]`, FastMCP: 1. **Automatically converts** string arguments from MCP clients to the expected types 2. **Generates helpful descriptions** showing the exact JSON string format needed 3. **Preserves direct usage** - you can still call prompts with properly typed arguments Since the MCP specification only allows string arguments, clients need to know what string format to use for complex types. FastMCP solves this by automatically enhancing the argument descriptions with JSON schema information, making it clear to both humans and LLMs how to format their arguments. ```python Python Code @mcp.prompt def analyze_data( numbers: list[int], metadata: dict[str, str], threshold: float ) -> str: """Analyze numerical data.""" avg = sum(numbers) / len(numbers) return f"Average: {avg}, above threshold: {avg > threshold}" ``` ```json Resulting MCP Prompt { "name": "analyze_data", "description": "Analyze numerical data.", "arguments": [ { "name": "numbers", "description": "Provide as a JSON string matching the following schema: {\"items\":{\"type\":\"integer\"},\"type\":\"array\"}", "required": true }, { "name": "metadata", "description": "Provide as a JSON string matching the following schema: {\"additionalProperties\":{\"type\":\"string\"},\"type\":\"object\"}", "required": true }, { "name": "threshold", "description": "Provide as a JSON string matching the following schema: {\"type\":\"number\"}", "required": true } ] } ``` **MCP clients will call this prompt with string arguments:** ```json { "numbers": "[1, 2, 3, 4, 5]", "metadata": "{\"source\": \"api\", \"version\": \"1.0\"}", "threshold": "2.5" } ``` **But you can still call it directly with proper types:** ```python # This also works for direct calls result = await prompt.render({ "numbers": [1, 2, 3, 4, 5], "metadata": {"source": "api", "version": "1.0"}, "threshold": 2.5 }) ``` Keep your type annotations simple when using this feature. Complex nested types or custom classes may not convert reliably from JSON strings. The automatically generated schema descriptions are the only guidance users receive about the expected format. Good choices: `list[int]`, `dict[str, str]`, `float`, `bool` Avoid: Complex Pydantic models, deeply nested structures, custom classes ### Return Values Prompt functions must return one of these types: - **`str`**: Sent as a single user message. - **`list[Message | str]`**: A sequence of messages (a conversation). Strings are auto-converted to user Messages. - **`PromptResult`**: Full control over messages, description, and metadata. See [PromptResult](#promptresult) below. ```python from fastmcp.prompts import Message @mcp.prompt def roleplay_scenario(character: str, situation: str) -> list[Message]: """Sets up a roleplaying scenario with initial messages.""" return [ Message(f"Let's roleplay. You are {character}. The situation is: {situation}"), Message("Okay, I understand. I am ready. What happens next?", role="assistant") ] ``` #### Message `Message` provides a user-friendly wrapper for prompt messages with automatic serialization. ```python from fastmcp.prompts import Message # String content (user role by default) Message("Hello, world!") # Explicit role Message("I can help with that.", role="assistant") # Auto-serialized to JSON text Message({"key": "value"}) Message(["item1", "item2"]) ``` `Message` accepts two fields: **`content`** - The message content. Strings pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized to text. **`role`** - The message role, either `"user"` (default) or `"assistant"`. The content data. Strings pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized. The message role. #### PromptResult `PromptResult` gives you explicit control over prompt responses: multiple messages, roles, and metadata at both the message and result level. ```python from fastmcp import FastMCP from fastmcp.prompts import PromptResult, Message mcp = FastMCP(name="PromptServer") @mcp.prompt def code_review(code: str) -> PromptResult: """Returns a code review prompt with metadata.""" return PromptResult( messages=[ Message(f"Please review this code:\n\n```\n{code}\n```"), Message("I'll analyze this code for issues.", role="assistant"), ], description="Code review prompt", meta={"review_type": "security", "priority": "high"} ) ``` For simple cases, you can pass a string directly to `PromptResult`: ```python return PromptResult("Please help me with this task") # auto-converts to single Message ``` Messages to return. Strings are wrapped as a single user Message. Optional description of the prompt result. If not provided, defaults to the prompt's docstring. Result-level metadata, included in the MCP response's `_meta` field. Use this for runtime metadata like categorization, priority, or other client-specific data. The `meta` field in `PromptResult` is for runtime metadata specific to this render response. This is separate from the `meta` parameter in `@mcp.prompt(meta={...})`, which provides static metadata about the prompt definition itself (returned when listing prompts). You can still return plain `str` or `list[Message | str]` from your prompt functions—`PromptResult` is opt-in for when you need to include metadata. ### Required vs. Optional Parameters Parameters in your function signature are considered **required** unless they have a default value. ```python @mcp.prompt def data_analysis_prompt( data_uri: str, # Required - no default value analysis_type: str = "summary", # Optional - has default value include_charts: bool = False # Optional - has default value ) -> str: """Creates a request to analyze data with specific parameters.""" prompt = f"Please perform a '{analysis_type}' analysis on the data found at {data_uri}." if include_charts: prompt += " Include relevant charts and visualizations." return prompt ``` In this example, the client *must* provide `data_uri`. If `analysis_type` or `include_charts` are omitted, their default values will be used. ### Component Visibility You can control which prompts are enabled for clients using server-level enabled control. Disabled prompts don't appear in `list_prompts` and can't be called. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.prompt(tags={"public"}) def public_prompt(topic: str) -> str: return f"Discuss: {topic}" @mcp.prompt(tags={"internal"}) def internal_prompt() -> str: return "Internal system prompt" # Disable specific prompts by key mcp.disable(keys={"prompt:internal_prompt"}) # Disable prompts by tag mcp.disable(tags={"internal"}) # Or use allowlist mode - only enable prompts with specific tags mcp.enable(tags={"public"}, only=True) ``` See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. ### Async Prompts FastMCP supports both standard (`def`) and asynchronous (`async def`) functions as prompts. Synchronous functions automatically run in a threadpool to avoid blocking the event loop. ```python # Synchronous prompt (runs in threadpool) @mcp.prompt def simple_question(question: str) -> str: """Generates a simple question to ask the LLM.""" return f"Question: {question}" # Asynchronous prompt @mcp.prompt async def data_based_prompt(data_id: str) -> str: """Generates a prompt based on data that needs to be fetched.""" # In a real scenario, you might fetch data from a database or API async with aiohttp.ClientSession() as session: async with session.get(f"https://api.example.com/data/{data_id}") as response: data = await response.json() return f"Analyze this data: {data['content']}" ``` Use `async def` when your prompt function performs I/O operations like network requests or database queries, since async is more efficient than threadpool dispatch. ### Accessing MCP Context Prompts can access additional MCP information and features through the `Context` object. To access it, add a parameter to your prompt function with a type annotation of `Context`: ```python {6} from fastmcp import FastMCP, Context mcp = FastMCP(name="PromptServer") @mcp.prompt async def generate_report_request(report_type: str, ctx: Context) -> str: """Generates a request for a report.""" return f"Please create a {report_type} report. Request ID: {ctx.request_id}" ``` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). ### Notifications FastMCP automatically sends `notifications/prompts/list_changed` notifications to connected clients when prompts are added, enabled, or disabled. This allows clients to stay up-to-date with the current prompt set without manually polling for changes. ```python @mcp.prompt def example_prompt() -> str: return "Hello!" # These operations trigger notifications: mcp.add_prompt(example_prompt) # Sends prompts/list_changed notification mcp.disable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification mcp.enable(keys={"prompt:example_prompt"}) # Sends prompts/list_changed notification ``` Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their prompt lists or update their interfaces. ## Server Behavior ### Duplicate Prompts You can configure how the FastMCP server handles attempts to register multiple prompts with the same name. Use the `on_duplicate_prompts` setting during `FastMCP` initialization. ```python from fastmcp import FastMCP mcp = FastMCP( name="PromptServer", on_duplicate_prompts="error" # Raise an error if a prompt name is duplicated ) @mcp.prompt def greeting(): return "Hello, how can I help you today?" # This registration attempt will raise a ValueError because # "greeting" is already registered and the behavior is "error". # @mcp.prompt # def greeting(): return "Hi there! What can I do for you?" ``` The duplicate behavior options are: - `"warn"` (default): Logs a warning, and the new prompt replaces the old one. - `"error"`: Raises a `ValueError`, preventing the duplicate registration. - `"replace"`: Silently replaces the existing prompt with the new one. - `"ignore"`: Keeps the original prompt and ignores the new registration attempt. ## Versioning Prompts support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. ================================================ FILE: docs/servers/providers/custom.mdx ================================================ --- title: Custom Providers sidebarTitle: Custom description: Build providers that source components from any data source icon: code tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Custom providers let you source components from anywhere - databases, APIs, configuration systems, or dynamic runtime logic. If you can write Python code to fetch or generate a component, you can wrap it in a provider. ## When to Build Custom The built-in providers handle common cases: decorators (`LocalProvider`), composition (`FastMCPProvider`), and proxying (`ProxyProvider`). Build a custom provider when your components come from somewhere else: - **Database-backed tools**: Admin users define tools in a database, and your server exposes them dynamically - **API-backed resources**: Resources that fetch content from external services on demand - **Configuration-driven components**: Components loaded from YAML/JSON config files at startup - **Multi-tenant systems**: Different users see different tools based on their permissions - **Plugin systems**: Third-party code registers components at runtime ## Providers vs Middleware Both providers and [middleware](/servers/middleware) can influence what components a client sees, but they work at different levels. **Providers** are objects that source components. They make it easy to reason about where tools, resources, and prompts come from - a database, another server, an API. **Middleware** intercepts individual requests. It's well-suited for request-specific decisions like logging, rate limiting, or authentication. You *could* use middleware to dynamically add tools based on request context. But it's often cleaner to have a provider source all possible tools, then use middleware or [visibility controls](/servers/visibility) to filter what each request can see. This separation makes it easier to reason about how components are sourced and how they interact with other server machinery. ## The Provider Interface A provider implements protected `_list_*` methods that return available components. The public `list_*` methods handle transforms automatically - you override the underscore-prefixed versions: ```python from collections.abc import Sequence from fastmcp.server.providers import Provider from fastmcp.tools import Tool from fastmcp.resources import Resource from fastmcp.prompts import Prompt class MyProvider(Provider): async def _list_tools(self) -> Sequence[Tool]: """Return all tools this provider offers.""" return [] async def _list_resources(self) -> Sequence[Resource]: """Return all resources this provider offers.""" return [] async def _list_prompts(self) -> Sequence[Prompt]: """Return all prompts this provider offers.""" return [] ``` You only need to implement the methods for component types you provide. The base class returns empty sequences by default. The `_get_*` methods (`_get_tool`, `_get_resource`, `_get_prompt`) have default implementations that search through the list results. Override them only if you can fetch individual components more efficiently than iterating the full list. ## What Providers Return Providers return component objects that are ready to use. When a client calls a tool, FastMCP invokes the tool's function - your provider isn't involved in execution. This means the `Tool`, `Resource`, or `Prompt` you return must actually work. The easiest way to create components is from functions: ```python from fastmcp.tools import Tool def add(a: int, b: int) -> int: """Add two numbers.""" return a + b tool = Tool.from_function(add) ``` The function's type hints become the input schema, and the docstring becomes the description. You can override these: ```python tool = Tool.from_function( add, name="calculator_add", description="Add two integers together" ) ``` Similar `from_function` methods exist for `Resource` and `Prompt`. ## Registering Providers Add providers when creating the server: ```python mcp = FastMCP( "MyServer", providers=[ DatabaseProvider(db_url), ConfigProvider(config_path), ] ) ``` Or add them after creation: ```python mcp = FastMCP("MyServer") mcp.add_provider(DatabaseProvider(db_url)) ``` ## A Simple Provider Here's a minimal provider that serves tools from a dictionary: ```python from collections.abc import Callable, Sequence from fastmcp import FastMCP from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DictProvider(Provider): def __init__(self, tools: dict[str, Callable]): super().__init__() self._tools = [ Tool.from_function(fn, name=name) for name, fn in tools.items() ] async def _list_tools(self) -> Sequence[Tool]: return self._tools ``` Use it like this: ```python def add(a: int, b: int) -> int: """Add two numbers.""" return a + b def multiply(a: int, b: int) -> int: """Multiply two numbers.""" return a * b mcp = FastMCP("Calculator", providers=[ DictProvider({"add": add, "multiply": multiply}) ]) ``` ## Lifecycle Management Providers often need to set up connections when the server starts and clean them up when it stops. Override the `lifespan` method: ```python from contextlib import asynccontextmanager from collections.abc import AsyncIterator, Sequence class DatabaseProvider(Provider): def __init__(self, db_url: str): super().__init__() self.db_url = db_url self.db = None @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: self.db = await connect_database(self.db_url) try: yield finally: await self.db.close() async def _list_tools(self) -> Sequence[Tool]: rows = await self.db.fetch("SELECT * FROM tools") return [self._make_tool(row) for row in rows] ``` FastMCP calls your provider's `lifespan` during server startup and shutdown. The connection is available to your methods while the server runs. ## Full Example: API-Backed Resources Here's a complete provider that fetches resources from an external REST API: ```python from contextlib import asynccontextmanager from collections.abc import AsyncIterator, Sequence from fastmcp.server.providers import Provider from fastmcp.resources import Resource import httpx class ApiResourceProvider(Provider): """Provides resources backed by an external API.""" def __init__(self, base_url: str, api_key: str): super().__init__() self.base_url = base_url self.api_key = api_key self.client = None @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: self.client = httpx.AsyncClient( base_url=self.base_url, headers={"Authorization": f"Bearer {self.api_key}"} ) try: yield finally: await self.client.aclose() async def _list_resources(self) -> Sequence[Resource]: response = await self.client.get("/resources") response.raise_for_status() return [ self._make_resource(item) for item in response.json()["items"] ] def _make_resource(self, data: dict) -> Resource: resource_id = data["id"] async def read_content() -> str: response = await self.client.get( f"/resources/{resource_id}/content" ) return response.text return Resource.from_function( read_content, uri=f"api://resources/{resource_id}", name=data["name"], description=data.get("description", ""), mime_type=data.get("mime_type", "text/plain") ) ``` Register it like any other provider: ```python from fastmcp import FastMCP mcp = FastMCP("API Resources", providers=[ ApiResourceProvider("https://api.example.com", "my-api-key") ]) ``` ================================================ FILE: docs/servers/providers/filesystem.mdx ================================================ --- title: Filesystem Provider sidebarTitle: Filesystem description: Automatic component discovery from Python files icon: folder-tree tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' `FileSystemProvider` scans a directory for Python files and automatically registers functions decorated with `@tool`, `@resource`, or `@prompt`. This enables a file-based organization pattern similar to Next.js routing, where your project structure becomes your component registry. ## Why Filesystem Discovery Traditional FastMCP servers require coordination between files. Either your tool files import the server to call `@server.tool()`, or your server file imports all the tool modules. Both approaches create coupling that some developers prefer to avoid. `FileSystemProvider` eliminates this coordination. Each file is self-contained—it uses standalone decorators (`@tool`, `@resource`, `@prompt`) that don't require access to a server instance. The provider discovers these files at startup, so you can add new tools without modifying your server file. This is a convention some teams prefer, not necessarily better for all projects. The tradeoffs: - **No coordination**: Files don't import the server; server doesn't import files - **Predictable naming**: Function names become component names (unless overridden) - **Development mode**: Optionally re-scan files on every request for rapid iteration ## Quick Start Create a provider pointing to your components directory, then pass it to your server. Use `Path(__file__).parent` to make the path relative to your server file. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) ``` In your `mcp/` directory, create Python files with decorated functions. ```python # mcp/tools/greet.py from fastmcp.tools import tool @tool def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" ``` When the server starts, `FileSystemProvider` scans the directory, imports all Python files, and registers any decorated functions it finds. ## Decorators FastMCP provides standalone decorators that mark functions for discovery: `@tool` from `fastmcp.tools`, `@resource` from `fastmcp.resources`, and `@prompt` from `fastmcp.prompts`. These support the full syntax of server-bound decorators—all the same parameters work identically. ### @tool Mark a function as a tool. The function name becomes the tool name by default. ```python from fastmcp.tools import tool @tool def calculate_sum(a: float, b: float) -> float: """Add two numbers together.""" return a + b ``` Customize the tool with optional parameters. ```python from fastmcp.tools import tool @tool( name="add-numbers", description="Add two numbers together.", tags={"math", "arithmetic"}, ) def add(a: float, b: float) -> float: return a + b ``` The decorator supports all standard tool options: `name`, `title`, `description`, `icons`, `tags`, `output_schema`, `annotations`, and `meta`. ### @resource Mark a function as a resource. Unlike `@tool`, the `@resource` decorator requires a URI argument. ```python from fastmcp.resources import resource @resource("config://app") def get_app_config() -> str: """Get application configuration.""" return '{"version": "1.0"}' ``` URIs with template parameters create resource templates. The provider automatically detects whether to register a static resource or a template based on whether the URI contains `{parameters}` or the function has arguments. ```python from fastmcp.resources import resource @resource("users://{user_id}/profile") def get_user_profile(user_id: str) -> str: """Get a user's profile by ID.""" return f'{{"id": "{user_id}", "name": "User"}}' ``` The decorator supports: `uri` (required), `name`, `title`, `description`, `icons`, `mime_type`, `tags`, `annotations`, and `meta`. ### @prompt Mark a function as a prompt template. ```python from fastmcp.prompts import prompt @prompt def code_review(code: str, language: str = "python") -> str: """Generate a code review prompt.""" return f"Please review this {language} code:\n\n```{language}\n{code}\n```" ``` ```python from fastmcp.prompts import prompt @prompt(name="explain-concept", tags={"education"}) def explain(topic: str) -> str: """Generate an explanation prompt.""" return f"Explain {topic} using clear examples and analogies." ``` The decorator supports: `name`, `title`, `description`, `icons`, `tags`, and `meta`. ## Directory Structure The directory structure is purely organizational. The provider recursively scans all `.py` files regardless of which subdirectory they're in. Subdirectories like `tools/`, `resources/`, and `prompts/` are optional conventions that help you organize code. ``` mcp/ ├── tools/ │ ├── greeting.py # @tool functions │ └── calculator.py # @tool functions ├── resources/ │ └── config.py # @resource functions └── prompts/ └── assistant.py # @prompt functions ``` You can also put all components in a single file or organize by feature rather than type. ``` mcp/ ├── user_management.py # @tool, @resource, @prompt for users ├── billing.py # @tool, @resource for billing └── analytics.py # @tool for analytics ``` ## Discovery Rules The provider follows these rules when scanning: | Rule | Behavior | |------|----------| | File extensions | Only `.py` files are scanned | | `__init__.py` | Skipped (used for package structure, not components) | | `__pycache__` | Skipped | | Private functions | Functions starting with `_` are ignored, even if decorated | | No decorators | Files without `@tool`, `@resource`, or `@prompt` are silently skipped | | Multiple components | A single file can contain any number of decorated functions | ### Package Imports If your directory contains an `__init__.py` file, the provider imports files as proper Python package members. This means relative imports work correctly within your components directory. ```python # mcp/__init__.py exists # mcp/tools/greeting.py from ..helpers import format_name # Relative imports work @tool def greet(name: str) -> str: return f"Hello, {format_name(name)}!" ``` Without `__init__.py`, files are imported directly using `importlib.util.spec_from_file_location`. ## Reload Mode During development, you may want changes to component files to take effect without restarting the server. Enable reload mode to re-scan the directory on every request. ```python from pathlib import Path from fastmcp.server.providers import FileSystemProvider provider = FileSystemProvider(Path(__file__).parent / "mcp", reload=True) ``` With `reload=True`, the provider: 1. Re-discovers all Python files on each request 2. Re-imports modules that have changed 3. Updates the component registry with any new, modified, or removed components Reload mode adds overhead to every request. Use it only during development, not in production. ## Error Handling When a file fails to import (syntax error, missing dependency, etc.), the provider logs a warning and continues scanning other files. Failed imports don't prevent the server from starting. ``` WARNING - Failed to import /path/to/broken.py: No module named 'missing_dep' ``` The provider tracks which files have failed and only re-logs warnings when the file's modification time changes. This prevents log spam when a broken file is repeatedly scanned in reload mode. ## Example Project A complete example is available in the repository at `examples/filesystem-provider/`. The structure demonstrates the recommended organization. ``` examples/filesystem-provider/ ├── server.py # Server entry point └── mcp/ ├── tools/ │ ├── greeting.py # greet, farewell tools │ └── calculator.py # add, multiply tools ├── resources/ │ └── config.py # Static and templated resources └── prompts/ └── assistant.py # code_review, explain prompts ``` The server entry point is minimal. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider provider = FileSystemProvider( root=Path(__file__).parent / "mcp", reload=True, ) mcp = FastMCP("FilesystemDemo", providers=[provider]) ``` Run with `fastmcp run examples/filesystem-provider/server.py` or inspect with `fastmcp inspect examples/filesystem-provider/server.py`. ================================================ FILE: docs/servers/providers/local.mdx ================================================ --- title: Local Provider sidebarTitle: Local description: The default provider for decorator-registered components icon: house tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' `LocalProvider` stores components that you define directly on your server. When you use `@mcp.tool`, `@mcp.resource`, or `@mcp.prompt`, you're adding components to your server's `LocalProvider`. ## How It Works Every FastMCP server has a `LocalProvider` as its first provider. Components registered via decorators or direct methods are stored here: ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") # These are stored in the server's `LocalProvider` @mcp.tool def greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!" @mcp.resource("data://config") def get_config() -> str: """Return configuration data.""" return '{"version": "1.0"}' @mcp.prompt def analyze(topic: str) -> str: """Create an analysis prompt.""" return f"Please analyze: {topic}" ``` The `LocalProvider` is always queried first when clients request components, ensuring that your directly-defined components take precedence over those from mounted or proxied servers. ## Component Registration ### Using Decorators The most common way to register components: ```python @mcp.tool def my_tool(x: int) -> str: return str(x) @mcp.resource("data://info") def my_resource() -> str: return "info" @mcp.prompt def my_prompt(topic: str) -> str: return f"Discuss: {topic}" ``` ### Using Direct Methods You can also add pre-built component objects: ```python from fastmcp.tools import Tool # Create a tool object my_tool = Tool.from_function(some_function, name="custom_tool") # Add it to the server mcp.add_tool(my_tool) mcp.add_resource(my_resource) mcp.add_prompt(my_prompt) ``` ### Removing Components Remove components by name or URI: ```python mcp.local_provider.remove_tool("my_tool") mcp.local_provider.remove_resource("data://info") mcp.local_provider.remove_prompt("my_prompt") ``` ## Duplicate Handling When you try to add a component that already exists, the behavior depends on the `on_duplicate` setting: | Mode | Behavior | |------|----------| | `"error"` (default) | Raise `ValueError` | | `"warn"` | Log warning and replace | | `"replace"` | Silently replace | | `"ignore"` | Keep existing component | Configure this when creating the server: ```python mcp = FastMCP("MyServer", on_duplicate="warn") ``` ## Component Visibility Components can be dynamically enabled or disabled at runtime. Disabled components don't appear in listings and can't be called. ```python @mcp.tool(tags={"admin"}) def delete_all() -> str: """Delete everything.""" return "Deleted" @mcp.tool def get_status() -> str: """Get system status.""" return "OK" # Disable admin tools mcp.disable(tags={"admin"}) # Or only enable specific tools mcp.enable(keys={"tool:get_status"}, only=True) ``` See [Visibility](/servers/visibility) for the full documentation on keys, tags, allowlist mode, and provider-level control. ## Standalone LocalProvider You can create a LocalProvider independently and attach it to multiple servers: ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider # Create a reusable provider shared_tools = LocalProvider() @shared_tools.tool def greet(name: str) -> str: return f"Hello, {name}!" @shared_tools.resource("data://version") def get_version() -> str: return "1.0.0" # Attach to multiple servers server1 = FastMCP("Server1", providers=[shared_tools]) server2 = FastMCP("Server2", providers=[shared_tools]) ``` This is useful for: - Sharing components across servers - Testing components in isolation - Building reusable component libraries Standalone providers also support visibility control with `enable()` and `disable()`. See [Visibility](/servers/visibility) for details. ================================================ FILE: docs/servers/providers/overview.mdx ================================================ --- title: Providers sidebarTitle: Overview description: How FastMCP sources tools, resources, and prompts icon: layer-group tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Every FastMCP server has one or more component providers. A provider is a source of tools, resources, and prompts - it's what makes components available to clients. ## What Is a Provider? When a client connects to your server and asks "what tools do you have?", FastMCP asks each provider that question and combines the results. When a client calls a specific tool, FastMCP finds which provider has it and delegates the call. You're already using providers. When you write `@mcp.tool`, you're adding a tool to your server's `LocalProvider` - the default provider that stores components you define directly in code. You just don't have to think about it for simple servers. Providers become important when your components come from multiple sources: another FastMCP server to include, a remote MCP server to proxy, or a database where tools are defined dynamically. Each source gets its own provider, and FastMCP queries them all seamlessly. ## Why Providers? The provider abstraction solves a common problem: as servers grow, you need to organize components across multiple sources without tangling everything together. **Composition**: Break a large server into focused modules. A "weather" server and a "calendar" server can each be developed independently, then mounted into a main server. Each mounted server becomes a `FastMCPProvider`. **Proxying**: Expose a remote MCP server through your local server. Maybe you're bridging transports (remote HTTP to local stdio) or aggregating multiple backends. Remote connections become `ProxyProvider` instances. **Dynamic sources**: Load tools from a database, generate them from an OpenAPI spec, or create them based on user permissions. Custom providers let components come from anywhere. ## Built-in Providers FastMCP includes providers for common patterns: | Provider | What it does | How you use it | |----------|--------------|----------------| | `LocalProvider` | Stores components you define in code | `@mcp.tool`, `mcp.add_tool()` | | `FastMCPProvider` | Wraps another FastMCP server | `mcp.mount(server)` | | `ProxyProvider` | Connects to remote MCP servers | `create_proxy(client)` | Most users only interact with `LocalProvider` (through decorators) and occasionally mount or proxy other servers. The provider abstraction stays invisible until you need it. ## Transforms [Transforms](/servers/transforms/transforms) modify components as they flow from providers to clients. Each transform sits in a chain, intercepting queries and modifying results before passing them along. | Transform | Purpose | |-----------|---------| | `Namespace` | Prefixes names to avoid conflicts | | `ToolTransform` | Modifies tool schemas (rename, description, arguments) | The most common use is namespacing mounted servers to prevent name collisions. When you call `mount(server, namespace="api")`, FastMCP creates a `Namespace` transform automatically. Transforms can be added to individual providers (affecting just that source) or to the server itself (affecting all components). See [Transforms](/servers/transforms/transforms) for the full picture. ## Provider Order When a client requests a tool, FastMCP queries providers in registration order. The first provider that has the tool handles the request. `LocalProvider` is always first, so your decorator-defined tools take precedence. Additional providers are queried in the order you added them. This means if two providers have a tool with the same name, the first one wins. ## When to Care About Providers **You can ignore providers entirely** if you're building a simple server with decorators. Just use `@mcp.tool`, `@mcp.resource`, and `@mcp.prompt` - FastMCP handles the rest. **Learn about providers when** you want to: - [Mount another server](/servers/composition) into yours - [Proxy a remote server](/servers/providers/proxy) through yours - [Control visibility state](/servers/visibility) of components - [Build dynamic sources](/servers/providers/custom) like database-backed tools ## Next Steps - [Local](/servers/providers/local) - How decorators work - [Mounting](/servers/composition) - Compose servers together - [Proxying](/servers/providers/proxy) - Connect to remote servers - [Transforms](/servers/transforms/transforms) - Namespace, rename, and modify components - [Visibility](/servers/visibility) - Control which components clients can access - [Custom](/servers/providers/custom) - Build your own providers ================================================ FILE: docs/servers/providers/proxy.mdx ================================================ --- title: MCP Proxy Provider sidebarTitle: MCP Proxy description: Source components from other MCP servers icon: arrows-retweet --- import { VersionBadge } from '/snippets/version-badge.mdx' The Proxy Provider sources components from another MCP server through a client connection. This lets you expose any MCP server's tools, resources, and prompts through your own server, whether the source is local or accessed over the network. ## Why Use Proxy Provider The Proxy Provider enables: - **Bridge transports**: Make an HTTP server available via stdio, or vice versa - **Aggregate servers**: Combine multiple source servers into one unified server - **Add security**: Act as a controlled gateway with authentication and authorization - **Simplify access**: Provide a stable endpoint even if backend servers change ```mermaid sequenceDiagram participant Client as Your Client participant Proxy as FastMCP Proxy participant Backend as Source Server Client->>Proxy: MCP Request (stdio) Proxy->>Backend: MCP Request (HTTP/stdio/SSE) Backend-->>Proxy: MCP Response Proxy-->>Client: MCP Response ``` ## Quick Start Create a proxy using `create_proxy()`: ```python from fastmcp.server import create_proxy # create_proxy() accepts URLs, file paths, and transports directly proxy = create_proxy("http://example.com/mcp", name="MyProxy") if __name__ == "__main__": proxy.run() ``` This gives you: - Safe concurrent request handling - Automatic forwarding of MCP features (sampling, elicitation, etc.) - Session isolation to prevent context mixing To mount a proxy inside another FastMCP server, see [Mounting External Servers](/servers/composition#mounting-external-servers). ## Transport Bridging A common use case is bridging transports between servers: ```python from fastmcp.server import create_proxy # Bridge HTTP server to local stdio http_proxy = create_proxy("http://example.com/mcp/sse", name="HTTP-to-stdio") # Run locally via stdio for Claude Desktop if __name__ == "__main__": http_proxy.run() # Defaults to stdio ``` Or expose a local server via HTTP: ```python from fastmcp.server import create_proxy # Bridge local server to HTTP local_proxy = create_proxy("local_server.py", name="stdio-to-HTTP") if __name__ == "__main__": local_proxy.run(transport="http", host="0.0.0.0", port=8080) ``` ## Session Isolation `create_proxy()` provides session isolation - each request gets its own isolated backend session: ```python from fastmcp.server import create_proxy # Each request creates a fresh backend session (recommended) proxy = create_proxy("backend_server.py") # Multiple clients can use this proxy simultaneously: # - Client A calls a tool → gets isolated session # - Client B calls a tool → gets different session # - No context mixing ``` ### Shared Sessions If you pass an already-connected client, the proxy reuses that session: ```python from fastmcp import Client from fastmcp.server import create_proxy async with Client("backend_server.py") as connected_client: # This proxy reuses the connected session proxy = create_proxy(connected_client) # ⚠️ Warning: All requests share the same session ``` Shared sessions may cause context mixing in concurrent scenarios. Use only in single-threaded situations or with explicit synchronization. ## MCP Feature Forwarding Proxies automatically forward MCP protocol features: | Feature | Description | |---------|-------------| | Roots | Filesystem root access requests | | Sampling | LLM completion requests | | Elicitation | User input requests | | Logging | Log messages from backend | | Progress | Progress notifications | ```python from fastmcp.server import create_proxy # All features forwarded automatically proxy = create_proxy("advanced_backend.py") # When the backend: # - Requests LLM sampling → forwarded to your client # - Logs messages → appear in your client # - Reports progress → shown in your client ``` ### Disabling Features Selectively disable forwarding: ```python from fastmcp.server.providers.proxy import ProxyClient backend = ProxyClient( "backend_server.py", sampling_handler=None, # Disable LLM sampling log_handler=None # Disable log forwarding ) ``` ## Configuration-Based Proxies Create proxies from configuration dictionaries: ```python from fastmcp.server import create_proxy config = { "mcpServers": { "default": { "url": "https://example.com/mcp", "transport": "http" } } } proxy = create_proxy(config, name="Config-Based Proxy") ``` ### Multi-Server Proxies Combine multiple servers with automatic namespacing: ```python from fastmcp.server import create_proxy config = { "mcpServers": { "weather": { "url": "https://weather-api.example.com/mcp", "transport": "http" }, "calendar": { "url": "https://calendar-api.example.com/mcp", "transport": "http" } } } # Creates unified proxy with prefixed components: # - weather_get_forecast # - calendar_add_event composite = create_proxy(config, name="Composite") ``` ## Component Prefixing Proxied components follow standard prefixing rules: | Component Type | Pattern | |----------------|---------| | Tools | `{prefix}_{tool_name}` | | Prompts | `{prefix}_{prompt_name}` | | Resources | `protocol://{prefix}/path` | | Templates | `protocol://{prefix}/...` | ## Mirrored Components Components from a proxy server are "mirrored" - they reflect the remote server's state and cannot be modified directly. To modify a proxied component (like disabling it), create a local copy: ```python from fastmcp import FastMCP from fastmcp.server import create_proxy proxy = create_proxy("backend_server.py") # Get mirrored tool mirrored_tool = await proxy.get_tool("useful_tool") # Create modifiable local copy local_tool = mirrored_tool.copy() # Add to your own server my_server = FastMCP("MyServer") my_server.add_tool(local_tool) # Now you can control enabled state my_server.disable(keys={local_tool.key}) ``` ## Performance Considerations Proxying introduces network latency: | Operation | Local | Proxied (HTTP) | |-----------|-------|----------------| | `list_tools()` | 1-2ms | 300-400ms | | `call_tool()` | 1-2ms | 200-500ms | When mounting proxy servers, this latency affects all operations on the parent server. ### Component List Caching `ProxyProvider` caches the backend's component lists (tools, resources, templates, prompts) so that individual lookups — like resolving a tool by name during `call_tool` — don't require a separate backend connection. The cache stores raw component metadata and is shared across all proxy sessions; per-session visibility, auth, and transforms are still applied after cache lookup by the server layer. The cache refreshes whenever an explicit `list_*` call is made, and entries expire after a configurable TTL (default 300 seconds). For backends whose component lists change dynamically, disable caching by setting `cache_ttl=0`. ```python from fastmcp.server.providers.proxy import ProxyProvider, ProxyClient # Default 300s TTL provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp")) # Custom TTL provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=60) # Disable caching provider = ProxyProvider(lambda: ProxyClient("http://backend/mcp"), cache_ttl=0) ``` ### Session Reuse for Stateless Backends By default, each tool call opens a fresh MCP session to the backend. This is the safe default because it prevents state from leaking between requests. However, for stateless HTTP backends where there's no session state to protect, this overhead is unnecessary. You can reuse a single backend session by providing a client factory that returns the same client instance: ```python from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient base_client = ProxyClient("http://backend:8000/mcp") shared_client = base_client.new() proxy = FastMCPProxy( client_factory=lambda: shared_client, name="ReusedSessionProxy", ) ``` This eliminates the MCP initialization handshake on every call, which can dramatically reduce latency under load. The `Client` uses reference counting for its session lifecycle, so concurrent callers sharing the same instance is safe. Only reuse sessions when you know the backend is stateless (e.g. stateless HTTP). For stateful backends (stdio processes, servers that track session state), use the default fresh-session behavior to avoid context mixing. ## Advanced Usage ### FastMCPProxy Class For explicit session control, use `FastMCPProxy` directly: ```python from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient # Custom session factory def create_client(): return ProxyClient("backend_server.py") proxy = FastMCPProxy(client_factory=create_client) ``` This gives you full control over session creation and reuse strategies. ### Adding Proxied Components to Existing Server Mount a proxy to add components from another server: ```python from fastmcp import FastMCP from fastmcp.server import create_proxy server = FastMCP("My Server") # Add local tools @server.tool def local_tool() -> str: return "Local result" # Mount proxied tools from another server external = create_proxy("http://external-server/mcp") server.mount(external) # Now server has both local and proxied tools ``` ================================================ FILE: docs/servers/providers/skills.mdx ================================================ --- title: Skills Provider sidebarTitle: Skills description: Expose agent skills as MCP resources icon: wand-magic-sparkles tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Agent skills are directories containing instructions and supporting files that teach an AI assistant how to perform specific tasks. Tools like Claude Code, Cursor, and VS Code Copilot each have their own skills directories where users can add custom capabilities. The Skills Provider exposes these skill directories as MCP resources, making skills discoverable and shareable across different AI tools and clients. ## Why Skills as Resources Skills live in platform-specific directories (`~/.claude/skills/`, `~/.cursor/skills/`, etc.) and typically contain a main instruction file plus supporting reference materials. When you want to share skills between tools or access them from a custom client, you need a way to discover and retrieve these files programmatically. The Skills Provider solves this by exposing each skill as a set of MCP resources. A client can list available skills, read the main instruction file, check the manifest to see what supporting files exist, and fetch any file it needs. This transforms local skill directories into a standardized API that works with any MCP client. ## Quick Start Create a provider pointing to your skills directory, then add it to your server. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider mcp = FastMCP("Skills Server") mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) ``` Each subdirectory containing a `SKILL.md` file becomes a discoverable skill. Clients can then list resources to see available skills and read them as needed. ```python from fastmcp import Client async with Client(mcp) as client: # List all skill resources resources = await client.list_resources() for r in resources: print(r.uri) # skill://my-skill/SKILL.md, skill://my-skill/_manifest, ... # Read a skill's main instruction file result = await client.read_resource("skill://my-skill/SKILL.md") print(result[0].text) ``` ## Skill Structure A skill is a directory containing a main instruction file (default: `SKILL.md`) and optionally supporting files. The directory name becomes the skill's identifier. ``` ~/.claude/skills/ ├── pdf-processing/ │ ├── SKILL.md # Main instructions │ ├── reference.md # Supporting documentation │ └── examples/ │ └── sample.pdf └── code-review/ └── SKILL.md ``` The main file can include YAML frontmatter to provide metadata. If no frontmatter exists, the provider extracts a description from the first meaningful line of content. ```markdown --- description: Process and extract information from PDF documents --- # PDF Processing Instructions for handling PDFs... ``` ## Resource URIs Each skill exposes three types of resources, all using the `skill://` URI scheme. The main instruction file contains the primary skill content. This is the resource clients read to understand what a skill does and how to use it. ``` skill://pdf-processing/SKILL.md ``` The manifest is a synthetic JSON resource listing all files in the skill directory with their sizes and SHA256 hashes. Clients use this to discover supporting files and verify content integrity. ``` skill://pdf-processing/_manifest ``` Reading the manifest returns structured file information. ```json { "skill": "pdf-processing", "files": [ {"path": "SKILL.md", "size": 1234, "hash": "sha256:abc123..."}, {"path": "reference.md", "size": 567, "hash": "sha256:def456..."}, {"path": "examples/sample.pdf", "size": 89012, "hash": "sha256:ghi789..."} ] } ``` Supporting files are any additional files in the skill directory. These might be reference documentation, code examples, or binary assets. ``` skill://pdf-processing/reference.md skill://pdf-processing/examples/sample.pdf ``` ## Provider Architecture The Skills Provider uses a two-layer architecture to handle both single skills and skill directories. ### SkillProvider `SkillProvider` handles a single skill directory. It loads the main file, parses any frontmatter, scans for supporting files, and creates the appropriate resources. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillProvider mcp = FastMCP("Single Skill") mcp.add_provider(SkillProvider(Path.home() / ".claude" / "skills" / "pdf-processing")) ``` Use `SkillProvider` when you want to expose exactly one skill, or when you need fine-grained control over individual skill configuration. ### SkillsDirectoryProvider `SkillsDirectoryProvider` scans one or more root directories and creates a `SkillProvider` for each valid skill folder it finds. A folder is considered a valid skill if it contains the main file (default: `SKILL.md`). ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider mcp = FastMCP("Skills") mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills")) ``` When scanning multiple root directories, provide them as a list. The first directory takes precedence if the same skill name appears in multiple roots. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider mcp = FastMCP("Skills") mcp.add_provider(SkillsDirectoryProvider(roots=[ Path.cwd() / ".claude" / "skills", # Project-level skills first Path.home() / ".claude" / "skills", # User-level fallback ])) ``` ## Vendor Providers FastMCP includes pre-configured providers for popular AI coding tools. Each vendor provider extends `SkillsDirectoryProvider` with the appropriate default directory for that platform. | Provider | Default Directory | |----------|-------------------| | `ClaudeSkillsProvider` | `~/.claude/skills/` | | `CursorSkillsProvider` | `~/.cursor/skills/` | | `VSCodeSkillsProvider` | `~/.copilot/skills/` | | `CodexSkillsProvider` | `/etc/codex/skills/` and `~/.codex/skills/` | | `GeminiSkillsProvider` | `~/.gemini/skills/` | | `GooseSkillsProvider` | `~/.config/agents/skills/` | | `CopilotSkillsProvider` | `~/.copilot/skills/` | | `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` | Vendor providers accept the same configuration options as `SkillsDirectoryProvider` (except for `roots`, which is locked to the platform default). ```python from fastmcp import FastMCP from fastmcp.server.providers.skills import ClaudeSkillsProvider mcp = FastMCP("Claude Skills") mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ ``` `CodexSkillsProvider` scans both system-level (`/etc/codex/skills/`) and user-level (`~/.codex/skills/`) directories, with system skills taking precedence. ## Supporting Files Disclosure The `supporting_files` parameter controls how supporting files (everything except the main file and manifest) appear to clients. ### Template Mode (Default) With `supporting_files="template"`, supporting files are accessed through a `ResourceTemplate` rather than being listed as individual resources. Clients see only the main file and manifest in `list_resources()`, then discover supporting files by reading the manifest. ```python from pathlib import Path from fastmcp.server.providers.skills import SkillsDirectoryProvider # Default behavior - supporting files hidden from list_resources() provider = SkillsDirectoryProvider( roots=Path.home() / ".claude" / "skills", supporting_files="template", # This is the default ) ``` This keeps the resource list compact when skills contain many files. Clients that need supporting files read the manifest first, then request specific files by URI. ### Resources Mode With `supporting_files="resources"`, every file in every skill appears as an individual resource in `list_resources()`. Clients get full enumeration upfront without needing to read manifests. ```python from pathlib import Path from fastmcp.server.providers.skills import SkillsDirectoryProvider # All files visible as individual resources provider = SkillsDirectoryProvider( roots=Path.home() / ".claude" / "skills", supporting_files="resources", ) ``` Use this mode when clients need to discover all available files without additional round trips, or when integrating with tools that expect flat resource lists. ## Reload Mode Enable reload mode to re-scan the skills directory on every request. Changes to skills take effect immediately without restarting the server. ```python from pathlib import Path from fastmcp.server.providers.skills import SkillsDirectoryProvider provider = SkillsDirectoryProvider( roots=Path.home() / ".claude" / "skills", reload=True, ) ``` With `reload=True`, the provider re-discovers skills on each `list_resources()` or `read_resource()` call. New skills appear, removed skills disappear, and modified content reflects current file state. Reload mode adds overhead to every request. Use it during development when you're actively editing skills, but disable it in production. ## Client Utilities FastMCP provides utilities for downloading skills from any MCP server that exposes them. These are standalone functions in `fastmcp.utilities.skills`. ### Discovering Skills Use `list_skills()` to see what skills are available on a server. ```python from fastmcp import Client from fastmcp.utilities.skills import list_skills async with Client("http://skills-server/mcp") as client: skills = await list_skills(client) for skill in skills: print(f"{skill.name}: {skill.description}") ``` ### Downloading Skills Use `download_skill()` to download a single skill, or `sync_skills()` to download all available skills. ```python from pathlib import Path from fastmcp import Client from fastmcp.utilities.skills import download_skill, sync_skills async with Client("http://skills-server/mcp") as client: # Download one skill path = await download_skill(client, "pdf-processing", Path.home() / ".claude" / "skills") # Or download all skills paths = await sync_skills(client, Path.home() / ".claude" / "skills") ``` Both functions accept an `overwrite` parameter. When `False` (default), existing skills are skipped. When `True`, existing files are replaced. ### Inspecting Manifests Use `get_skill_manifest()` to see what files a skill contains before downloading. ```python from fastmcp import Client from fastmcp.utilities.skills import get_skill_manifest async with Client("http://skills-server/mcp") as client: manifest = await get_skill_manifest(client, "pdf-processing") for file in manifest.files: print(f"{file.path} ({file.size} bytes, {file.hash})") ``` ================================================ FILE: docs/servers/resources.mdx ================================================ --- title: Resources & Templates sidebarTitle: Resources description: Expose data sources and dynamic content generators to your MCP client. icon: folder-open --- import { VersionBadge } from "/snippets/version-badge.mdx" Resources represent data or files that an MCP client can read, and resource templates extend this concept by allowing clients to request dynamically generated resources based on parameters passed in the URI. FastMCP simplifies defining both static and dynamic resources, primarily using the `@mcp.resource` decorator. ## What Are Resources? Resources provide read-only access to data for the LLM or client application. When a client requests a resource URI: 1. FastMCP finds the corresponding resource definition. 2. If it's dynamic (defined by a function), the function is executed. 3. The content (text, JSON, binary data) is returned to the client. This allows LLMs to access files, database content, configuration, or dynamically generated information relevant to the conversation. ## Resources ### The `@resource` Decorator The most common way to define a resource is by decorating a Python function. The decorator requires the resource's unique URI. ```python import json from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Basic dynamic resource returning a string @mcp.resource("resource://greeting") def get_greeting() -> str: """Provides a simple greeting message.""" return "Hello from FastMCP Resources!" # Resource returning JSON data @mcp.resource("data://config") def get_config() -> str: """Provides application configuration as JSON.""" return json.dumps({ "theme": "dark", "version": "1.2.0", "features": ["tools", "resources"], }) ``` **Key Concepts:** * **URI:** The first argument to `@resource` is the unique URI (e.g., `"resource://greeting"`) clients use to request this data. * **Lazy Loading:** The decorated function (`get_greeting`, `get_config`) is only executed when a client specifically requests that resource URI via `resources/read`. * **Inferred Metadata:** By default: * Resource Name: Taken from the function name (`get_greeting`). * Resource Description: Taken from the function's docstring. #### Decorator Arguments You can customize the resource's properties using arguments in the `@mcp.resource` decorator: ```python from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Example specifying metadata @mcp.resource( uri="data://app-status", # Explicit URI (required) name="ApplicationStatus", # Custom name description="Provides the current status of the application.", # Custom description mime_type="application/json", # Explicit MIME type tags={"monitoring", "status"}, # Categorization tags meta={"version": "2.1", "team": "infrastructure"} # Custom metadata ) def get_application_status() -> str: """Internal function description (ignored if description is provided above).""" return json.dumps({"status": "ok", "uptime": 12345, "version": mcp.settings.version}) ``` The unique identifier for the resource A human-readable name. If not provided, defaults to function name Explanation of the resource. If not provided, defaults to docstring Specifies the content type. FastMCP often infers a default like `text/plain` or `application/json`, but explicit is better for non-text types A set of strings used to categorize the resource. These can be used by the server and, in some cases, by clients to filter or group available resources. Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. A boolean to enable or disable the resource. See [Component Visibility](#component-visibility) for the recommended approach. Optional list of icon representations for this resource or template. See [Icons](/servers/icons) for detailed examples An optional `Annotations` object or dictionary to add additional metadata about the resource. If true, the resource is read-only and does not modify its environment. If true, reading the resource repeatedly will have no additional effect on its environment. Optional meta information about the resource. This data is passed through to the MCP client as the `meta` field of the client-side resource object and can be used for custom metadata, versioning, or other application-specific purposes. Optional version identifier for this resource. See [Versioning](/servers/versioning) for details. #### Using with Methods For decorating instance or class methods, use the standalone `@resource` decorator and register the bound method. See [Tools: Using with Methods](/servers/tools#using-with-methods) for the pattern. ### Return Values Resource functions must return one of three types: - **`str`**: Sent as `TextResourceContents` (with `mime_type="text/plain"` by default). - **`bytes`**: Base64 encoded and sent as `BlobResourceContents`. You should specify an appropriate `mime_type` (e.g., `"image/png"`, `"application/octet-stream"`). - **`ResourceResult`**: Full control over contents, MIME types, and metadata. See [ResourceResult](#resourceresult) below. To return structured data like dicts or lists, serialize them to JSON strings using `json.dumps()`. This explicit approach ensures your type checker catches errors during development rather than at runtime when a client reads the resource. #### ResourceResult `ResourceResult` gives you explicit control over resource responses: multiple content items, per-item MIME types, and metadata at both the item and result level. ```python from fastmcp import FastMCP from fastmcp.resources import ResourceResult, ResourceContent mcp = FastMCP() @mcp.resource("data://users") def get_users() -> ResourceResult: return ResourceResult( contents=[ ResourceContent(content='[{"id": 1}]', mime_type="application/json"), ResourceContent(content="# Users\n...", mime_type="text/markdown"), ], meta={"total": 1} ) ``` `ResourceContent` accepts three fields: **`content`** - The actual resource content. Can be `str` (text content) or `bytes` (binary content). This is the data that will be returned to the client. **`mime_type`** - Optional MIME type for the content. Defaults to `"text/plain"` for string content and `"application/octet-stream"` for binary content. **`meta`** - Optional metadata dictionary that will be included in the MCP response's `meta` field. Use this for runtime metadata like Content Security Policy headers, caching hints, or other client-specific data. For simple cases, you can pass `str` or `bytes` directly to `ResourceResult`: ```python return ResourceResult("plain text") # auto-converts to ResourceContent return ResourceResult(b"\x00\x01\x02") # binary content ``` Content to return. Strings and bytes are wrapped in a single `ResourceContent`. Use a list of `ResourceContent` for multiple items or custom MIME types. Result-level metadata, included in the MCP response's `_meta` field. The content data. Strings and bytes pass through directly. Other types (dict, list, BaseModel) are automatically JSON-serialized. MIME type. Defaults to `text/plain` for strings, `application/octet-stream` for bytes, `application/json` for serialized objects. Item-level metadata for this specific content. ### Component Visibility You can control which resources are enabled for clients using server-level enabled control. Disabled resources don't appear in `list_resources` and can't be read. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.resource("data://public", tags={"public"}) def get_public(): return "public" @mcp.resource("data://secret", tags={"internal"}) def get_secret(): return "secret" # Disable specific resources by key mcp.disable(keys={"resource:data://secret"}) # Disable resources by tag mcp.disable(tags={"internal"}) # Or use allowlist mode - only enable resources with specific tags mcp.enable(tags={"public"}, only=True) ``` See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. ### Accessing MCP Context Resources and resource templates can access additional MCP information and features through the `Context` object. To access it, add a parameter to your resource function with a type annotation of `Context`: ```python {6, 14} from fastmcp import FastMCP, Context mcp = FastMCP(name="DataServer") @mcp.resource("resource://system-status") async def get_system_status(ctx: Context) -> str: """Provides system status information.""" return json.dumps({ "status": "operational", "request_id": ctx.request_id }) @mcp.resource("resource://{name}/details") async def get_details(name: str, ctx: Context) -> str: """Get details for a specific name.""" return json.dumps({ "name": name, "accessed_at": ctx.request_id }) ``` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). ### Async Resources FastMCP supports both `async def` and regular `def` resource functions. Synchronous functions automatically run in a threadpool to avoid blocking the event loop. For I/O-bound operations, async functions are more efficient: ```python import aiofiles from fastmcp import FastMCP mcp = FastMCP(name="DataServer") @mcp.resource("file:///app/data/important_log.txt", mime_type="text/plain") async def read_important_log() -> str: """Reads content from a specific log file asynchronously.""" try: async with aiofiles.open("/app/data/important_log.txt", mode="r") as f: content = await f.read() return content except FileNotFoundError: return "Log file not found." ``` ### Resource Classes While `@mcp.resource` is ideal for dynamic content, you can directly register pre-defined resources (like static files or simple text) using `mcp.add_resource()` and concrete `Resource` subclasses. ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.resources import FileResource, TextResource, DirectoryResource mcp = FastMCP(name="DataServer") # 1. Exposing a static file directly readme_path = Path("./README.md").resolve() if readme_path.exists(): # Use a file:// URI scheme readme_resource = FileResource( uri=f"file://{readme_path.as_posix()}", path=readme_path, # Path to the actual file name="README File", description="The project's README.", mime_type="text/markdown", tags={"documentation"} ) mcp.add_resource(readme_resource) # 2. Exposing simple, predefined text notice_resource = TextResource( uri="resource://notice", name="Important Notice", text="System maintenance scheduled for Sunday.", tags={"notification"} ) mcp.add_resource(notice_resource) # 3. Exposing a directory listing data_dir_path = Path("./app_data").resolve() if data_dir_path.is_dir(): data_listing_resource = DirectoryResource( uri="resource://data-files", path=data_dir_path, # Path to the directory name="Data Directory Listing", description="Lists files available in the data directory.", recursive=False # Set to True to list subdirectories ) mcp.add_resource(data_listing_resource) # Returns JSON list of files ``` **Common Resource Classes:** - `TextResource`: For simple string content. - `BinaryResource`: For raw `bytes` content. - `FileResource`: Reads content from a local file path. Handles text/binary modes and lazy reading. - `HttpResource`: Fetches content from an HTTP(S) URL (requires `httpx`). - `DirectoryResource`: Lists files in a local directory (returns JSON). - (`FunctionResource`: Internal class used by `@mcp.resource`). Use these when the content is static or sourced directly from a file/URL, bypassing the need for a dedicated Python function. ### Notifications FastMCP automatically sends `notifications/resources/list_changed` notifications to connected clients when resources or templates are added, enabled, or disabled. This allows clients to stay up-to-date with the current resource set without manually polling for changes. ```python @mcp.resource("data://example") def example_resource() -> str: return "Hello!" # These operations trigger notifications: mcp.add_resource(example_resource) # Sends resources/list_changed notification mcp.disable(keys={"resource:data://example"}) # Sends resources/list_changed notification mcp.enable(keys={"resource:data://example"}) # Sends resources/list_changed notification ``` Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their resource lists or update their interfaces. ### Annotations FastMCP allows you to add specialized metadata to your resources through annotations. These annotations communicate how resources behave to client applications without consuming token context in LLM prompts. Annotations serve several purposes in client applications: - Indicating whether resources are read-only or may have side effects - Describing the safety profile of resources (idempotent vs. non-idempotent) - Helping clients optimize caching and access patterns You can add annotations to a resource using the `annotations` parameter in the `@mcp.resource` decorator: ```python @mcp.resource( "data://config", annotations={ "readOnlyHint": True, "idempotentHint": True } ) def get_config() -> str: """Get application configuration.""" return json.dumps({"version": "1.0", "debug": False}) ``` FastMCP supports these standard annotations: | Annotation | Type | Default | Purpose | | :--------- | :--- | :------ | :------ | | `readOnlyHint` | boolean | true | Indicates if the resource only provides data without side effects | | `idempotentHint` | boolean | true | Indicates if repeated reads have the same effect as a single read | Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and optimize access patterns, but won't enforce behavior on their own. Always focus on making your annotations accurately represent what your resource actually does. ## Resource Templates Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature. Resource templates share most configuration options with regular resources (name, description, mime_type, tags, annotations), but add the ability to define URI parameters that map to function parameters. Resource templates generate a new resource for each unique set of parameters, which means that resources can be dynamically created on-demand. For example, if the resource template `"user://profile/{name}"` is registered, MCP clients could request `"user://profile/ford"` or `"user://profile/marvin"` to retrieve either of those two user profiles as resources, without having to register each resource individually. Functions with `*args` are not supported as resource templates. However, unlike tools and prompts, resource templates do support `**kwargs` because the URI template defines specific parameter names that will be collected and passed as keyword arguments. Here is a complete example that shows how to define two resource templates: ```python import json from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Template URI includes {city} placeholder @mcp.resource("weather://{city}/current") def get_weather(city: str) -> str: """Provides weather information for a specific city.""" return json.dumps({ "city": city.capitalize(), "temperature": 22, "condition": "Sunny", "unit": "celsius" }) # Template with multiple parameters and annotations @mcp.resource( "repos://{owner}/{repo}/info", annotations={ "readOnlyHint": True, "idempotentHint": True } ) def get_repo_info(owner: str, repo: str) -> str: """Retrieves information about a GitHub repository.""" return json.dumps({ "owner": owner, "name": repo, "full_name": f"{owner}/{repo}", "stars": 120, "forks": 48 }) ``` With these two templates defined, clients can request a variety of resources: - `weather://london/current` → Returns weather for London - `weather://paris/current` → Returns weather for Paris - `repos://PrefectHQ/fastmcp/info` → Returns info about the PrefectHQ/fastmcp repository - `repos://prefecthq/prefect/info` → Returns info about the prefecthq/prefect repository ### RFC 6570 URI Templates FastMCP implements [RFC 6570 URI Templates](https://datatracker.ietf.org/doc/html/rfc6570) for resource templates, providing a standardized way to define parameterized URIs. This includes support for simple expansion, wildcard path parameters, and form-style query parameters. #### Wildcard Parameters Resource templates support wildcard parameters that can match multiple path segments. While standard parameters (`{param}`) only match a single path segment and don't cross "/" boundaries, wildcard parameters (`{param*}`) can capture multiple segments including slashes. Wildcards capture all subsequent path segments *up until* the defined part of the URI template (whether literal or another parameter). This allows you to have multiple wildcard parameters in a single URI template. ```python {15, 23} from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Standard parameter only matches one segment @mcp.resource("files://{filename}") def get_file(filename: str) -> str: """Retrieves a file by name.""" # Will only match files:// return f"File content for: {filename}" # Wildcard parameter can match multiple segments @mcp.resource("path://{filepath*}") def get_path_content(filepath: str) -> str: """Retrieves content at a specific path.""" # Can match path://docs/server/resources.mdx return f"Content at path: {filepath}" # Mixing standard and wildcard parameters @mcp.resource("repo://{owner}/{path*}/template.py") def get_template_file(owner: str, path: str) -> dict: """Retrieves a file from a specific repository and path, but only if the resource ends with `template.py`""" # Can match repo://PrefectHQ/fastmcp/src/resources/template.py return { "owner": owner, "path": path + "/template.py", "content": f"File at {path}/template.py in {owner}'s repository" } ``` Wildcard parameters are useful when: - Working with file paths or hierarchical data - Creating APIs that need to capture variable-length path segments - Building URL-like patterns similar to REST APIs Note that like regular parameters, each wildcard parameter must still be a named parameter in your function signature, and all required function parameters must appear in the URI template. #### Query Parameters FastMCP supports RFC 6570 form-style query parameters using the `{?param1,param2}` syntax. Query parameters provide a clean way to pass optional configuration to resources without cluttering the path. Query parameters must be optional function parameters (have default values), while path parameters map to required function parameters. This enforces a clear separation: required data goes in the path, optional configuration in query params. ```python from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Basic query parameters @mcp.resource("data://{id}{?format}") def get_data(id: str, format: str = "json") -> str: """Retrieve data in specified format.""" if format == "xml": return f"" return f'{{"id": "{id}"}}' # Multiple query parameters with type coercion @mcp.resource("api://{endpoint}{?version,limit,offset}") def call_api(endpoint: str, version: int = 1, limit: int = 10, offset: int = 0) -> dict: """Call API endpoint with pagination.""" return { "endpoint": endpoint, "version": version, "limit": limit, "offset": offset, "results": fetch_results(endpoint, version, limit, offset) } # Query parameters with wildcards @mcp.resource("files://{path*}{?encoding,lines}") def read_file(path: str, encoding: str = "utf-8", lines: int = 100) -> str: """Read file with optional encoding and line limit.""" return read_file_content(path, encoding, lines) ``` **Example requests:** - `data://123` → Uses default format `"json"` - `data://123?format=xml` → Uses format `"xml"` - `api://users?version=2&limit=50` → `version=2, limit=50, offset=0` - `files://src/main.py?encoding=ascii&lines=50` → Custom encoding and line limit FastMCP automatically coerces query parameter string values to the correct types based on your function's type hints (`int`, `float`, `bool`, `str`). **Query parameters vs. hidden defaults:** Query parameters expose optional configuration to clients. To hide optional parameters from clients entirely (always use defaults), simply omit them from the URI template: ```python # Clients CAN override max_results via query string @mcp.resource("search://{query}{?max_results}") def search_configurable(query: str, max_results: int = 10) -> dict: return {"query": query, "limit": max_results} # Clients CANNOT override max_results (not in URI template) @mcp.resource("search://{query}") def search_fixed(query: str, max_results: int = 10) -> dict: return {"query": query, "limit": max_results} ``` ### Template Parameter Rules FastMCP enforces these validation rules when creating resource templates: 1. **Required function parameters** (no default values) must appear in the URI path template 2. **Query parameters** (specified with `{?param}` syntax) must be optional function parameters with default values 3. **All URI template parameters** (path and query) must exist as function parameters Optional function parameters (those with default values) can be: - Included as query parameters (`{?param}`) - clients can override via query string - Omitted from URI template - always uses default value, not exposed to clients - Used in alternative path templates - enables multiple ways to access the same resource **Multiple templates for one function:** Create multiple resource templates that expose the same function through different URI patterns by manually applying decorators: ```python from fastmcp import FastMCP mcp = FastMCP(name="DataServer") # Define a user lookup function that can be accessed by different identifiers def lookup_user(name: str | None = None, email: str | None = None) -> dict: """Look up a user by either name or email.""" if email: return find_user_by_email(email) # pseudocode elif name: return find_user_by_name(name) # pseudocode else: return {"error": "No lookup parameters provided"} # Manually apply multiple decorators to the same function mcp.resource("users://email/{email}")(lookup_user) mcp.resource("users://name/{name}")(lookup_user) ``` Now an LLM or client can retrieve user information in two different ways: - `users://email/alice@example.com` → Looks up user by email (with name=None) - `users://name/Bob` → Looks up user by name (with email=None) This approach allows a single function to be registered with multiple URI patterns while keeping the implementation clean and straightforward. Templates provide a powerful way to expose parameterized data access points following REST-like principles. ## Error Handling If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`. By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. If you want to mask internal error details for security reasons, you can: 1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: ```python mcp = FastMCP(name="SecureServer", mask_error_details=True) ``` 2. Or use `ResourceError` to explicitly control what error information is sent to clients: ```python from fastmcp import FastMCP from fastmcp.exceptions import ResourceError mcp = FastMCP(name="DataServer") @mcp.resource("resource://safe-error") def fail_with_details() -> str: """This resource provides detailed error information.""" # ResourceError contents are always sent back to clients, # regardless of mask_error_details setting raise ResourceError("Unable to retrieve data: file not found") @mcp.resource("resource://masked-error") def fail_with_masked_details() -> str: """This resource masks internal error details when mask_error_details=True.""" # This message would be masked if mask_error_details=True raise ValueError("Sensitive internal file path: /etc/secrets.conf") @mcp.resource("data://{id}") def get_data_by_id(id: str) -> dict: """Template resources also support the same error handling pattern.""" if id == "secure": raise ValueError("Cannot access secure data") elif id == "missing": raise ResourceError("Data ID 'missing' not found in database") return {"id": id, "value": "data"} ``` When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message. ## Server Behavior ### Duplicate Resources You can configure how the FastMCP server handles attempts to register multiple resources or templates with the same URI. Use the `on_duplicate_resources` setting during `FastMCP` initialization. ```python from fastmcp import FastMCP mcp = FastMCP( name="ResourceServer", on_duplicate_resources="error" # Raise error on duplicates ) @mcp.resource("data://config") def get_config_v1(): return {"version": 1} # This registration attempt will raise a ValueError because # "data://config" is already registered and the behavior is "error". # @mcp.resource("data://config") # def get_config_v2(): return {"version": 2} ``` The duplicate behavior options are: - `"warn"` (default): Logs a warning, and the new resource/template replaces the old one. - `"error"`: Raises a `ValueError`, preventing the duplicate registration. - `"replace"`: Silently replaces the existing resource/template with the new one. - `"ignore"`: Keeps the original resource/template and ignores the new registration attempt. ## Versioning Resources and resource templates support versioning, allowing you to maintain multiple implementations under the same URI while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. ================================================ FILE: docs/servers/sampling.mdx ================================================ --- title: Sampling sidebarTitle: Sampling description: Request LLM text generation from the client or a configured provider through the MCP context. icon: robot --- import { VersionBadge } from "/snippets/version-badge.mdx" LLM sampling allows your MCP tools to request text generation from an LLM during execution. This enables tools to leverage AI capabilities for analysis, generation, reasoning, and more—without the client needing to orchestrate multiple calls. By default, sampling requests are routed to the client's LLM. You can also configure a fallback handler to use a specific provider (like OpenAI) when the client doesn't support sampling, or to always use your own LLM regardless of client capabilities. ## Overview The simplest use of sampling is passing a prompt string to `ctx.sample()`. The method sends the prompt to the LLM, waits for the complete response, and returns a `SamplingResult`. You can access the generated text through the `.text` attribute. ```python from fastmcp import FastMCP, Context mcp = FastMCP() @mcp.tool async def summarize(content: str, ctx: Context) -> str: """Generate a summary of the provided content.""" result = await ctx.sample(f"Please summarize this:\n\n{content}") return result.text or "" ``` The `SamplingResult` also provides `.result` (identical to `.text` for plain text responses) and `.history` containing the full message exchange—useful if you need to continue the conversation or debug the interaction. ### System Prompts System prompts let you establish the LLM's role and behavioral guidelines before it processes your request. This is useful for controlling tone, enforcing constraints, or providing context that shouldn't clutter the user-facing prompt. ````python from fastmcp import FastMCP, Context mcp = FastMCP() @mcp.tool async def generate_code(concept: str, ctx: Context) -> str: """Generate a Python code example for a concept.""" result = await ctx.sample( messages=f"Write a Python example demonstrating '{concept}'.", system_prompt=( "You are an expert Python programmer. " "Provide concise, working code without explanations." ), temperature=0.7, max_tokens=300 ) return f"```python\n{result.text}\n```" ```` The `temperature` parameter controls randomness—higher values (up to 1.0) produce more varied outputs, while lower values make responses more deterministic. The `max_tokens` parameter limits response length. ### Model Preferences Model preferences let you hint at which LLM the client should use for a request. You can pass a single model name or a list of preferences in priority order. These are hints rather than requirements—the actual model used depends on what the client has available. ```python from fastmcp import FastMCP, Context mcp = FastMCP() @mcp.tool async def technical_analysis(data: str, ctx: Context) -> str: """Analyze data using a reasoning-focused model.""" result = await ctx.sample( messages=f"Analyze this data:\n\n{data}", model_preferences=["claude-opus-4-5", "gpt-5-2"], temperature=0.2, ) return result.text or "" ``` Use model preferences when different tasks benefit from different model characteristics. Creative writing might prefer faster models with higher temperature, while complex analysis might benefit from larger reasoning-focused models. ### Multi-Turn Conversations For requests that need conversational context, construct a list of `SamplingMessage` objects representing the conversation history. Each message has a `role` ("user" or "assistant") and `content` (a `TextContent` object). ```python from mcp.types import SamplingMessage, TextContent from fastmcp import FastMCP, Context mcp = FastMCP() @mcp.tool async def contextual_analysis(query: str, data: str, ctx: Context) -> str: """Analyze data with conversational context.""" messages = [ SamplingMessage( role="user", content=TextContent(type="text", text=f"Here's my data: {data}"), ), SamplingMessage( role="assistant", content=TextContent(type="text", text="I see the data. What would you like to know?"), ), SamplingMessage( role="user", content=TextContent(type="text", text=query), ), ] result = await ctx.sample(messages=messages) return result.text or "" ``` The LLM receives the full conversation thread and responds with awareness of the preceding context. ### Fallback Handlers Client support for sampling is optional—some clients may not implement it. To ensure your tools work regardless of client capabilities, configure a `sampling_handler` that sends requests directly to an LLM provider. FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format. Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`. ```python from fastmcp import FastMCP from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler server = FastMCP( name="My Server", sampling_handler=OpenAISamplingHandler(default_model="gpt-4o-mini"), sampling_handler_behavior="fallback", ) ``` The `sampling_handler_behavior` parameter controls when the handler is used: - **`"fallback"`** (default): Use the handler only when the client doesn't support sampling. This lets capable clients use their own LLM while ensuring your tools still work with clients that lack sampling support. - **`"always"`**: Always use the handler, bypassing the client entirely. Use this when you need guaranteed control over which LLM processes requests—for cost control, compliance requirements, or when specific model characteristics are essential. ## Structured Output When you need validated, typed data instead of free-form text, use the `result_type` parameter. FastMCP ensures the LLM returns data matching your type, handling validation and retries automatically. The `result_type` parameter accepts Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. When you specify a result type, FastMCP automatically creates a `final_response` tool that the LLM calls to provide its response. If validation fails, the error is sent back to the LLM for retry. ```python from pydantic import BaseModel from fastmcp import FastMCP, Context mcp = FastMCP() class SentimentResult(BaseModel): sentiment: str confidence: float reasoning: str @mcp.tool async def analyze_sentiment(text: str, ctx: Context) -> SentimentResult: """Analyze text sentiment with structured output.""" result = await ctx.sample( messages=f"Analyze the sentiment of: {text}", result_type=SentimentResult, ) return result.result # A validated SentimentResult object ``` When you call this tool, the LLM returns a structured response that FastMCP validates against your Pydantic model. You access the validated object through `result.result`, while `result.text` contains the JSON representation. ### Structured Output with Tools Combine structured output with tools for agentic workflows that return validated data. The LLM uses your tools to gather information, then returns a response matching your type. ```python from pydantic import BaseModel from fastmcp import FastMCP, Context mcp = FastMCP() def search(query: str) -> str: """Search the web for information.""" return f"Results for: {query}" def fetch_url(url: str) -> str: """Fetch content from a URL.""" return f"Content from: {url}" class ResearchResult(BaseModel): summary: str sources: list[str] confidence: float @mcp.tool async def research(topic: str, ctx: Context) -> ResearchResult: """Research a topic and return structured findings.""" result = await ctx.sample( messages=f"Research: {topic}", tools=[search, fetch_url], result_type=ResearchResult, ) return result.result ``` Structured output with automatic validation only applies to `sample()`. With `sample_step()`, you must manage structured output yourself. ## Tool Use Sampling with tools enables agentic workflows where the LLM can call functions to gather information before responding. This implements [SEP-1577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1577), allowing the LLM to autonomously orchestrate multi-step operations. Pass Python functions to the `tools` parameter, and FastMCP handles the execution loop automatically—calling tools, returning results to the LLM, and continuing until the LLM provides a final response. ### Defining Tools Define regular Python functions with type hints and docstrings. FastMCP extracts the function's name, docstring, and parameter types to create tool schemas that the LLM can understand. ```python from fastmcp import FastMCP, Context def search(query: str) -> str: """Search the web for information.""" return f"Results for: {query}" def get_time() -> str: """Get the current time.""" from datetime import datetime return datetime.now().strftime("%H:%M:%S") mcp = FastMCP() @mcp.tool async def research(question: str, ctx: Context) -> str: """Answer questions using available tools.""" result = await ctx.sample( messages=question, tools=[search, get_time], ) return result.text or "" ``` The LLM sees each function's signature and docstring, using this information to decide when and how to call them. Tool errors are caught and sent back to the LLM, allowing it to recover gracefully. An internal safety limit prevents infinite loops. ### Custom Tool Definitions For custom names or descriptions, use `SamplingTool.from_function()`: ```python from fastmcp.server.sampling import SamplingTool tool = SamplingTool.from_function( my_func, name="custom_name", description="Custom description" ) result = await ctx.sample(messages="...", tools=[tool]) ``` ### Error Handling By default, when a sampling tool raises an exception, the error message (including details) is sent back to the LLM so it can attempt recovery. To prevent sensitive information from leaking to the LLM, use the `mask_error_details` parameter: ```python result = await ctx.sample( messages=question, tools=[search], mask_error_details=True, # Generic error messages only ) ``` When `mask_error_details=True`, tool errors become generic messages like `"Error executing tool 'search'"` instead of exposing stack traces or internal details. To intentionally provide specific error messages to the LLM regardless of masking, raise `ToolError`: ```python from fastmcp.exceptions import ToolError def search(query: str) -> str: """Search for information.""" if not query.strip(): raise ToolError("Search query cannot be empty") return f"Results for: {query}" ``` `ToolError` messages always pass through to the LLM, making it the escape hatch for errors you want the LLM to see and handle. ### Concurrent Tool Execution By default, tools execute sequentially — one at a time, in order. When your tools are independent (no shared state between them), you can execute them in parallel with `tool_concurrency`: ```python result = await ctx.sample( messages="Research these three topics", tools=[search, fetch_url], tool_concurrency=0, # Unlimited parallel execution ) ``` The `tool_concurrency` parameter controls how many tools run at once: - **`None`** (default): Sequential execution - **`0`**: Unlimited parallel execution - **`N > 0`**: Execute at most N tools concurrently For tools that must not run concurrently (file writes, shared state mutations, etc.), mark them as `sequential` when creating the `SamplingTool`: ```python from fastmcp.server.sampling import SamplingTool db_writer = SamplingTool.from_function( write_to_db, sequential=True, # Forces all tools in the batch to run sequentially ) result = await ctx.sample( messages="Process this data", tools=[search, db_writer], tool_concurrency=0, # Would be parallel, but db_writer forces sequential ) ``` When any tool in a batch has `sequential=True`, the entire batch executes sequentially regardless of `tool_concurrency`. This is a conservative guarantee — if one tool needs ordering, all tools in that batch respect it. ### Client Requirements Sampling with tools requires the client to advertise the `sampling.tools` capability. FastMCP clients do this automatically. For external clients that don't support tool-enabled sampling, configure a fallback handler with `sampling_handler_behavior="always"`. ## Advanced Control While `sample()` handles the tool execution loop automatically, some scenarios require fine-grained control over each step. The `sample_step()` method makes a single LLM call and returns a `SampleStep` containing the response and updated history. Unlike `sample()`, `sample_step()` is stateless—it doesn't remember previous calls. You control the conversation by passing the full message history each time. The returned `step.history` includes all messages up through the current response, making it easy to continue the loop. Use `sample_step()` when you need to: - Inspect tool calls before they execute - Implement custom termination conditions - Add logging, metrics, or checkpointing between steps - Build custom agentic loops with domain-specific logic ### Basic Loop By default, `sample_step()` executes any tool calls and includes the results in the history. Call it in a loop, passing the updated history each time, until a stop condition is met. ```python from mcp.types import SamplingMessage from fastmcp import FastMCP, Context mcp = FastMCP() def search(query: str) -> str: return f"Results for: {query}" def get_time() -> str: return "12:00 PM" @mcp.tool async def controlled_agent(question: str, ctx: Context) -> str: """Agent with manual loop control.""" messages: list[str | SamplingMessage] = [question] while True: step = await ctx.sample_step( messages=messages, tools=[search, get_time], ) if step.is_tool_use: # Tools already executed (execute_tools=True by default) for call in step.tool_calls: print(f"Called tool: {call.name}") if not step.is_tool_use: return step.text or "" messages = step.history ``` ### SampleStep Properties Each `SampleStep` provides information about what the LLM returned: | Property | Description | |----------|-------------| | `step.is_tool_use` | True if the LLM requested tool calls | | `step.tool_calls` | List of tool calls requested (if any) | | `step.text` | The text content (if any) | | `step.history` | All messages exchanged so far | The contents of `step.history` depend on `execute_tools`: - **`execute_tools=True`** (default): Includes tool results, ready for the next iteration - **`execute_tools=False`**: Includes the assistant's tool request, but you add results yourself ### Manual Tool Execution Set `execute_tools=False` to handle tool execution yourself. When disabled, `step.history` contains the user message and the assistant's response with tool calls—but no tool results. You execute the tools and append the results as a user message. ```python from mcp.types import SamplingMessage, ToolResultContent, TextContent from fastmcp import FastMCP, Context mcp = FastMCP() @mcp.tool async def research(question: str, ctx: Context) -> str: """Research with manual tool handling.""" def search(query: str) -> str: return f"Results for: {query}" def get_time() -> str: return "12:00 PM" tools = {"search": search, "get_time": get_time} messages: list[SamplingMessage] = [question] while True: step = await ctx.sample_step( messages=messages, tools=list(tools.values()), execute_tools=False, ) if not step.is_tool_use: return step.text or "" # Execute tools and collect results tool_results = [] for call in step.tool_calls: fn = tools[call.name] result = fn(**call.input) tool_results.append( ToolResultContent( type="tool_result", toolUseId=call.id, content=[TextContent(type="text", text=result)], ) ) messages = list(step.history) messages.append(SamplingMessage(role="user", content=tool_results)) ``` To report an error to the LLM, set `isError=True` on the tool result: ```python tool_result = ToolResultContent( type="tool_result", toolUseId=call.id, content=[TextContent(type="text", text="Permission denied")], isError=True, ) ``` ## Method Reference Request text generation from the LLM, running to completion automatically. The prompt to send. Can be a simple string or a list of messages for multi-turn conversations. Instructions that establish the LLM's role and behavior. Controls randomness (0.0 = deterministic, 1.0 = creative). Maximum tokens to generate. Hints for which model the client should use. Functions the LLM can call during sampling. A type for validated structured output. Supports Pydantic models, dataclasses, and basic types like `int`, `list[str]`, or `dict[str, int]`. If True, mask detailed error messages from tool execution. When None (default), uses the global `settings.mask_error_details` value. Tools can raise `ToolError` to bypass masking and provide specific error messages to the LLM. Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. If any tool has `sequential=True`, all tools execute sequentially regardless. - `.text`: The raw text response (or JSON for structured output) - `.result`: The typed result—same as `.text` for plain text, or a validated Pydantic object for structured output - `.history`: All messages exchanged during sampling Make a single LLM sampling call. Use this for fine-grained control over the sampling loop. The prompt or conversation history. Instructions that establish the LLM's role and behavior. Controls randomness (0.0 = deterministic, 1.0 = creative). Maximum tokens to generate. Functions the LLM can call during sampling. Controls tool usage: `"auto"`, `"required"`, or `"none"`. If True, execute tool calls and append results to history. If False, return immediately with tool calls available for manual execution. If True, mask detailed error messages from tool execution. Controls parallel execution of tools. `None` (default) for sequential, `0` for unlimited parallel, or a positive integer for bounded concurrency. - `.response`: The raw LLM response - `.history`: Messages including input, assistant response, and tool results - `.is_tool_use`: True if the LLM requested tool execution - `.tool_calls`: List of tool calls (if any) - `.text`: The text content (if any) ================================================ FILE: docs/servers/server.mdx ================================================ --- title: The FastMCP Server sidebarTitle: Overview description: The core FastMCP server class for building MCP applications icon: server --- import { VersionBadge } from "/snippets/version-badge.mdx" The `FastMCP` class is the central piece of every FastMCP application. It acts as the container for your tools, resources, and prompts, managing communication with MCP clients and orchestrating the entire server lifecycle. ## Creating a Server At its simplest, a FastMCP server just needs a name. Everything else has sensible defaults. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") ``` Instructions help clients (and the LLMs behind them) understand what your server does and how to use it effectively. ```python mcp = FastMCP( "DataAnalysis", instructions="Provides tools for analyzing numerical datasets. Start with get_summary() for an overview.", ) ``` ## Components FastMCP servers expose three types of components to clients, each serving a distinct role in the MCP protocol. **Tools** are functions that clients invoke to perform actions or access external systems. ```python @mcp.tool def multiply(a: float, b: float) -> float: """Multiplies two numbers together.""" return a * b ``` **Resources** expose data that clients can read — passive data sources rather than invocable functions. ```python @mcp.resource("data://config") def get_config() -> dict: return {"theme": "dark", "version": "1.0"} ``` **Prompts** are reusable message templates that guide LLM interactions. ```python @mcp.prompt def analyze_data(data_points: list[float]) -> str: formatted_data = ", ".join(str(point) for point in data_points) return f"Please analyze these data points: {formatted_data}" ``` Each component type has detailed documentation: [Tools](/servers/tools), [Resources](/servers/resources) (including [Resource Templates](/servers/resources#resource-templates)), and [Prompts](/servers/prompts). ## Running the Server Start your server by calling `mcp.run()`. The `if __name__` guard ensures compatibility with MCP clients that launch your server as a subprocess. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool def greet(name: str) -> str: """Greet a user by name.""" return f"Hello, {name}!" if __name__ == "__main__": mcp.run() ``` FastMCP supports several transports: - **STDIO** (default): For local integrations and CLI tools - **HTTP**: For web services using the Streamable HTTP protocol - **SSE**: Legacy web transport (deprecated) ```python # Run with HTTP transport mcp.run(transport="http", host="127.0.0.1", port=9000) ``` The server can also be run using the FastMCP CLI. For detailed information on transports and deployment, see [Running Your Server](/deployment/running-server). ## Configuration Reference The `FastMCP` constructor accepts parameters organized into four categories: identity, composition, behavior, and handlers. ### Identity These parameters control how your server presents itself to clients. A human-readable name for your server, shown in client applications and logs Description of how to interact with this server. Clients surface these instructions to help LLMs understand the server's purpose and available functionality Version string for your server. Defaults to the FastMCP library version if not provided URL to a website with more information about your server. Displayed in client applications List of icon representations for your server. See [Icons](/servers/icons) for details ### Composition These parameters control what your server is built from — its components, middleware, providers, and lifecycle. Tools to register on the server. An alternative to the `@mcp.tool` decorator when you need to add tools programmatically Authentication provider for securing HTTP-based transports. See [Authentication](/servers/auth/authentication) for configuration [Middleware](/servers/middleware) that intercepts and transforms every MCP message flowing through the server — requests, responses, and notifications in both directions. Use for cross-cutting concerns like logging, error handling, and rate limiting [Providers](/servers/providers) that supply tools, resources, and prompts dynamically. Providers are queried at request time, so they can serve components from databases, APIs, or other external sources Server-level [transforms](/servers/transforms/transforms) to apply to all components. Transforms modify how tools, resources, and prompts are presented to clients — for example, [search transforms](/servers/transforms/tool-search) replace large catalogs with on-demand discovery Server-level setup and teardown logic that runs when the server starts and stops. See [Lifespans](/servers/lifespan) for composable lifespans ### Behavior These parameters tune how the server processes requests and communicates with clients. How to handle duplicate component registrations When `False` (default), FastMCP uses Pydantic's flexible validation that coerces compatible inputs (e.g., `"10"` → `10` for int parameters). When `True`, validates inputs against the exact JSON Schema before calling your function, rejecting type mismatches. See [Input Validation Modes](/servers/tools#input-validation-modes) for details When `True`, replaces internal error details in tool/resource responses with a generic message to avoid leaking implementation details to clients. Defaults to the `FASTMCP_MASK_ERROR_DETAILS` environment variable Maximum items per page for list operations (`tools/list`, `resources/list`, etc.). When `None`, all results are returned in a single response. See [Pagination](/servers/pagination) for details Enable background task support. When `True`, tools and resources can return `CreateTaskResult` to run work asynchronously while the client polls for results Default minimum log level for messages sent to MCP clients via `context.log()`. When set, messages below this level are suppressed. Individual clients can override this per-session using the MCP `logging/setLevel` request. One of `"debug"`, `"info"`, `"notice"`, `"warning"`, `"error"`, `"critical"`, `"alert"`, or `"emergency"` Automatically dereference `$ref` pointers in JSON schemas generated from complex Pydantic models. Most clients require flat schemas without `$ref`, so this should usually stay enabled ### Handlers and Storage These parameters provide custom handlers for MCP capabilities and persistent storage for session state. Custom handler for MCP sampling requests (server-initiated LLM calls). See [Sampling](/servers/sampling) for details When `"fallback"`, the sampling handler is used only when no tool-specific handler exists. When `"always"`, this handler is used for all sampling requests Persistent key-value store for session state that survives across requests. Defaults to an in-memory store. Provide a custom implementation for persistence across server restarts ## Tag-Based Filtering Tags let you categorize components and selectively expose them. This is useful for creating different views of your server for different environments or user types. ```python @mcp.tool(tags={"public", "utility"}) def public_tool() -> str: return "This tool is public" @mcp.tool(tags={"internal", "admin"}) def admin_tool() -> str: return "This tool is for admins only" ``` The filtering logic works as follows: - **Enable with `only=True`**: Switches to allowlist mode — only components with at least one matching tag are exposed - **Disable**: Components with any matching tag are hidden - **Precedence**: Later calls override earlier ones, so call `disable` after `enable` to exclude from an allowlist To ensure a component is never exposed, you can set `enabled=False` on the component itself. See the component-specific documentation for details. ```python # Only expose components tagged with "public" mcp = FastMCP() mcp.enable(tags={"public"}, only=True) # Hide components tagged as "internal" or "deprecated" mcp = FastMCP() mcp.disable(tags={"internal", "deprecated"}) # Combine both: show admin tools but hide deprecated ones mcp = FastMCP() mcp.enable(tags={"admin"}, only=True).disable(tags={"deprecated"}) ``` This filtering applies to all component types (tools, resources, resource templates, and prompts) and affects both listing and access. ## Custom Routes When running with HTTP transport, you can add custom web routes alongside your MCP endpoint using the `@custom_route` decorator. ```python from fastmcp import FastMCP from starlette.requests import Request from starlette.responses import PlainTextResponse mcp = FastMCP("MyServer") @mcp.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> PlainTextResponse: return PlainTextResponse("OK") if __name__ == "__main__": mcp.run(transport="http") # Health check at http://localhost:8000/health ``` Custom routes are useful for health checks, status endpoints, and simple webhooks. For more complex web applications, consider [mounting your MCP server into a FastAPI or Starlette app](/deployment/http#integration-with-web-frameworks). ================================================ FILE: docs/servers/storage-backends.mdx ================================================ --- title: Storage Backends sidebarTitle: Storage Backends description: Configure persistent and distributed storage for caching and OAuth state management icon: database tag: NEW --- import { VersionBadge } from "/snippets/version-badge.mdx" FastMCP uses pluggable storage backends for caching responses and managing OAuth state. By default, all storage is in-memory, which is perfect for development but doesn't persist across restarts. FastMCP includes support for multiple storage backends, and you can easily extend it with custom implementations. The storage layer is powered by **[py-key-value-aio](https://github.com/strawgate/py-key-value)**, an async key-value library maintained by a core FastMCP maintainer. This library provides a unified interface for multiple backends, making it easy to swap implementations based on your deployment needs. ## Available Backends ### In-Memory Storage **Best for:** Development, testing, single-process deployments In-memory storage is the default for all FastMCP storage needs. It's fast, requires no setup, and is perfect for getting started. ```python from key_value.aio.stores.memory import MemoryStore # Used by default - no configuration needed # But you can also be explicit: cache_store = MemoryStore() ``` **Characteristics:** - ✅ No setup required - ✅ Very fast - ❌ Data lost on restart - ❌ Not suitable for multi-process deployments ### File Storage **Best for:** Single-server production deployments, persistent caching File storage persists data to the filesystem as one JSON file per key, allowing it to survive server restarts. This is the default backend for OAuth storage on Mac and Windows. ```python from pathlib import Path from key_value.aio.stores.filetree import ( FileTreeStore, FileTreeV1KeySanitizationStrategy, FileTreeV1CollectionSanitizationStrategy, ) from fastmcp.server.middleware.caching import ResponseCachingMiddleware storage_dir = Path("/var/cache/fastmcp") store = FileTreeStore( data_directory=storage_dir, key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(storage_dir), collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(storage_dir), ) # Persistent response cache middleware = ResponseCachingMiddleware(cache_storage=store) ``` The sanitization strategies ensure keys and collection names are safe for the filesystem — alphanumeric names pass through as-is for readability, while special characters are hashed to prevent path traversal. **Characteristics:** - ✅ Data persists across restarts - ✅ No external dependencies - ✅ Human-readable files on disk - ❌ Not suitable for distributed deployments - ❌ Filesystem access required ### Redis **Best for:** Distributed production deployments, shared caching across multiple servers Redis support requires an optional dependency: `pip install 'py-key-value-aio[redis]'` Redis provides distributed caching and state management, ideal for production deployments with multiple server instances. ```python from key_value.aio.stores.redis import RedisStore from fastmcp.server.middleware.caching import ResponseCachingMiddleware # Distributed response cache middleware = ResponseCachingMiddleware( cache_storage=RedisStore(host="redis.example.com", port=6379) ) ``` With authentication: ```python from key_value.aio.stores.redis import RedisStore cache_store = RedisStore( host="redis.example.com", port=6379, password="your-redis-password" ) ``` For OAuth token storage: ```python import os from fastmcp.server.auth.providers.github import GitHubProvider from key_value.aio.stores.redis import RedisStore auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", jwt_signing_key=os.environ["JWT_SIGNING_KEY"], client_storage=RedisStore(host="redis.example.com", port=6379) ) ``` **Characteristics:** - ✅ Distributed and highly available - ✅ Fast in-memory performance - ✅ Works across multiple server instances - ✅ Built-in TTL support - ❌ Requires Redis infrastructure - ❌ Network latency vs local storage ### Other Backends from py-key-value-aio The py-key-value-aio library includes additional implementations for various storage systems: - **DynamoDB** - AWS distributed database - **MongoDB** - NoSQL document store - **Elasticsearch** - Distributed search and analytics - **Memcached** - Distributed memory caching - **RocksDB** - Embedded high-performance key-value store - **Valkey** - Redis-compatible server For configuration details on these backends, consult the [py-key-value-aio documentation](https://github.com/strawgate/py-key-value). Before using these backends in production, review the [py-key-value documentation](https://github.com/strawgate/py-key-value) to understand the maturity level and limitations of your chosen backend. Some backends may be in preview or have specific constraints that make them unsuitable for production use. ## Use Cases in FastMCP ### Server-Side OAuth Token Storage The [OAuth Proxy](/servers/auth/oauth-proxy) and OAuth auth providers use storage for persisting OAuth client registrations and upstream tokens. **By default, storage is automatically encrypted using `FernetEncryptionWrapper`.** When providing custom storage, wrap it in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest. **Development (default behavior):** By default, FastMCP automatically manages keys and storage based on your platform: - **Mac/Windows**: Keys are auto-managed via system keyring, storage defaults to disk. Suitable **only** for development and local testing. - **Linux**: Keys are ephemeral, storage defaults to memory. No configuration needed: ```python from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id="your-id", client_secret="your-secret", base_url="https://your-server.com" ) ``` **Production:** For production deployments, configure explicit keys and persistent network-accessible storage with encryption: ```python import os from fastmcp.server.auth.providers.github import GitHubProvider from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from cryptography.fernet import Fernet auth = GitHubProvider( client_id=os.environ["GITHUB_CLIENT_ID"], client_secret=os.environ["GITHUB_CLIENT_SECRET"], base_url="https://your-server.com", # Explicit JWT signing key (required for production) jwt_signing_key=os.environ["JWT_SIGNING_KEY"], # Encrypted persistent storage (required for production) client_storage=FernetEncryptionWrapper( key_value=RedisStore(host="redis.example.com", port=6379), fernet=Fernet(os.environ["STORAGE_ENCRYPTION_KEY"]) ) ) ``` Both parameters are required for production. **Wrap your storage in `FernetEncryptionWrapper` to encrypt sensitive OAuth tokens at rest** - without it, tokens are stored in plaintext. See [OAuth Token Security](/deployment/http#oauth-token-security) and [Key and Storage Management](/servers/auth/oauth-proxy#key-and-storage-management) for complete setup details. ### Response Caching Middleware The [Response Caching Middleware](/servers/middleware#caching-middleware) caches tool calls, resource reads, and prompt requests. Storage configuration is passed via the `cache_storage` parameter: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.middleware.caching import ResponseCachingMiddleware from key_value.aio.stores.filetree import ( FileTreeStore, FileTreeV1KeySanitizationStrategy, FileTreeV1CollectionSanitizationStrategy, ) mcp = FastMCP("My Server") cache_dir = Path("cache") cache_store = FileTreeStore( data_directory=cache_dir, key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(cache_dir), collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(cache_dir), ) # Cache to disk instead of memory mcp.add_middleware(ResponseCachingMiddleware(cache_storage=cache_store)) ``` For multi-server deployments sharing a Redis instance: ```python from fastmcp.server.middleware.caching import ResponseCachingMiddleware from key_value.aio.stores.redis import RedisStore from key_value.aio.wrappers.prefix_collections import PrefixCollectionsWrapper base_store = RedisStore(host="redis.example.com") namespaced_store = PrefixCollectionsWrapper( key_value=base_store, prefix="my-server" ) middleware = ResponseCachingMiddleware(cache_storage=namespaced_store) ``` ### Client-Side OAuth Token Storage The [FastMCP Client](/clients/client) uses storage for persisting OAuth tokens locally. By default, tokens are stored in memory: ```python from pathlib import Path from fastmcp.client.auth import OAuthClientProvider from key_value.aio.stores.filetree import ( FileTreeStore, FileTreeV1KeySanitizationStrategy, FileTreeV1CollectionSanitizationStrategy, ) # Store tokens on disk for persistence across restarts token_dir = Path("~/.local/share/fastmcp/tokens").expanduser() token_storage = FileTreeStore( data_directory=token_dir, key_sanitization_strategy=FileTreeV1KeySanitizationStrategy(token_dir), collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy(token_dir), ) oauth_provider = OAuthClientProvider( mcp_url="https://your-mcp-server.com/mcp/sse", token_storage=token_storage ) ``` This allows clients to reconnect without re-authenticating after restarts. ## Choosing a Backend | Backend | Development | Single Server | Multi-Server | Cloud Native | |---------|-------------|---------------|--------------|--------------| | Memory | ✅ Best | ⚠️ Limited | ❌ | ❌ | | File | ✅ Good | ✅ Recommended | ❌ | ⚠️ | | Redis | ⚠️ Overkill | ✅ Good | ✅ Best | ✅ Best | | DynamoDB | ❌ | ⚠️ | ✅ | ✅ Best (AWS) | | MongoDB | ❌ | ⚠️ | ✅ | ✅ Good | **Decision tree:** 1. **Just starting?** Use **Memory** (default) - no configuration needed 2. **Single server, needs persistence?** Use **File** 3. **Multiple servers or cloud deployment?** Use **Redis** or **DynamoDB** 4. **Existing infrastructure?** Look for a matching py-key-value-aio backend ## More Resources - [py-key-value-aio GitHub](https://github.com/strawgate/py-key-value) - Full library documentation - [Response Caching Middleware](/servers/middleware#caching-middleware) - Using storage for caching - [OAuth Token Security](/deployment/http#oauth-token-security) - Production OAuth configuration - [HTTP Deployment](/deployment/http) - Complete deployment guide ================================================ FILE: docs/servers/tasks.mdx ================================================ --- title: Background Tasks sidebarTitle: Background Tasks description: Run long-running operations asynchronously with progress tracking icon: clock tag: "NEW" --- import { VersionBadge } from "/snippets/version-badge.mdx" Background tasks require the `tasks` optional extra. See [installation instructions](#enabling-background-tasks) below. FastMCP implements the MCP background task protocol ([SEP-1686](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)), giving your servers a production-ready distributed task scheduler with a single decorator change. **What is Docket?** FastMCP's task system is powered by [Docket](https://github.com/chrisguidry/docket), originally built by [Prefect](https://prefect.io) to power [Prefect Cloud](https://www.prefect.io/prefect/cloud)'s managed task scheduling and execution service, where it processes millions of concurrent tasks every day. Docket is now open-sourced for the community. ## What Are MCP Background Tasks? In MCP, all component interactions are blocking by default. When a client calls a tool, reads a resource, or fetches a prompt, it sends a request and waits for the response. For operations that take seconds or minutes, this creates a poor user experience. The MCP background task protocol solves this by letting clients: 1. **Start** an operation and receive a task ID immediately 2. **Track** progress as the operation runs 3. **Retrieve** the result when ready FastMCP handles all of this for you. Add `task=True` to your decorator, and your function gains full background execution with progress reporting, distributed processing, and horizontal scaling. ### MCP Background Tasks vs Python Concurrency You can always use Python's concurrency primitives (asyncio, threads, multiprocessing) or external task queues in your FastMCP servers. FastMCP is just Python—run code however you like. MCP background tasks are different: they're **protocol-native**. This means MCP clients that support the task protocol can start operations, receive progress updates, and retrieve results through the standard MCP interface. The coordination happens at the protocol level, not inside your application code. ## Enabling Background Tasks Background tasks require the `tasks` extra: ```bash pip install "fastmcp[tasks]" ``` Add `task=True` to any tool, resource, resource template, or prompt decorator. This marks the component as capable of background execution. ```python {6} import asyncio from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool(task=True) async def slow_computation(duration: int) -> str: """A long-running operation.""" for i in range(duration): await asyncio.sleep(1) return f"Completed in {duration} seconds" ``` When a client requests background execution, the call returns immediately with a task ID. The work executes in a background worker, and the client can poll for status or wait for the result. Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time. ## Execution Modes For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes: | Mode | Client calls without task | Client calls with task | |------|--------------------------|------------------------| | `"forbidden"` | Executes synchronously | Error: task not supported | | `"optional"` | Executes synchronously | Executes as background task | | `"required"` | Error: task required | Executes as background task | ```python from fastmcp import FastMCP from fastmcp.server.tasks import TaskConfig mcp = FastMCP("MyServer") # Supports both sync and background execution (default when task=True) @mcp.tool(task=TaskConfig(mode="optional")) async def flexible_task() -> str: return "Works either way" # Requires background execution - errors if client doesn't request task @mcp.tool(task=TaskConfig(mode="required")) async def must_be_background() -> str: return "Only runs as a background task" # No task support (default when task=False or omitted) @mcp.tool(task=TaskConfig(mode="forbidden")) async def sync_only() -> str: return "Never runs as background task" ``` The boolean shortcuts map to these modes: - `task=True` → `TaskConfig(mode="optional")` - `task=False` → `TaskConfig(mode="forbidden")` ### Poll Interval When clients poll for task status, the server tells them how frequently to check back. By default, FastMCP suggests a 5-second interval, but you can customize this per component: ```python from datetime import timedelta from fastmcp import FastMCP from fastmcp.server.tasks import TaskConfig mcp = FastMCP("MyServer") # Poll every 2 seconds for a fast-completing task @mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=2))) async def quick_task() -> str: return "Done quickly" # Poll every 30 seconds for a long-running task @mcp.tool(task=TaskConfig(mode="optional", poll_interval=timedelta(seconds=30))) async def slow_task() -> str: return "Eventually done" ``` Shorter intervals give clients faster feedback but increase server load. Longer intervals reduce load but delay status updates. ### Server-Wide Default To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`. ```python mcp = FastMCP("MyServer", tasks=True) ``` If your server defines any synchronous tools, resources, or prompts, you will need to explicitly set `task=False` on their decorators to avoid an error. ### Graceful Degradation When a client requests background execution but the component has `mode="forbidden"`, FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities. Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required. ### Configuration | Environment Variable | Default | Description | |---------------------|---------|-------------| | `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) | ## Backends FastMCP supports two backends for task execution, each with different tradeoffs. ### In-Memory Backend (Default) The in-memory backend (`memory://`) requires zero configuration and works out of the box. **Advantages:** - No external dependencies - Simple single-process deployment **Disadvantages:** - **Ephemeral**: If the server restarts, all pending tasks are lost - **Higher latency**: ~250ms task pickup time vs single-digit milliseconds with Redis - **No horizontal scaling**: Single process only—you cannot add additional workers ### Redis Backend For production deployments, use Redis (or Valkey) as your backend by setting `FASTMCP_DOCKET_URL=redis://localhost:6379`. **Advantages:** - **Persistent**: Tasks survive server restarts - **Fast**: Single-digit millisecond task pickup latency - **Scalable**: Add workers to distribute load across processes or machines ## Workers Every FastMCP server with task-enabled components automatically starts an **embedded worker**. You do not need to start a separate worker process for tasks to execute. To scale horizontally, add more workers using the CLI: ```bash fastmcp tasks worker server.py ``` Each additional worker pulls tasks from the same queue, distributing load across processes. Configure worker concurrency via environment: ```bash export FASTMCP_DOCKET_CONCURRENCY=20 fastmcp tasks worker server.py ``` Additional workers only work with Redis/Valkey backends. The in-memory backend is single-process only. Task-enabled components must be defined at server startup to be registered with all workers. Components added dynamically after the server starts will not be available for background execution. ## Progress Reporting The `Progress` dependency lets you report progress back to clients. Inject it as a parameter with a default value, and FastMCP will provide the active progress reporter. ```python from fastmcp import FastMCP from fastmcp.dependencies import Progress mcp = FastMCP("MyServer") @mcp.tool(task=True) async def process_files(files: list[str], progress: Progress = Progress()) -> str: await progress.set_total(len(files)) for file in files: await progress.set_message(f"Processing {file}") # ... do work ... await progress.increment() return f"Processed {len(files)} files" ``` The progress API: - `await progress.set_total(n)` — Set the total number of steps - `await progress.increment(amount=1)` — Increment progress - `await progress.set_message(text)` — Update the status message Progress works in both immediate and background execution modes—you can use the same code regardless of how the client invokes your function. ## Docket Dependencies FastMCP exposes Docket's full dependency injection system within your task-enabled functions. Beyond `Progress`, you can access the Docket instance, worker information, and use advanced features like retries and timeouts. ```python from docket import Docket, Worker from fastmcp import FastMCP from fastmcp.dependencies import Progress, CurrentDocket, CurrentWorker mcp = FastMCP("MyServer") @mcp.tool(task=True) async def my_task( progress: Progress = Progress(), docket: Docket = CurrentDocket(), worker: Worker = CurrentWorker(), ) -> str: # Schedule additional background work await docket.add(another_task, arg1, arg2) # Access worker metadata worker_name = worker.name return "Done" ``` With `CurrentDocket()`, you can schedule additional background tasks, chain work together, and coordinate complex workflows. See the [Docket documentation](https://chrisguidry.github.io/docket/) for the complete API, including retry policies, timeouts, and custom dependencies. ================================================ FILE: docs/servers/telemetry.mdx ================================================ --- title: OpenTelemetry sidebarTitle: Telemetry description: Native OpenTelemetry instrumentation for distributed tracing. icon: chart-line tag: NEW --- FastMCP includes native OpenTelemetry instrumentation for observability. Traces are automatically generated for tool, prompt, resource, and resource template operations, providing visibility into server behavior, request handling, and provider delegation chains. ## How It Works FastMCP uses the OpenTelemetry API for instrumentation. This means: - **Zero configuration required** - Instrumentation is always active - **No overhead when unused** - Without an SDK, all operations are no-ops - **Bring your own SDK** - You control collection, export, and sampling - **Works with any OTEL backend** - Jaeger, Zipkin, Datadog, New Relic, etc. ## Enabling Telemetry The easiest way to export traces is using `opentelemetry-instrument`, which configures the SDK automatically: ```bash pip install opentelemetry-distro opentelemetry-exporter-otlp opentelemetry-bootstrap -a install ``` Then run your server with tracing enabled: ```bash opentelemetry-instrument \ --service_name my-fastmcp-server \ --exporter_otlp_endpoint http://localhost:4317 \ fastmcp run server.py ``` Or configure via environment variables: ```bash export OTEL_SERVICE_NAME=my-fastmcp-server export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 opentelemetry-instrument fastmcp run server.py ``` This works with any OTLP-compatible backend (Jaeger, Zipkin, Grafana Tempo, Datadog, etc.) and requires no changes to your FastMCP code. Learn more about the OpenTelemetry Python SDK, auto-instrumentation, and available exporters. ## Tracing FastMCP creates spans for all MCP operations, providing end-to-end visibility into request handling. ### Server Spans The server creates spans for each operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): | Span Name | Description | |-----------|-------------| | `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) | | `resources/read {uri}` | Resource read (e.g., `resources/read config://database`) | | `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) | For mounted servers, an additional `delegate {name}` span shows the delegation to the child server. ### Client Spans The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read {uri}`, `prompts/get {name}`). ### Span Hierarchy Spans form a hierarchy showing the request flow. For mounted servers: ``` tools/call weather_forecast (CLIENT) └── tools/call weather_forecast (SERVER, provider=FastMCPProvider) └── delegate get_weather (INTERNAL) └── tools/call get_weather (SERVER, provider=LocalProvider) ``` For proxy providers connecting to remote servers: ``` tools/call remote_search (CLIENT) └── tools/call remote_search (SERVER, provider=ProxyProvider) └── [remote server spans via trace context propagation] ``` ## Programmatic Configuration For more control, configure the SDK in your Python code before importing FastMCP: ```python from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter # Configure the SDK with OTLP exporter provider = TracerProvider() processor = BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")) provider.add_span_processor(processor) trace.set_tracer_provider(provider) # Now import and use FastMCP - traces will be exported automatically from fastmcp import FastMCP mcp = FastMCP("my-server") @mcp.tool() def greet(name: str) -> str: return f"Hello, {name}!" ``` The SDK must be configured **before** importing FastMCP to ensure the tracer provider is set when FastMCP initializes. ### Local Development For quick local trace visualization, [otel-desktop-viewer](https://github.com/CtrlSpice/otel-desktop-viewer) is a lightweight single-binary tool: ```bash # macOS brew install nico-barbas/brew/otel-desktop-viewer # Or download from GitHub releases ``` Run it alongside your server: ```bash # Terminal 1: Start the viewer (UI at http://localhost:8000, OTLP on :4317) otel-desktop-viewer # Terminal 2: Run your server with tracing opentelemetry-instrument fastmcp run server.py ``` For more features, use [Jaeger](https://www.jaegertracing.io/): ```bash docker run -d --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ jaegertracing/all-in-one:latest ``` Then view traces at http://localhost:16686 ## Custom Spans You can add your own spans using the FastMCP tracer: ```python from fastmcp import FastMCP from fastmcp.telemetry import get_tracer mcp = FastMCP("custom-spans") @mcp.tool() async def complex_operation(input: str) -> str: tracer = get_tracer() with tracer.start_as_current_span("parse_input") as span: span.set_attribute("input.length", len(input)) parsed = parse(input) with tracer.start_as_current_span("process_data") as span: span.set_attribute("data.count", len(parsed)) result = process(parsed) return result ``` ## Error Handling When errors occur, spans are automatically marked with error status and the exception is recorded: ```python @mcp.tool() def risky_operation() -> str: raise ValueError("Something went wrong") # The span will have: # - status = ERROR # - exception event with stack trace ``` ## Attributes Reference ### RPC Semantic Conventions Standard [RPC semantic conventions](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/): | Attribute | Value | |-----------|-------| | `rpc.system` | `"mcp"` | | `rpc.service` | Server name | | `rpc.method` | MCP protocol method | ### MCP Semantic Conventions FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/): | Attribute | Description | |-----------|-------------| | `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) | | `mcp.session.id` | Session identifier for the MCP connection | | `mcp.resource.uri` | The resource URI (for resource operations) | ### Auth Attributes Standard [identity attributes](https://opentelemetry.io/docs/specs/semconv/attributes-registry/enduser/): | Attribute | Description | |-----------|-------------| | `enduser.id` | Client ID from access token (when authenticated) | | `enduser.scope` | Space-separated OAuth scopes (when authenticated) | ### FastMCP Custom Attributes All custom attributes use the `fastmcp.` prefix for features unique to FastMCP: | Attribute | Description | |-----------|-------------| | `fastmcp.server.name` | Server name | | `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `resource_template` | | `fastmcp.component.key` | Full component identifier (e.g., `tool:greet`) | | `fastmcp.provider.type` | Provider class (`LocalProvider`, `FastMCPProvider`, `ProxyProvider`) | Provider-specific attributes for delegation context: | Attribute | Description | |-----------|-------------| | `fastmcp.delegate.original_name` | Original tool/prompt name before namespacing | | `fastmcp.delegate.original_uri` | Original resource URI before namespacing | | `fastmcp.proxy.backend_name` | Remote server tool/prompt name | | `fastmcp.proxy.backend_uri` | Remote server resource URI | ## Testing with Telemetry For testing, use the in-memory exporter: ```python import pytest from collections.abc import Generator from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from fastmcp import FastMCP @pytest.fixture def trace_exporter() -> Generator[InMemorySpanExporter, None, None]: exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) original_provider = trace.get_tracer_provider() trace.set_tracer_provider(provider) yield exporter exporter.clear() trace.set_tracer_provider(original_provider) async def test_tool_creates_span(trace_exporter: InMemorySpanExporter) -> None: mcp = FastMCP("test") @mcp.tool() def hello() -> str: return "world" await mcp.call_tool("hello", {}) spans = trace_exporter.get_finished_spans() assert any(s.name == "tools/call hello" for s in spans) ``` ================================================ FILE: docs/servers/testing.mdx ================================================ --- title: Testing your FastMCP Server sidebarTitle: Testing description: How to test your FastMCP server. icon: vial --- The best way to ensure a reliable and maintainable FastMCP Server is to test it! The FastMCP Client combined with Pytest provides a simple and powerful way to test your FastMCP servers. ## Prerequisites Testing FastMCP servers requires `pytest-asyncio` to handle async test functions and fixtures. Install it as a development dependency: ```bash pip install pytest-asyncio ``` We recommend configuring pytest to automatically handle async tests by setting the asyncio mode to `auto` in your `pyproject.toml`: ```toml [tool.pytest.ini_options] asyncio_mode = "auto" ``` This eliminates the need to decorate every async test with `@pytest.mark.asyncio`. ## Testing with Pytest Fixtures Using Pytest Fixtures, you can wrap your FastMCP Server in a Client instance that makes interacting with your server fast and easy. This is especially useful when building your own MCP Servers and enables a tight development loop by allowing you to avoid using a separate tool like MCP Inspector during development: ```python import pytest from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from my_project.main import mcp @pytest.fixture async def main_mcp_client(): async with Client(transport=mcp) as mcp_client: yield mcp_client async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): list_tools = await main_mcp_client.list_tools() assert len(list_tools) == 5 ``` We recommend the [inline-snapshot library](https://github.com/15r10nk/inline-snapshot) for asserting complex data structures coming from your MCP Server. This library allows you to write tests that are easy to read and understand, and are also easy to update when the data structure changes. ```python from inline_snapshot import snapshot async def test_list_tools(main_mcp_client: Client[FastMCPTransport]): list_tools = await main_mcp_client.list_tools() assert list_tools == snapshot() ``` Simply run `pytest --inline-snapshot=fix,create` to fill in the `snapshot()` with actual data. For values that change you can leverage the [dirty-equals](https://github.com/samuelcolvin/dirty-equals) library to perform flexible equality assertions on dynamic or non-deterministic values. Using the pytest `parametrize` decorator, you can easily test your tools with a wide variety of inputs. ```python import pytest from my_project.main import mcp from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport @pytest.fixture async def main_mcp_client(): async with Client(mcp) as client: yield client @pytest.mark.parametrize( "first_number, second_number, expected", [ (1, 2, 3), (2, 3, 5), (3, 4, 7), ], ) async def test_add( first_number: int, second_number: int, expected: int, main_mcp_client: Client[FastMCPTransport], ): result = await main_mcp_client.call_tool( name="add", arguments={"x": first_number, "y": second_number} ) assert result.data is not None assert isinstance(result.data, int) assert result.data == expected ``` The [FastMCP Repository contains thousands of tests](https://github.com/PrefectHQ/fastmcp/tree/main/tests) for the FastMCP Client and Server. Everything from connecting to remote MCP servers, to testing tools, resources, and prompts is covered, take a look for inspiration! ================================================ FILE: docs/servers/tools.mdx ================================================ --- title: Tools sidebarTitle: Tools description: Expose functions as executable capabilities for your MCP client. icon: wrench --- import { VersionBadge } from '/snippets/version-badge.mdx' Tools are the core building blocks that allow your LLM to interact with external systems, execute code, and access data that isn't in its training data. In FastMCP, tools are Python functions exposed to LLMs through the MCP protocol. Tools in FastMCP transform regular Python functions into capabilities that LLMs can invoke during conversations. When an LLM decides to use a tool: 1. It sends a request with parameters based on the tool's schema. 2. FastMCP validates these parameters against your function's signature. 3. Your function executes with the validated inputs. 4. The result is returned to the LLM, which can use it in its response. This allows LLMs to perform tasks like querying databases, calling APIs, making calculations, or accessing files—extending their capabilities beyond what's in their training data. ## The `@tool` Decorator Creating a tool is as simple as decorating a Python function with `@mcp.tool`: ```python from fastmcp import FastMCP mcp = FastMCP(name="CalculatorServer") @mcp.tool def add(a: int, b: int) -> int: """Adds two integer numbers together.""" return a + b ``` When this tool is registered, FastMCP automatically: - Uses the function name (`add`) as the tool name. - Uses the function's docstring (`Adds two integer numbers...`) as the tool description. - Generates an input schema based on the function's parameters and type annotations. - Handles parameter validation and error reporting. The way you define your Python function dictates how the tool appears and behaves for the LLM client. Functions with `*args` or `**kwargs` are not supported as tools. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists. ### Decorator Arguments While FastMCP infers the name and description from your function, you can override these and add additional metadata using arguments to the `@mcp.tool` decorator: ```python @mcp.tool( name="find_products", # Custom tool name for the LLM description="Search the product catalog with optional category filtering.", # Custom description tags={"catalog", "search"}, # Optional tags for organization/filtering meta={"version": "1.2", "author": "product-team"} # Custom metadata ) def search_products_implementation(query: str, category: str | None = None) -> list[dict]: """Internal function description (ignored if description is provided above).""" # Implementation... print(f"Searching for '{query}' in category '{category}'") return [{"id": 2, "name": "Another Product"}] ``` Sets the explicit tool name exposed via MCP. If not provided, uses the function name Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose A set of strings used to categorize the tool. These can be used by the server and, in some cases, by clients to filter or group available tools. Deprecated in v3.0.0. Use `mcp.enable()` / `mcp.disable()` at the server level instead. A boolean to enable or disable the tool. See [Component Visibility](#component-visibility) for the recommended approach. Optional list of icon representations for this tool. See [Icons](/servers/icons) for detailed examples An optional `ToolAnnotations` object or dictionary to add additional metadata about the tool. A human-readable title for the tool. If true, the tool does not modify its environment. If true, the tool may perform destructive updates to its environment. If true, calling the tool repeatedly with the same arguments will have no additional effect on the its environment. If true, this tool may interact with an "open world" of external entities. If false, the tool's domain of interaction is closed. Optional meta information about the tool. This data is passed through to the MCP client as the `meta` field of the client-side tool object and can be used for custom metadata, versioning, or other application-specific purposes. Execution timeout in seconds. If the tool takes longer than this to complete, an MCP error is returned to the client. See [Timeouts](#timeouts) for details. Optional version identifier for this tool. See [Versioning](/servers/versioning) for details. Optional JSON schema for the tool's output. When provided, the tool must return structured output matching this schema. If not provided, FastMCP automatically generates a schema from the function's return type annotation. See [Output Schemas](#output-schemas) for details. ### Using with Methods The `@mcp.tool` decorator registers tools immediately, which doesn't work with instance or class methods (you'd see `self` or `cls` as required parameters). For methods, use the standalone `@tool` decorator to attach metadata, then register the bound method: ```python from fastmcp import FastMCP from fastmcp.tools import tool class Calculator: def __init__(self, multiplier: int): self.multiplier = multiplier @tool() def multiply(self, x: int) -> int: """Multiply x by the instance multiplier.""" return x * self.multiplier calc = Calculator(multiplier=3) mcp = FastMCP() mcp.add_tool(calc.multiply) # Registers with correct schema (only 'x', not 'self') ``` ### Async Support FastMCP supports both asynchronous (`async def`) and synchronous (`def`) functions as tools. Synchronous tools automatically run in a threadpool to avoid blocking the event loop, so multiple tool calls can execute concurrently even if individual tools perform blocking operations. ```python from fastmcp import FastMCP import time mcp = FastMCP() @mcp.tool def slow_tool(x: int) -> int: """This sync function won't block other concurrent requests.""" time.sleep(2) # Runs in threadpool, not on the event loop return x * 2 ``` For I/O-bound operations like network requests or database queries, async tools are still preferred since they're more efficient than threadpool dispatch. Use sync tools when working with synchronous libraries or for simple operations where the threading overhead doesn't matter. ## Arguments By default, FastMCP converts Python functions into MCP tools by inspecting the function's signature and type annotations. This allows you to use standard Python type annotations for your tools. In general, the framework strives to "just work": idiomatic Python behaviors like parameter defaults and type annotations are automatically translated into MCP schemas. However, there are a number of ways to customize the behavior of your tools. FastMCP automatically dereferences `$ref` entries in tool schemas to ensure compatibility with MCP clients that don't fully support JSON Schema references (e.g., VS Code Copilot, Claude Desktop). This means complex Pydantic models with shared types are inlined in the schema rather than using `$defs` references. Dereferencing happens at serve-time via middleware, so your schemas are stored with `$ref` intact and only inlined when sent to clients. If you know your clients handle `$ref` correctly and prefer smaller schemas, you can opt out: ```python mcp = FastMCP("my-server", dereference_schemas=False) ``` ### Type Annotations MCP tools have typed arguments, and FastMCP uses type annotations to determine those types. Therefore, you should use standard Python type annotations for tool arguments: ```python @mcp.tool def analyze_text( text: str, max_tokens: int = 100, language: str | None = None ) -> dict: """Analyze the provided text.""" # Implementation... ``` FastMCP supports a wide range of type annotations, including all Pydantic types: | Type Annotation | Example | Description | | :---------------------- | :---------------------------- | :---------------------------------- | | Basic types | `int`, `float`, `str`, `bool` | Simple scalar values | | Binary data | `bytes` | Binary content (raw strings, not auto-decoded base64) | | Date and Time | `datetime`, `date`, `timedelta` | Date and time objects (ISO format strings) | | Collection types | `list[str]`, `dict[str, int]`, `set[int]` | Collections of items | | Optional types | `float \| None`, `Optional[float]`| Parameters that may be null/omitted | | Union types | `str \| int`, `Union[str, int]`| Parameters accepting multiple types | | Constrained types | `Literal["A", "B"]`, `Enum` | Parameters with specific allowed values | | Paths | `Path` | File system paths (auto-converted from strings) | | UUIDs | `UUID` | Universally unique identifiers (auto-converted from strings) | | Pydantic models | `UserData` | Complex structured data with validation | FastMCP supports all types that Pydantic supports as fields, including all Pydantic custom types. A few FastMCP-specific behaviors to note: **Binary Data**: `bytes` parameters accept raw strings without automatic base64 decoding. For base64 data, use `str` and decode manually with `base64.b64decode()`. **Enums**: Clients send enum values (`"red"`), not names (`"RED"`). Your function receives the Enum member (`Color.RED`). **Paths and UUIDs**: String inputs are automatically converted to `Path` and `UUID` objects. **Pydantic Models**: Must be provided as JSON objects (dicts), not stringified JSON. Even with flexible validation, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. ### Optional Arguments FastMCP follows Python's standard function parameter conventions. Parameters without default values are required, while those with default values are optional. ```python @mcp.tool def search_products( query: str, # Required - no default value max_results: int = 10, # Optional - has default value sort_by: str = "relevance", # Optional - has default value category: str | None = None # Optional - can be None ) -> list[dict]: """Search the product catalog.""" # Implementation... ``` In this example, the LLM must provide a `query` parameter, while `max_results`, `sort_by`, and `category` will use their default values if not explicitly provided. ### Validation Modes By default, FastMCP uses Pydantic's flexible validation that coerces compatible inputs to match your type annotations. This improves compatibility with LLM clients that may send string representations of values (like `"10"` for an integer parameter). If you need stricter validation that rejects any type mismatches, you can enable strict input validation. Strict mode uses the MCP SDK's built-in JSON Schema validation to validate inputs against the exact schema before passing them to your function: ```python # Enable strict validation for this server mcp = FastMCP("StrictServer", strict_input_validation=True) @mcp.tool def add_numbers(a: int, b: int) -> int: """Add two numbers.""" return a + b # With strict_input_validation=True, sending {"a": "10", "b": "20"} will fail # With strict_input_validation=False (default), it will be coerced to integers ``` **Validation Behavior Comparison:** | Input Type | strict_input_validation=False (default) | strict_input_validation=True | | :--------- | :-------------------------------------- | :--------------------------- | | String integers (`"10"` for `int`) | ✅ Coerced to integer | ❌ Validation error | | String floats (`"3.14"` for `float`) | ✅ Coerced to float | ❌ Validation error | | String booleans (`"true"` for `bool`) | ✅ Coerced to boolean | ❌ Validation error | | Lists with string elements (`["1", "2"]` for `list[int]`) | ✅ Elements coerced | ❌ Validation error | | Pydantic model fields with type mismatches | ✅ Fields coerced | ❌ Validation error | | Invalid values (`"abc"` for `int`) | ❌ Validation error | ❌ Validation error | **Note on Pydantic Models:** Even with `strict_input_validation=False`, Pydantic model parameters must be provided as JSON objects (dicts), not as stringified JSON. For example, `{"user": {"name": "Alice"}}` works, but `{"user": '{"name": "Alice"}'}` does not. The default flexible validation mode is recommended for most use cases as it handles common LLM client behaviors gracefully while still providing strong type safety through Pydantic's validation. ### Parameter Metadata You can provide additional metadata about parameters in several ways: #### Simple String Descriptions For basic parameter descriptions, you can use a convenient shorthand with `Annotated`: ```python from typing import Annotated @mcp.tool def process_image( image_url: Annotated[str, "URL of the image to process"], resize: Annotated[bool, "Whether to resize the image"] = False, width: Annotated[int, "Target width in pixels"] = 800, format: Annotated[str, "Output image format"] = "jpeg" ) -> dict: """Process an image with optional resizing.""" # Implementation... ``` This shorthand syntax is equivalent to using `Field(description=...)` but more concise for simple descriptions. This shorthand syntax is only applied to `Annotated` types with a single string description. #### Advanced Metadata with Field For validation constraints and advanced metadata, use Pydantic's `Field` class with `Annotated`: ```python from typing import Annotated from pydantic import Field @mcp.tool def process_image( image_url: Annotated[str, Field(description="URL of the image to process")], resize: Annotated[bool, Field(description="Whether to resize the image")] = False, width: Annotated[int, Field(description="Target width in pixels", ge=1, le=2000)] = 800, format: Annotated[ Literal["jpeg", "png", "webp"], Field(description="Output image format") ] = "jpeg" ) -> dict: """Process an image with optional resizing.""" # Implementation... ``` You can also use the Field as a default value, though the Annotated approach is preferred: ```python @mcp.tool def search_database( query: str = Field(description="Search query string"), limit: int = Field(10, description="Maximum number of results", ge=1, le=100) ) -> list: """Search the database with the provided query.""" # Implementation... ``` Field provides several validation and documentation features: - `description`: Human-readable explanation of the parameter (shown to LLMs) - `ge`/`gt`/`le`/`lt`: Greater/less than (or equal) constraints - `min_length`/`max_length`: String or collection length constraints - `pattern`: Regex pattern for string validation - `default`: Default value if parameter is omitted ### Hiding Parameters from the LLM To inject values at runtime without exposing them to the LLM (such as `user_id`, credentials, or database connections), use dependency injection with `Depends()`. Parameters using `Depends()` are automatically excluded from the tool schema: ```python from fastmcp import FastMCP from fastmcp.dependencies import Depends mcp = FastMCP() def get_user_id() -> str: return "user_123" # Injected at runtime @mcp.tool def get_user_details(user_id: str = Depends(get_user_id)) -> str: # user_id is injected by the server, not provided by the LLM return f"Details for {user_id}" ``` See [Custom Dependencies](/servers/context#custom-dependencies) for more details on dependency injection. ## Return Values FastMCP tools can return data in two complementary formats: **traditional content blocks** (like text and images) and **structured outputs** (machine-readable JSON). When you add return type annotations, FastMCP automatically generates **output schemas** to validate the structured data and enables clients to deserialize results back to Python objects. Understanding how these three concepts work together: - **Return Values**: What your Python function returns (determines both content blocks and structured data) - **Structured Outputs**: JSON data sent alongside traditional content for machine processing - **Output Schemas**: JSON Schema declarations that describe and validate the structured output format The following sections explain each concept in detail. ### Content Blocks FastMCP automatically converts tool return values into appropriate MCP content blocks: - **`str`**: Sent as `TextContent` - **`bytes`**: Base64 encoded and sent as `BlobResourceContents` (within an `EmbeddedResource`) - **`fastmcp.utilities.types.Image`**: Sent as `ImageContent` - **`fastmcp.utilities.types.Audio`**: Sent as `AudioContent` - **`fastmcp.utilities.types.File`**: Sent as base64-encoded `EmbeddedResource` - **MCP SDK content blocks**: Sent as-is - **A list of any of the above**: Converts each item according to the above rules - **`None`**: Results in an empty response #### Media Helper Classes FastMCP provides helper classes for returning images, audio, and files. When you return one of these classes, either directly or as part of a list, FastMCP automatically converts it to the appropriate MCP content block. For example, if you return a `fastmcp.utilities.types.Image` object, FastMCP will convert it to an MCP `ImageContent` block with the correct MIME type and base64 encoding. ```python from fastmcp.utilities.types import Image, Audio, File @mcp.tool def get_chart() -> Image: """Generate a chart image.""" return Image(path="chart.png") @mcp.tool def get_multiple_charts() -> list[Image]: """Return multiple charts.""" return [Image(path="chart1.png"), Image(path="chart2.png")] ``` Helper classes are only automatically converted to MCP content blocks when returned **directly** or as part of a **list**. For more complex containers like dicts, you can manually convert them to MCP types: ```python # ✅ Automatic conversion return Image(path="chart.png") return [Image(path="chart1.png"), "text content"] # ❌ Will not be automatically converted return {"image": Image(path="chart.png")} # ✅ Manual conversion for nested use return {"image": Image(path="chart.png").to_image_content()} ``` Each helper class accepts either `path=` or `data=` (mutually exclusive): - **`path`**: File path (string or Path object) - MIME type detected from extension - **`data`**: Raw bytes - requires `format=` parameter for MIME type - **`format`**: Optional format override (e.g., "png", "wav", "pdf") - **`name`**: Optional name for `File` when using `data=` - **`annotations`**: Optional MCP annotations for the content ### Structured Output The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content) structured content, which is a new way to return data from tools. Structured content is a JSON object that is sent alongside traditional content. FastMCP automatically creates structured outputs alongside traditional content when your tool returns data that has a JSON object representation. This provides machine-readable JSON data that clients can deserialize back to Python objects. **Automatic Structured Content Rules:** - **Object-like results** (`dict`, Pydantic models, dataclasses) → Always become structured content (even without output schema) - **Non-object results** (`int`, `str`, `list`) → Only become structured content if there's an output schema to validate/serialize them - **All results** → Always become traditional content blocks for backward compatibility This automatic behavior enables clients to receive machine-readable data alongside human-readable content without requiring explicit output schemas for object-like returns. #### Dictionaries and Objects When your tool returns a dictionary, dataclass, or Pydantic model, FastMCP automatically creates structured content from it. The structured content contains the actual object data, making it easy for clients to deserialize back to native objects. ```python Tool Definition @mcp.tool def get_user_data(user_id: str) -> dict: """Get user data.""" return {"name": "Alice", "age": 30, "active": True} ``` ```json MCP Result { "content": [ { "type": "text", "text": "{\n \"name\": \"Alice\",\n \"age\": 30,\n \"active\": true\n}" } ], "structuredContent": { "name": "Alice", "age": 30, "active": true } } ``` #### Primitives and Collections When your tool returns a primitive type (int, str, bool) or a collection (list, set), FastMCP needs a return type annotation to generate structured content. The annotation tells FastMCP how to validate and serialize the result. Without a type annotation, the tool only produces `content`: ```python Tool Definition @mcp.tool def calculate_sum(a: int, b: int): """Calculate sum without return annotation.""" return a + b # Returns 8 ``` ```json MCP Result { "content": [ { "type": "text", "text": "8" } ] } ``` When you add a return annotation, such as `-> int`, FastMCP generates `structuredContent` by wrapping the primitive value in a `{"result": ...}` object, since JSON schemas require object-type roots for structured output: ```python Tool Definition @mcp.tool def calculate_sum(a: int, b: int) -> int: """Calculate sum with return annotation.""" return a + b # Returns 8 ``` ```json MCP Result { "content": [ { "type": "text", "text": "8" } ], "structuredContent": { "result": 8 } } ``` #### Typed Models Return type annotations work with any type that can be converted to a JSON schema. Dataclasses and Pydantic models are particularly useful because FastMCP extracts their field definitions to create detailed schemas. ```python Tool Definition from dataclasses import dataclass from fastmcp import FastMCP mcp = FastMCP() @dataclass class Person: name: str age: int email: str @mcp.tool def get_user_profile(user_id: str) -> Person: """Get a user's profile information.""" return Person( name="Alice", age=30, email="alice@example.com", ) ``` ```json Generated Output Schema { "properties": { "name": {"title": "Name", "type": "string"}, "age": {"title": "Age", "type": "integer"}, "email": {"title": "Email", "type": "string"} }, "required": ["name", "age", "email"], "title": "Person", "type": "object" } ``` ```json MCP Result { "content": [ { "type": "text", "text": "{\"name\": \"Alice\", \"age\": 30, \"email\": \"alice@example.com\"}" } ], "structuredContent": { "name": "Alice", "age": 30, "email": "alice@example.com" } } ``` The `Person` dataclass becomes an output schema (second tab) that describes the expected format. When executed, clients receive the result (third tab) with both `content` and `structuredContent` fields. ### Output Schemas The 6/18/2025 MCP spec update [introduced](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema) output schemas, which are a new way to describe the expected output format of a tool. When an output schema is provided, the tool *must* return structured output that matches the schema. When you add return type annotations to your functions, FastMCP automatically generates JSON schemas that describe the expected output format. These schemas help MCP clients understand and validate the structured data they receive. #### Primitive Type Wrapping For primitive return types (like `int`, `str`, `bool`), FastMCP automatically wraps the result under a `"result"` key to create valid structured output: ```python Primitive Return Type @mcp.tool def calculate_sum(a: int, b: int) -> int: """Add two numbers together.""" return a + b ``` ```json Generated Schema (Wrapped) { "type": "object", "properties": { "result": {"type": "integer"} }, "x-fastmcp-wrap-result": true } ``` ```json Structured Output { "result": 8 } ``` #### Manual Schema Control You can override the automatically generated schema by providing a custom `output_schema`: ```python @mcp.tool(output_schema={ "type": "object", "properties": { "data": {"type": "string"}, "metadata": {"type": "object"} } }) def custom_schema_tool() -> dict: """Tool with custom output schema.""" return {"data": "Hello", "metadata": {"version": "1.0"}} ``` Schema generation works for most common types including basic types, collections, union types, Pydantic models, TypedDict structures, and dataclasses. **Important Constraints**: - Output schemas must be object types (`"type": "object"`) - If you provide an output schema, your tool **must** return structured output that matches it - However, you can provide structured output without an output schema (using `ToolResult`) ### ToolResult and Metadata For complete control over tool responses, return a `ToolResult` object. This gives you explicit control over all aspects of the tool's output: traditional content, structured data, and metadata. ```python from fastmcp.tools.tool import ToolResult from mcp.types import TextContent @mcp.tool def advanced_tool() -> ToolResult: """Tool with full control over output.""" return ToolResult( content=[TextContent(type="text", text="Human-readable summary")], structured_content={"data": "value", "count": 42}, meta={"execution_time_ms": 145} ) ``` `ToolResult` accepts three fields: **`content`** - The traditional MCP content blocks that clients display to users. Can be a string (automatically converted to `TextContent`), a list of MCP content blocks, or any serializable value (converted to JSON string). At least one of `content` or `structured_content` must be provided. ```python # Simple string ToolResult(content="Hello, world!") # List of content blocks ToolResult(content=[ TextContent(type="text", text="Result: 42"), ImageContent(type="image", data="base64...", mimeType="image/png") ]) ``` **`structured_content`** - A dictionary containing structured data that matches your tool's output schema. This enables clients to programmatically process the results. If you provide `structured_content`, it must be a dictionary or `None`. If only `structured_content` is provided, it will also be used as `content` (converted to JSON string). ```python ToolResult( content="Found 3 users", structured_content={"users": [{"name": "Alice"}, {"name": "Bob"}]} ) ``` **`meta`** Runtime metadata about the tool execution. Use this for performance metrics, debugging information, or any client-specific data that doesn't belong in the content or structured output. ```python ToolResult( content="Analysis complete", structured_content={"result": "positive"}, meta={ "execution_time_ms": 145, "model_version": "2.1", "confidence": 0.95 } ) ``` The `meta` field in `ToolResult` is for runtime metadata about tool execution (e.g., execution time, performance metrics). This is separate from the `meta` parameter in `@mcp.tool(meta={...})`, which provides static metadata about the tool definition itself. When returning `ToolResult`, you have full control - FastMCP won't automatically wrap or transform your data. `ToolResult` can be returned with or without an output schema. ### Custom Serialization When you need custom serialization (like YAML, Markdown tables, or specialized formats), return `ToolResult` with your serialized content. This makes the serialization explicit and visible in your tool's code: ```python import yaml from fastmcp import FastMCP from fastmcp.tools.tool import ToolResult mcp = FastMCP("MyServer") @mcp.tool def get_config() -> ToolResult: """Returns configuration as YAML.""" data = {"api_key": "abc123", "debug": True, "rate_limit": 100} return ToolResult( content=yaml.dump(data, sort_keys=False), structured_content=data ) ``` For reusable serialization across multiple tools, create a wrapper decorator that returns `ToolResult`. This lets you compose serializers with other behaviors (logging, validation, caching) and keeps the serialization visible at the tool definition. See [examples/custom_tool_serializer_decorator.py](https://github.com/PrefectHQ/fastmcp/blob/main/examples/custom_tool_serializer_decorator.py) for a complete implementation. ## Error Handling If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`. By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. If you want to mask internal error details for security reasons, you can: 1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: ```python mcp = FastMCP(name="SecureServer", mask_error_details=True) ``` 2. Or use `ToolError` to explicitly control what error information is sent to clients: ```python from fastmcp import FastMCP from fastmcp.exceptions import ToolError @mcp.tool def divide(a: float, b: float) -> float: """Divide a by b.""" if b == 0: # Error messages from ToolError are always sent to clients, # regardless of mask_error_details setting raise ToolError("Division by zero is not allowed.") # If mask_error_details=True, this message would be masked if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("Both arguments must be numbers.") return a / b ``` When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message. ## Timeouts Tools can specify a `timeout` parameter to limit how long execution can take. When the timeout is exceeded, the client receives an MCP error and the tool stops processing. This protects your server from unexpectedly slow operations that could block resources or leave clients waiting indefinitely. ```python from fastmcp import FastMCP mcp = FastMCP() @mcp.tool(timeout=30.0) async def fetch_data(url: str) -> dict: """Fetch data with a 30-second timeout.""" # If this takes longer than 30 seconds, # the client receives an MCP error ... ``` Timeouts are specified in seconds as a float. When a tool exceeds its timeout, FastMCP returns an MCP error with code `-32000` and a message indicating which tool timed out and how long it ran. Both sync and async tools support timeouts—sync functions run in thread pools, so the timeout applies to the entire operation regardless of execution model. Tools must explicitly opt-in to timeouts. There is no server-level default timeout setting. ### Timeouts vs Background Tasks Timeouts apply to **foreground execution**—when a tool runs directly in response to a client request. They protect your server from tools that unexpectedly hang due to network issues, resource contention, or other transient problems. The `timeout` parameter does **not** apply to background tasks. When a tool runs as a background task (`task=True`), execution happens in a Docket worker where the FastMCP timeout is not enforced. For task timeouts, use Docket's `Timeout` dependency directly in your function signature: ```python from datetime import timedelta from docket import Timeout @mcp.tool(task=True) async def long_running_task( data: str, timeout: Timeout = Timeout(timedelta(minutes=10)) ) -> str: """Task with a 10-minute timeout enforced by Docket.""" ... ``` See the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/#task-timeouts) for more on task timeouts and retries. When a tool times out, FastMCP logs a warning suggesting task mode. For operations you know will be long-running, use `task=True` instead—background tasks offload work to distributed workers and let clients poll for progress. ## Component Visibility You can control which tools are enabled for clients using server-level enabled control. Disabled tools don't appear in `list_tools` and can't be called. ```python from fastmcp import FastMCP mcp = FastMCP("MyServer") @mcp.tool(tags={"admin"}) def admin_action() -> str: """Admin-only action.""" return "Done" @mcp.tool(tags={"public"}) def public_action() -> str: """Public action.""" return "Done" # Disable specific tools by key mcp.disable(keys={"tool:admin_action"}) # Disable tools by tag mcp.disable(tags={"admin"}) # Or use allowlist mode - only enable tools with specific tags mcp.enable(tags={"public"}, only=True) ``` See [Visibility](/servers/visibility) for the complete visibility control API including key formats, tag-based filtering, and provider-level control. ## MCP Annotations FastMCP allows you to add specialized metadata to your tools through annotations. These annotations communicate how tools behave to client applications without consuming token context in LLM prompts. Annotations serve several purposes in client applications: - Adding user-friendly titles for display purposes - Indicating whether tools modify data or systems - Describing the safety profile of tools (destructive vs. non-destructive) - Signaling if tools interact with external systems You can add annotations to a tool using the `annotations` parameter in the `@mcp.tool` decorator: ```python @mcp.tool( annotations={ "title": "Calculate Sum", "readOnlyHint": True, "openWorldHint": False } ) def calculate_sum(a: float, b: float) -> float: """Add two numbers together.""" return a + b ``` FastMCP supports these standard annotations: | Annotation | Type | Default | Purpose | | :--------- | :--- | :------ | :------ | | `title` | string | - | Display name for user interfaces | | `readOnlyHint` | boolean | false | Indicates if the tool only reads without making changes | | `destructiveHint` | boolean | true | For non-readonly tools, signals if changes are destructive | | `idempotentHint` | boolean | false | Indicates if repeated identical calls have the same effect as a single call | | `openWorldHint` | boolean | true | Specifies if the tool interacts with external systems | Remember that annotations help make better user experiences but should be treated as advisory hints. They help client applications present appropriate UI elements and safety controls, but won't enforce security boundaries on their own. Always focus on making your annotations accurately represent what your tool actually does. ### Using Annotation Hints MCP clients like Claude and ChatGPT use annotation hints to determine when to skip confirmation prompts and how to present tools to users. The most commonly used hint is `readOnlyHint`, which signals that a tool only reads data without making changes. **Read-only tools** improve user experience by: - Skipping confirmation prompts for safe operations - Allowing broader access without security concerns - Enabling more aggressive batching and caching Mark a tool as read-only when it retrieves data, performs calculations, or checks status without modifying state: ```python from fastmcp import FastMCP from mcp.types import ToolAnnotations mcp = FastMCP("Data Server") @mcp.tool(annotations={"readOnlyHint": True}) def get_user(user_id: str) -> dict: """Retrieve user information by ID.""" return {"id": user_id, "name": "Alice"} @mcp.tool( annotations=ToolAnnotations( readOnlyHint=True, idempotentHint=True, # Same result for repeated calls openWorldHint=False # Only internal data ) ) def search_products(query: str) -> list[dict]: """Search the product catalog.""" return [{"id": 1, "name": "Widget", "price": 29.99}] # Write operations - no readOnlyHint @mcp.tool() def update_user(user_id: str, name: str) -> dict: """Update user information.""" return {"id": user_id, "name": name, "updated": True} @mcp.tool(annotations={"destructiveHint": True}) def delete_user(user_id: str) -> dict: """Permanently delete a user account.""" return {"deleted": user_id} ``` For tools that write to databases, send notifications, create/update/delete resources, or trigger workflows, omit `readOnlyHint` or set it to `False`. Use `destructiveHint=True` for operations that cannot be undone. Client-specific behavior: - **ChatGPT**: Skips confirmation prompts for read-only tools in Chat mode (see [ChatGPT integration](/integrations/chatgpt)) - **Claude**: Uses hints to understand tool safety profiles and make better execution decisions ## Notifications FastMCP automatically sends `notifications/tools/list_changed` notifications to connected clients when tools are added, removed, enabled, or disabled. This allows clients to stay up-to-date with the current tool set without manually polling for changes. ```python @mcp.tool def example_tool() -> str: return "Hello!" # These operations trigger notifications: mcp.add_tool(example_tool) # Sends tools/list_changed notification mcp.disable(keys={"tool:example_tool"}) # Sends tools/list_changed notification mcp.enable(keys={"tool:example_tool"}) # Sends tools/list_changed notification mcp.local_provider.remove_tool("example_tool") # Sends tools/list_changed notification ``` Notifications are only sent when these operations occur within an active MCP request context (e.g., when called from within a tool or other MCP operation). Operations performed during server initialization do not trigger notifications. Clients can handle these notifications using a [message handler](/clients/notifications) to automatically refresh their tool lists or update their interfaces. ## Accessing the MCP Context Tools can access MCP features like logging, reading resources, or reporting progress through the `Context` object. To use it, add a parameter to your tool function with the type hint `Context`. ```python from fastmcp import FastMCP, Context mcp = FastMCP(name="ContextDemo") @mcp.tool async def process_data(data_uri: str, ctx: Context) -> dict: """Process data from a resource with progress reporting.""" await ctx.info(f"Processing data from {data_uri}") # Read a resource resource = await ctx.read_resource(data_uri) data = resource[0].content if resource else "" # Report progress await ctx.report_progress(progress=50, total=100) # Example request to the client's LLM for help summary = await ctx.sample(f"Summarize this in 10 words: {data[:200]}") await ctx.report_progress(progress=100, total=100) return { "length": len(data), "summary": summary.text } ``` The Context object provides access to: - **Logging**: `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()` - **Progress Reporting**: `ctx.report_progress(progress, total)` - **Resource Access**: `ctx.read_resource(uri)` - **LLM Sampling**: `ctx.sample(...)` - **Request Information**: `ctx.request_id`, `ctx.client_id` For full documentation on the Context object and all its capabilities, see the [Context documentation](/servers/context). ## Server Behavior ### Duplicate Tools You can control how the FastMCP server behaves if you try to register multiple tools with the same name. This is configured using the `on_duplicate_tools` argument when creating the `FastMCP` instance. ```python from fastmcp import FastMCP mcp = FastMCP( name="StrictServer", # Configure behavior for duplicate tool names on_duplicate_tools="error" ) @mcp.tool def my_tool(): return "Version 1" # This will now raise a ValueError because 'my_tool' already exists # and on_duplicate_tools is set to "error". # @mcp.tool # def my_tool(): return "Version 2" ``` The duplicate behavior options are: - `"warn"` (default): Logs a warning and the new tool replaces the old one. - `"error"`: Raises a `ValueError`, preventing the duplicate registration. - `"replace"`: Silently replaces the existing tool with the new one. - `"ignore"`: Keeps the original tool and ignores the new registration attempt. ### Removing Tools You can dynamically remove tools from a server through its [local provider](/servers/providers/local): ```python from fastmcp import FastMCP mcp = FastMCP(name="DynamicToolServer") @mcp.tool def calculate_sum(a: int, b: int) -> int: """Add two numbers together.""" return a + b mcp.local_provider.remove_tool("calculate_sum") ``` ## Versioning Tools support versioning, allowing you to maintain multiple implementations under the same name while clients automatically receive the highest version. See [Versioning](/servers/versioning) for complete documentation on version comparison, retrieval, and migration patterns. ================================================ FILE: docs/servers/transforms/code-mode.mdx ================================================ --- title: Code Mode sidebarTitle: Code Mode description: Let LLMs write Python to orchestrate tools in a sandbox icon: flask tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' CodeMode is experimental. The core interface is stable, but the specific discovery tools and their parameters may evolve as we learn more about what works best in practice. Standard MCP tool usage has two scaling problems. First, every tool in the catalog is loaded into the LLM's context upfront — with hundreds of tools, that's tens of thousands of tokens spent before the LLM even reads the user's request. Second, every tool call is a round-trip: the LLM calls a tool, the result passes back through the context window, the LLM reasons about it, calls another tool, and so on. Intermediate results that only exist to feed the next step still burn tokens flowing through the model. CodeMode solves both problems. Instead of seeing your entire tool catalog, the LLM gets meta-tools for discovering what's available and for writing and executing code that calls the tools it needs. It discovers on demand, writes a script that chains tool calls in a sandbox, and gets back only the final answer. The approach was introduced by Cloudflare in [Code Mode](https://blog.cloudflare.com/code-mode/) and explored further by Anthropic in [Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp). ## Getting Started CodeMode requires the `code-mode` extra for sandbox support. Install it with `pip install "fastmcp[code-mode]"`. You take a normal server with normally registered tools and add a `CodeMode` transform. The transform wraps your existing tools in the code mode machinery — your tool functions don't change at all: ```python from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import CodeMode mcp = FastMCP("Server", transforms=[CodeMode()]) @mcp.tool def add(x: int, y: int) -> int: """Add two numbers.""" return x + y @mcp.tool def multiply(x: int, y: int) -> int: """Multiply two numbers.""" return x * y ``` Clients connecting to this server no longer see `add` and `multiply` directly. Instead, they see the meta-tools that CodeMode provides — tools for discovering what's available and executing code against it. The original tools are still there, but they're accessed through the CodeMode layer. ## Discovery Before the LLM can write code that calls your tools, it needs to know what tools exist and how to call them. This is the **discovery** process — the LLM uses meta-tools to learn about your tool catalog, then writes code against what it finds. The fundamental tradeoff is **tokens vs. round-trips**. Each discovery step is an LLM round-trip: the model calls a tool, waits for the response, reasons about it, then decides what to do next. More steps mean less wasted context (each step is targeted) but more latency and API calls. Fewer steps mean the LLM gets information upfront but pays for detail it might not need. By default, CodeMode gives the LLM three tools — `search`, `get_schema`, and `execute` — creating a three-stage discovery flow: First, the LLM uses the `search` meta-tool to find tools by keyword. For example, it might do `search(query="math numbers")` and receive the following response: ``` - add: Add two numbers. - multiply: Multiply two numbers. ``` This lets the LLM know which tools are available and what they do, significantly reducing the surface area it needs to consider. Next, the LLM calls `get_schema` to get parameter details for the tools it found in the previous step. For example, it might do `get_schema(tools=["add", "multiply"])` and receive the following response: ``` ### add Add two numbers. **Parameters** - `x` (integer, required) - `y` (integer, required) ### multiply Multiply two numbers. **Parameters** - `x` (integer, required) - `y` (integer, required) ``` Now the LLM knows the parameters for the tools it found, and can write code that chains the tool calls. If it needed more detail, it could have called `get_schema` with `detail="full"` to get the complete JSON schema. Finally, the LLM writes and executes code that chains the tool calls in a Python sandbox. Inside the sandbox, `call_tool(name, params)` is the only function available. The LLM uses this to compose tools into a workflow and return a final result. For example, it might write the following code and call the `execute` tool with it: ```python a = await call_tool("add", {"x": 3, "y": 4}) b = await call_tool("multiply", {"x": a, "y": 2}) return b ``` The result is returned to the LLM. This three-stage flow works well for most servers — each step pulls in only the information needed for the next one, keeping context usage minimal. But CodeMode's discovery surface is fully configurable. The sections below explain each built-in discovery tool and how to combine them into different patterns. ## Discovery Tools CodeMode ships with four built-in discovery tools: `Search`, `GetSchemas`, `GetTags`, and `ListTools`. By default, only `Search` and `GetSchemas` are enabled. Each tool supports a `default_detail` parameter that sets the default verbosity level, and the LLM can override the detail level on any individual call. ### Detail Levels `Search` and `GetSchemas` share the same three detail levels, so the same `detail` value produces the same output format regardless of which tool the LLM calls: | Level | Output | Token cost | |---|---|---| | `"brief"` | Tool names and one-line descriptions | Cheapest — good for scanning | | `"detailed"` | Compact markdown with parameter names, types, and required markers | Medium — often enough to write code | | `"full"` | Complete JSON schema | Most expensive — everything | `Search` defaults to `"brief"` and `GetSchemas` defaults to `"detailed"`. ### Search `Search` finds tools by natural-language query using BM25 ranking. At its default `"brief"` detail, results include just tool names and descriptions — enough to decide which tools are worth inspecting further. The LLM can request `"detailed"` to get parameter schemas inline, or `"full"` for the complete JSON. Search results include an annotation like `"2 of 10 tools:"` when the result set is smaller than the full catalog, so the LLM knows there are more tools to discover with different queries. You can cap result count with `default_limit`. The LLM can also override the limit per call. This is useful for large catalogs where you want to keep search results focused: ```python Search(default_limit=5) # return at most 5 results per search ``` If your tools use [tags](/servers/tools#tags), Search also accepts a `tags` parameter so the LLM can narrow results to specific categories before searching. ### GetSchemas `GetSchemas` returns parameter details for specific tools by name. At its default `"detailed"` level, it renders compact markdown with parameter names, types, and required markers. At `"full"`, it returns the complete JSON schema — useful when tools have deeply nested parameters that the compact format doesn't capture. ### GetTags `GetTags` lets the LLM browse tools by category using [tag](/servers/tools#tags) metadata. At brief detail, the LLM sees tag names with counts. At full detail, it sees tools listed under each tag: ``` - math (3 tools) - text (2 tools) - untagged (1 tool) ``` `GetTags` isn't included in the defaults — add it when browsing by category would help the LLM orient itself in a large catalog. The LLM can browse tags first, then pass specific tags into Search to narrow results. ### ListTools `ListTools` dumps the entire catalog at whatever detail level the LLM requests. It supports the same three detail levels as `Search` and `GetSchemas`, defaulting to `"brief"`. `ListTools` isn't included in the defaults — for large catalogs, search-based discovery is more token-efficient. But for smaller catalogs (under ~20 tools), letting the LLM see everything upfront can be faster than multiple search round-trips: ```python from fastmcp.experimental.transforms.code_mode import CodeMode, ListTools, GetSchemas code_mode = CodeMode( discovery_tools=[ListTools(), GetSchemas()], ) ``` ## Discovery Patterns The right discovery configuration depends on your server — how many tools you have and how complex their parameters are. It may be tempting to minimize round-trips by collapsing everything into fewer steps, but for the complex servers that benefit most from CodeMode, our experience is that staged discovery leads to better results. Flooding the LLM with detailed schemas for tools it doesn't end up using can hurt more than the extra round-trip costs. Each pattern below is a complete, copyable configuration. ### Three-Stage The default. The LLM searches for candidates, inspects schemas for the ones it wants, then writes code. Best for **large or complex tool sets** where you want to minimize context usage — the LLM only pays for schemas it actually needs. ```python from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import CodeMode mcp = FastMCP("Server", transforms=[CodeMode()]) ``` If your tools use [tags](/servers/tools#tags), add `GetTags` so the LLM can browse by category before searching — giving it four stages of progressive disclosure: ```python from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import GetTags, Search, GetSchemas code_mode = CodeMode( discovery_tools=[GetTags(), Search(), GetSchemas()], ) mcp = FastMCP("Server", transforms=[code_mode]) ``` ### Two-Stage Search returns parameter schemas inline, so the LLM can go straight from search to execute. Best for **smaller catalogs** where the extra tokens per search result are a reasonable price for one fewer round-trip. ```python from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import Search, GetSchemas code_mode = CodeMode( discovery_tools=[Search(default_detail="detailed"), GetSchemas()], ) mcp = FastMCP("Server", transforms=[code_mode]) ``` `GetSchemas` is still available as a fallback — the LLM can call it with `detail="full"` if it encounters a tool with complex nested parameters where the compact markdown isn't enough. ### Single-Stage Skip discovery entirely and bake tool instructions into the execute tool's description. Best for **very simple servers** where the LLM already knows what tools are available — maybe there are only a few, or they're described in the system prompt. ```python from fastmcp import FastMCP from fastmcp.experimental.transforms.code_mode import CodeMode code_mode = CodeMode( discovery_tools=[], execute_description=( "Available tools:\n" "- add(x: int, y: int) -> int: Add two numbers\n" "- multiply(x: int, y: int) -> int: Multiply two numbers\n\n" "Write Python using `await call_tool(name, params)` and `return` the result." ), ) mcp = FastMCP("Server", transforms=[code_mode]) ``` ## Custom Discovery Tools Discovery tools are composable — you can mix the built-ins with your own. Each discovery tool is a callable that receives catalog access and returns a `Tool`. The catalog accessor is a function (not the catalog itself) because the catalog is request-scoped — different users may see different tools based on auth. Here's a minimal example: ```python from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import GetToolCatalog, GetSchemas from fastmcp.server.context import Context from fastmcp.tools.tool import Tool def list_all_tools(get_catalog: GetToolCatalog) -> Tool: async def list_tools(ctx: Context) -> str: """List all available tool names.""" tools = await get_catalog(ctx) return ", ".join(t.name for t in tools) return Tool.from_function(fn=list_tools, name="list_tools") code_mode = CodeMode(discovery_tools=[list_all_tools, GetSchemas()]) ``` The LLM sees the docstring of each discovery tool's inner function as its description — that's how it learns what each tool does and when to use it. Write docstrings that explain what the tool returns and when the LLM should call it. Discovery tools and the execute tool can also have custom names: ```python from fastmcp.experimental.transforms.code_mode import Search, GetSchemas code_mode = CodeMode( discovery_tools=[ Search(name="find_tools"), GetSchemas(name="describe"), ], execute_tool_name="run_workflow", ) mcp = FastMCP("Server", transforms=[code_mode]) ``` ## Sandbox Configuration ### Resource Limits The default `MontySandboxProvider` can enforce execution limits — timeouts, memory caps, recursion depth, and more. Without limits, LLM-generated scripts can run indefinitely. ```python from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import MontySandboxProvider sandbox = MontySandboxProvider( limits={"max_duration_secs": 10, "max_memory": 50_000_000}, ) mcp = FastMCP("Server", transforms=[CodeMode(sandbox_provider=sandbox)]) ``` All keys are optional — omit any to leave that dimension uncapped: | Key | Type | Description | |---|---|---| | `max_duration_secs` | `float` | Maximum wall-clock execution time | | `max_memory` | `int` | Memory ceiling in bytes | | `max_allocations` | `int` | Cap on total object allocations | | `max_recursion_depth` | `int` | Maximum recursion depth | | `gc_interval` | `int` | Garbage collection frequency | ### Custom Sandbox Providers You can replace the default sandbox with any object implementing the `SandboxProvider` protocol: ```python from collections.abc import Callable from typing import Any from fastmcp.experimental.transforms.code_mode import CodeMode from fastmcp.experimental.transforms.code_mode import SandboxProvider class RemoteSandboxProvider: async def run( self, code: str, *, inputs: dict[str, Any] | None = None, external_functions: dict[str, Callable[..., Any]] | None = None, ) -> Any: # Send code to your remote sandbox runtime ... mcp = FastMCP( "Server", transforms=[CodeMode(sandbox_provider=RemoteSandboxProvider())], ) ``` The `external_functions` dict contains async callables injected into the sandbox scope — `execute` uses this to provide `call_tool`. ================================================ FILE: docs/servers/transforms/namespace.mdx ================================================ --- title: Namespace Transform sidebarTitle: Namespace description: Prefix component names to prevent conflicts icon: tag tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' The `Namespace` transform prefixes all component names, preventing conflicts when composing multiple servers. Tools and prompts receive an underscore-separated prefix. Resources and templates receive a path-segment prefix in their URIs. | Component | Original | With `Namespace("api")` | |-----------|----------|-------------------------| | Tool | `my_tool` | `api_my_tool` | | Prompt | `my_prompt` | `api_my_prompt` | | Resource | `data://info` | `data://api/info` | | Template | `data://{id}` | `data://api/{id}` | The most common use is through the `mount()` method's `namespace` parameter. ```python from fastmcp import FastMCP weather = FastMCP("Weather") calendar = FastMCP("Calendar") @weather.tool def get_data() -> str: return "Weather data" @calendar.tool def get_data() -> str: return "Calendar data" # Without namespacing, these would conflict main = FastMCP("Main") main.mount(weather, namespace="weather") main.mount(calendar, namespace="calendar") # Clients see: weather_get_data, calendar_get_data ``` You can also apply namespacing directly using the `Namespace` transform. ```python from fastmcp import FastMCP from fastmcp.server.transforms import Namespace mcp = FastMCP("Server") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" # Namespace all components mcp.add_transform(Namespace("api")) # Tool is now: api_greet ``` ================================================ FILE: docs/servers/transforms/namespacing.mdx ================================================ --- title: Namespacing sidebarTitle: Namespacing description: Namespace and transform components with transforms icon: wand-magic-sparkles redirect: /servers/transforms/transforms --- This page has moved to [Transforms](/servers/transforms/transforms). ================================================ FILE: docs/servers/transforms/prompts-as-tools.mdx ================================================ --- title: Prompts as Tools sidebarTitle: Prompts as Tools description: Expose prompts to tool-only clients icon: message-lines tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Some MCP clients only support tools. They cannot list or get prompts directly because they lack prompt protocol support. The `PromptsAsTools` transform bridges this gap by generating tools that provide access to your server's prompts. When you add `PromptsAsTools` to a server, it creates two tools that clients can call instead of using the prompt protocol: - **`list_prompts`** returns JSON describing all available prompts and their arguments - **`get_prompt`** renders a specific prompt with provided arguments This means any client that can call tools can now access prompts, even if the client has no native prompt support. ## Basic Usage Pass your FastMCP server to `PromptsAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to prompt operations automatically, exactly as it would for direct `prompts/get` calls. `PromptsAsTools` (and `ResourcesAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of prompts, create a dedicated FastMCP server for those prompts and apply the transform there. ```python from fastmcp import FastMCP from fastmcp.server.transforms import PromptsAsTools mcp = FastMCP("My Server") @mcp.prompt def analyze_code(code: str, language: str = "python") -> str: """Analyze code for potential issues.""" return f"Analyze this {language} code:\n{code}" @mcp.prompt def explain_concept(concept: str) -> str: """Explain a programming concept.""" return f"Explain: {concept}" # Add the transform - creates list_prompts and get_prompt tools mcp.add_transform(PromptsAsTools(mcp)) ``` Clients now see three items: whatever tools you defined directly, plus `list_prompts` and `get_prompt`. ## Listing Prompts The `list_prompts` tool returns JSON with metadata for each prompt, including its arguments. ```python result = await client.call_tool("list_prompts", {}) prompts = json.loads(result.data) # [ # { # "name": "analyze_code", # "description": "Analyze code for potential issues.", # "arguments": [ # {"name": "code", "description": null, "required": true}, # {"name": "language", "description": null, "required": false} # ] # }, # { # "name": "explain_concept", # "description": "Explain a programming concept.", # "arguments": [ # {"name": "concept", "description": null, "required": true} # ] # } #] ``` Each argument includes: - `name`: The argument name - `description`: Optional description from type hints or docstrings - `required`: Whether the argument must be provided ## Getting Prompts The `get_prompt` tool accepts a prompt name and optional arguments dict. It returns the rendered prompt as JSON with a messages array. ```python # Prompt with required and optional arguments result = await client.call_tool( "get_prompt", { "name": "analyze_code", "arguments": { "code": "x = 1\nprint(x)", "language": "python" } } ) response = json.loads(result.data) # { # "messages": [ # { # "role": "user", # "content": "Analyze this python code:\nx = 1\nprint(x)" # } # ] # } ``` If a prompt has no arguments, you can omit the `arguments` field or pass an empty dict: ```python result = await client.call_tool( "get_prompt", {"name": "simple_prompt"} ) ``` ## Message Format Rendered prompts return a messages array following the standard MCP format. Each message includes: - `role`: The message role ("user" or "assistant") - `content`: The message text content Multi-message prompts are supported - the array will contain all messages in order. ## Binary Content Unlike resources, prompts always return text content. There is no binary encoding needed. ================================================ FILE: docs/servers/transforms/resources-as-tools.mdx ================================================ --- title: Resources as Tools sidebarTitle: Resources as Tools description: Expose resources to tool-only clients icon: toolbox tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Some MCP clients only support tools. They cannot list or read resources directly because they lack resource protocol support. The `ResourcesAsTools` transform bridges this gap by generating tools that provide access to your server's resources. When you add `ResourcesAsTools` to a server, it creates two tools that clients can call instead of using the resource protocol: - **`list_resources`** returns JSON describing all available resources and templates - **`read_resource`** reads a specific resource by URI This means any client that can call tools can now access resources, even if the client has no native resource support. ## Basic Usage Pass your FastMCP server to `ResourcesAsTools` when adding the transform. The generated tools route through the server at runtime, which means all server middleware — auth, visibility, rate limiting — applies to resource operations automatically, exactly as it would for direct `resources/read` calls. `ResourcesAsTools` (and `PromptsAsTools`) should be applied to a FastMCP server instance, not a raw Provider. The generated tools call back into the server's middleware chain at runtime, so they need a server to route through. If you want to expose only a subset of resources, create a dedicated FastMCP server for those resources and apply the transform there. ```python from fastmcp import FastMCP from fastmcp.server.transforms import ResourcesAsTools mcp = FastMCP("My Server") @mcp.resource("config://app") def app_config() -> str: """Application configuration.""" return '{"app_name": "My App", "version": "1.0.0"}' @mcp.resource("user://{user_id}/profile") def user_profile(user_id: str) -> str: """Get a user's profile by ID.""" return f'{{"user_id": "{user_id}", "name": "User {user_id}"}}' # Add the transform - creates list_resources and read_resource tools mcp.add_transform(ResourcesAsTools(mcp)) ``` Clients now see three tools: whatever tools you defined directly, plus `list_resources` and `read_resource`. Both generated tools are annotated with `readOnlyHint=True`, since they only read data. Clients that respect tool annotations (like Cursor) can use this to auto-confirm these tool calls without prompting the user. ## Static Resources vs Templates Resources come in two forms, and the `list_resources` tool distinguishes between them in its JSON output. Static resources have fixed URIs. They represent concrete data that exists at a known location. In the listing output, static resources include a `uri` field containing the exact URI to request. Resource templates have parameterized URIs with placeholders like `{user_id}`. They represent patterns for accessing dynamic data. In the listing output, templates include a `uri_template` field showing the pattern with its placeholders. When a client calls `list_resources`, it receives JSON like this: ```json [ { "uri": "config://app", "name": "app_config", "description": "Application configuration.", "mime_type": "text/plain" }, { "uri_template": "user://{user_id}/profile", "name": "user_profile", "description": "Get a user's profile by ID." } ] ``` The client can distinguish resource types by checking which field is present: `uri` for static resources, `uri_template` for templates. ## Reading Resources The `read_resource` tool accepts a single `uri` argument. For static resources, pass the exact URI. For templates, fill in the placeholders with actual values. ```python # Reading a static resource result = await client.call_tool("read_resource", {"uri": "config://app"}) print(result.data) # '{"app_name": "My App", "version": "1.0.0"}' # Reading a templated resource - fill in {user_id} with an actual ID result = await client.call_tool("read_resource", {"uri": "user://42/profile"}) print(result.data) # '{"user_id": "42", "name": "User 42"}' ``` The transform handles template matching automatically. When you request `user://42/profile`, it matches against the `user://{user_id}/profile` template, extracts `user_id=42`, and calls your resource function with that parameter. ## Binary Content Resources that return binary data (like images or files) are automatically base64-encoded when read through the `read_resource` tool. This ensures binary content can be transmitted as a string in the tool response. ```python @mcp.resource("data://binary", mime_type="application/octet-stream") def binary_data() -> bytes: return b"\x00\x01\x02\x03" # Client receives base64-encoded string result = await client.call_tool("read_resource", {"uri": "data://binary"}) decoded = base64.b64decode(result.data) # b'\x00\x01\x02\x03' ``` ================================================ FILE: docs/servers/transforms/tool-search.mdx ================================================ --- title: Tool Search sidebarTitle: Tool Search description: Replace large tool catalogs with on-demand search icon: magnifying-glass tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' When a server exposes hundreds or thousands of tools, sending the full catalog to an LLM wastes tokens and degrades tool selection accuracy. Search transforms solve this by replacing the tool listing with a search interface — the LLM discovers tools on demand instead of receiving everything upfront. ## How It Works When you add a search transform, `list_tools()` returns just two synthetic tools instead of the full catalog: - **`search_tools`** finds tools matching a query and returns their full definitions - **`call_tool`** executes a discovered tool by name The original tools are still callable. They're hidden from the listing but remain fully functional — the search transform controls *discovery*, not *access*. Both synthetic tools search across tool names, descriptions, parameter names, and parameter descriptions. A search for `"email"` would match a tool named `send_email`, a tool with "email" in its description, or a tool with an `email_address` parameter. Search results are returned in the same JSON format as `list_tools`, including the full input schema, so the LLM can construct valid calls immediately without a second round-trip. ## Search Strategies FastMCP provides two search transforms. They share the same interface — two synthetic tools, same configuration options — but differ in how they match queries to tools. ### Regex Search `RegexSearchTransform` matches tools against a regex pattern using case-insensitive `re.search`. It has zero overhead and no index to build, making it a good default when the LLM knows roughly what it's looking for. ```python from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("My Server", transforms=[RegexSearchTransform()]) @mcp.tool def search_database(query: str, limit: int = 10) -> list[dict]: """Search the database for records matching the query.""" ... @mcp.tool def delete_record(record_id: str) -> bool: """Delete a record from the database by its ID.""" ... @mcp.tool def send_email(to: str, subject: str, body: str) -> bool: """Send an email to the given recipient.""" ... ``` The LLM's `search_tools` call takes a `pattern` parameter — a regex string: ```python # Exact substring match result = await client.call_tool("search_tools", {"pattern": "database"}) # Returns: search_database, delete_record # Regex pattern result = await client.call_tool("search_tools", {"pattern": "send.*email|notify"}) # Returns: send_email ``` Results are returned in catalog order. If the pattern is invalid regex, the search returns an empty list rather than raising an error. ### BM25 Search `BM25SearchTransform` ranks tools by relevance using the [BM25 Okapi](https://en.wikipedia.org/wiki/Okapi_BM25) algorithm. It's better for natural language queries because it scores each tool based on term frequency and document rarity, returning results ranked by relevance rather than filtering by match/no-match. ```python from fastmcp import FastMCP from fastmcp.server.transforms.search import BM25SearchTransform mcp = FastMCP("My Server", transforms=[BM25SearchTransform()]) # ... define tools ... ``` The LLM's `search_tools` call takes a `query` parameter — natural language: ```python result = await client.call_tool("search_tools", { "query": "tools for deleting things from the database" }) # Returns: delete_record ranked first, search_database second ``` BM25 builds an in-memory index from the searchable text of all tools. The index is created lazily on the first search and automatically rebuilt whenever the tool catalog changes — for example, when tools are added, removed, or have their descriptions updated. The staleness check is based on a hash of all searchable text, so description changes are detected even when tool names stay the same. ### Which to Choose Use **regex** when your LLM is good at constructing targeted patterns and you want deterministic, predictable results. Regex is also simpler to debug — you can see exactly what pattern was sent. Use **BM25** when your LLM tends to describe what it needs in natural language, or when your tool catalog has nuanced descriptions where relevance ranking adds value. BM25 handles partial matches and synonyms better because it scores on individual terms rather than requiring a single pattern to match. ## Configuration Both search transforms accept the same configuration options. ### Limiting Results By default, search returns at most 5 tools. Adjust `max_results` based on your catalog size and how much context you want the LLM to receive per search: ```python mcp.add_transform(RegexSearchTransform(max_results=10)) mcp.add_transform(BM25SearchTransform(max_results=3)) ``` With regex, results stop as soon as the limit is reached (first N matches in catalog order). With BM25, all tools are scored and the top N by relevance are returned. ### Pinning Tools Some tools should always be visible regardless of search. Use `always_visible` to pin them in the listing alongside the synthetic tools: ```python mcp.add_transform(RegexSearchTransform( always_visible=["help", "status"], )) # list_tools returns: help, status, search_tools, call_tool ``` Pinned tools appear directly in `list_tools` so the LLM can call them without searching. They're excluded from search results to avoid duplication. ### Custom Tool Names The default names `search_tools` and `call_tool` can be changed to avoid conflicts with real tools: ```python mcp.add_transform(RegexSearchTransform( search_tool_name="find_tools", call_tool_name="run_tool", )) ``` ## The `call_tool` Proxy The `call_tool` proxy forwards calls to the real tool. When a client calls `call_tool(name="search_database", arguments={...})`, the proxy resolves `search_database` through the server's normal tool pipeline — including transforms and middleware — and executes it. The proxy rejects attempts to call the synthetic tools themselves. `call_tool(name="call_tool")` raises an error rather than recursing. Tools discovered through search can also be called directly via `client.call_tool("search_database", {...})` without going through the proxy. The proxy exists for LLMs that only know about the tools returned by `list_tools` and need a way to invoke discovered tools through a tool they can see. ## Auth and Visibility Search results respect the full authorization pipeline. Tools filtered by middleware, visibility transforms, or component-level auth checks won't appear in search results. The search tool queries `list_tools()` through the complete pipeline at search time, so the same filtering that controls what a client sees in the listing also controls what they can discover through search. ```python from fastmcp.server.transforms import Visibility from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("My Server") # ... define tools ... # Disable admin tools globally mcp.add_transform(Visibility(False, tags={"admin"})) # Add search — admin tools won't appear in results mcp.add_transform(RegexSearchTransform()) ``` Session-level visibility changes (via `ctx.disable_components()`) are also reflected immediately in search results. ================================================ FILE: docs/servers/transforms/tool-transformation.mdx ================================================ --- title: Tool Transformation sidebarTitle: Tool Transformation description: Modify tool schemas - rename, reshape arguments, and customize behavior icon: wrench tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Tool transformation lets you modify tool schemas - renaming tools, changing descriptions, adjusting tags, and reshaping argument schemas. FastMCP provides two mechanisms that share the same configuration options but differ in timing. **Deferred transformation** with `ToolTransform` applies modifications when tools flow through a transform chain. Use this for tools from mounted servers, proxies, or other providers where you don't control the source directly. **Immediate transformation** with `Tool.from_tool()` creates a modified tool object right away. Use this when you have direct access to a tool and want to transform it before registration. ## ToolTransform The `ToolTransform` class is a transform that modifies tools as they flow through a provider. Provide a dictionary mapping original tool names to their transformation configuration. ```python from fastmcp import FastMCP from fastmcp.server.transforms import ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig mcp = FastMCP("Server") @mcp.tool def verbose_internal_data_fetcher(query: str) -> str: """Fetches data from the internal database.""" return f"Results for: {query}" # Rename the tool to something simpler mcp.add_transform(ToolTransform({ "verbose_internal_data_fetcher": ToolTransformConfig( name="search", description="Search the database.", ) })) # Clients see "search" with the cleaner description ``` `ToolTransform` is useful when you want to modify tools from mounted or proxied servers without changing the original source. ## Tool.from_tool() Use `Tool.from_tool()` when you have the tool object and want to create a transformed version for registration. ```python from fastmcp import FastMCP from fastmcp.tools import Tool, tool from fastmcp.tools.tool_transform import ArgTransform # Create a tool without registering it @tool def search(q: str, limit: int = 10) -> list[str]: """Search for items.""" return [f"Result {i} for {q}" for i in range(limit)] # Transform it before registration better_search = Tool.from_tool( search, name="find_items", description="Find items matching your search query.", transform_args={ "q": ArgTransform( name="query", description="The search terms to look for.", ), }, ) mcp = FastMCP("Server") mcp.add_tool(better_search) ``` The standalone `@tool` decorator (from `fastmcp.tools`) creates a Tool object without registering it to any server. This separates creation from registration, letting you transform tools before deciding where they go. ## Modification Options Both mechanisms support the same modifications. **Tool-level options:** | Option | Description | |--------|-------------| | `name` | New name for the tool | | `description` | New description | | `title` | Human-readable title | | `tags` | Set of tags for categorization | | `annotations` | MCP ToolAnnotations | | `meta` | Custom metadata dictionary | | `enabled` | Whether the tool is visible to clients (default `True`) | **Argument-level options** (via `ArgTransform` or `ArgTransformConfig`): | Option | Description | |--------|-------------| | `name` | Rename the argument | | `description` | New description for the argument | | `default` | New default value | | `default_factory` | Callable that generates a default (requires `hide=True`) | | `hide` | Remove from client-visible schema | | `required` | Make an optional argument required | | `type` | Change the argument's type | | `examples` | Example values for the argument | ## Hiding Arguments Hide arguments to simplify the interface or inject values the client shouldn't control. ```python from fastmcp.tools.tool_transform import ArgTransform # Hide with a constant value transform_args = { "api_key": ArgTransform(hide=True, default="secret-key"), } # Hide with a dynamic value import uuid transform_args = { "request_id": ArgTransform(hide=True, default_factory=lambda: str(uuid.uuid4())), } ``` Hidden arguments disappear from the tool's schema. The client never sees them, but the underlying function receives the configured value. `default_factory` requires `hide=True`. Visible arguments need static defaults that can be represented in JSON Schema. ## Renaming Arguments Rename arguments to make them more intuitive for LLMs or match your API conventions. ```python from fastmcp.tools import Tool, tool from fastmcp.tools.tool_transform import ArgTransform @tool def search(q: str, n: int = 10) -> list[str]: """Search for items.""" return [] better_search = Tool.from_tool( search, transform_args={ "q": ArgTransform(name="query", description="Search terms"), "n": ArgTransform(name="max_results", description="Maximum results to return"), }, ) ``` ## Custom Transform Functions For advanced scenarios, provide a `transform_fn` that intercepts tool execution. The function can validate inputs, modify outputs, or add custom logic while still calling the original tool via `forward()`. ```python from fastmcp import FastMCP from fastmcp.tools import Tool, tool from fastmcp.tools.tool_transform import forward, ArgTransform @tool def divide(a: float, b: float) -> float: """Divide a by b.""" return a / b async def safe_divide(numerator: float, denominator: float) -> float: if denominator == 0: raise ValueError("Cannot divide by zero") return await forward(numerator=numerator, denominator=denominator) safe_division = Tool.from_tool( divide, name="safe_divide", transform_fn=safe_divide, transform_args={ "a": ArgTransform(name="numerator"), "b": ArgTransform(name="denominator"), }, ) mcp = FastMCP("Server") mcp.add_tool(safe_division) ``` The `forward()` function handles argument mapping automatically. Call it with the transformed argument names, and it maps them back to the original function's parameters. For direct access to the original function without mapping, use `forward_raw()` with the original parameter names. ## Context-Aware Tool Factories You can write functions that act as "factories," generating specialized versions of a tool for different contexts. For example, create a `get_my_data` tool for the current user by hiding the `user_id` parameter and providing it automatically. ```python from fastmcp import FastMCP from fastmcp.tools import Tool, tool from fastmcp.tools.tool_transform import ArgTransform # A generic tool that requires a user_id @tool def get_user_data(user_id: str, query: str) -> str: """Fetch data for a specific user.""" return f"Data for user {user_id}: {query}" def create_user_tool(user_id: str) -> Tool: """Factory that creates a user-specific version of get_user_data.""" return Tool.from_tool( get_user_data, name="get_my_data", description="Fetch your data. No need to specify a user ID.", transform_args={ "user_id": ArgTransform(hide=True, default=user_id), }, ) # Create a server with a tool customized for the current user mcp = FastMCP("User Server") current_user_id = "user-123" # e.g., from auth context mcp.add_tool(create_user_tool(current_user_id)) # Clients see "get_my_data(query: str)" — user_id is injected automatically ``` This pattern is useful for multi-tenant servers where each connection gets tools pre-configured with their identity, or for wrapping generic tools with environment-specific defaults. ================================================ FILE: docs/servers/transforms/transforms.mdx ================================================ --- title: Transforms Overview sidebarTitle: Overview description: Modify components as they flow through your server icon: wand-magic-sparkles tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Transforms modify components as they flow from providers to clients. When a client asks "what tools do you have?", the request passes through each transform in the chain. Each transform can modify the components before passing them along. ## Mental Model Think of transforms as filters in a pipeline. Components flow from providers through transforms to reach clients: ``` Provider → [Transform A] → [Transform B] → Client ``` When listing components, transforms receive sequences and return transformed sequences—a pure function pattern. When getting a specific component by name, transforms use a middleware pattern with `call_next`, working in reverse: mapping the client's requested name back to the original, then transforming the result. ## Built-in Transforms FastMCP provides several transforms for common use cases: - **[Namespace](/servers/transforms/namespace)** - Prefix component names to prevent conflicts when composing servers - **[Tool Transformation](/servers/transforms/tool-transformation)** - Rename tools, modify descriptions, reshape arguments - **[Enabled](/servers/visibility)** - Control which components are visible at runtime - **[Tool Search](/servers/transforms/tool-search)** - Replace large tool catalogs with on-demand search - **[Resources as Tools](/servers/transforms/resources-as-tools)** - Expose resources to tool-only clients - **[Prompts as Tools](/servers/transforms/prompts-as-tools)** - Expose prompts to tool-only clients - **[Code Mode (Experimental)](/servers/transforms/code-mode)** - Replace many tools with programmable `search` + `execute` ## Server vs Provider Transforms Transforms can be added at two levels, each serving different purposes. ### Provider-Level Transforms Provider transforms apply to components from a specific provider. They run first, modifying components before they reach the server level. ```python from fastmcp import FastMCP from fastmcp.server.providers import FastMCPProvider from fastmcp.server.transforms import Namespace, ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig sub_server = FastMCP("Sub") @sub_server.tool def process(data: str) -> str: return f"Processed: {data}" # Create provider and add transforms provider = FastMCPProvider(sub_server) provider.add_transform(Namespace("api")) provider.add_transform(ToolTransform({ "api_process": ToolTransformConfig(description="Process data through the API"), })) main = FastMCP("Main", providers=[provider]) # Tool is now: api_process with updated description ``` When using `mount()`, the returned provider reference lets you add transforms directly. ```python main = FastMCP("Main") mount = main.mount(sub_server, namespace="api") mount.add_transform(ToolTransform({...})) ``` ### Server-Level Transforms Server transforms apply to all components from all providers. They run after provider transforms, seeing the already-transformed names. ```python from fastmcp import FastMCP from fastmcp.server.transforms import Namespace mcp = FastMCP("Server", transforms=[Namespace("v1")]) @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" # All tools become v1_toolname ``` Server-level transforms are useful for API versioning or applying consistent naming across your entire server. ### Transform Order Transforms stack in the order they're added. The first transform added is innermost (closest to the provider), and subsequent transforms wrap it. ```python from fastmcp.server.providers import FastMCPProvider from fastmcp.server.transforms import Namespace, ToolTransform from fastmcp.tools.tool_transform import ToolTransformConfig provider = FastMCPProvider(server) provider.add_transform(Namespace("api")) # Applied first provider.add_transform(ToolTransform({ # Sees namespaced names "api_verbose_name": ToolTransformConfig(name="short"), })) # Flow: "verbose_name" -> "api_verbose_name" -> "short" ``` When a client requests "short", the transforms reverse the mapping: ToolTransform maps "short" to "api_verbose_name", then Namespace strips the prefix to find "verbose_name" in the provider. ## Custom Transforms Create custom transforms by subclassing `Transform` and overriding the methods you need. ```python from collections.abc import Sequence from fastmcp.server.transforms import Transform, GetToolNext from fastmcp.tools.tool import Tool class TagFilter(Transform): """Filter tools to only those with specific tags.""" def __init__(self, required_tags: set[str]): self.required_tags = required_tags async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: return [t for t in tools if t.tags & self.required_tags] async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: tool = await call_next(name) if tool and tool.tags & self.required_tags: return tool return None ``` The `Transform` base class provides default implementations that pass through unchanged. Override only the methods relevant to your transform. Each component type has two methods with different patterns: | Method | Pattern | Purpose | |--------|---------|---------| | `list_tools(tools)` | Pure function | Transform the sequence of tools | | `get_tool(name, call_next)` | Middleware | Transform lookup by name | | `list_resources(resources)` | Pure function | Transform the sequence of resources | | `get_resource(uri, call_next)` | Middleware | Transform lookup by URI | | `list_resource_templates(templates)` | Pure function | Transform the sequence of templates | | `get_resource_template(uri, call_next)` | Middleware | Transform template lookup by URI | | `list_prompts(prompts)` | Pure function | Transform the sequence of prompts | | `get_prompt(name, call_next)` | Middleware | Transform lookup by name | List methods receive sequences directly and return transformed sequences. Get methods use `call_next` for routing flexibility—when a client requests "new_name", your transform maps it back to "original_name" before calling `call_next()`. ```python class PrefixTransform(Transform): def __init__(self, prefix: str): self.prefix = prefix async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: return [t.model_copy(update={"name": f"{self.prefix}_{t.name}"}) for t in tools] async def get_tool(self, name: str, call_next: GetToolNext) -> Tool | None: # Reverse the prefix to find the original if not name.startswith(f"{self.prefix}_"): return None original = name[len(self.prefix) + 1:] tool = await call_next(original) if tool: return tool.model_copy(update={"name": name}) return None ``` ================================================ FILE: docs/servers/versioning.mdx ================================================ --- title: Versioning sidebarTitle: Versioning description: Serve multiple API versions from a single codebase icon: code-branch tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Component versioning lets you maintain multiple implementations of the same tool, resource, or prompt under a single identifier. You register each version, and FastMCP handles the rest: clients see the highest version by default, but you can filter to expose exactly the versions you want. The primary use case is serving different API versions from one codebase. Instead of maintaining separate deployments for v1 and v2 clients, you version your components and use `VersionFilter` to create distinct API surfaces. ## Versioned API Surfaces Consider a server that needs to support both v1 and v2 clients. The v2 API adds new parameters to existing tools, and you want both versions to coexist cleanly. Define your components on a shared provider, then create separate servers with different version filters. ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider from fastmcp.server.transforms import VersionFilter # Define versioned components on a shared provider components = LocalProvider() @components.tool(version="1.0") def calculate(x: int, y: int) -> int: """Add two numbers.""" return x + y @components.tool(version="2.0") def calculate(x: int, y: int, z: int = 0) -> int: """Add two or three numbers.""" return x + y + z # Create servers that share the provider with different filters api_v1 = FastMCP("API v1", providers=[components]) api_v1.add_transform(VersionFilter(version_lt="2.0")) api_v2 = FastMCP("API v2", providers=[components]) api_v2.add_transform(VersionFilter(version_gte="2.0")) ``` Clients connecting to `api_v1` see the two-argument `calculate`. Clients connecting to `api_v2` see the three-argument version. Both servers share the same component definitions. `VersionFilter` accepts two keyword-only parameters that mirror comparison operators: `version_gte` (greater than or equal) and `version_lt` (less than). You can use either or both to define your version range. ```python # Versions < 3.0 (v1.x and v2.x) VersionFilter(version_lt="3.0") # Versions >= 2.0 (v2.x and later) VersionFilter(version_gte="2.0") # Versions in range [2.0, 3.0) (only v2.x) VersionFilter(version_gte="2.0", version_lt="3.0") ``` **Unversioned components are exempt from version filtering by default.** Set `include_unversioned=False` to exclude them. Including them by default ensures that adding version filtering to a server with mixed versioned and unversioned components doesn't accidentally hide the unversioned ones. To prevent confusion, FastMCP forbids mixing versioned and unversioned components with the same name. ### Filtering Mounted Servers When you mount child servers and apply a `VersionFilter` to the parent, the filter applies to components from mounted servers as well. Range filtering (`version_gte` and `version_lt`) is handled at the provider level, meaning mounted servers don't need to know about the parent's version constraints. ```python from fastmcp import FastMCP from fastmcp.server.transforms import VersionFilter # Child server with versioned components child = FastMCP("Child") @child.tool(version="1.0") def process(data: str) -> str: return data.upper() @child.tool(version="2.0") def process(data: str, mode: str = "default") -> str: return data.upper() if mode == "default" else data.lower() # Parent server mounts child and applies version filter parent = FastMCP("Parent") parent.mount(child, namespace="child") parent.add_transform(VersionFilter(version_lt="2.0")) # Clients see only child_process v1.0 ``` The parent's `VersionFilter` sees components after they've been namespaced, but filters based on version regardless of namespace. This lets you apply version policies consistently across your entire server hierarchy. ## Declaring Versions Add a `version` parameter to any component decorator. FastMCP stores versions as strings and groups components by their identifier (name for tools and prompts, URI for resources). ```python from fastmcp import FastMCP mcp = FastMCP() @mcp.tool(version="1.0") def process(data: str) -> str: """Original processing.""" return data.upper() @mcp.tool(version="2.0") def process(data: str, mode: str = "default") -> str: """Enhanced processing with mode selection.""" if mode == "reverse": return data[::-1].upper() return data.upper() ``` Both versions are registered. When a client lists tools, they see only `process` with version 2.0 (the highest). When they invoke `process`, version 2.0 executes. The same pattern applies to resources and prompts. ### Versioned vs Unversioned Components For any given component name, you must choose one approach: either version all implementations or version none of them. Mixing versioned and unversioned components with the same name raises an error at registration time. ```python from fastmcp import FastMCP mcp = FastMCP() @mcp.tool def calculate(x: int, y: int) -> int: """Unversioned tool.""" return x + y @mcp.tool(version="2.0") # Raises ValueError def calculate(x: int, y: int, z: int = 0) -> int: """Cannot mix versioned with unversioned.""" return x + y + z ``` The error message explains the conflict: "Cannot add versioned tool 'calculate' (version='2.0'): an unversioned tool with this name already exists. Either version all components or none." This restriction helps keep version filtering behavior predictable. Resources and prompts follow the same pattern. ```python @mcp.resource("config://app", version="1.0") def config_v1() -> str: return '{"format": "legacy"}' @mcp.resource("config://app", version="2.0") def config_v2() -> str: return '{"format": "modern", "schema": "v2"}' @mcp.prompt(version="1.0") def summarize(text: str) -> str: return f"Summarize: {text}" @mcp.prompt(version="2.0") def summarize(text: str, style: str = "concise") -> str: return f"Summarize in a {style} style: {text}" ``` ### Version Discovery When clients list components, each versioned component includes metadata about all available versions. This lets clients discover what versions exist before deciding which to use. The `meta.fastmcp.versions` field contains all registered versions sorted from highest to lowest. ```python from fastmcp import Client async with Client(server) as client: tools = await client.list_tools() for tool in tools: if tool.meta: fastmcp_meta = tool.meta.get("fastmcp", {}) # Current version being returned (highest by default) print(f"Version: {fastmcp_meta.get('version')}") # All available versions for this component print(f"Available: {fastmcp_meta.get('versions')}") ``` For a tool with versions `"1.0"` and `"2.0"`, listing returns the `2.0` implementation with `meta.fastmcp.version` set to `"2.0"` and `meta.fastmcp.versions` set to `["2.0", "1.0"]`. Unversioned components omit these fields entirely. This discovery mechanism enables clients to make informed decisions about which version to request, support graceful degradation when newer versions introduce breaking changes, or display version information in developer tools. ## Requesting Specific Versions By default, clients receive and invoke the highest version of each component. When you need a specific version, FastMCP provides two approaches: the FastMCP client API for Python applications, and the MCP protocol mechanism for any MCP-compatible client. ### FastMCP Client The FastMCP client's `call_tool` and `get_prompt` methods accept an optional `version` parameter. When specified, the server executes that exact version instead of the highest. ```python from fastmcp import Client async with Client(server) as client: # Call the highest version (default behavior) result = await client.call_tool("calculate", {"x": 1, "y": 2}) # Call a specific version result_v1 = await client.call_tool("calculate", {"x": 1, "y": 2}, version="1.0") # Get a specific prompt version prompt = await client.get_prompt("summarize", {"text": "..."}, version="1.0") ``` If the requested version doesn't exist, the server raises a `NotFoundError`. This ensures you get exactly what you asked for rather than silently falling back to a different version. ### MCP Protocol For generic MCP clients that don't have built-in version support, pass the version through the `_meta` field in arguments. FastMCP servers extract the version from `_meta.fastmcp.version` before processing. ```json Tool Call Arguments { "x": 1, "y": 2, "_meta": { "fastmcp": { "version": "1.0" } } } ``` ```json Prompt Arguments { "text": "Summarize this document...", "_meta": { "fastmcp": { "version": "1.0" } } } ``` The `_meta` field is part of the MCP request params, not arguments, so your component implementation never sees it. This convention allows version selection to work across any MCP client without requiring protocol changes. The FastMCP client handles this automatically when you pass the `version` parameter. ## Version Comparison FastMCP compares versions to determine which is "highest" when multiple versions share an identifier. The comparison behavior depends on the version format. For [PEP 440](https://peps.python.org/pep-0440/) versions (like `"1.0"`, `"2.1.3"`, `"1.0a1"`), FastMCP uses semantic comparison where numeric segments are compared as numbers. ```python # PEP 440 versions compare semantically "1" < "2" < "10" # Numeric order (not "1" < "10" < "2") "1.9" < "1.10" # Numeric order (not "1.10" < "1.9") "1.0a1" < "1.0b1" < "1.0" # Pre-releases sort before releases ``` For other formats (dates, custom schemes), FastMCP falls back to lexicographic string comparison. This works well for ISO dates and other naturally sortable formats. ```python # Non-PEP 440 versions compare as strings "2025-01-15" < "2025-02-01" # ISO dates sort correctly "alpha" < "beta" # Alphabetical order ``` The `v` prefix is stripped before comparison, so `"v1.0"` and `"1.0"` are treated as equal for sorting purposes. ## Retrieving Specific Versions Server-side code can retrieve specific versions rather than just the highest. This is useful during migrations when you need to compare behavior between versions or access legacy implementations. The `get_tool`, `get_resource`, and `get_prompt` methods accept an optional `version` parameter. Without it, they return the highest version. With it, they return exactly that version. ```python from fastmcp import FastMCP mcp = FastMCP() @mcp.tool(version="1.0") def add(x: int, y: int) -> int: return x + y @mcp.tool(version="2.0") def add(x: int, y: int) -> int: return x + y + 100 # Different behavior # Get highest version (default) tool = await mcp.get_tool("add") print(tool.version) # "2.0" # Get specific version tool_v1 = await mcp.get_tool("add", version="1.0") print(tool_v1.version) # "1.0" ``` If the requested version doesn't exist, a `NotFoundError` is raised. ## Removing Versions The `remove_tool`, `remove_resource`, and `remove_prompt` methods on the server's [local provider](/servers/providers/local) accept an optional `version` parameter that controls what gets removed. ```python # Remove ALL versions of a component mcp.local_provider.remove_tool("calculate") # Remove only a specific version mcp.local_provider.remove_tool("calculate", version="1.0") ``` When you remove a specific version, other versions remain registered. When you remove without specifying a version, all versions are removed. ## Migration Workflow Versioning supports gradual migration when updating component behavior. You can deploy new versions alongside old ones, verify the new behavior works correctly, then clean up. When migrating an existing unversioned component to use versioning, start by assigning an initial version to your existing implementation. Then add the new version alongside it. ```python from fastmcp import FastMCP mcp = FastMCP() @mcp.tool(version="1.0") def process_data(input: str) -> str: """Original implementation, now versioned.""" return legacy_process(input) @mcp.tool(version="2.0") def process_data(input: str, options: dict | None = None) -> str: """Updated implementation with new options parameter.""" return modern_process(input, options or {}) ``` Clients automatically see version 2.0 (the highest). During the transition, your server code can still access the original implementation via `get_tool("process_data", version="1.0")`. Once the migration is complete, remove the old version. ```python mcp.local_provider.remove_tool("process_data", version="1.0") ``` ================================================ FILE: docs/servers/visibility.mdx ================================================ --- title: Component Visibility sidebarTitle: Visibility description: Control which components are available to clients icon: toggle-on tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' Components can be dynamically enabled or disabled at runtime. A disabled tool disappears from listings and cannot be called. This enables runtime access control, feature flags, and context-aware component exposure. ## Component Visibility Every FastMCP server provides `enable()` and `disable()` methods for controlling component availability. ### Disabling Components The `disable()` method marks components as disabled. Disabled components are filtered out from all client queries. ```python from fastmcp import FastMCP mcp = FastMCP("Server") @mcp.tool(tags={"admin"}) def delete_everything() -> str: """Delete all data.""" return "Deleted" @mcp.tool(tags={"admin"}) def reset_system() -> str: """Reset the system.""" return "Reset" @mcp.tool def get_status() -> str: """Get system status.""" return "OK" # Disable admin tools mcp.disable(tags={"admin"}) # Clients only see: get_status ``` ### Enabling Components The `enable()` method re-enables previously disabled components. ```python # Re-enable admin tools mcp.enable(tags={"admin"}) # Clients now see all three tools ``` ## Keys and Tags Visibility filtering works with two identifiers: keys (for specific components) and tags (for groups). ### Component Keys Every component has a unique key in the format `{type}:{identifier}`. | Component | Key Format | Example | |-----------|------------|---------| | Tool | `tool:{name}` | `tool:delete_everything` | | Resource | `resource:{uri}` | `resource:data://config` | | Template | `template:{uri}` | `template:file://{path}` | | Prompt | `prompt:{name}` | `prompt:analyze` | Use keys to target specific components. ```python # Disable a specific tool mcp.disable(keys={"tool:delete_everything"}) # Disable multiple specific components mcp.disable(keys={"tool:reset_system", "resource:data://secrets"}) ``` ### Tags Tags group components for bulk operations. Define tags when creating components, then filter by them. ```python from fastmcp import FastMCP mcp = FastMCP("Server") @mcp.tool(tags={"public", "read"}) def get_data() -> str: return "data" @mcp.tool(tags={"admin", "write"}) def set_data(value: str) -> str: return f"Set: {value}" @mcp.tool(tags={"admin", "dangerous"}) def delete_data() -> str: return "Deleted" # Disable all admin tools mcp.disable(tags={"admin"}) # Disable all dangerous tools (some overlap with admin) mcp.disable(tags={"dangerous"}) ``` A component is disabled if it has **any** of the disabled tags. The component doesn't need all the tags; one match is enough. ### Combining Keys and Tags You can specify both keys and tags in a single call. The filters combine additively. ```python # Disable specific tools AND all dangerous-tagged components mcp.disable(keys={"tool:debug_info"}, tags={"dangerous"}) ``` ## Allowlist Mode By default, visibility filtering uses blocklist mode: everything is enabled unless explicitly disabled. The `only=True` parameter switches to allowlist mode, where **only** specified components are enabled. ```python from fastmcp import FastMCP mcp = FastMCP("Server") @mcp.tool(tags={"safe"}) def read_only_operation() -> str: return "Read" @mcp.tool(tags={"safe"}) def list_items() -> list[str]: return ["a", "b", "c"] @mcp.tool(tags={"dangerous"}) def delete_all() -> str: return "Deleted" @mcp.tool def untagged_tool() -> str: return "Untagged" # Only enable safe tools - everything else is disabled mcp.enable(tags={"safe"}, only=True) # Clients see: read_only_operation, list_items # Disabled: delete_all, untagged_tool ``` Allowlist mode is useful for restrictive environments where you want to explicitly opt-in components rather than opt-out. ### Allowlist Behavior When you call `enable(only=True)`: 1. Default visibility state switches to "disabled" 2. Previous allowlists are cleared 3. Only specified keys/tags become enabled ```python # Start fresh - only enable these specific tools mcp.enable(keys={"tool:safe_read", "tool:safe_write"}, only=True) # Later, switch to a different allowlist mcp.enable(tags={"production"}, only=True) ``` ### Ordering and Overrides Later `enable()` and `disable()` calls override earlier ones. This lets you create broad rules with specific exceptions. ```python mcp.enable(tags={"api"}, only=True) # Allow all api-tagged mcp.disable(keys={"tool:api_admin"}) # Later disable overrides for this tool # api_admin is disabled because the later disable() overrides the allowlist ``` You can always re-enable something that was disabled by adding another `enable()` call after it. ## Server vs Provider Visibility state operates at two levels: the server and individual providers. ### Server-Level Server-level visibility state applies to all components from all providers. When you call `mcp.enable()` or `mcp.disable()`, you're filtering the final view that clients see. ```python from fastmcp import FastMCP main = FastMCP("Main") main.mount(sub_server, namespace="api") @main.tool(tags={"internal"}) def local_debug() -> str: return "Debug" # Disable internal tools from ALL sources main.disable(tags={"internal"}) ``` ### Provider-Level Each provider can add its own visibility transforms. These run before server-level transforms, so the server can override provider-level disables. ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider # Create provider with visibility control admin_tools = LocalProvider() @admin_tools.tool(tags={"admin"}) def admin_action() -> str: return "Admin" @admin_tools.tool def regular_action() -> str: return "Regular" # Disable at provider level admin_tools.disable(tags={"admin"}) # Server can override if needed mcp = FastMCP("Server", providers=[admin_tools]) mcp.enable(names={"admin_action"}) # Re-enables despite provider disable ``` Provider-level transforms are useful for setting default visibility that servers can selectively override. ### Layered Transforms Provider transforms run first, then server transforms. Later transforms override earlier ones, so the server has final say. ```python from fastmcp import FastMCP from fastmcp.server.providers import LocalProvider provider = LocalProvider() @provider.tool(tags={"feature", "beta"}) def new_feature() -> str: return "New" # Provider enables feature-tagged provider.enable(tags={"feature"}, only=True) # Server disables beta-tagged (runs after provider) mcp = FastMCP("Server", providers=[provider]) mcp.disable(tags={"beta"}) # new_feature is disabled (server's later disable overrides provider's enable) ``` ## Per-Session Visibility Server-level visibility changes affect all connected clients simultaneously. When you need different clients to see different components, use per-session visibility instead. Session visibility lets individual sessions customize their view of available components. When a tool calls `ctx.enable_components()` or `ctx.disable_components()`, those rules apply only to the current session. Other sessions continue to see the global defaults. This enables patterns like progressive disclosure, role-based access, and on-demand feature activation. ```python from fastmcp import FastMCP from fastmcp.server.context import Context mcp = FastMCP("Session-Aware Server") @mcp.tool(tags={"premium"}) def premium_analysis(data: str) -> str: """Advanced analysis available to premium users.""" return f"Premium analysis of: {data}" @mcp.tool async def unlock_premium(ctx: Context) -> str: """Unlock premium features for this session.""" await ctx.enable_components(tags={"premium"}) return "Premium features unlocked" @mcp.tool async def reset_features(ctx: Context) -> str: """Reset to default feature set.""" await ctx.reset_visibility() return "Features reset to defaults" # Premium tools are disabled globally by default mcp.disable(tags={"premium"}) ``` All sessions start with `premium_analysis` hidden. When a session calls `unlock_premium`, that session gains access to premium tools while other sessions remain unaffected. Calling `reset_features` returns the session to the global defaults. ### How Session Rules Work Session rules override global transforms. When listing components, FastMCP first applies global enable/disable rules, then applies session-specific rules on top. Rules within a session accumulate, and later rules override earlier ones for the same component. ```python @mcp.tool async def customize_session(ctx: Context) -> str: # Enable finance tools for this session await ctx.enable_components(tags={"finance"}) # Also enable admin tools await ctx.enable_components(tags={"admin"}) # Later: disable a specific admin tool await ctx.disable_components(names={"dangerous_admin_tool"}) return "Session customized" ``` Each call adds a rule to the session. The `dangerous_admin_tool` ends up disabled because its disable rule was added after the admin enable rule. ### Filter Criteria The session visibility methods accept the same filter criteria as `server.enable()` and `server.disable()`: | Parameter | Description | |-----------|-------------| | `names` | Component names or URIs to match | | `keys` | Component keys (e.g., `{"tool:my_tool"}`) | | `tags` | Tags to match (component must have at least one) | | `version` | Version specification to match | | `components` | Component types (`{"tool"}`, `{"resource"}`, `{"prompt"}`, `{"template"}`) | | `match_all` | If `True`, matches all components regardless of other criteria | ```python from fastmcp.utilities.versions import VersionSpec @mcp.tool async def enable_recent_tools(ctx: Context) -> str: """Enable only tools from version 2.0.0 or later.""" await ctx.enable_components( version=VersionSpec(gte="2.0.0"), components={"tool"} ) return "Recent tools enabled" ``` ### Automatic Notifications When session visibility changes, FastMCP automatically sends notifications to that session. Clients receive `ToolListChangedNotification`, `ResourceListChangedNotification`, and `PromptListChangedNotification` so they can refresh their component lists. These notifications go only to the affected session. When you specify the `components` parameter, FastMCP optimizes by sending only the relevant notifications: ```python # Only sends ToolListChangedNotification await ctx.enable_components(tags={"finance"}, components={"tool"}) # Sends all three notifications (no components filter) await ctx.enable_components(tags={"finance"}) ``` ### Namespace Activation Pattern A common pattern organizes tools into namespaces using tag prefixes, disables them globally, then provides activation tools that unlock namespaces on demand: ```python from fastmcp import FastMCP from fastmcp.server.context import Context server = FastMCP("Multi-Domain Assistant") # Finance namespace @server.tool(tags={"namespace:finance"}) def analyze_portfolio(symbols: list[str]) -> str: return f"Analysis for: {', '.join(symbols)}" @server.tool(tags={"namespace:finance"}) def get_market_data(symbol: str) -> dict: return {"symbol": symbol, "price": 150.25} # Admin namespace @server.tool(tags={"namespace:admin"}) def list_users() -> list[str]: return ["alice", "bob", "charlie"] # Activation tools - always visible @server.tool async def activate_finance(ctx: Context) -> str: await ctx.enable_components(tags={"namespace:finance"}) return "Finance tools activated" @server.tool async def activate_admin(ctx: Context) -> str: await ctx.enable_components(tags={"namespace:admin"}) return "Admin tools activated" @server.tool async def deactivate_all(ctx: Context) -> str: await ctx.reset_visibility() return "All namespaces deactivated" # Disable namespace tools globally server.disable(tags={"namespace:finance", "namespace:admin"}) ``` Sessions start seeing only the activation tools. Calling `activate_finance` reveals finance tools for that session only. Multiple namespaces can be activated independently, and `deactivate_all` returns to the initial state. ### Method Reference - **`await ctx.enable_components(...) -> None`**: Enable matching components for this session - **`await ctx.disable_components(...) -> None`**: Disable matching components for this session - **`await ctx.reset_visibility() -> None`**: Clear all session rules, returning to global defaults ## Client Notifications When visibility state changes, FastMCP automatically notifies connected clients. Clients supporting the MCP notification protocol receive `list_changed` events and can refresh their component lists. This happens automatically. You don't need to trigger notifications manually. ```python # This automatically notifies clients mcp.disable(tags={"maintenance"}) # Clients receive: tools/list_changed, resources/list_changed, etc. ``` ## Filtering Logic Understanding the filtering logic helps when debugging visibility state issues. The `is_enabled()` function checks a component's internal metadata: 1. If the component has `meta.fastmcp._internal.visibility = False`, it's disabled 2. If the component has `meta.fastmcp._internal.visibility = True`, it's enabled 3. If no visibility state is set, the component is enabled by default When multiple `enable()` and `disable()` calls are made, transforms are applied in order. **Later transforms override earlier ones**, so the last matching transform wins. ## The Visibility Transform Under the hood, `enable()` and `disable()` add `Visibility` transforms to the server or provider. The `Visibility` transform marks components with visibility metadata, and the server applies the final filter after all provider and server transforms complete. ```python from fastmcp import FastMCP from fastmcp.server.transforms import Visibility mcp = FastMCP("Server") # Using the convenience method (recommended) mcp.disable(names={"secret_tool"}) # Equivalent to: mcp.add_transform(Visibility(False, names={"secret_tool"})) ``` Server-level transforms override provider-level transforms. If a component is disabled at the provider level but enabled at the server level, the server-level `enable()` can re-enable it. ================================================ FILE: docs/snippets/local-focus.mdx ================================================ export const LocalFocusTip = () => { return ( This integration focuses on running local FastMCP server files with STDIO transport. For remote servers running with HTTP or SSE transport, use your client's native configuration - FastMCP's integrations focus on simplifying the complex local setup with dependencies and uv commands. ); }; ================================================ FILE: docs/snippets/version-badge.mdx ================================================ export const VersionBadge = ({ version }) => { return ( New in version {version} ); }; ================================================ FILE: docs/snippets/youtube-embed.mdx ================================================ export const YouTubeEmbed = ({ videoId, title }) => { return ( """ # --------------------------------------------------------------------------- # Host page HTML # --------------------------------------------------------------------------- _HOST_HTML_TEMPLATE = """\ FastMCP Dev — {tool_name} {import_map_tag}
Launching {tool_name}…
""" # --------------------------------------------------------------------------- # Picker UI (Prefab-based, built in Python) # --------------------------------------------------------------------------- def _has_ui_resource(tool: dict[str, Any]) -> bool: """Return True if the tool has a UI resourceUri in its metadata.""" for key in ("meta", "_meta"): m = tool.get(key) if isinstance(m, dict): ui = m.get("ui") if isinstance(ui, dict) and ui.get("resourceUri"): return True return False def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any]: """Dynamically create a Pydantic model from a JSON Schema for form generation.""" import pydantic import pydantic.fields properties: dict[str, Any] = input_schema.get("properties") or {} required: list[str] = input_schema.get("required") or [] field_definitions: dict[str, Any] = {} for prop_name, prop in properties.items(): json_type = prop.get("type", "string") match json_type: case "integer": py_type: type = int case "number": py_type = float case "boolean": py_type = bool case _: py_type = str title = prop.get("title") or prop_name.replace("_", " ").title() description = prop.get("description") is_required = prop_name in required if is_required: default = pydantic.fields.PydanticUndefined elif "default" in prop: default = prop["default"] else: default = None py_type = py_type | None # type: ignore[assignment] extra: dict[str, Any] = {} if prop.get("enum"): from typing import Literal py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment] if prop.get("format") == "textarea" or ( isinstance(prop.get("json_schema_extra"), dict) and prop["json_schema_extra"].get("ui", {}).get("type") == "textarea" ): extra["json_schema_extra"] = {"ui": {"type": "textarea"}} field_definitions[prop_name] = ( py_type, pydantic.Field( default=default, title=title, description=description, **extra ), ) return pydantic.create_model(f"{tool_name.title()}Form", **field_definitions) def _build_picker_html(tools: list[dict[str, Any]]) -> str: """Build Prefab picker page: dropdown selector with per-tool forms.""" try: from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast from prefab_ui.app import PrefabApp from prefab_ui.components import ( Button, Column, Heading, Label, Markdown, Muted, Page, Pages, Select, SelectOption, ) from prefab_ui.components.form import Form from prefab_ui.rx import RESULT, Rx except ImportError: return "

prefab-ui not installed. Run: pip install fastmcp[apps]

" if not tools: with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view: Heading("FastMCP App Preview") Muted( "No UI tools found on this server. Use @app.ui() to register entry-point tools." ) return PrefabApp(title="FastMCP App Preview", view=view).html() first_name: str = tools[0]["name"] def _tool_title(tool: dict[str, Any]) -> str: return tool.get("title") or tool["name"] with Column(gap=6, css_class="p-8 max-w-lg mx-auto") as view: Heading("FastMCP App Preview") if len(tools) > 1: with Column(gap=1): Label("Tool") with Select( placeholder="Choose a tool…", on_change=SetState("activeTool", Rx("$event")), ): for tool in tools: SelectOption( _tool_title(tool), value=tool["name"], selected=tool["name"] == first_name, ) else: Heading(_tool_title(tools[0]), level=3) with Pages(name="activeTool", default_value=first_name): for tool in tools: name: str = tool["name"] desc: str = tool.get("description") or "" input_schema: dict[str, Any] = tool.get("inputSchema") or {} model = _model_from_schema(name, input_schema) body: dict[str, Any] = {"tool": name} for field_name in model.model_fields: body[field_name] = Rx(field_name) with Page(name, value=name), Column(gap=4): if desc: Muted(desc, css_class="pb-2") with Form( on_submit=Fetch.post( "/api/launch", body=body, on_success=OpenLink(RESULT), on_error=ShowToast(Rx("$error"), variant="error"), # type: ignore[arg-type] ), ): Form.from_model(model, fields_only=True) Button( "Launch", variant="success", button_type="submit", ) Markdown( "Generated by [Prefab](https://prefab.prefect.io) 🎨", css_class="text-xs text-muted-foreground text-right", ) return PrefabApp(title="FastMCP App Preview", view=view).html() # --------------------------------------------------------------------------- # MCP tool listing helper # --------------------------------------------------------------------------- async def _list_tools(mcp_url: str) -> list[dict[str, Any]]: """Return raw tool dicts from the MCP server at mcp_url.""" try: from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client except ImportError: return [] try: async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 async with ClientSession(read, write) as session: await session.initialize() result = await session.list_tools() return [t.model_dump() for t in result.tools] except Exception as exc: logger.debug(f"Could not list tools from {mcp_url}: {exc}") return [] async def _read_mcp_resource(mcp_url: str, uri: str) -> str | None: """Read an MCP resource by URI and return its text content.""" try: from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from pydantic import AnyUrl except ImportError: return None try: async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 async with ClientSession(read, write) as session: await session.initialize() result = await session.read_resource(AnyUrl(uri)) for content in result.contents: text = getattr(content, "text", None) if text: return text return None except Exception as exc: logger.debug(f"Could not read resource {uri} from {mcp_url}: {exc}") return None # --------------------------------------------------------------------------- # app-bridge.js download, patch, and Zod import-map generation # --------------------------------------------------------------------------- def _fetch_app_bridge_bundle_sync( version: str, sdk_version: str, ) -> tuple[str, str]: """Download app-bridge.js and build an import-map that fixes Zod v4 on esm.sh. Returns ``(app_bridge_js, import_map_json)`` where *import_map_json* is a JSON string ready to embed in a ``' ) ready = await _wait_for_server(mcp_url, timeout=15.0) if not ready: raise RuntimeError(f"User server did not start on port {mcp_port}") logger.info(f"FastMCP dev UI at {dev_url}") dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag) config = uvicorn.Config( dev_app, host="localhost", port=dev_port, log_level="warning", ws="websockets-sansio", ) server = uvicorn.Server(config) # Suppress uvicorn's own signal handlers — they use signal.signal() which # conflicts with asyncio and causes hangs. We cancel the task instead. server.install_signal_handlers = lambda: None # type: ignore[method-assign] async def _open_browser() -> None: await asyncio.sleep(0.8) webbrowser.open(dev_url) await asyncio.gather(server.serve(), _open_browser()) # Register signal handlers before any work starts so that Ctrl+C during # startup (server spawn, npm fetch, server-ready poll) is handled the same # way as Ctrl+C during the running phase — both cancel the body task and # fall through to the cleanup finally block. loop = asyncio.get_running_loop() task = asyncio.ensure_future(_body()) def _on_signal() -> None: # Silence uvicorn's error logger before cancelling so that the # CancelledError propagating through uvicorn doesn't get logged as # an ERROR during the forced shutdown. logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) task.cancel() if sys.platform != "win32": loop.add_signal_handler(signal.SIGINT, _on_signal) loop.add_signal_handler(signal.SIGTERM, _on_signal) try: await task except asyncio.CancelledError: pass finally: if sys.platform != "win32": loop.remove_signal_handler(signal.SIGINT) loop.remove_signal_handler(signal.SIGTERM) if user_proc is not None and user_proc.returncode is None: # Kill the entire process group (not just the top-level process) # because --reload creates a watcher that spawns child processes. # Killing only the watcher leaves the actual server holding the port. try: if sys.platform != "win32": os.killpg(os.getpgid(user_proc.pid), signal.SIGTERM) else: user_proc.kill() except (ProcessLookupError, PermissionError): user_proc.kill() await user_proc.wait() ================================================ FILE: src/fastmcp/cli/auth.py ================================================ """Authentication-related CLI commands.""" import cyclopts from fastmcp.cli.cimd import cimd_app auth_app = cyclopts.App( name="auth", help="Authentication-related utilities and configuration.", ) # Nest CIMD commands under auth auth_app.command(cimd_app) ================================================ FILE: src/fastmcp/cli/cimd.py ================================================ """CIMD (Client ID Metadata Document) CLI commands.""" from __future__ import annotations import asyncio import json import sys from pathlib import Path from typing import Annotated import cyclopts from rich.console import Console from fastmcp.server.auth.cimd import ( CIMDFetcher, CIMDFetchError, CIMDValidationError, ) from fastmcp.utilities.logging import get_logger logger = get_logger("cli.cimd") console = Console() cimd_app = cyclopts.App( name="cimd", help="CIMD (Client ID Metadata Document) utilities for OAuth authentication.", ) @cimd_app.command(name="create") def create_command( *, name: Annotated[ str, cyclopts.Parameter(help="Human-readable name of the client application"), ], redirect_uri: Annotated[ list[str], cyclopts.Parameter( name=["--redirect-uri", "-r"], help="Allowed redirect URIs (can specify multiple)", ), ], client_id: Annotated[ str | None, cyclopts.Parameter( name="--client-id", help="The URL where this document will be hosted (sets client_id directly)", ), ] = None, client_uri: Annotated[ str | None, cyclopts.Parameter( name="--client-uri", help="URL of the client's home page", ), ] = None, logo_uri: Annotated[ str | None, cyclopts.Parameter( name="--logo-uri", help="URL of the client's logo image", ), ] = None, scope: Annotated[ str | None, cyclopts.Parameter( name="--scope", help="Space-separated list of scopes the client may request", ), ] = None, output: Annotated[ str | None, cyclopts.Parameter( name=["--output", "-o"], help="Output file path (default: stdout)", ), ] = None, pretty: Annotated[ bool, cyclopts.Parameter( help="Pretty-print JSON output", ), ] = True, ) -> None: """Generate a CIMD document for hosting. Create a Client ID Metadata Document that you can host at an HTTPS URL. The URL where you host this document becomes your client_id. Example: fastmcp cimd create --name "My App" -r "http://localhost:*/callback" After creating the document, host it at an HTTPS URL with a non-root path, for example: https://myapp.example.com/oauth/client.json """ # Build the document doc = { "client_id": client_id or "https://YOUR-DOMAIN.com/path/to/client.json", "client_name": name, "redirect_uris": redirect_uri, "token_endpoint_auth_method": "none", "grant_types": ["authorization_code"], "response_types": ["code"], } # Add optional fields if client_uri: doc["client_uri"] = client_uri if logo_uri: doc["logo_uri"] = logo_uri if scope: doc["scope"] = scope # Format output json_output = json.dumps(doc, indent=2) if pretty else json.dumps(doc) # Write output if output: output_path = Path(output).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w") as f: f.write(json_output) f.write("\n") console.print(f"[green]✓[/green] CIMD document written to {output}") if not client_id: console.print( "\n[yellow]Important:[/yellow] client_id is a placeholder. Update it to the URL where you will host this document, or re-run with --client-id." ) else: print(json_output) if not client_id: # Print instructions to stderr so they don't interfere with piping stderr_console = Console(stderr=True) stderr_console.print( "\n[yellow]Important:[/yellow] client_id is a placeholder." " Update it to the URL where you will host this document," " or re-run with --client-id." ) @cimd_app.command(name="validate") def validate_command( url: Annotated[ str, cyclopts.Parameter(help="URL of the CIMD document to validate"), ], *, timeout: Annotated[ float, cyclopts.Parameter( name=["--timeout", "-t"], help="HTTP request timeout in seconds", ), ] = 10.0, ) -> None: """Validate a hosted CIMD document. Fetches the document from the given URL and validates: - URL is valid CIMD URL (HTTPS, non-root path) - Document is valid JSON - Document conforms to CIMD schema - client_id in document matches the URL Example: fastmcp cimd validate https://myapp.example.com/oauth/client.json """ async def _validate() -> bool: fetcher = CIMDFetcher(timeout=timeout) # Check URL format first if not fetcher.is_cimd_client_id(url): console.print(f"[red]✗[/red] Invalid CIMD URL: {url}") console.print() console.print("CIMD URLs must:") console.print(" • Use HTTPS (not HTTP)") console.print(" • Have a non-root path (e.g., /client.json, not just /)") return False console.print(f"[blue]→[/blue] Fetching {url}...") try: doc = await fetcher.fetch(url) except CIMDFetchError as e: console.print(f"[red]✗[/red] Failed to fetch document: {e}") return False except CIMDValidationError as e: console.print(f"[red]✗[/red] Validation error: {e}") return False # Success - show document details console.print("[green]✓[/green] Valid CIMD document") console.print() console.print("[bold]Document details:[/bold]") console.print(f" client_id: {doc.client_id}") console.print(f" client_name: {doc.client_name or '(not set)'}") console.print(f" token_endpoint_auth_method: {doc.token_endpoint_auth_method}") if doc.redirect_uris: console.print(" redirect_uris:") for uri in doc.redirect_uris: console.print(f" • {uri}") else: console.print(" redirect_uris: (none)") if doc.scope: console.print(f" scope: {doc.scope}") if doc.client_uri: console.print(f" client_uri: {doc.client_uri}") return True success = asyncio.run(_validate()) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/cli.py ================================================ """FastMCP CLI tools using Cyclopts.""" 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 cyclopts import Parameter from rich.console import Console from rich.table import Table import fastmcp from fastmcp.cli import run as run_module from fastmcp.cli.auth import auth_app from fastmcp.cli.client import call_command, discover_command, list_command from fastmcp.cli.generate import generate_cli_command from fastmcp.cli.install import install_app from fastmcp.cli.tasks import tasks_app from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config from fastmcp.utilities.inspect import ( InspectFormat, format_info, inspect_fastmcp, ) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.version_check import check_for_newer_version logger = get_logger("cli") console = Console() app = cyclopts.App( name="fastmcp", help="FastMCP - The fast, Pythonic way to build MCP servers and clients.", version=fastmcp.__version__, # Disable automatic negative parameters by default default_parameter=Parameter(negative=()), ) def _get_npx_command(): """Get the correct npx command for the current platform.""" if sys.platform == "win32": # Try both npx.cmd and npx.exe on Windows for cmd in ["npx.cmd", "npx.exe", "npx"]: try: subprocess.run([cmd, "--version"], check=True, capture_output=True) return cmd except (subprocess.CalledProcessError, FileNotFoundError): continue return None return "npx" # On Unix-like systems, just use npx def _parse_env_var(env_var: str) -> tuple[str, str]: """Parse environment variable string in format KEY=VALUE.""" if "=" not in env_var: logger.error("Invalid environment variable format. Must be KEY=VALUE") sys.exit(1) key, value = env_var.split("=", 1) return key.strip(), value.strip() @contextmanager def with_argv(args: list[str] | None): """Temporarily replace sys.argv if args provided. 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. 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 def version( *, copy: Annotated[ bool, cyclopts.Parameter("--copy", help="Copy version information to clipboard"), ] = False, ): """Display version information and platform details.""" info = { "FastMCP version": fastmcp.__version__, "MCP version": importlib.metadata.version("mcp"), "Python version": platform.python_version(), "Platform": platform.platform(), "FastMCP root path": Path(fastmcp.__file__ or ".").resolve().parents[1], } g = Table.grid(padding=(0, 1)) g.add_column(style="bold", justify="left") g.add_column(style="cyan", justify="right") for k, v in info.items(): g.add_row(k + ":", str(v).replace("\n", " ")) if copy: # Use Rich's plain text rendering for copying plain_console = Console(file=None, force_terminal=False, legacy_windows=False) with plain_console.capture() as capture: plain_console.print(g) pyperclip.copy(capture.get()) console.print("[green]✓[/green] Version information copied to clipboard") else: console.print(g) # Check for updates (not included in --copy output) if newer_version := check_for_newer_version(): console.print() console.print( f"[bold]🎉 FastMCP update available:[/bold] [green]{newer_version}[/green]" ) console.print("[dim]Run: pip install --upgrade fastmcp[/dim]") # Create dev subcommand group dev_app = cyclopts.App(name="dev", help="Development tools for MCP servers") @dev_app.command async def inspector( server_spec: str | None = None, *, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory containing pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, inspector_version: Annotated[ str | None, cyclopts.Parameter( "--inspector-version", help="Version of the MCP Inspector to use", ), ] = None, ui_port: Annotated[ int | None, cyclopts.Parameter( "--ui-port", help="Port for the MCP Inspector UI", ), ] = None, server_port: Annotated[ int | None, cyclopts.Parameter( "--server-port", help="Port for the MCP Inspector Proxy server", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, reload: Annotated[ bool, cyclopts.Parameter( "--reload", help="Enable auto-reload on file changes (enabled by default)", negative="--no-reload", ), ] = True, reload_dir: Annotated[ list[Path] | None, cyclopts.Parameter( "--reload-dir", help="Directories to watch for changes (default: current directory)", ), ] = None, module: Annotated[ bool, cyclopts.Parameter( name=["--module", "-m"], help="Run a Python module (python -m ) instead of importing a server object", ), ] = False, ) -> None: """Run an MCP server with the MCP Inspector for development. Args: server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json """ try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, editable=[str(p) for p in with_editable] if with_editable else None, port=server_port, # Use deployment config for server port ) # Get server port from config if not specified via CLI if not server_port: server_port = config.deployment.port except FileNotFoundError: sys.exit(1) logger.debug( "Starting dev server", extra={ "server_spec": server_spec, "with_editable": config.environment.editable, "with_packages": config.environment.dependencies, "ui_port": ui_port, "server_port": server_port, }, ) try: if not config: logger.error("No configuration available") sys.exit(1) assert config is not None # For type checker # Skip server-object validation in module mode — the module # manages its own startup and may not expose an importable server. if not module: await config.source.load_server() env_vars = {} if ui_port: env_vars["CLIENT_PORT"] = str(ui_port) if server_port: env_vars["SERVER_PORT"] = str(server_port) # Get the correct npx command npx_cmd = _get_npx_command() if not npx_cmd: logger.error( "npx not found. Please ensure Node.js and npm are properly installed " "and added to your system PATH." ) sys.exit(1) inspector_cmd = "@modelcontextprotocol/inspector" if inspector_version: inspector_cmd += f"@{inspector_version}" # Build the fastmcp run command fastmcp_cmd = ["fastmcp", "run", server_spec, "--no-banner"] # Forward module mode flag if module: fastmcp_cmd.append("--module") # Add reload flags if enabled - the server will handle reloading if reload: fastmcp_cmd.append("--reload") if reload_dir: for dir_path in reload_dir: fastmcp_cmd.extend(["--reload-dir", str(dir_path)]) # Use the environment from config (already has CLI overrides applied) uv_cmd = config.environment.build_command(fastmcp_cmd) # Set marker to prevent infinite loops when subprocess calls FastMCP env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"} # Run the MCP Inspector command process = subprocess.run( [npx_cmd, inspector_cmd, *uv_cmd], check=True, env=env, ) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error( "Dev server failed", extra={ "file": str(server_spec), "error": str(e), "returncode": e.returncode, }, ) sys.exit(e.returncode) except FileNotFoundError: logger.error( "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(server_spec)}, ) sys.exit(1) @dev_app.command async def apps( server_spec: str, *, mcp_port: Annotated[ int, cyclopts.Parameter( "--mcp-port", help="Port for the user's MCP server", ), ] = 8000, dev_port: Annotated[ int, cyclopts.Parameter( "--dev-port", help="Port for the FastMCP dev UI", ), ] = 8080, reload: Annotated[ bool, cyclopts.Parameter( "--reload", negative="--no-reload", help="Auto-reload the MCP server on file changes", ), ] = True, ) -> None: """Preview a FastMCPApp UI in the browser. Starts the MCP server from SERVER_SPEC on --mcp-port, launches a local dev UI on --dev-port with a tool picker and AppBridge host, then opens the browser automatically. Requires fastmcp[apps] to be installed (prefab-ui). """ try: import prefab_ui # noqa: F401 except ImportError: logger.error( "fastmcp dev apps requires prefab-ui. Install with: pip install 'fastmcp[apps]'" ) sys.exit(1) from fastmcp.cli.apps_dev import run_dev_apps await run_dev_apps(server_spec, mcp_port=mcp_port, dev_port=dev_port, reload=reload) @app.command async def run( server_spec: str | None = None, *server_args: str, transport: Annotated[ run_module.TransportType | None, cyclopts.Parameter( name=["--transport", "-t"], help="Transport protocol to use", ), ] = None, host: Annotated[ str | None, cyclopts.Parameter( "--host", help="Host to bind to when using http transport (default: 127.0.0.1)", ), ] = None, port: Annotated[ int | None, cyclopts.Parameter( name=["--port", "-p"], help="Port to bind to when using http transport (default: 8000)", ), ] = None, path: Annotated[ str | None, cyclopts.Parameter( "--path", help="The route path for the server (default: /mcp/ for http transport, /sse/ for sse transport)", ), ] = None, log_level: Annotated[ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, cyclopts.Parameter( name=["--log-level", "-l"], help="Log level", ), ] = None, no_banner: Annotated[ bool, cyclopts.Parameter("--no-banner", help="Don't show the server banner"), ] = False, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, skip_source: Annotated[ bool, cyclopts.Parameter( "--skip-source", help="Skip source preparation step (use when source is already prepared)", ), ] = False, skip_env: Annotated[ bool, cyclopts.Parameter( "--skip-env", help="Skip environment configuration (for internal use when already in a uv environment)", ), ] = False, reload: Annotated[ bool, cyclopts.Parameter( "--reload", negative="--no-reload", help="Enable auto-reload on file changes (development mode)", ), ] = False, reload_dir: Annotated[ list[Path] | None, cyclopts.Parameter( "--reload-dir", help="Directories to watch for changes (default: current directory)", ), ] = None, stateless: Annotated[ bool, cyclopts.Parameter( "--stateless", help="Run in stateless mode (no session, used internally for reload)", ), ] = False, module: Annotated[ bool, cyclopts.Parameter( name=["--module", "-m"], help="Run a Python module (python -m ) instead of importing a server object", ), ] = False, ) -> None: """Run an MCP server or connect to a remote one. 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 7. Module mode: "-m my_module" - runs the module directly via python -m Server arguments can be passed after -- : fastmcp run server.py -- --config config.json --debug Args: server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect """ # --- Module mode: delegate to python -m and exit early --- if module: if server_spec is None: logger.error("A module name is required when using --module / -m") sys.exit(1) # Warn about options that are ignored in module mode ignored_options: list[str] = [] if transport: ignored_options.append("--transport") if host: ignored_options.append("--host") if port: ignored_options.append("--port") if path: ignored_options.append("--path") if ignored_options: logger.warning( f"Options {', '.join(ignored_options)} are ignored in module mode " f"(-m). The module manages its own server startup." ) # Build environment wrapper if needed env_builder = None if not skip_env and not is_already_in_uv_subprocess(): from fastmcp.utilities.mcp_server_config.v1.environments.uv import ( UVEnvironment, ) env = UVEnvironment( python=python, dependencies=with_packages or None, requirements=with_requirements, project=project, ) test_cmd = ["test"] if env.build_command(test_cmd) != test_cmd: env_builder = env.build_command if reload: # Build a fastmcp run command for the reload watcher to restart reload_cmd = ["fastmcp", "run", server_spec, "--module", "--no-reload"] if log_level: reload_cmd.extend(["--log-level", log_level]) if no_banner: reload_cmd.append("--no-banner") if env_builder is not None: reload_cmd.append("--skip-env") if server_args: reload_cmd.append("--") reload_cmd.extend(server_args) if env_builder is not None: reload_cmd = env_builder(reload_cmd) await run_module.run_with_reload( reload_cmd, reload_dirs=reload_dir, is_stdio=True ) return run_module.run_module_command( server_spec, env_command_builder=env_builder, extra_args=list(server_args) if server_args else None, ) return # Check if we were spawned by uv (or user explicitly set --skip-env) if skip_env or is_already_in_uv_subprocess(): skip_env = True try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, transport=transport, host=host, port=port, path=path, log_level=log_level, server_args=list(server_args) if server_args else None, ) except FileNotFoundError: sys.exit(1) # Get effective values (CLI overrides take precedence) final_transport = transport or config.deployment.transport final_host = host or config.deployment.host final_port = port or config.deployment.port final_path = path or config.deployment.path final_log_level = log_level or config.deployment.log_level final_server_args = server_args or config.deployment.args # Use CLI override if provided, otherwise use settings # no_banner CLI flag overrides the show_server_banner setting final_no_banner = ( no_banner if no_banner else not fastmcp.settings.show_server_banner ) logger.debug( "Running server or client", extra={ "server_spec": server_spec, "transport": final_transport, "host": final_host, "port": final_port, "path": final_path, "log_level": final_log_level, "server_args": list(final_server_args) if final_server_args else [], }, ) # Handle reload mode if reload: # SSE is incompatible with reload (no stateless mode exists) if final_transport == "sse": logger.warning( "--reload is not supported with SSE transport (sessions are lost on restart). " "Use streamable-http transport instead, or use --no-reload. " "Running without reload." ) # Fall through to normal execution else: # Build command for subprocess (with --no-reload to prevent infinite spawning) reload_cmd = ["fastmcp", "run", server_spec] if final_transport: reload_cmd.extend(["--transport", final_transport]) if final_transport != "stdio": if final_host: reload_cmd.extend(["--host", final_host]) if final_port: reload_cmd.extend(["--port", str(final_port)]) if final_path: reload_cmd.extend(["--path", final_path]) if final_log_level: reload_cmd.extend(["--log-level", final_log_level]) if final_no_banner: reload_cmd.append("--no-banner") reload_cmd.append("--no-reload") # Prevent infinite spawning reload_cmd.append("--stateless") # Stateless mode for reload compatibility # If environment setup is needed, wrap with uv test_cmd = ["test"] needs_uv = ( config.environment.build_command(test_cmd) != test_cmd and not skip_env ) if needs_uv: # Add --skip-env to prevent nested uv runs (child would spawn another uv) reload_cmd.append("--skip-env") if final_server_args: reload_cmd.append("--") reload_cmd.extend(final_server_args) if needs_uv: reload_cmd = config.environment.build_command(reload_cmd) is_stdio = final_transport in ("stdio", None) await run_module.run_with_reload( reload_cmd, reload_dirs=reload_dir, is_stdio=is_stdio ) return # Check if we need to use uv run (but skip if we're already in uv or user said to skip) # We check if the environment would modify the command test_cmd = ["test"] needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Build the inner fastmcp command inner_cmd = ["fastmcp", "run", server_spec] # Add transport options to the inner command if final_transport: inner_cmd.extend(["--transport", final_transport]) # Only add HTTP-specific options for non-stdio transports if final_transport != "stdio": if final_host: inner_cmd.extend(["--host", final_host]) if final_port: inner_cmd.extend(["--port", str(final_port)]) if final_path: inner_cmd.extend(["--path", final_path]) if final_log_level: inner_cmd.extend(["--log-level", final_log_level]) if final_no_banner: inner_cmd.append("--no-banner") # Add skip-env flag to prevent infinite recursion inner_cmd.append("--skip-env") # Add server args if any if final_server_args: inner_cmd.append("--") inner_cmd.extend(final_server_args) # Build the full uv command using the config's environment cmd = config.environment.build_command(inner_cmd) # Set marker to prevent infinite loops when subprocess calls FastMCP again env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} # Run the command logger.debug(f"Running command: {' '.join(cmd)}") try: process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.exception( f"Failed to run: {e}", extra={ "server_spec": server_spec, "error": str(e), "returncode": e.returncode, }, ) sys.exit(e.returncode) else: # Use direct import for backwards compatibility try: await run_module.run_command( server_spec=server_spec, transport=final_transport, host=final_host, port=final_port, path=final_path, log_level=final_log_level, server_args=list(final_server_args) if final_server_args else [], show_banner=not final_no_banner, skip_source=skip_source, stateless=stateless, ) except Exception as e: logger.exception( f"Failed to run: {e}", extra={ "server_spec": server_spec, "error": str(e), }, ) sys.exit(1) @app.command async def inspect( server_spec: str | None = None, *, format: Annotated[ InspectFormat | None, cyclopts.Parameter( name=["--format", "-f"], help="Output format: fastmcp (FastMCP-specific) or mcp (MCP protocol). Required when using -o.", ), ] = None, output: Annotated[ Path | None, cyclopts.Parameter( name=["--output", "-o"], help="Output file path for the JSON report. If not specified, outputs to stdout when format is provided.", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, skip_env: Annotated[ bool, cyclopts.Parameter( "--skip-env", help="Skip environment configuration (for internal use when already in a uv environment)", ), ] = False, ) -> None: """Inspect an MCP server and display information or generate a JSON report. This command analyzes an MCP server. Without flags, it displays a text summary. Use --format to output complete JSON data. Examples: # Show text summary fastmcp inspect server.py # Output FastMCP format JSON to stdout fastmcp inspect server.py --format fastmcp # Save MCP protocol format to file (format required with -o) fastmcp inspect server.py --format mcp -o manifest.json # Inspect from fastmcp.json configuration fastmcp inspect fastmcp.json fastmcp inspect # auto-detect fastmcp.json Args: server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json """ # Check if we were spawned by uv (or user explicitly set --skip-env) if skip_env or is_already_in_uv_subprocess(): skip_env = True try: # Load config and apply CLI overrides config, server_spec = load_and_merge_config( server_spec, python=python, with_packages=with_packages or [], with_requirements=with_requirements, project=project, ) # Check if it's an MCPConfig (which inspect doesn't support) if server_spec.endswith(".json") and config is None: # This might be an MCPConfig, check the file try: with open(Path(server_spec)) as f: data = json.load(f) if "mcpServers" in data: logger.error("MCPConfig files are not supported by inspect command") sys.exit(1) except (json.JSONDecodeError, FileNotFoundError): pass except FileNotFoundError: sys.exit(1) # Check if we need to use uv run (but skip if we're already in uv or user said to skip) # We check if the environment would modify the command test_cmd = ["test"] needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env if needs_uv: # Build and run uv command # The environment is already configured in the config object inspect_command = [ "fastmcp", "inspect", server_spec, "--skip-env", # Prevent infinite recursion ] # Add format and output flags if specified if format: inspect_command.extend(["--format", format.value]) if output: inspect_command.extend(["--output", str(output)]) # Run the command using subprocess import subprocess cmd = config.environment.build_command(inspect_command) env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} process = subprocess.run(cmd, check=True, env=env) sys.exit(process.returncode) logger.debug( "Inspecting server", extra={ "server_spec": server_spec, "format": format, "output": str(output) if output else None, }, ) try: # Load the server using the config if not config: logger.error("No configuration available") sys.exit(1) assert config is not None # For type checker server = await config.source.load_server() # Get basic server information info = await inspect_fastmcp(server) # Check for invalid combination if output and not format: console.print( "[bold red]Error:[/bold red] --format is required when using -o/--output" ) console.print( "[dim]Use --format fastmcp or --format mcp to specify the output format[/dim]" ) sys.exit(1) # If no format specified, show text summary if format is None: # Display text summary console.print() # Server section console.print("[bold]Server[/bold]") console.print(f" Name: {info.name}") if info.version: console.print(f" Version: {info.version}") if info.website_url: console.print(f" Website: {info.website_url}") if info.icons: console.print(f" Icons: {len(info.icons)}") console.print(f" Generation: {info.server_generation}") if info.instructions: console.print(f" Instructions: {info.instructions}") console.print() # Components section console.print("[bold]Components[/bold]") console.print(f" Tools: {len(info.tools)}") console.print(f" Prompts: {len(info.prompts)}") console.print(f" Resources: {len(info.resources)}") console.print(f" Templates: {len(info.templates)}") console.print() # Environment section console.print("[bold]Environment[/bold]") console.print(f" FastMCP: {info.fastmcp_version}") console.print(f" MCP: {info.mcp_version}") console.print() console.print( "[dim]Use --format \\[fastmcp|mcp] for complete JSON output[/dim]" ) return # Generate formatted JSON output formatted_json = await format_info(server, format, info) # Output to file or stdout if output: # Ensure output directory exists output.parent.mkdir(parents=True, exist_ok=True) # Write JSON report with output.open("wb") as f: f.write(formatted_json) logger.info(f"Server inspection complete. Report saved to {output}") # Print confirmation to console console.print( f"[bold green]✓[/bold green] Server inspection saved to: [cyan]{output}[/cyan]" ) console.print(f" Server: [bold]{info.name}[/bold]") console.print(f" Format: {format.value}") else: # Output JSON to stdout console.print(formatted_json.decode("utf-8")) except Exception as e: logger.exception( f"Failed to inspect server: {e}", extra={ "server_spec": server_spec, "error": str(e), }, ) console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}") sys.exit(1) # Create project subcommand group project_app = cyclopts.App(name="project", help="Manage FastMCP projects") @project_app.command async def prepare( config_path: Annotated[ str | None, cyclopts.Parameter(help="Path to fastmcp.json configuration file"), ] = None, output_dir: Annotated[ str | None, cyclopts.Parameter(help="Directory to create the persistent environment in"), ] = None, skip_source: Annotated[ bool, cyclopts.Parameter(help="Skip source preparation (e.g., git clone)"), ] = False, ) -> None: """Prepare a FastMCP project by creating a persistent uv environment. This command creates a persistent uv project with all dependencies installed: - Creates a pyproject.toml with dependencies from the config - Installs all Python packages into a .venv - Prepares the source (git clone, download, etc.) unless --skip-source After running this command, you can use: fastmcp run --project This is useful for: - CI/CD pipelines with separate build and run stages - Docker images where you prepare during build - Production deployments where you want fast startup times Example: fastmcp project prepare myserver.json --output-dir ./prepared-env fastmcp run myserver.json --project ./prepared-env """ from pathlib import Path # Require output-dir if output_dir is None: logger.error( "The --output-dir parameter is required.\n" "Please specify where to create the persistent environment." ) sys.exit(1) # Auto-detect fastmcp.json if not provided if config_path is None: found_config = MCPServerConfig.find_config() if found_config: config_path = str(found_config) logger.info(f"Using configuration from {config_path}") else: logger.error( "No configuration file specified and no fastmcp.json found.\n" "Please specify a configuration file or create a fastmcp.json." ) sys.exit(1) assert config_path is not None config_file = Path(config_path) if not config_file.exists(): logger.error(f"Configuration file not found: {config_path}") sys.exit(1) assert output_dir is not None output_path = Path(output_dir) try: # Load the configuration config = MCPServerConfig.from_file(config_file) # Prepare environment and source await config.prepare( skip_source=skip_source, output_dir=output_path, ) console.print( f"[bold green]✓[/bold green] Project prepared successfully in {output_path}!\n" f"You can now run the server with:\n" f" [cyan]fastmcp run {config_path} --project {output_dir}[/cyan]" ) except Exception as e: logger.error(f"Failed to prepare project: {e}") console.print(f"[bold red]✗[/bold red] Failed to prepare project: {e}") sys.exit(1) # Add dev subcommand group app.command(dev_app) # Add project subcommand group app.command(project_app) # Add install subcommands using proper Cyclopts pattern app.command(install_app) # Add tasks subcommand group app.command(tasks_app) # Add client query commands app.command(list_command, name="list") app.command(call_command, name="call") app.command(discover_command, name="discover") app.command(generate_cli_command, name="generate-cli") # Add auth subcommand group (includes CIMD commands) app.command(auth_app) if __name__ == "__main__": app() ================================================ FILE: src/fastmcp/cli/client.py ================================================ """Client-side CLI commands for querying and invoking MCP servers.""" import difflib import json import shlex import sys from pathlib import Path from typing import Annotated, Any, Literal import cyclopts import mcp.types from rich.console import Console from rich.markup import escape as escape_rich_markup from fastmcp.cli.discovery import DiscoveredServer, discover_servers, resolve_name from fastmcp.client.client import CallToolResult, Client from fastmcp.client.elicitation import ElicitResult from fastmcp.client.transports.base import ClientTransport from fastmcp.client.transports.http import StreamableHttpTransport from fastmcp.client.transports.sse import SSETransport from fastmcp.client.transports.stdio import StdioTransport from fastmcp.utilities.logging import get_logger logger = get_logger("cli.client") console = Console() # --------------------------------------------------------------------------- # Server spec resolution # --------------------------------------------------------------------------- _JSON_SCHEMA_TYPE_MAP: dict[str, str] = { "string": "str", "integer": "int", "number": "float", "boolean": "bool", "array": "list", "object": "dict", "null": "None", } def resolve_server_spec( server_spec: str | None, *, command: str | None = None, transport: str | None = None, ) -> str | dict[str, Any] | ClientTransport: """Turn CLI inputs into something ``Client()`` accepts. Exactly one of ``server_spec`` or ``command`` should be provided. Resolution order for ``server_spec``: 1. URLs (``http://``, ``https://``) — passed through as-is. If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` so ``infer_transport`` picks the right transport. 2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. 3. Anything else — name-based resolution via ``resolve_name``. When ``command`` is provided, the string is shell-split into a ``StdioTransport(command, args)``. """ if command is not None and server_spec is not None: console.print( "[bold red]Error:[/bold red] Cannot use both a server spec and --command" ) sys.exit(1) if command is not None: return _build_stdio_from_command(command) if server_spec is None: console.print( "[bold red]Error:[/bold red] Provide a server spec or use --command" ) sys.exit(1) assert isinstance(server_spec, str) spec: str = server_spec # 1. URL if spec.startswith(("http://", "https://")): if transport == "sse" and not spec.rstrip("/").endswith("/sse"): spec = spec.rstrip("/") + "/sse" return spec # 2. File path (must be a file, not a directory) path = Path(spec) is_file = path.is_file() or ( not path.is_dir() and spec.endswith((".py", ".js", ".json")) ) if is_file: if spec.endswith(".json"): return _resolve_json_spec(path) if spec.endswith(".py"): # Run via `fastmcp run` so scripts don't need mcp.run() resolved_path = path.resolve() return StdioTransport( command="fastmcp", args=["run", str(resolved_path), "--no-banner"], ) # .js — pass through for Client's infer_transport return spec # 3. Name-based resolution (bare name or source:name) try: return resolve_name(spec) except ValueError as exc: console.print(f"[bold red]Error:[/bold red] {exc}") sys.exit(1) def _build_stdio_from_command(command_str: str) -> StdioTransport: """Shell-split a command string into a ``StdioTransport``.""" try: parts = shlex.split(command_str) except ValueError as exc: console.print(f"[bold red]Error:[/bold red] Invalid command: {exc}") sys.exit(1) if not parts: console.print("[bold red]Error:[/bold red] Empty --command") sys.exit(1) return StdioTransport(command=parts[0], args=parts[1:]) def _resolve_json_spec(path: Path) -> str | dict[str, Any]: """Disambiguate a ``.json`` server spec.""" if not path.exists(): console.print( f"[bold red]Error:[/bold red] File not found: [cyan]{path}[/cyan]" ) sys.exit(1) try: data = json.loads(path.read_text()) except json.JSONDecodeError as exc: console.print(f"[bold red]Error:[/bold red] Invalid JSON in {path}: {exc}") sys.exit(1) if isinstance(data, dict) and "mcpServers" in data: return data # Likely a fastmcp.json (MCPServerConfig) — not directly usable as a client target. console.print( f"[bold red]Error:[/bold red] [cyan]{path}[/cyan] is a FastMCP server config, not an MCPConfig.\n" f"Start the server first, then query it:\n\n" f" fastmcp run {path}\n" f" fastmcp list http://localhost:8000/mcp\n" ) sys.exit(1) def _is_http_target(resolved: str | dict[str, Any] | ClientTransport) -> bool: """Return True if the resolved target will use an HTTP-based transport. MCPConfig dicts are excluded because ``MCPConfigTransport`` manages individual server transports internally and does not support top-level auth. """ if isinstance(resolved, str): return resolved.startswith(("http://", "https://")) return isinstance(resolved, (StreamableHttpTransport, SSETransport)) async def _terminal_elicitation_handler( message: str, response_type: type[Any] | None, params: Any, context: Any, ) -> ElicitResult[dict[str, Any]]: """Prompt the user on the terminal for elicitation responses. Prints the server's message and prompts for each field in the schema. The user can type 'decline' or 'cancel' instead of a value to abort. """ from mcp.types import ElicitRequestFormParams console.print(f"\n[bold yellow]Server asks:[/bold yellow] {message}") if not isinstance(params, ElicitRequestFormParams): answer = console.input( "[dim](press Enter to accept, or type 'decline'):[/dim] " ) if answer.strip().lower() == "decline": return ElicitResult(action="decline") if answer.strip().lower() == "cancel": return ElicitResult(action="cancel") return ElicitResult(action="accept", content={}) schema = params.requestedSchema properties = schema.get("properties", {}) required = set(schema.get("required", [])) if not properties: answer = console.input( "[dim](press Enter to accept, or type 'decline'):[/dim] " ) if answer.strip().lower() == "decline": return ElicitResult(action="decline") if answer.strip().lower() == "cancel": return ElicitResult(action="cancel") return ElicitResult(action="accept", content={}) result: dict[str, Any] = {} for field_name, field_schema in properties.items(): type_hint = field_schema.get("type", "string") req_marker = " [red]*[/red]" if field_name in required else "" prompt_text = f" [cyan]{field_name}[/cyan] ({type_hint}){req_marker}: " raw = console.input(prompt_text) if raw.strip().lower() == "decline": return ElicitResult(action="decline") if raw.strip().lower() == "cancel": return ElicitResult(action="cancel") if raw == "" and field_name not in required: continue result[field_name] = coerce_value(raw, field_schema) return ElicitResult(action="accept", content=result) def _build_client( resolved: str | dict[str, Any] | ClientTransport, *, timeout: float | None = None, auth: str | None = None, ) -> Client: """Build a ``Client`` from a resolved server spec. Applies ``auth='oauth'`` automatically for HTTP-based targets unless the caller explicitly passes ``--auth none`` to disable it. ``auth=None`` means "not specified" (use default), ``auth="none"`` means "explicitly disabled". """ if auth == "none": effective_auth: str | None = None elif auth is not None: effective_auth = auth elif _is_http_target(resolved): effective_auth = "oauth" else: effective_auth = None return Client( resolved, timeout=timeout, auth=effective_auth, elicitation_handler=_terminal_elicitation_handler, ) # --------------------------------------------------------------------------- # Argument coercion # --------------------------------------------------------------------------- def coerce_value(raw: str, schema: dict[str, Any]) -> Any: """Coerce a string CLI value according to a JSON-Schema type hint.""" schema_type = schema.get("type", "string") if schema_type == "integer": try: return int(raw) except ValueError: raise ValueError(f"Expected integer, got {raw!r}") from None if schema_type == "number": try: return float(raw) except ValueError: raise ValueError(f"Expected number, got {raw!r}") from None if schema_type == "boolean": if raw.lower() in ("true", "1", "yes"): return True if raw.lower() in ("false", "0", "no"): return False raise ValueError(f"Expected boolean, got {raw!r}") if schema_type in ("array", "object"): try: return json.loads(raw) except json.JSONDecodeError: raise ValueError(f"Expected JSON {schema_type}, got {raw!r}") from None # Default: treat as string return raw def parse_tool_arguments( raw_args: tuple[str, ...], input_json: str | None, input_schema: dict[str, Any], ) -> dict[str, Any]: """Build a tool-call argument dict from CLI inputs. A single JSON object argument is treated as the full argument dict. ``--input-json`` provides the base dict; ``key=value`` pairs override. Values are coerced using the tool's ``inputSchema``. """ # A single positional arg that looks like JSON → treat as input-json if len(raw_args) == 1 and raw_args[0].startswith("{") and input_json is None: input_json = raw_args[0] raw_args = () result: dict[str, Any] = {} if input_json is not None: try: parsed = json.loads(input_json) except json.JSONDecodeError as exc: console.print(f"[bold red]Error:[/bold red] Invalid --input-json: {exc}") sys.exit(1) if not isinstance(parsed, dict): console.print( "[bold red]Error:[/bold red] --input-json must be a JSON object" ) sys.exit(1) result.update(parsed) properties = input_schema.get("properties", {}) for arg in raw_args: if "=" not in arg: console.print( f"[bold red]Error:[/bold red] Invalid argument [cyan]{arg}[/cyan] — expected key=value" ) sys.exit(1) key, value = arg.split("=", 1) prop_schema = properties.get(key, {}) try: result[key] = coerce_value(value, prop_schema) except ValueError as exc: console.print( f"[bold red]Error:[/bold red] Argument [cyan]{key}[/cyan]: {exc}" ) sys.exit(1) return result # --------------------------------------------------------------------------- # Tool signature formatting # --------------------------------------------------------------------------- def _json_schema_type_to_str(schema: dict[str, Any]) -> str: """Produce a short Python-style type string from a JSON-Schema fragment.""" if "anyOf" in schema: parts = [_json_schema_type_to_str(s) for s in schema["anyOf"]] return " | ".join(parts) schema_type = schema.get("type", "any") if isinstance(schema_type, list): return " | ".join(_JSON_SCHEMA_TYPE_MAP.get(t, t) for t in schema_type) return _JSON_SCHEMA_TYPE_MAP.get(schema_type, schema_type) def format_tool_signature(tool: mcp.types.Tool) -> str: """Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas.""" params: list[str] = [] schema = tool.inputSchema properties = schema.get("properties", {}) required = set(schema.get("required", [])) for prop_name, prop_schema in properties.items(): type_str = _json_schema_type_to_str(prop_schema) if prop_name in required: params.append(f"{prop_name}: {type_str}") else: default = prop_schema.get("default") default_repr = repr(default) if default is not None else "..." params.append(f"{prop_name}: {type_str} = {default_repr}") sig = f"{tool.name}({', '.join(params)})" if tool.outputSchema: ret = _json_schema_type_to_str(tool.outputSchema) sig += f" -> {ret}" return sig # --------------------------------------------------------------------------- # Output formatting # --------------------------------------------------------------------------- def _print_schema(label: str, schema: dict[str, Any]) -> None: """Print a JSON schema with a label.""" properties = schema.get("properties", {}) if not properties: return console.print(f" [dim]{label}: {json.dumps(schema)}[/dim]") def _sanitize_untrusted_text(value: str) -> str: """Escape rich markup and encode control chars for terminal-safe output.""" sanitized = escape_rich_markup(value) return "".join( ch if ch in {"\n", "\t"} or (0x20 <= ord(ch) < 0x7F) or ord(ch) > 0x9F else f"\\x{ord(ch):02x}" for ch in sanitized ) def _format_call_result_text(result: CallToolResult) -> None: """Pretty-print a tool call result to the console.""" if result.is_error: for block in result.content: if isinstance(block, mcp.types.TextContent): console.print( f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(block.text)}" ) else: console.print( f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(str(block))}" ) return if result.structured_content is not None: console.print_json(json.dumps(result.structured_content)) return for block in result.content: if isinstance(block, mcp.types.TextContent): console.print(_sanitize_untrusted_text(block.text)) elif isinstance(block, mcp.types.ImageContent): size = len(block.data) * 3 // 4 # rough decoded size console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") elif isinstance(block, mcp.types.AudioContent): size = len(block.data) * 3 // 4 console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") else: console.print(_sanitize_untrusted_text(str(block))) def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]: """Serialize a single content block to a JSON-safe dict.""" if isinstance(block, mcp.types.TextContent): return {"type": "text", "text": block.text} if isinstance(block, mcp.types.ImageContent): return {"type": "image", "mimeType": block.mimeType, "data": block.data} if isinstance(block, mcp.types.AudioContent): return {"type": "audio", "mimeType": block.mimeType, "data": block.data} return {"type": "unknown", "value": str(block)} def _call_result_to_dict(result: CallToolResult) -> dict[str, Any]: """Serialize a ``CallToolResult`` to a JSON-safe dict.""" content_list = [_content_block_to_dict(block) for block in result.content] out: dict[str, Any] = {"content": content_list, "is_error": result.is_error} if result.structured_content is not None: out["structured_content"] = result.structured_content return out def _tools_to_json(tools: list[mcp.types.Tool]) -> list[dict[str, Any]]: """Serialize a list of tools to JSON-safe dicts.""" return [ { "name": t.name, "description": t.description, "inputSchema": t.inputSchema, **({"outputSchema": t.outputSchema} if t.outputSchema else {}), } for t in tools ] # --------------------------------------------------------------------------- # Call handlers (tool, resource, prompt) # --------------------------------------------------------------------------- async def _handle_tool_call( client: Client, tool_name: str, arguments: tuple[str, ...], input_json: str | None, json_output: bool, ) -> None: """Handle a tool call within an open client session.""" tools = await client.list_tools() tool_map = {t.name: t for t in tools} if tool_name not in tool_map: close_matches = difflib.get_close_matches( tool_name, tool_map.keys(), n=3, cutoff=0.5 ) msg = f"Tool [cyan]{tool_name}[/cyan] not found." if close_matches: suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) msg += f" Did you mean: {suggestions}?" console.print(f"[bold red]Error:[/bold red] {msg}") sys.exit(1) tool = tool_map[tool_name] parsed_args = parse_tool_arguments(arguments, input_json, tool.inputSchema) required = set(tool.inputSchema.get("required", [])) provided = set(parsed_args.keys()) missing = required - provided if missing: missing_str = ", ".join(f"[cyan]{m}[/cyan]" for m in sorted(missing)) console.print( f"[bold red]Error:[/bold red] Missing required arguments: {missing_str}" ) console.print() sig = format_tool_signature(tool) console.print(f" [dim]{sig}[/dim]") sys.exit(1) result = await client.call_tool(tool_name, parsed_args, raise_on_error=False) if json_output: console.print_json(json.dumps(_call_result_to_dict(result))) else: _format_call_result_text(result) if result.is_error: sys.exit(1) async def _handle_resource( client: Client, uri: str, json_output: bool, ) -> None: """Handle a resource read within an open client session.""" contents = await client.read_resource(uri) if json_output: data = [] for block in contents: if isinstance(block, mcp.types.TextResourceContents): data.append( { "uri": str(block.uri), "mimeType": block.mimeType, "text": block.text, } ) elif isinstance(block, mcp.types.BlobResourceContents): data.append( { "uri": str(block.uri), "mimeType": block.mimeType, "blob": block.blob, } ) console.print_json(json.dumps(data)) return for block in contents: if isinstance(block, mcp.types.TextResourceContents): console.print(_sanitize_untrusted_text(block.text)) elif isinstance(block, mcp.types.BlobResourceContents): size = len(block.blob) * 3 // 4 console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") async def _handle_prompt( client: Client, prompt_name: str, arguments: tuple[str, ...], input_json: str | None, json_output: bool, ) -> None: """Handle a prompt get within an open client session.""" # Prompt arguments are always string->string, but we reuse # parse_tool_arguments for the key=value / --input-json parsing. # Pass an empty schema so values stay as strings. parsed_args = parse_tool_arguments(arguments, input_json, {"type": "object"}) prompts = await client.list_prompts() prompt_map = {p.name: p for p in prompts} if prompt_name not in prompt_map: close_matches = difflib.get_close_matches( prompt_name, prompt_map.keys(), n=3, cutoff=0.5 ) msg = f"Prompt [cyan]{prompt_name}[/cyan] not found." if close_matches: suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) msg += f" Did you mean: {suggestions}?" console.print(f"[bold red]Error:[/bold red] {msg}") sys.exit(1) result = await client.get_prompt(prompt_name, parsed_args or None) if json_output: data: dict[str, Any] = {} if result.description: data["description"] = result.description data["messages"] = [ { "role": msg.role, "content": _content_block_to_dict(msg.content), } for msg in result.messages ] console.print_json(json.dumps(data)) return for msg in result.messages: console.print(f"[bold]{_sanitize_untrusted_text(msg.role)}:[/bold]") if isinstance(msg.content, mcp.types.TextContent): console.print(f" {_sanitize_untrusted_text(msg.content.text)}") elif isinstance(msg.content, mcp.types.ImageContent): size = len(msg.content.data) * 3 // 4 console.print( f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]" ) else: console.print(f" {_sanitize_untrusted_text(str(msg.content))}") console.print() # --------------------------------------------------------------------------- # Commands # --------------------------------------------------------------------------- async def list_command( server_spec: Annotated[ str | None, cyclopts.Parameter( help="Server URL, Python file, MCPConfig JSON, or .js file", ), ] = None, *, command: Annotated[ str | None, cyclopts.Parameter( "--command", help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", ), ] = None, transport: Annotated[ Literal["http", "sse"] | None, cyclopts.Parameter( name=["--transport", "-t"], help="Force transport type for URL targets (http or sse)", ), ] = None, resources: Annotated[ bool, cyclopts.Parameter("--resources", help="Also list resources"), ] = False, prompts: Annotated[ bool, cyclopts.Parameter("--prompts", help="Also list prompts"), ] = False, input_schema: Annotated[ bool, cyclopts.Parameter("--input-schema", help="Show full input schemas"), ] = False, output_schema: Annotated[ bool, cyclopts.Parameter("--output-schema", help="Show full output schemas"), ] = False, json_output: Annotated[ bool, cyclopts.Parameter("--json", help="Output as JSON"), ] = False, timeout: Annotated[ float | None, cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), ] = None, auth: Annotated[ str | None, cyclopts.Parameter( "--auth", help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, ) -> None: """List tools available on an MCP server. Examples: fastmcp list http://localhost:8000/mcp fastmcp list server.py fastmcp list mcp.json --json fastmcp list --command 'npx -y @mcp/server' --resources fastmcp list http://server/mcp --transport sse """ resolved = resolve_server_spec(server_spec, command=command, transport=transport) client = _build_client(resolved, timeout=timeout, auth=auth) try: async with client: tools = await client.list_tools() if json_output: data: dict[str, Any] = {"tools": _tools_to_json(tools)} if resources: res = await client.list_resources() data["resources"] = [ { "uri": str(r.uri), "name": r.name, "description": r.description, "mimeType": r.mimeType, } for r in res ] if prompts: prm = await client.list_prompts() data["prompts"] = [ { "name": p.name, "description": p.description, "arguments": [a.model_dump() for a in (p.arguments or [])], } for p in prm ] console.print_json(json.dumps(data)) return # Text output if not tools: console.print("[dim]No tools found.[/dim]") else: console.print(f"[bold]Tools ({len(tools)})[/bold]") console.print() for tool in tools: sig = format_tool_signature(tool) console.print(f" [cyan]{_sanitize_untrusted_text(sig)}[/cyan]") if tool.description: console.print( f" {_sanitize_untrusted_text(tool.description)}" ) if input_schema: _print_schema("Input", tool.inputSchema) if output_schema and tool.outputSchema: _print_schema("Output", tool.outputSchema) console.print() if resources: res = await client.list_resources() console.print(f"[bold]Resources ({len(res)})[/bold]") console.print() if not res: console.print(" [dim]No resources found.[/dim]") for r in res: console.print( f" [cyan]{_sanitize_untrusted_text(str(r.uri))}[/cyan]" ) desc_parts = [r.name or "", r.description or ""] desc = " — ".join(p for p in desc_parts if p) if desc: console.print(f" {_sanitize_untrusted_text(desc)}") console.print() if prompts: prm = await client.list_prompts() console.print(f"[bold]Prompts ({len(prm)})[/bold]") console.print() if not prm: console.print(" [dim]No prompts found.[/dim]") for p in prm: args_str = "" if p.arguments: parts = [a.name for a in p.arguments] args_str = f"({', '.join(parts)})" console.print( f" [cyan]{_sanitize_untrusted_text(p.name + args_str)}[/cyan]" ) if p.description: console.print(f" {_sanitize_untrusted_text(p.description)}") console.print() except Exception as exc: console.print(f"[bold red]Error:[/bold red] {exc}") sys.exit(1) async def call_command( server_spec: Annotated[ str | None, cyclopts.Parameter( help="Server URL, Python file, MCPConfig JSON, or .js file", ), ] = None, target: Annotated[ str, cyclopts.Parameter( help="Tool name, resource URI, or prompt name (with --prompt)", ), ] = "", *arguments: str, command: Annotated[ str | None, cyclopts.Parameter( "--command", help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", ), ] = None, transport: Annotated[ Literal["http", "sse"] | None, cyclopts.Parameter( name=["--transport", "-t"], help="Force transport type for URL targets (http or sse)", ), ] = None, prompt: Annotated[ bool, cyclopts.Parameter("--prompt", help="Treat target as a prompt name"), ] = False, input_json: Annotated[ str | None, cyclopts.Parameter( "--input-json", help="JSON string of arguments (merged with key=value args)", ), ] = None, json_output: Annotated[ bool, cyclopts.Parameter("--json", help="Output raw JSON result"), ] = False, timeout: Annotated[ float | None, cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), ] = None, auth: Annotated[ str | None, cyclopts.Parameter( "--auth", help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, ) -> None: """Call a tool, read a resource, or get a prompt on an MCP server. By default the target is treated as a tool name. If the target contains ``://`` it is treated as a resource URI. Pass ``--prompt`` to treat it as a prompt name. Arguments are passed as key=value pairs. Use --input-json for complex or nested arguments. Examples: ``` fastmcp call server.py greet name=World fastmcp call server.py resource://docs/readme fastmcp call server.py analyze --prompt data='[1,2,3]' fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' ``` """ if not target: console.print( "[bold red]Error:[/bold red] Missing target.\n\n" "Usage: fastmcp call [key=value ...]\n\n" " target can be a tool name, a resource URI, or a prompt name (with --prompt).\n\n" "Use [cyan]fastmcp list [/cyan] to see available tools." ) sys.exit(1) resolved = resolve_server_spec(server_spec, command=command, transport=transport) client = _build_client(resolved, timeout=timeout, auth=auth) try: async with client: if prompt: await _handle_prompt(client, target, arguments, input_json, json_output) elif "://" in target: await _handle_resource(client, target, json_output) else: await _handle_tool_call( client, target, arguments, input_json, json_output ) except Exception as exc: console.print(f"[bold red]Error:[/bold red] {exc}") sys.exit(1) async def discover_command( *, source: Annotated[ list[str] | None, cyclopts.Parameter( "--source", help="Only show servers from these sources (e.g. claude-code, cursor, gemini)", ), ] = None, json_output: Annotated[ bool, cyclopts.Parameter("--json", help="Output as JSON"), ] = False, ) -> None: """Discover MCP servers configured in editor and project configs. Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and project-level mcp.json files for MCP server definitions. Discovered server names can be used directly with ``fastmcp list`` and ``fastmcp call`` instead of specifying a URL or file path. Examples: fastmcp discover fastmcp discover --source claude-code fastmcp discover --source cursor --source gemini --json fastmcp list weather fastmcp call cursor:weather get_forecast city=London """ servers = discover_servers() if source: servers = [s for s in servers if s.source in source] if json_output: data: list[dict[str, Any]] = [ { "name": s.name, "source": s.source, "qualified_name": s.qualified_name, "transport_summary": s.transport_summary, "config_path": str(s.config_path), } for s in servers ] console.print_json(json.dumps(data)) return if not servers: console.print("[dim]No MCP servers found.[/dim]") console.print() console.print("Searched:") console.print(" • Claude Desktop config") console.print(" • ~/.claude.json (Claude Code)") console.print(" • .cursor/mcp.json (walked up from cwd)") console.print(" • ~/.gemini/settings.json (Gemini CLI)") console.print(" • ~/.config/goose/config.yaml (Goose)") console.print(" • ./mcp.json") return from rich.table import Table # Group by source by_source: dict[str, list[DiscoveredServer]] = {} for s in servers: by_source.setdefault(s.source, []).append(s) for source_name, group in by_source.items(): console.print() console.print(f"[bold]Source:[/bold] {source_name}") console.print(f"[bold]Config:[/bold] [dim]{group[0].config_path}[/dim]") console.print() table = Table( show_header=True, header_style="bold", show_edge=False, pad_edge=False, box=None, padding=(0, 2), ) table.add_column("Server", style="cyan") table.add_column("Transport", style="dim") for s in group: table.add_row(s.name, s.transport_summary) console.print(table) console.print() ================================================ FILE: src/fastmcp/cli/discovery.py ================================================ """Discover MCP servers configured in editor config files. Scans filesystem-readable config files from editors like Claude Desktop, Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level ``mcp.json`` files. Each discovered server can be resolved by name (or ``source:name``) so the CLI can connect without requiring a URL or file path. """ import json import os import sys from dataclasses import dataclass from pathlib import Path from typing import Any import yaml from fastmcp.client.transports.base import ClientTransport from fastmcp.mcp_config import ( MCPConfig, MCPServerTypes, RemoteMCPServer, StdioMCPServer, ) from fastmcp.utilities.logging import get_logger logger = get_logger("cli.discovery") # --------------------------------------------------------------------------- # Data model # --------------------------------------------------------------------------- @dataclass(frozen=True) class DiscoveredServer: """A single MCP server found in an editor or project config.""" name: str source: str config: MCPServerTypes config_path: Path @property def qualified_name(self) -> str: """Fully qualified ``source:name`` identifier.""" return f"{self.source}:{self.name}" @property def transport_summary(self) -> str: """Human-readable one-liner describing the transport.""" cfg = self.config if isinstance(cfg, StdioMCPServer): parts = [cfg.command, *cfg.args] return f"stdio: {' '.join(parts)}" if isinstance(cfg, RemoteMCPServer): transport = cfg.transport or "http" return f"{transport}: {cfg.url}" return str(type(cfg).__name__) # --------------------------------------------------------------------------- # Scanners — one per config source # --------------------------------------------------------------------------- def _normalize_server_entry(entry: dict[str, Any]) -> dict[str, Any]: """Normalize editor-specific server config fields to MCPConfig format. Handles two known differences: - Claude Code uses ``type`` where MCPConfig uses ``transport`` for remote servers. - Gemini CLI uses ``httpUrl`` where MCPConfig uses ``url``. """ # Gemini: httpUrl → url if "httpUrl" in entry and "url" not in entry: entry = {**entry, "url": entry["httpUrl"]} del entry["httpUrl"] # Claude Code / others: type → transport (for url-based entries only) if "url" in entry and "type" in entry and "transport" not in entry: transport = entry["type"] entry = {k: v for k, v in entry.items() if k != "type"} entry["transport"] = transport return entry def _parse_mcp_servers( servers_dict: dict[str, Any], *, source: str, config_path: Path, ) -> list[DiscoveredServer]: """Parse an ``mcpServers``-style dict into discovered servers.""" if not servers_dict: return [] normalized = { name: _normalize_server_entry(entry) for name, entry in servers_dict.items() if isinstance(entry, dict) } try: config = MCPConfig.from_dict({"mcpServers": normalized}) except Exception as exc: logger.warning("Could not parse MCP servers from %s: %s", config_path, exc) return [] return [ DiscoveredServer( name=name, source=source, config=server, config_path=config_path ) for name, server in config.mcpServers.items() ] def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]: """Parse an mcpServers-style JSON file into discovered servers.""" try: text = path.read_text() except OSError as exc: logger.debug("Could not read %s: %s", path, exc) return [] try: data: dict[str, Any] = json.loads(text) except json.JSONDecodeError as exc: logger.warning("Invalid JSON in %s: %s", path, exc) return [] if not isinstance(data, dict) or "mcpServers" not in data: return [] return _parse_mcp_servers(data["mcpServers"], source=source, config_path=path) def _scan_claude_desktop() -> list[DiscoveredServer]: """Scan the Claude Desktop config file.""" if sys.platform == "win32": config_dir = Path(Path.home(), "AppData", "Roaming", "Claude") elif sys.platform == "darwin": config_dir = Path(Path.home(), "Library", "Application Support", "Claude") elif sys.platform.startswith("linux"): config_dir = Path( os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" ) else: return [] path = config_dir / "claude_desktop_config.json" return _parse_mcp_config(path, "claude-desktop") def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]: """Scan ``~/.claude.json`` for global and project-scoped MCP servers.""" path = Path.home() / ".claude.json" try: text = path.read_text() except OSError: return [] try: data: dict[str, Any] = json.loads(text) except json.JSONDecodeError as exc: logger.warning("Invalid JSON in %s: %s", path, exc) return [] if not isinstance(data, dict): return [] results: list[DiscoveredServer] = [] # Global servers if global_servers := data.get("mcpServers"): if isinstance(global_servers, dict): results.extend( _parse_mcp_servers( global_servers, source="claude-code", config_path=path ) ) # Project-scoped servers matching start_dir resolved_dir = str(start_dir.resolve()) projects = data.get("projects", {}) if isinstance(projects, dict): project_data = projects.get(resolved_dir, {}) if isinstance(project_data, dict): if project_servers := project_data.get("mcpServers"): if isinstance(project_servers, dict): results.extend( _parse_mcp_servers( project_servers, source="claude-code", config_path=path, ) ) return results def _scan_cursor_workspace(start_dir: Path) -> list[DiscoveredServer]: """Walk up from *start_dir* looking for ``.cursor/mcp.json``.""" current = start_dir.resolve() home = Path.home().resolve() while True: candidate = current / ".cursor" / "mcp.json" if candidate.is_file(): return _parse_mcp_config(candidate, "cursor") parent = current.parent # Stop at filesystem root or home directory if parent == current or current == home: break current = parent return [] def _scan_project_mcp_json(start_dir: Path) -> list[DiscoveredServer]: """Check for ``mcp.json`` in *start_dir*.""" candidate = start_dir.resolve() / "mcp.json" if candidate.is_file(): return _parse_mcp_config(candidate, "project") return [] def _scan_gemini(start_dir: Path) -> list[DiscoveredServer]: """Scan Gemini CLI settings for MCP servers. Checks both user-level ``~/.gemini/settings.json`` and project-level ``.gemini/settings.json``. """ results: list[DiscoveredServer] = [] # User-level user_path = Path.home() / ".gemini" / "settings.json" results.extend(_parse_mcp_config(user_path, "gemini")) # Project-level project_path = start_dir.resolve() / ".gemini" / "settings.json" if project_path != user_path: results.extend(_parse_mcp_config(project_path, "gemini")) return results def _scan_goose() -> list[DiscoveredServer]: """Scan Goose config for MCP server extensions. Goose uses YAML (``~/.config/goose/config.yaml``) with a different schema — MCP servers are defined as ``extensions`` with ``type: stdio``. """ if sys.platform == "win32": config_dir = Path( os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"), "Block", "goose", "config", ) else: config_dir = Path( os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "goose", ) path = config_dir / "config.yaml" try: text = path.read_text() except OSError: return [] try: data = yaml.safe_load(text) except yaml.YAMLError as exc: logger.warning("Invalid YAML in %s: %s", path, exc) return [] if not isinstance(data, dict): return [] extensions = data.get("extensions", {}) if not isinstance(extensions, dict): return [] # Convert Goose extensions to mcpServers format servers: dict[str, Any] = {} for name, ext in extensions.items(): if not isinstance(ext, dict): continue if not ext.get("enabled", True): continue ext_type = ext.get("type", "") if ext_type == "stdio" and "cmd" in ext: servers[name] = { "command": ext["cmd"], "args": ext.get("args", []), "env": ext.get("envs", {}), } elif ext_type == "sse" and "uri" in ext: servers[name] = {"url": ext["uri"], "transport": "sse"} return _parse_mcp_servers(servers, source="goose", config_path=path) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer]: """Run all scanners and return the combined results. Duplicate names across sources are preserved — callers can use :pyattr:`DiscoveredServer.qualified_name` to disambiguate. """ cwd = start_dir or Path.cwd() results: list[DiscoveredServer] = [] results.extend(_scan_claude_desktop()) results.extend(_scan_claude_code(cwd)) results.extend(_scan_cursor_workspace(cwd)) results.extend(_scan_gemini(cwd)) results.extend(_scan_goose()) results.extend(_scan_project_mcp_json(cwd)) return results def resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport: """Resolve a server name (or ``source:name``) to a transport. Raises :class:`ValueError` when the name is not found or is ambiguous. """ servers = discover_servers(start_dir) # Qualified form: "cursor:weather" if ":" in name: source, server_name = name.split(":", 1) matches = [s for s in servers if s.source == source and s.name == server_name] if not matches: raise ValueError( f"No server named '{server_name}' found in source '{source}'." ) return matches[0].config.to_transport() # Bare name: "weather" matches = [s for s in servers if s.name == name] if not matches: if servers: available = ", ".join(sorted({s.name for s in servers})) raise ValueError(f"No server named '{name}' found. Available: {available}") locations = [ "Claude Desktop config", "~/.claude.json (Claude Code)", ".cursor/mcp.json (walked up from cwd)", "~/.gemini/settings.json (Gemini CLI)", "~/.config/goose/config.yaml (Goose)", "./mcp.json", ] raise ValueError( f"No server named '{name}' found. Searched: {', '.join(locations)}" ) if len(matches) == 1: return matches[0].config.to_transport() # Ambiguous — list qualified alternatives alternatives = ", ".join(f"'{m.qualified_name}'" for m in matches) raise ValueError( f"Ambiguous server name '{name}' — found in multiple sources. " f"Use a qualified name: {alternatives}" ) ================================================ FILE: src/fastmcp/cli/generate.py ================================================ """Generate a standalone CLI script and agent skill from an MCP server.""" import keyword import re import sys import textwrap from pathlib import Path from typing import Annotated, Any from urllib.parse import urlparse import cyclopts import mcp.types import pydantic_core from mcp import McpError from rich.console import Console from fastmcp.cli.client import _build_client, resolve_server_spec from fastmcp.client.transports.base import ClientTransport from fastmcp.client.transports.stdio import StdioTransport from fastmcp.utilities.logging import get_logger logger = get_logger("cli.generate") console = Console() # --------------------------------------------------------------------------- # JSON Schema type → Python type string # --------------------------------------------------------------------------- _SIMPLE_TYPES = {"string", "integer", "number", "boolean", "null"} def _is_simple_type(schema: dict[str, Any]) -> bool: """Check if a schema represents a simple (non-complex) type.""" schema_type = schema.get("type") if isinstance(schema_type, list): # Union of types - simple only if all are simple return all(t in _SIMPLE_TYPES for t in schema_type) return schema_type in _SIMPLE_TYPES def _is_simple_array(schema: dict[str, Any]) -> tuple[bool, str | None]: """Check if schema is an array of simple types. Returns (is_simple_array, item_type_str). """ if schema.get("type") != "array": return False, None items = schema.get("items", {}) if not _is_simple_type(items): return False, None # Map JSON Schema type to Python type item_type = items.get("type", "string") if isinstance(item_type, list): return False, None type_map = { "string": "str", "integer": "int", "number": "float", "boolean": "bool", } py_type = type_map.get(item_type) if py_type is None: return False, None return True, py_type def _schema_to_python_type(schema: dict[str, Any]) -> tuple[str, bool]: """Convert a JSON Schema to a Python type annotation. Returns (type_annotation, needs_json_parsing). """ # Check for simple array first is_simple_arr, item_type = _is_simple_array(schema) if is_simple_arr: return f"list[{item_type}]", False # Check for simple type if _is_simple_type(schema): schema_type = schema.get("type", "string") if isinstance(schema_type, list): # Union of simple types type_map = { "string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None", } parts = [type_map.get(t, "str") for t in schema_type] return " | ".join(parts), False type_map = { "string": "str", "integer": "int", "number": "float", "boolean": "bool", "null": "None", } return type_map.get(schema_type, "str"), False # Complex type - needs JSON parsing return "str", True def _format_schema_for_help(schema: dict[str, Any]) -> str: """Format a JSON schema for display in help text.""" # Pretty print the schema, indented for help text schema_str = pydantic_core.to_json(schema, indent=2).decode() # Indent each line for help text alignment lines = schema_str.split("\n") indented = "\n ".join(lines) return f"JSON Schema: {indented}" # --------------------------------------------------------------------------- # Transport serialization # --------------------------------------------------------------------------- def serialize_transport( resolved: str | dict[str, Any] | ClientTransport, ) -> tuple[str, set[str]]: """Serialize a resolved transport to a Python expression string. Returns ``(expression, extra_imports)`` where *extra_imports* is a set of import lines needed by the expression. """ if isinstance(resolved, str): return repr(resolved), set() if isinstance(resolved, StdioTransport): parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"] if resolved.env: parts.append(f"env={resolved.env!r}") if resolved.cwd: parts.append(f"cwd={resolved.cwd!r}") expr = f"StdioTransport({', '.join(parts)})" imports = {"from fastmcp.client.transports import StdioTransport"} return expr, imports if isinstance(resolved, dict): return repr(resolved), set() # Fallback: try repr return repr(resolved), set() # --------------------------------------------------------------------------- # Per-tool code generation # --------------------------------------------------------------------------- def _to_python_identifier(name: str) -> str: """Sanitize a string into a valid Python identifier.""" safe = re.sub(r"[^a-zA-Z0-9_]", "_", name) if safe and safe[0].isdigit(): safe = f"_{safe}" safe = safe or "_unnamed" if keyword.iskeyword(safe): safe = f"{safe}_" return safe def _tool_function_source(tool: mcp.types.Tool) -> str: """Generate the source for a single ``@call_tool_app.command`` function.""" schema = tool.inputSchema properties: dict[str, Any] = schema.get("properties", {}) required = set(schema.get("required", [])) # Build parameter lines and track which need JSON parsing param_lines: list[str] = [] call_args: list[str] = [] json_params: list[tuple[str, str]] = [] # (prop_name, safe_name) seen_names: dict[str, str] = {} # safe_name -> original prop_name for prop_name, prop_schema in properties.items(): py_type, needs_json = _schema_to_python_type(prop_schema) help_text = prop_schema.get("description", "") is_required = prop_name in required safe_name = _to_python_identifier(prop_name) # Check for name collisions after sanitization if safe_name in seen_names: raise ValueError( f"Parameter name collision: '{prop_name}' and '{seen_names[safe_name]}' " f"both sanitize to '{safe_name}'" ) seen_names[safe_name] = prop_name # For complex types, add schema to help text if needs_json: schema_help = _format_schema_for_help(prop_schema) help_text = f"{help_text}\\n{schema_help}" if help_text else schema_help json_params.append((prop_name, safe_name)) # Escape special characters in help text help_escaped = ( help_text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") ) # Build parameter annotation if is_required: annotation = ( f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' ) param_lines.append(f" {safe_name}: {annotation},") else: default = prop_schema.get("default") if default is not None: # For complex types with defaults, serialize to JSON string if needs_json: default_str = pydantic_core.to_json(default, fallback=str).decode() annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' param_lines.append( f" {safe_name}: {annotation} = {default_str!r}," ) else: annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' param_lines.append(f" {safe_name}: {annotation} = {default!r},") else: # For list types, default to empty list; others default to None if py_type.startswith("list["): annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' param_lines.append(f" {safe_name}: {annotation} = [],") else: annotation = f'Annotated[{py_type} | None, cyclopts.Parameter(help="{help_escaped}")]' param_lines.append(f" {safe_name}: {annotation} = None,") call_args.append(f"{prop_name!r}: {safe_name}") # Function name: sanitize to valid Python identifier fn_name = _to_python_identifier(tool.name) # Docstring - use single-quoted docstrings to avoid triple-quote escaping issues description = (tool.description or "").replace("\\", "\\\\").replace("'", "\\'") lines = [] lines.append("") # Always pass name= to preserve the original tool name (cyclopts # would otherwise convert underscores to hyphens). lines.append(f"@call_tool_app.command(name={tool.name!r})") lines.append(f"async def {fn_name}(") if param_lines: lines.append(" *,") lines.extend(param_lines) lines.append(") -> None:") lines.append(f" '''{description}'''") # Add JSON parsing for complex parameters if json_params: lines.append(" # Parse JSON parameters") for _prop_name, safe_name in json_params: lines.append( f" {safe_name}_parsed = json.loads({safe_name}) if isinstance({safe_name}, str) else {safe_name}" ) lines.append("") # Build call arguments, using parsed versions for JSON params call_arg_parts = [] for prop_name, _ in properties.items(): safe_name = _to_python_identifier(prop_name) if any(pn == prop_name for pn, _ in json_params): call_arg_parts.append(f"{prop_name!r}: {safe_name}_parsed") else: call_arg_parts.append(f"{prop_name!r}: {safe_name}") dict_items = ", ".join(call_arg_parts) lines.append(f" await _call_tool({tool.name!r}, {{{dict_items}}})") lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # Full script generation # --------------------------------------------------------------------------- def generate_cli_script( server_name: str, server_spec: str, transport_code: str, extra_imports: set[str], tools: list[mcp.types.Tool], ) -> str: """Generate the full CLI script source code.""" # Determine app name from server_name - sanitize for use in string literal app_name = ( server_name.replace(" ", "-").lower().replace("\\", "\\\\").replace('"', '\\"') ) # --- Header --- lines: list[str] = [] lines.append("#!/usr/bin/env python3") lines.append(f'"""CLI for {server_name} MCP server.') lines.append("") lines.append(f"Generated by: fastmcp generate-cli {server_spec}") lines.append('"""') lines.append("") # --- Imports --- lines.append("import json") lines.append("import sys") lines.append("from typing import Annotated") lines.append("") lines.append("import cyclopts") lines.append("import mcp.types") lines.append("from rich.console import Console") lines.append("") lines.append("from fastmcp import Client") for imp in sorted(extra_imports): lines.append(imp) lines.append("") # --- Transport config --- lines.append("# Modify this to change how the CLI connects to the MCP server.") lines.append(f"CLIENT_SPEC = {transport_code}") lines.append("") # --- App setup --- server_name_escaped = server_name.replace("\\", "\\\\").replace('"', '\\"') lines.append( f'app = cyclopts.App(name="{app_name}", help="CLI for {server_name_escaped} MCP server")' ) lines.append( 'call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server")' ) lines.append("app.command(call_tool_app)") lines.append("") lines.append("console = Console()") lines.append("") lines.append("") # --- Shared helpers --- lines.append( textwrap.dedent("""\ # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _print_tool_result(result): if result.is_error: for block in result.content: if isinstance(block, mcp.types.TextContent): console.print(f"[bold red]Error:[/bold red] {block.text}") else: console.print(f"[bold red]Error:[/bold red] {block}") sys.exit(1) if result.structured_content is not None: console.print_json(json.dumps(result.structured_content)) return for block in result.content: if isinstance(block, mcp.types.TextContent): console.print(block.text) elif isinstance(block, mcp.types.ImageContent): size = len(block.data) * 3 // 4 console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") elif isinstance(block, mcp.types.AudioContent): size = len(block.data) * 3 // 4 console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") async def _call_tool(tool_name: str, arguments: dict) -> None: # Filter out None values and empty lists (defaults for optional array params) filtered = { k: v for k, v in arguments.items() if v is not None and (not isinstance(v, list) or len(v) > 0) } async with Client(CLIENT_SPEC) as client: result = await client.call_tool(tool_name, filtered, raise_on_error=False) _print_tool_result(result) if result.is_error: sys.exit(1)""") ) lines.append("") lines.append("") # --- Generic commands --- lines.append( textwrap.dedent("""\ # --------------------------------------------------------------------------- # List / read commands # --------------------------------------------------------------------------- @app.command async def list_tools() -> None: \"\"\"List available tools.\"\"\" async with Client(CLIENT_SPEC) as client: tools = await client.list_tools() if not tools: console.print("[dim]No tools found.[/dim]") return for tool in tools: sig_parts = [] props = tool.inputSchema.get("properties", {}) required = set(tool.inputSchema.get("required", [])) for pname, pschema in props.items(): ptype = pschema.get("type", "string") if pname in required: sig_parts.append(f"{pname}: {ptype}") else: sig_parts.append(f"{pname}: {ptype} = ...") sig = f"{tool.name}({', '.join(sig_parts)})" console.print(f" [cyan]{sig}[/cyan]") if tool.description: console.print(f" {tool.description}") console.print() @app.command async def list_resources() -> None: \"\"\"List available resources.\"\"\" async with Client(CLIENT_SPEC) as client: resources = await client.list_resources() if not resources: console.print("[dim]No resources found.[/dim]") return for r in resources: console.print(f" [cyan]{r.uri}[/cyan]") desc_parts = [r.name or "", r.description or ""] desc = " — ".join(p for p in desc_parts if p) if desc: console.print(f" {desc}") console.print() @app.command async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: \"\"\"Read a resource by URI.\"\"\" async with Client(CLIENT_SPEC) as client: contents = await client.read_resource(uri) for block in contents: if isinstance(block, mcp.types.TextResourceContents): console.print(block.text) elif isinstance(block, mcp.types.BlobResourceContents): size = len(block.blob) * 3 // 4 console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") @app.command async def list_prompts() -> None: \"\"\"List available prompts.\"\"\" async with Client(CLIENT_SPEC) as client: prompts = await client.list_prompts() if not prompts: console.print("[dim]No prompts found.[/dim]") return for p in prompts: args_str = "" if p.arguments: parts = [a.name for a in p.arguments] args_str = f"({', '.join(parts)})" console.print(f" [cyan]{p.name}{args_str}[/cyan]") if p.description: console.print(f" {p.description}") console.print() @app.command async def get_prompt( name: Annotated[str, cyclopts.Parameter(help="Prompt name")], *arguments: str, ) -> None: \"\"\"Get a prompt by name. Pass arguments as key=value pairs.\"\"\" parsed: dict[str, str] = {} for arg in arguments: if "=" not in arg: console.print(f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value") sys.exit(1) key, value = arg.split("=", 1) parsed[key] = value async with Client(CLIENT_SPEC) as client: result = await client.get_prompt(name, parsed or None) for msg in result.messages: console.print(f"[bold]{msg.role}:[/bold]") if isinstance(msg.content, mcp.types.TextContent): console.print(f" {msg.content.text}") elif isinstance(msg.content, mcp.types.ImageContent): size = len(msg.content.data) * 3 // 4 console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]") else: console.print(f" {msg.content}") console.print()""") ) lines.append("") lines.append("") # --- Generated tool commands --- if tools: lines.append( "# ---------------------------------------------------------------------------" ) lines.append("# Tool commands (generated from server schema)") lines.append( "# ---------------------------------------------------------------------------" ) for tool in tools: lines.append(_tool_function_source(tool)) # --- Entry point --- lines.append("") lines.append('if __name__ == "__main__":') lines.append(" app()") lines.append("") return "\n".join(lines) # --------------------------------------------------------------------------- # Skill (SKILL.md) generation # --------------------------------------------------------------------------- _JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { "string": "string", "integer": "integer", "number": "number", "boolean": "boolean", "null": "null", "array": "array", "object": "object", } def _param_to_cli_flag(prop_name: str) -> str: """Convert a JSON Schema property name to its CLI flag form. Replicates cyclopts' default_name_transform: camelCase → snake_case, lowercase, underscores → hyphens, strip leading/trailing hyphens. """ safe = _to_python_identifier(prop_name) # camelCase / PascalCase → snake_case safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) safe = safe.lower().replace("_", "-").strip("-") return f"--{safe}" if safe else "--arg" def _schema_type_label(prop_schema: dict[str, Any]) -> str: """Return a human-readable type label for a property schema.""" schema_type = prop_schema.get("type", "string") if isinstance(schema_type, list): labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] return " | ".join(labels) label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) # For arrays, include item type if simple if schema_type == "array": items = prop_schema.get("items", {}) item_type = items.get("type", "") if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: return f"array[{item_type}]" return label def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: """Generate a SKILL.md section for a single tool.""" schema = tool.inputSchema properties: dict[str, Any] = schema.get("properties", {}) required = set(schema.get("required", [])) # Build example invocation flags flag_parts_list: list[str] = [] for p, p_schema in properties.items(): flag = _param_to_cli_flag(p) schema_type = p_schema.get("type") is_bool = schema_type == "boolean" or ( isinstance(schema_type, list) and "boolean" in schema_type ) if is_bool: flag_parts_list.append(flag) else: flag_parts_list.append(f"{flag} ") flag_parts = " ".join(flag_parts_list) invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" if flag_parts: invocation += f" {flag_parts}" # Build parameter table rows rows: list[str] = [] for prop_name, prop_schema in properties.items(): flag = f"`{_param_to_cli_flag(prop_name)}`" type_label = _schema_type_label(prop_schema).replace("|", "\\|") is_required = "yes" if prop_name in required else "no" description = prop_schema.get("description", "") _, needs_json = _schema_to_python_type(prop_schema) if needs_json: description = ( f"{description} (JSON string)" if description else "JSON string" ) description = description.replace("\n", " ").replace("|", "\\|") rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") param_table = "" if rows: header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" param_table = f"\n{header}\n" + "\n".join(rows) + "\n" lines: list[str] = [f"### {tool.name}"] if tool.description: lines.extend(["", tool.description]) lines.extend(["", "```bash", invocation, "```"]) if param_table: lines.extend(["", param_table.strip("\n")]) return "\n".join(lines) def generate_skill_content( server_name: str, cli_filename: str, tools: list[mcp.types.Tool], ) -> str: """Generate a SKILL.md file for a generated CLI script.""" skill_name = ( server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") ) safe_name = server_name.replace("\\", "").replace('"', "") description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." lines = [ "---", f'name: "{skill_name}-cli"', f'description: "{description}"', "---", "", f"# {server_name} CLI", "", ] if tools: tool_bodies = "\n\n".join( _tool_skill_section(tool, cli_filename) for tool in tools ) lines.extend(["## Tool Commands", "", tool_bodies, ""]) lines.extend( [ "## Utility Commands", "", "```bash", f"uv run --with fastmcp python {cli_filename} list-tools", f"uv run --with fastmcp python {cli_filename} list-resources", f"uv run --with fastmcp python {cli_filename} read-resource ", f"uv run --with fastmcp python {cli_filename} list-prompts", f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", "```", "", ] ) return "\n".join(lines) # --------------------------------------------------------------------------- # CLI command # --------------------------------------------------------------------------- async def generate_cli_command( server_spec: Annotated[ str, cyclopts.Parameter( help="Server URL, Python file, MCPConfig JSON, discovered name, or .js file", ), ], output: Annotated[ str, cyclopts.Parameter( help="Output file path (default: cli.py)", ), ] = "cli.py", *, force: Annotated[ bool, cyclopts.Parameter( name=["-f", "--force"], help="Overwrite output file if it exists", ), ] = False, timeout: Annotated[ float | None, cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), ] = None, auth: Annotated[ str | None, cyclopts.Parameter( "--auth", help="Auth method: 'oauth', a bearer token string, or 'none' to disable", ), ] = None, no_skill: Annotated[ bool, cyclopts.Parameter( "--no-skill", help="Skip generating a SKILL.md agent skill alongside the CLI", ), ] = False, ) -> None: """Generate a standalone CLI script from an MCP server. Connects to the server, reads its tools/resources/prompts, and writes a Python script that can invoke them directly. Also generates a SKILL.md agent skill file unless --no-skill is passed. Examples: fastmcp generate-cli weather fastmcp generate-cli weather my_cli.py fastmcp generate-cli http://localhost:8000/mcp fastmcp generate-cli server.py output.py -f fastmcp generate-cli weather --no-skill """ output_path = Path(output) skill_path = output_path.parent / "SKILL.md" # Check both files up front before doing any work existing: list[Path] = [] if output_path.exists() and not force: existing.append(output_path) if not no_skill and skill_path.exists() and not force: existing.append(skill_path) if existing: names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) console.print( f"[bold red]Error:[/bold red] {names} already exist(s). " f"Use [cyan]-f[/cyan] to overwrite." ) sys.exit(1) # Resolve the server spec to a transport resolved = resolve_server_spec(server_spec) transport_code, extra_imports = serialize_transport(resolved) # Derive a human-friendly server name from the spec server_name = _derive_server_name(server_spec) # Connect and discover capabilities client = _build_client(resolved, timeout=timeout, auth=auth) try: async with client: tools = await client.list_tools() console.print( f"[dim]Discovered {len(tools)} tool(s) from {server_spec}[/dim]" ) except (RuntimeError, TimeoutError, McpError, OSError) as exc: console.print(f"[bold red]Error:[/bold red] Could not connect: {exc}") sys.exit(1) # Generate and write the script script = generate_cli_script( server_name=server_name, server_spec=server_spec, transport_code=transport_code, extra_imports=extra_imports, tools=tools, ) output_path.write_text(script) output_path.chmod(output_path.stat().st_mode | 0o111) # make executable console.print( f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " f"with {len(tools)} tool command(s)" ) if not no_skill: skill_content = generate_skill_content( server_name=server_name, cli_filename=output_path.name, tools=tools, ) skill_path.write_text(skill_content) console.print(f"[green]✓[/green] Wrote [cyan]{skill_path}[/cyan]") console.print(f"[dim]Run: python {output_path} --help[/dim]") def _derive_server_name(server_spec: str) -> str: """Derive a human-friendly name from a server spec.""" # URL — use hostname if server_spec.startswith(("http://", "https://")): parsed = urlparse(server_spec) return parsed.hostname or "server" # File path — use stem if server_spec.endswith((".py", ".js", ".json")): return Path(server_spec).stem # Bare name or qualified name if ":" in server_spec: name = server_spec.split(":", 1)[1] return name or server_spec.split(":", 1)[0] return server_spec ================================================ FILE: src/fastmcp/cli/install/__init__.py ================================================ """Install subcommands for FastMCP CLI using Cyclopts.""" import cyclopts from .claude_code import claude_code_command from .claude_desktop import claude_desktop_command from .cursor import cursor_command from .gemini_cli import gemini_cli_command from .goose import goose_command from .mcp_json import mcp_json_command from .stdio import stdio_command # Create a cyclopts app for install subcommands install_app = cyclopts.App( name="install", help="Install MCP servers in various clients and formats.", ) # Register each command from its respective module install_app.command(claude_code_command, name="claude-code") install_app.command(claude_desktop_command, name="claude-desktop") install_app.command(cursor_command, name="cursor") install_app.command(gemini_cli_command, name="gemini-cli") install_app.command(goose_command, name="goose") install_app.command(mcp_json_command, name="mcp-json") install_app.command(stdio_command, name="stdio") ================================================ FILE: src/fastmcp/cli/install/claude_code.py ================================================ """Claude Code integration for FastMCP install using Cyclopts.""" import shutil import subprocess import sys from pathlib import Path from typing import Annotated import cyclopts from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args, validate_server_name logger = get_logger(__name__) def find_claude_command() -> str | None: """Find the Claude Code CLI command. Checks common installation locations since 'claude' is often a shell alias that doesn't work with subprocess calls. """ # First try shutil.which() in case it's a real executable in PATH claude_in_path = shutil.which("claude") if claude_in_path: try: result = subprocess.run( [claude_in_path, "--version"], check=True, capture_output=True, text=True, ) if "Claude Code" in result.stdout: return claude_in_path except (subprocess.CalledProcessError, FileNotFoundError): pass # Check common installation locations (aliases don't work with subprocess) potential_paths = [ # Default Claude Code installation location (after migration) Path.home() / ".claude" / "local" / "claude", # npm global installation on macOS/Linux (default) Path("/usr/local/bin/claude"), # npm global installation with custom prefix Path.home() / ".npm-global" / "bin" / "claude", ] for path in potential_paths: if path.exists(): try: result = subprocess.run( [str(path), "--version"], check=True, capture_output=True, text=True, ) if "Claude Code" in result.stdout: return str(path) except (subprocess.CalledProcessError, FileNotFoundError): continue return None def check_claude_code_available() -> bool: """Check if Claude Code CLI is available.""" return find_claude_command() is not None def install_claude_code( file: Path, server_object: str | None, name: str, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, ) -> bool: """Install FastMCP server in Claude Code. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude Code with_editable: Optional list of directories 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 """ # Check if Claude Code CLI is available claude_cmd = find_claude_command() if not claude_cmd: print( "[red]Claude Code CLI not found.[/red]\n" "[blue]Please ensure Claude Code is installed. Try running 'claude --version' to verify.[/blue]" ) return False env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) validate_server_name(name) # Build claude mcp add command cmd_parts = [claude_cmd, "mcp", "add", name] # Add environment variables if specified if env_vars: for key, value in env_vars.items(): cmd_parts.extend(["-e", f"{key}={value}"]) # Add server name and command cmd_parts.append("--") cmd_parts.extend(full_command) try: # Run the claude mcp add command subprocess.run(cmd_parts, check=True, capture_output=True, text=True) return True except subprocess.CalledProcessError as e: print( f"[red]Failed to install '[bold]{name}[/bold]' in Claude Code: {e.stderr.strip() if e.stderr else str(e)}[/red]" ) return False except Exception as e: print(f"[red]Failed to install '[bold]{name}[/bold]' in Claude Code: {e}[/red]") return False async def claude_code_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server in Claude Code", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, ) -> None: """Install an MCP server in Claude Code. Args: server_spec: Python file to install, optionally with :object suffix """ # Convert None to empty lists for list parameters with_editable = with_editable or [] with_packages = with_packages or [] env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_claude_code( file=file, server_object=server_object, name=name, with_editable=with_editable, with_packages=packages, env_vars=env_dict, python_version=python, with_requirements=with_requirements, project=project, ) if success: print(f"[green]Successfully installed '{name}' in Claude Code[/green]") else: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/claude_desktop.py ================================================ """Claude Desktop integration for FastMCP install using Cyclopts.""" import os import sys from pathlib import Path from typing import Annotated import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args logger = get_logger(__name__) def get_claude_config_path(config_path: Path | None = None) -> Path | None: """Get the Claude config directory based on platform. Args: config_path: Optional custom path to the Claude Desktop config directory """ if config_path: if not config_path.exists(): print(f"[red]The specified config path does not exist: {config_path}[/red]") return None return config_path if sys.platform == "win32": path = Path(Path.home(), "AppData", "Roaming", "Claude") elif sys.platform == "darwin": path = Path(Path.home(), "Library", "Application Support", "Claude") elif sys.platform.startswith("linux"): path = Path( os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" ) else: return None if path.exists(): return path return None def install_claude_desktop( file: Path, server_object: str | None, name: str, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, config_path: Path | None = None, ) -> bool: """Install FastMCP server in Claude Desktop. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Claude's config with_editable: Optional list of directories 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 config_path: Optional custom path to Claude Desktop config directory Returns: True if installation was successful, False otherwise """ config_dir = get_claude_config_path(config_path=config_path) if not config_dir: if not config_path: print( "[red]Claude Desktop config directory not found.[/red]\n" "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]" ) return False config_file = config_dir / "claude_desktop_config.json" env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( command=full_command[0], args=full_command[1:], env=env_vars or {}, ) try: # Handle environment variable merging manually since we need to preserve existing config if config_file.exists(): import json content = config_file.read_text().strip() if content: config = json.loads(content) if "mcpServers" in config and name in config["mcpServers"]: existing_env = config["mcpServers"][name].get("env", {}) if env_vars: # New vars take precedence over existing ones merged_env = {**existing_env, **env_vars} else: merged_env = existing_env server_config.env = merged_env # Update configuration with correct function signature update_config_file(config_file, name, server_config) print(f"[green]Successfully installed '{name}' in Claude Desktop[/green]") return True except Exception as e: print(f"[red]Failed to install server: {e}[/red]") return False async def claude_desktop_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server in Claude Desktop's config", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, config_path: Annotated[ Path | None, cyclopts.Parameter( "--config-path", help="Custom path to Claude Desktop config directory", ), ] = None, ) -> None: """Install an MCP server in Claude Desktop. Args: server_spec: Python file to install, optionally with :object suffix """ # Convert None to empty lists for list parameters with_editable = with_editable or [] with_packages = with_packages or [] env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_claude_desktop( file=file, server_object=server_object, name=name, with_editable=with_editable, with_packages=with_packages, env_vars=env_dict, python_version=python, with_requirements=with_requirements, project=project, config_path=config_path, ) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/cursor.py ================================================ """Cursor integration for FastMCP install using Cyclopts.""" import base64 import sys from pathlib import Path from typing import Annotated from urllib.parse import quote import cyclopts from rich import print from fastmcp.mcp_config import StdioMCPServer, update_config_file from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import open_deeplink as _shared_open_deeplink from .shared import process_common_args logger = get_logger(__name__) def generate_cursor_deeplink( server_name: str, server_config: StdioMCPServer, ) -> str: """Generate a Cursor deeplink for installing the MCP server. Args: server_name: Name of the server server_config: Server configuration Returns: Deeplink URL that can be clicked to install the server """ # Create the configuration structure expected by Cursor # Base64 encode the configuration (URL-safe for query parameter) config_json = server_config.model_dump_json(exclude_none=True) config_b64 = base64.urlsafe_b64encode(config_json.encode()).decode() # Generate the deeplink URL with properly encoded server name encoded_name = quote(server_name, safe="") deeplink = f"cursor://anysphere.cursor-deeplink/mcp/install?name={encoded_name}&config={config_b64}" return deeplink def open_deeplink(deeplink: str) -> bool: """Attempt to open a Cursor deeplink URL using the system's default handler. Args: deeplink: The deeplink URL to open Returns: True if the command succeeded, False otherwise """ return _shared_open_deeplink(deeplink, expected_scheme="cursor") def install_cursor_workspace( file: Path, server_object: str | None, name: str, workspace_path: Path, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, ) -> 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 list of directories 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 """ # Ensure workspace path is absolute and exists workspace_path = workspace_path.resolve() if not workspace_path.exists(): print(f"[red]Workspace directory does not exist: {workspace_path}[/red]") return False if not workspace_path.is_dir(): print(f"[red]Workspace path is not a directory: {workspace_path}[/red]") return False # Create .cursor directory in workspace cursor_dir = workspace_path / ".cursor" cursor_dir.mkdir(exist_ok=True) config_file = cursor_dir / "mcp.json" env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Create server configuration server_config = StdioMCPServer( command=full_command[0], args=full_command[1:], env=env_vars or {}, ) try: # Create the config file if it doesn't exist if not config_file.exists(): config_file.write_text('{"mcpServers": {}}') # Update configuration with the new server update_config_file(config_file, name, server_config) print( f"[green]Successfully installed '{name}' to workspace at {workspace_path}[/green]" ) return True except Exception as e: print(f"[red]Failed to install server to workspace: {e}[/red]") return False def install_cursor( file: Path, server_object: str | None, name: str, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, workspace: Path | None = None, ) -> bool: """Install FastMCP server in Cursor. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Cursor with_editable: Optional list of directories 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 workspace: Optional workspace directory for project-specific installation Returns: True if installation was successful, False otherwise """ env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) # If workspace is specified, install to workspace-specific config if workspace: return install_cursor_workspace( file=file, server_object=server_object, name=name, workspace_path=workspace, with_editable=with_editable, with_packages=with_packages, env_vars=env_vars, python_version=python_version, with_requirements=with_requirements, project=project, ) # Create server configuration server_config = StdioMCPServer( command=full_command[0], args=full_command[1:], env=env_vars or {}, ) # Generate deeplink deeplink = generate_cursor_deeplink(name, server_config) print(f"[blue]Opening Cursor to install '{name}'[/blue]") if open_deeplink(deeplink): print("[green]Cursor should now open with the installation dialog[/green]") return True else: print( "[red]Could not open Cursor automatically.[/red]\n" f"[blue]Please copy this link and open it in Cursor: {deeplink}[/blue]" ) return False async def cursor_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server in Cursor", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, workspace: Annotated[ Path | None, cyclopts.Parameter( "--workspace", help="Install to workspace directory (will create .cursor/ inside it) instead of using deeplink", ), ] = None, ) -> None: """Install an MCP server in Cursor. Args: server_spec: Python file to install, optionally with :object suffix """ # Convert None to empty lists for list parameters with_editable = with_editable or [] with_packages = with_packages or [] env_vars = env_vars or [] file, server_object, name, with_packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_cursor( file=file, server_object=server_object, name=name, with_editable=with_editable, with_packages=with_packages, env_vars=env_dict, python_version=python, with_requirements=with_requirements, project=project, workspace=workspace, ) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/gemini_cli.py ================================================ """Gemini CLI integration for FastMCP install using Cyclopts.""" import shutil import subprocess import sys from pathlib import Path from typing import Annotated import cyclopts from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args, validate_server_name logger = get_logger(__name__) def find_gemini_command() -> str | None: """Find the Gemini CLI command.""" # First try shutil.which() in case it's a real executable in PATH gemini_in_path = shutil.which("gemini") if gemini_in_path: try: # If 'gemini --version' fails, it's not the correct path subprocess.run( [gemini_in_path, "--version"], check=True, capture_output=True, ) return gemini_in_path except (subprocess.CalledProcessError, FileNotFoundError): pass # Check common installation locations (aliases don't work with subprocess) potential_paths = [ # Default Gemini CLI installation location (after migration) Path.home() / ".gemini" / "local" / "gemini", # npm global installation on macOS/Linux (default) Path("/usr/local/bin/gemini"), # npm global installation with custom prefix Path.home() / ".npm-global" / "bin" / "gemini", # Homebrew installation on macOS Path("/opt/homebrew/bin/gemini"), ] for path in potential_paths: if path.exists(): # If 'gemini --version' fails, it's not the correct path try: subprocess.run( [str(path), "--version"], check=True, capture_output=True, ) return str(path) except (subprocess.CalledProcessError, FileNotFoundError): continue return None def check_gemini_cli_available() -> bool: """Check if Gemini CLI is available.""" return find_gemini_command() is not None def install_gemini_cli( file: Path, server_object: str | None, name: str, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, ) -> bool: """Install FastMCP server in Gemini CLI. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in Gemini CLI with_editable: Optional list of directories 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 """ # Check if Gemini CLI is available gemini_cmd = find_gemini_command() if not gemini_cmd: print( "[red]Gemini CLI not found.[/red]\n" "[blue]Please ensure Gemini CLI is installed. Try running 'gemini --version' to verify.[/blue]\n" "[blue]You can install it using 'npm install -g @google/gemini-cli'.[/blue]\n" ) return False env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Build gemini mcp add command cmd_parts = [gemini_cmd, "mcp", "add"] # Add environment variables if specified (before the name and command) if env_vars: for key, value in env_vars.items(): cmd_parts.extend(["-e", f"{key}={value}"]) validate_server_name(name) # Add server name and command cmd_parts.extend([name, full_command[0], "--"]) cmd_parts.extend(full_command[1:]) try: # Run the gemini mcp add command subprocess.run(cmd_parts, check=True, capture_output=True, text=True) return True except subprocess.CalledProcessError as e: print( f"[red]Failed to install '[bold]{name}[/bold]' in Gemini CLI: {e.stderr.strip() if e.stderr else str(e)}[/red]" ) return False except Exception as e: print(f"[red]Failed to install '[bold]{name}[/bold]' in Gemini CLI: {e}[/red]") return False async def gemini_cli_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server in Gemini CLI", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, ) -> None: """Install an MCP server in Gemini CLI. Args: server_spec: Python file to install, optionally with :object suffix """ # Convert None to empty lists for list parameters with_editable = with_editable or [] with_packages = with_packages or [] env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_gemini_cli( file=file, server_object=server_object, name=name, with_editable=with_editable, with_packages=packages, env_vars=env_dict, python_version=python, with_requirements=with_requirements, project=project, ) if success: print(f"[green]Successfully installed '{name}' in Gemini CLI") else: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/goose.py ================================================ """Goose integration for FastMCP install using Cyclopts.""" import re import sys from pathlib import Path from typing import Annotated from urllib.parse import quote import cyclopts from rich import print from fastmcp.utilities.logging import get_logger from .shared import open_deeplink, process_common_args logger = get_logger(__name__) def _slugify(name: str) -> str: """Convert a display name to a URL-safe identifier. Lowercases, replaces non-alphanumeric runs with hyphens, and strips leading/trailing hyphens. """ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") return slug or "fastmcp-server" def generate_goose_deeplink( name: str, command: str, args: list[str], *, description: str = "MCP server installed via FastMCP", ) -> str: """Generate a Goose deeplink for installing an MCP extension. Args: name: Human-readable display name for the extension. command: The executable command (e.g. "uv"). args: Arguments to the command. description: Short description shown in Goose. Returns: A goose://extension?... deeplink URL. """ extension_id = _slugify(name) params: list[str] = [f"cmd={quote(command, safe='')}"] for arg in args: params.append(f"arg={quote(arg, safe='')}") params.append(f"id={quote(extension_id, safe='')}") params.append(f"name={quote(name, safe='')}") params.append(f"description={quote(description, safe='')}") return f"goose://extension?{'&'.join(params)}" def _build_uvx_command( server_spec: str, *, python_version: str | None = None, with_packages: list[str] | None = None, ) -> list[str]: """Build a uvx command for running a FastMCP server. Goose requires uvx (not uv run) as the command. The uvx format is: uvx [--with pkg] [--python X] fastmcp run uvx automatically infers that the `fastmcp` command comes from the `fastmcp` package, so --from is not needed. """ args: list[str] = ["uvx"] if python_version: args.extend(["--python", python_version]) for pkg in sorted(set(with_packages or [])): if pkg != "fastmcp": args.extend(["--with", pkg]) args.extend(["fastmcp", "run", server_spec]) return args def install_goose( file: Path, server_object: str | None, name: str, *, with_packages: list[str] | None = None, python_version: str | None = None, ) -> bool: """Install FastMCP server in Goose via deeplink. Args: file: Path to the server file. server_object: Optional server object name (for :object suffix). name: Name for the extension in Goose. with_packages: Optional list of additional packages to install. python_version: Optional Python version to use. Returns: True if installation was successful, False otherwise. """ if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) full_command = _build_uvx_command( server_spec, python_version=python_version, with_packages=with_packages, ) deeplink = generate_goose_deeplink( name=name, command=full_command[0], args=full_command[1:], ) print(f"[blue]Opening Goose to install '{name}'[/blue]") if open_deeplink(deeplink, expected_scheme="goose"): print("[green]Goose should now open with the installation dialog[/green]") return True else: print( "[red]Could not open Goose automatically.[/red]\n" f"[blue]Please copy this link and open it in Goose: {deeplink}[/blue]" ) return False async def goose_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the extension in Goose", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)", ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, ) -> None: """Install an MCP server in Goose. Uses uvx to run the server. Environment variables are not included in the deeplink; use `fastmcp install mcp-json` to generate a full config for manual installation. Args: server_spec: Python file to install, optionally with :object suffix """ with_packages = with_packages or [] env_vars = env_vars or [] if env_vars or env_file: print( "[red]Goose deeplinks cannot include environment variables.[/red]\n" "[yellow]Use `fastmcp install mcp-json` to generate a config, then add it " "to your Goose config file with env vars: " "https://block.github.io/goose/docs/getting-started/using-extensions/#config-entry[/yellow]" ) sys.exit(1) file, server_object, name, with_packages, _env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_goose( file=file, server_object=server_object, name=name, with_packages=with_packages, python_version=python, ) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/mcp_json.py ================================================ """MCP configuration JSON generation for FastMCP install using Cyclopts.""" import json import sys from pathlib import Path from typing import Annotated import cyclopts import pyperclip from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args logger = get_logger(__name__) def install_mcp_json( file: Path, server_object: str | None, name: str, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, env_vars: dict[str, str] | None = None, copy: bool = False, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, ) -> bool: """Generate MCP configuration JSON for manual installation. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) name: Name for the server in MCP config with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install env_vars: Optional dictionary of environment variables copy: If True, copy to clipboard instead of printing to stdout 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 generation was successful, False otherwise """ try: env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) # Build MCP server configuration server_config: dict[str, str | list[str] | dict[str, str]] = { "command": full_command[0], "args": full_command[1:], } # Add environment variables if provided if env_vars: server_config["env"] = env_vars # Wrap with server name as root key config = {name: server_config} # Convert to JSON json_output = json.dumps(config, indent=2) # Handle output if copy: pyperclip.copy(json_output) print(f"[green]MCP configuration for '{name}' copied to clipboard[/green]") else: # Print to stdout (for piping) print(json_output) return True except Exception as e: print(f"[red]Failed to generate MCP configuration: {e}[/red]") return False async def mcp_json_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server in MCP config", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, env_vars: Annotated[ list[str] | None, cyclopts.Parameter( "--env", help="Environment variables in KEY=VALUE format (can be used multiple times)", ), ] = None, env_file: Annotated[ Path | None, cyclopts.Parameter( "--env-file", help="Load environment variables from .env file", ), ] = None, copy: Annotated[ bool, cyclopts.Parameter( "--copy", help="Copy configuration to clipboard instead of printing to stdout", ), ] = False, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, ) -> None: """Generate MCP configuration JSON for manual installation. Args: server_spec: Python file to install, optionally with :object suffix """ # Convert None to empty lists for list parameters with_editable = with_editable or [] with_packages = with_packages or [] env_vars = env_vars or [] file, server_object, name, packages, env_dict = await process_common_args( server_spec, server_name, with_packages, env_vars, env_file ) success = install_mcp_json( file=file, server_object=server_object, name=name, with_editable=with_editable, with_packages=packages, env_vars=env_dict, copy=copy, python_version=python, with_requirements=with_requirements, project=project, ) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/install/shared.py ================================================ """Shared utilities for install commands.""" import json import os import re import subprocess import sys from pathlib import Path from urllib.parse import urlparse from dotenv import dotenv_values from pydantic import ValidationError from rich import print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger(__name__) # Server names are passed as subprocess arguments to CLI tools like `claude` # and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run # through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can # cause command injection. Restrict names to safe characters. _SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$") def validate_server_name(name: str) -> str: """Validate that a server name is safe for use as a subprocess argument. Raises SystemExit if the name contains shell metacharacters. """ if not _SAFE_NAME_RE.match(name): print( f"[red]Invalid server name '[bold]{name}[/bold]': " "names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]" ) sys.exit(1) return name def parse_env_var(env_var: str) -> tuple[str, str]: """Parse environment variable string in format KEY=VALUE.""" if "=" not in env_var: print( f"[red]Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]" ) sys.exit(1) key, value = env_var.split("=", 1) return key.strip(), value.strip() async def process_common_args( server_spec: str, server_name: str | None, with_packages: list[str] | None, env_vars: list[str] | None, env_file: Path | None, ) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]: """Process common arguments shared by all install commands. Handles both fastmcp.json config files and traditional file.py:object syntax. """ # Convert None to empty lists for list parameters with_packages = with_packages or [] env_vars = env_vars or [] # Create MCPServerConfig from server_spec config = None config_path: Path | None = 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) try: with open(config_path) as f: data = json.load(f) # 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 MCPServerConfig config = MCPServerConfig.from_file(config_path) # Merge packages from config if not overridden if config.environment.dependencies: # Merge with CLI packages (CLI takes precedence) config_packages = list(config.environment.dependencies) 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: # Create config from file path source = FileSystemSource(path=server_spec) config = MCPServerConfig(source=source) # Extract file and server_object from the source # The FileSystemSource handles parsing path:object syntax source_path = Path(config.source.path).expanduser() # If loaded from a JSON config, resolve relative paths against the config's directory if not source_path.is_absolute() and config_path is not None: file = (config_path.parent / source_path).resolve() else: file = source_path.resolve() # Update the source path so load_server() resolves correctly config.source.path = str(file) server_object = ( config.source.entrypoint if hasattr(config.source, "entrypoint") else None ) logger.debug( "Installing server", extra={ "file": str(file), "server_name": server_name, "server_object": server_object, "with_packages": with_packages, }, ) # Verify the resolved file actually exists if not file.is_file(): print(f"[red]Server file not found: {file}[/red]") sys.exit(1) # Try to import server to get its name and dependencies. # load_server() resolves paths against cwd, which may differ from our # config-relative resolution, so we catch SystemExit from its file check. name = server_name server = None if not name: try: server = await config.source.load_server() name = server.name except (ImportError, ModuleNotFoundError, SystemExit) as e: logger.debug( "Could not import server (likely missing dependencies), using file name", extra={"error": str(e)}, ) name = file.stem # Process environment variables if provided env_dict: dict[str, str] | None = None if env_file or env_vars: env_dict = {} # Load from .env file if specified if env_file: try: env_dict |= { k: v for k, v in dotenv_values(env_file).items() if v is not None } except Exception as e: print(f"[red]Failed to load .env file: {e}[/red]") sys.exit(1) # Add command line environment variables for env_var in env_vars: key, value = parse_env_var(env_var) env_dict[key] = value return file, server_object, name, with_packages, env_dict def open_deeplink(url: str, *, expected_scheme: str) -> bool: """Attempt to open a deeplink URL using the system's default handler. Args: url: The deeplink URL to open. expected_scheme: The URL scheme to validate (e.g. "cursor", "goose"). Returns: True if the command succeeded, False otherwise. """ parsed = urlparse(url) if parsed.scheme != expected_scheme: logger.warning( f"Invalid deeplink scheme: {parsed.scheme}, expected {expected_scheme}" ) return False try: if sys.platform == "darwin": subprocess.run(["open", url], check=True, capture_output=True) elif sys.platform == "win32": os.startfile(url) else: subprocess.run(["xdg-open", url], check=True, capture_output=True) return True except (subprocess.CalledProcessError, FileNotFoundError, OSError): return False ================================================ FILE: src/fastmcp/cli/install/stdio.py ================================================ """Stdio command generation for FastMCP install using Cyclopts.""" import builtins import shlex import sys from pathlib import Path from typing import Annotated import cyclopts import pyperclip from rich import print as rich_print from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from .shared import process_common_args logger = get_logger(__name__) def install_stdio( file: Path, server_object: str | None, *, with_editable: list[Path] | None = None, with_packages: list[str] | None = None, copy: bool = False, python_version: str | None = None, with_requirements: Path | None = None, project: Path | None = None, ) -> bool: """Generate the stdio command for running a FastMCP server. Args: file: Path to the server file server_object: Optional server object name (for :object suffix) with_editable: Optional list of directories to install in editable mode with_packages: Optional list of additional packages to install copy: If True, copy to clipboard instead of printing to stdout 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 generation was successful, False otherwise """ try: env_config = UVEnvironment( python=python_version, dependencies=(with_packages or []) + ["fastmcp"], requirements=with_requirements, project=project, editable=with_editable, ) # Build server spec from parsed components if server_object: server_spec = f"{file.resolve()}:{server_object}" else: server_spec = str(file.resolve()) # Build the full command full_command = env_config.build_command(["fastmcp", "run", server_spec]) command_str = shlex.join(full_command) if copy: pyperclip.copy(command_str) rich_print("[green]✓ Command copied to clipboard[/green]") else: builtins.print(command_str) return True except (OSError, ValueError, pyperclip.PyperclipException) as e: rich_print(f"[red]Failed to generate stdio command: {e}[/red]") return False async def stdio_command( server_spec: str, *, server_name: Annotated[ str | None, cyclopts.Parameter( name=["--name", "-n"], help="Custom name for the server (used for dependency resolution)", ), ] = None, with_editable: Annotated[ list[Path] | None, cyclopts.Parameter( "--with-editable", help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", ), ] = None, with_packages: Annotated[ list[str] | None, cyclopts.Parameter( "--with", help="Additional packages to install (can be used multiple times)" ), ] = None, copy: Annotated[ bool, cyclopts.Parameter( "--copy", help="Copy command to clipboard instead of printing to stdout", ), ] = False, python: Annotated[ str | None, cyclopts.Parameter( "--python", help="Python version to use (e.g., 3.10, 3.11)", ), ] = None, with_requirements: Annotated[ Path | None, cyclopts.Parameter( "--with-requirements", help="Requirements file to install dependencies from", ), ] = None, project: Annotated[ Path | None, cyclopts.Parameter( "--project", help="Run the command within the given project directory", ), ] = None, ) -> None: """Generate the stdio command for running a FastMCP server. Outputs the shell command that an MCP host would use to start this server over stdio transport. Useful for manual configuration or debugging. Args: server_spec: Python file to run, optionally with :object suffix """ with_editable = with_editable or [] with_packages = with_packages or [] file, server_object, _name, packages, _env_dict = await process_common_args( server_spec, server_name, with_packages, [], None ) success = install_stdio( file=file, server_object=server_object, with_editable=with_editable, with_packages=packages, copy=copy, python_version=python, with_requirements=with_requirements, project=project, ) if not success: sys.exit(1) ================================================ FILE: src/fastmcp/cli/run.py ================================================ """FastMCP run command implementation with enhanced type hints.""" import asyncio import contextlib import json import os import re import signal import subprocess import sys from collections.abc import Callable from pathlib import Path from typing import Any, Literal from mcp.server.fastmcp import FastMCP as FastMCP1x from watchfiles import Change, awatch from fastmcp.server.server import FastMCP, create_proxy from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import ( MCPServerConfig, ) from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.run") # Type aliases for better type safety TransportType = Literal["stdio", "http", "sse", "streamable-http"] LogLevelType = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] # File extensions to watch for reload WATCHED_EXTENSIONS: set[str] = { # Python ".py", # JavaScript/TypeScript ".js", ".ts", ".jsx", ".tsx", # Markup/Content ".html", ".md", ".mdx", ".txt", ".xml", # Styles ".css", ".scss", ".sass", ".less", # Data/Config ".json", ".yaml", ".yml", ".toml", # Framework-specific ".vue", ".svelte", # GraphQL ".graphql", ".gql", # Images ".svg", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", # Media ".mp3", ".mp4", ".wav", ".webm", # Fonts ".woff", ".woff2", ".ttf", ".eot", } def is_url(path: str) -> bool: """Check if a string is a URL.""" url_pattern = re.compile(r"^https?://") return bool(url_pattern.match(path)) def create_client_server(url: str) -> Any: """Create a FastMCP server from a client URL. Args: url: The URL to connect to Returns: A FastMCP server instance """ try: import fastmcp client = fastmcp.Client(url) server = create_proxy(client) return server except Exception as e: logger.error(f"Failed to create client for URL {url}: {e}") sys.exit(1) def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]: """Create a FastMCP server from a MCPConfig.""" with mcp_config_path.open() as src: mcp_config = json.load(src) server = create_proxy(mcp_config) return server def load_mcp_server_config(config_path: Path) -> MCPServerConfig: """Load a FastMCP configuration from a fastmcp.json file. Args: config_path: Path to fastmcp.json file Returns: MCPServerConfig object """ config = MCPServerConfig.from_file(config_path) # Apply runtime settings from deployment config config.deployment.apply_runtime_settings(config_path) return config async def 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, skip_source: bool = False, stateless: bool = False, ) -> None: """Run a MCP server or connect to a remote one. Args: 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 path: Path to bind to when using http transport log_level: Log level 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 stateless: Whether to run in stateless mode (no session) """ # 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}") # Special case: MCPConfig files (legacy) elif server_spec.endswith(".json"): # 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_mcp_server_config(config_path) # Merge deployment config with CLI arguments (CLI takes precedence) 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 source only (environment is handled by uv run) await config.prepare_source() if not skip_source else None # 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: # Regular file case - create a MCPServerConfig with FileSystemSource source = FileSystemSource(path=server_spec) config = MCPServerConfig(source=source) # Prepare source only (environment is handled by uv run) await config.prepare_source() if not skip_source else None # 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 # handle v1 servers if isinstance(server, FastMCP1x): await run_v1_server_async(server, host=host, port=port, transport=transport) return kwargs = {} if transport: kwargs["transport"] = transport if host: kwargs["host"] = host if port: kwargs["port"] = port if path: kwargs["path"] = path if log_level: kwargs["log_level"] = log_level if stateless: kwargs["stateless"] = True if not show_banner: kwargs["show_banner"] = False try: await server.run_async(**kwargs) except Exception as e: logger.error(f"Failed to run server: {e}") sys.exit(1) def run_module_command( module_name: str, *, env_command_builder: Callable[[list[str]], list[str]] | None = None, extra_args: list[str] | None = None, ) -> None: """Run a Python module directly using ``python -m ``. When ``-m`` is used, the module manages its own server startup. No server-object discovery or transport overrides are applied. Args: module_name: Dotted module name (e.g. ``my_package``). env_command_builder: An optional callable that wraps a command list with environment setup (e.g. ``UVEnvironment.build_command``). extra_args: Extra arguments forwarded after the module name. """ # Use bare "python" when an env wrapper (e.g. uv run) is active so that # the wrapper can resolve the interpreter via --python / environment config. # Fall back to sys.executable for direct execution without a wrapper. python = "python" if env_command_builder is not None else sys.executable cmd: list[str] = [python, "-m", module_name] if extra_args: cmd.extend(extra_args) # Wrap with environment (e.g. uv run) if configured if env_command_builder is not None: cmd = env_command_builder(cmd) logger.debug(f"Running module: {' '.join(cmd)}") try: process = subprocess.run(cmd, check=True) sys.exit(process.returncode) except subprocess.CalledProcessError as e: logger.error(f"Module {module_name} exited with code {e.returncode}") sys.exit(e.returncode) async def run_v1_server_async( server: FastMCP1x, host: str | None = None, port: int | None = None, transport: TransportType | None = None, ) -> None: """Run a FastMCP 1.x server using async methods. Args: server: FastMCP 1.x server instance host: Host to bind to port: Port to bind to transport: Transport protocol to use """ if host: server.settings.host = host if port: server.settings.port = port match transport: case "stdio": await server.run_stdio_async() case "http" | "streamable-http" | None: await server.run_streamable_http_async() case "sse": await server.run_sse_async() def _watch_filter(_change: Change, path: str) -> bool: """Filter for files that should trigger reload.""" return any(path.endswith(ext) for ext in WATCHED_EXTENSIONS) async def _terminate_process(process: asyncio.subprocess.Process) -> None: """Terminate a subprocess and all its children. Sends SIGTERM to the process group first for graceful shutdown, then falls back to SIGKILL if the process doesn't exit in time. """ if process.returncode is not None: return pid = process.pid if sys.platform != "win32": # Send SIGTERM to the entire process group for graceful shutdown with contextlib.suppress(ProcessLookupError, OSError): os.killpg(os.getpgid(pid), signal.SIGTERM) # Wait briefly for graceful exit try: await asyncio.wait_for(process.wait(), timeout=3.0) return except asyncio.TimeoutError: pass # Force kill the entire process group with contextlib.suppress(ProcessLookupError, OSError): os.killpg(os.getpgid(pid), signal.SIGKILL) else: process.kill() await process.wait() async def run_with_reload( cmd: list[str], reload_dirs: list[Path] | None = None, is_stdio: bool = False, ) -> None: """Run a command with file watching and auto-reload. Args: cmd: Command to run as subprocess (should include --no-reload) reload_dirs: Directories to watch for changes (default: cwd) is_stdio: Whether this is stdio transport """ watch_paths = reload_dirs or [Path.cwd()] process: asyncio.subprocess.Process | None = None first_run = True if is_stdio: logger.info("Reload mode enabled (using stateless sessions)") else: logger.info( "Reload mode enabled (using stateless HTTP). " "Some features requiring bidirectional communication " "(like elicitation) are not available." ) # Handle SIGTERM/SIGINT gracefully with proper asyncio integration shutdown_event = asyncio.Event() loop = asyncio.get_running_loop() def signal_handler() -> None: logger.info("Received shutdown signal, stopping...") shutdown_event.set() # Windows doesn't support add_signal_handler if sys.platform != "win32": loop.add_signal_handler(signal.SIGTERM, signal_handler) loop.add_signal_handler(signal.SIGINT, signal_handler) try: while not shutdown_event.is_set(): # Build command - add --no-banner on restarts to reduce noise if first_run or "--no-banner" in cmd: run_cmd = cmd else: run_cmd = [*cmd, "--no-banner"] first_run = False process = await asyncio.create_subprocess_exec( *run_cmd, stdin=None, stdout=None, stderr=None, # Own process group so _terminate_process can kill the whole tree start_new_session=sys.platform != "win32", ) # Watch for either: file changes OR process death watch_task = asyncio.create_task( anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) # ty: ignore[invalid-argument-type] ) wait_task = asyncio.create_task(process.wait()) shutdown_task = asyncio.create_task(shutdown_event.wait()) done, pending = await asyncio.wait( [watch_task, wait_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task if shutdown_task in done: # User requested shutdown break if wait_task in done: # Server died on its own - wait for file change before restart code = wait_task.result() if code != 0: logger.error( f"Server exited with code {code}, waiting for file change..." ) else: logger.info("Server exited, waiting for file change...") # Wait for file change or shutdown (avoid hot loop on crash) watch_task = asyncio.create_task( anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) # ty: ignore[invalid-argument-type] ) shutdown_task = asyncio.create_task(shutdown_event.wait()) done, pending = await asyncio.wait( [watch_task, shutdown_task], return_when=asyncio.FIRST_COMPLETED, ) for task in pending: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task if shutdown_task in done: break logger.info("Detected changes, restarting...") else: # File changed - restart server changes = watch_task.result() logger.info( f"Detected changes in {len(changes)} file(s), restarting..." ) await _terminate_process(process) except KeyboardInterrupt: # Handle Ctrl+C on Windows (where add_signal_handler isn't available) logger.info("Received shutdown signal, stopping...") finally: # Clean up signal handlers if sys.platform != "win32": loop.remove_signal_handler(signal.SIGTERM) loop.remove_signal_handler(signal.SIGINT) if process and process.returncode is None: await _terminate_process(process) ================================================ FILE: src/fastmcp/cli/tasks.py ================================================ """FastMCP tasks CLI for Docket task management.""" import asyncio import sys from typing import Annotated import cyclopts from rich.console import Console from fastmcp.utilities.cli import load_and_merge_config from fastmcp.utilities.logging import get_logger logger = get_logger("cli.tasks") console = Console() tasks_app = cyclopts.App( name="tasks", help="Manage FastMCP background tasks using Docket", ) def check_distributed_backend() -> None: """Check if Docket is configured with a distributed backend. The CLI worker runs as a separate process, so it needs Redis/Valkey to coordinate with the main server process. Raises: SystemExit: If using memory:// URL """ import fastmcp docket_url = fastmcp.settings.docket.url # Check for memory:// URL and provide helpful error if docket_url.startswith("memory://"): console.print( "[bold red]✗ In-memory backend not supported by CLI[/bold red]\n\n" "Your Docket configuration uses an in-memory backend (memory://) which\n" "only works within a single process.\n\n" "To use [cyan]fastmcp tasks[/cyan] CLI commands (which run in separate\n" "processes), you need a distributed backend:\n\n" "[bold]1. Install Redis or Valkey:[/bold]\n" " [dim]macOS:[/dim] brew install redis\n" " [dim]Ubuntu:[/dim] apt install redis-server\n" " [dim]Valkey:[/dim] See https://valkey.io/\n\n" "[bold]2. Start the service:[/bold]\n" " redis-server\n\n" "[bold]3. Configure Docket URL:[/bold]\n" " [dim]Environment variable:[/dim]\n" " export FASTMCP_DOCKET_URL=redis://localhost:6379/0\n\n" "[bold]4. Try again[/bold]\n\n" "The memory backend works great for single-process servers, but the CLI\n" "commands need a distributed backend to coordinate across processes.\n\n" "Need help? See: [cyan]https://gofastmcp.com/docs/tasks[/cyan]" ) sys.exit(1) @tasks_app.command def worker( server_spec: Annotated[ str | None, cyclopts.Parameter( help="Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json" ), ] = None, ) -> None: """Start an additional worker to process background tasks. Connects to your Docket backend and processes tasks in parallel with any other running workers. Configure via environment variables (FASTMCP_DOCKET_*). Example: fastmcp tasks worker server.py fastmcp tasks worker examples/tasks/server.py """ import fastmcp check_distributed_backend() # Load server to get task functions try: config, _resolved_spec = load_and_merge_config(server_spec) except FileNotFoundError: sys.exit(1) # Load the server server = asyncio.run(config.source.load_server()) async def run_worker(): """Enter server lifespan and camp forever.""" async with server._lifespan_manager(): console.print( f"[bold green]✓[/bold green] Starting worker for [cyan]{server.name}[/cyan]" ) console.print(f" Docket: {fastmcp.settings.docket.name}") console.print(f" Backend: {fastmcp.settings.docket.url}") console.print(f" Concurrency: {fastmcp.settings.docket.concurrency}") # Server's lifespan has started its worker - just camp here forever while True: await asyncio.sleep(3600) try: asyncio.run(run_worker()) except KeyboardInterrupt: console.print("\n[yellow]Worker stopped[/yellow]") sys.exit(0) ================================================ FILE: src/fastmcp/client/__init__.py ================================================ from .auth import OAuth, BearerAuth from .client import Client from .transports import ( ClientTransport, FastMCPTransport, NodeStdioTransport, NpxStdioTransport, PythonStdioTransport, SSETransport, StdioTransport, StreamableHttpTransport, UvStdioTransport, UvxStdioTransport, ) __all__ = [ "BearerAuth", "Client", "ClientTransport", "FastMCPTransport", "NodeStdioTransport", "NpxStdioTransport", "OAuth", "PythonStdioTransport", "SSETransport", "StdioTransport", "StreamableHttpTransport", "UvStdioTransport", "UvxStdioTransport", ] ================================================ FILE: src/fastmcp/client/auth/__init__.py ================================================ from .bearer import BearerAuth from .oauth import OAuth __all__ = ["BearerAuth", "OAuth"] ================================================ FILE: src/fastmcp/client/auth/bearer.py ================================================ import httpx from pydantic import SecretStr from fastmcp.utilities.logging import get_logger __all__ = ["BearerAuth"] logger = get_logger(__name__) class BearerAuth(httpx.Auth): def __init__(self, token: str): self.token = SecretStr(token) def auth_flow(self, request): request.headers["Authorization"] = f"Bearer {self.token.get_secret_value()}" yield request ================================================ FILE: src/fastmcp/client/auth/oauth.py ================================================ from __future__ import annotations import time import webbrowser from collections.abc import AsyncGenerator from contextlib import aclosing from typing import Any import anyio import httpx from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.shared._httpx_utils import McpHttpClientFactory from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, OAuthToken, ) from pydantic import AnyHttpUrl from typing_extensions import override from uvicorn.server import Server from fastmcp.client.oauth_callback import ( OAuthCallbackResult, create_oauth_callback_server, ) from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger __all__ = ["OAuth"] logger = get_logger(__name__) class ClientNotFoundError(Exception): """Raised when OAuth client credentials are not found on the server.""" async def check_if_auth_required( mcp_url: str, httpx_kwargs: dict[str, Any] | None = None ) -> bool: """ Check if the MCP endpoint requires authentication by making a test request. Returns: True if auth appears to be required, False otherwise """ async with httpx.AsyncClient(**(httpx_kwargs or {})) as client: try: # Try a simple request to the endpoint response = await client.get(mcp_url, timeout=5.0) # If we get 401/403, auth is likely required if response.status_code in (401, 403): return True # Check for WWW-Authenticate header if "WWW-Authenticate" in response.headers: # noqa: SIM103 return True # If we get a successful response, auth may not be required return False except httpx.RequestError: # If we can't connect, assume auth might be required return True class TokenStorageAdapter(TokenStorage): _server_url: str _key_value_store: AsyncKeyValue _storage_oauth_token: PydanticAdapter[OAuthToken] _storage_client_info: PydanticAdapter[OAuthClientInformationFull] def __init__(self, async_key_value: AsyncKeyValue, server_url: str): self._server_url = server_url self._key_value_store = async_key_value self._storage_oauth_token = PydanticAdapter[OAuthToken]( default_collection="mcp-oauth-token", key_value=async_key_value, pydantic_model=OAuthToken, raise_on_validation_error=True, ) self._storage_client_info = PydanticAdapter[OAuthClientInformationFull]( default_collection="mcp-oauth-client-info", key_value=async_key_value, pydantic_model=OAuthClientInformationFull, raise_on_validation_error=True, ) def _get_token_cache_key(self) -> str: return f"{self._server_url}/tokens" def _get_client_info_cache_key(self) -> str: return f"{self._server_url}/client_info" async def clear(self) -> None: await self._storage_oauth_token.delete(key=self._get_token_cache_key()) await self._storage_client_info.delete(key=self._get_client_info_cache_key()) @override async def get_tokens(self) -> OAuthToken | None: return await self._storage_oauth_token.get(key=self._get_token_cache_key()) @override async def set_tokens(self, tokens: OAuthToken) -> None: # Don't set TTL based on access token expiry - the refresh token may be # valid much longer. Use 1 year as a reasonable upper bound; the OAuth # provider handles actual token expiry/refresh logic. await self._storage_oauth_token.put( key=self._get_token_cache_key(), value=tokens, ttl=60 * 60 * 24 * 365, # 1 year ) @override async def get_client_info(self) -> OAuthClientInformationFull | None: return await self._storage_client_info.get( key=self._get_client_info_cache_key() ) @override async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: ttl: int | None = None if client_info.client_secret_expires_at: ttl = client_info.client_secret_expires_at - int(time.time()) await self._storage_client_info.put( key=self._get_client_info_cache_key(), value=client_info, ttl=ttl, ) class OAuth(OAuthClientProvider): """ OAuth client provider for MCP servers with browser-based authentication. This class provides OAuth authentication for FastMCP clients by opening a browser for user authorization and running a local callback server. """ _bound: bool def __init__( self, mcp_url: str | None = None, scopes: str | list[str] | None = None, client_name: str = "FastMCP Client", token_storage: AsyncKeyValue | None = None, additional_client_metadata: dict[str, Any] | None = None, callback_port: int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, # Alternative to dynamic client registration: # --- Clients host a static JSON document at an HTTPS URL --- client_metadata_url: str | None = None, # --- OR clients provide full client information --- client_id: str | None = None, client_secret: str | None = None, ): """ Initialize OAuth client provider for an MCP server. Args: mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/"). Optional when OAuth is passed to Client(auth=...), which provides the URL automatically from the transport. scopes: OAuth scopes to request. Can be a space-separated string or a list of strings. client_name: Name for this client during registration token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided additional_client_metadata: Extra fields for OAuthClientMetadata callback_port: Fixed port for OAuth callback (default: random available port) client_metadata_url: A CIMD (Client ID Metadata Document) URL. When provided, this URL is used as the client_id instead of performing Dynamic Client Registration. Must be an HTTPS URL with a non-root path (e.g. "https://myapp.example.com/oauth/client.json"). client_id: Pre-registered OAuth client ID. When provided, skips dynamic client registration and uses these static credentials instead. client_secret: OAuth client secret (optional, used with client_id) """ # Store config for deferred binding if mcp_url not yet known self._scopes = scopes self._client_name = client_name self._token_storage = token_storage self._additional_client_metadata = additional_client_metadata self._callback_port = callback_port self._client_metadata_url = client_metadata_url self._client_id = client_id self._client_secret = client_secret self._static_client_info = None self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient self._bound = False if mcp_url is not None: self._bind(mcp_url) def _bind(self, mcp_url: str) -> None: """Bind this OAuth provider to a specific MCP server URL. Called automatically when mcp_url is provided to __init__, or by the transport when OAuth is used without an explicit URL. """ if self._bound: return mcp_url = mcp_url.rstrip("/") self.redirect_port = self._callback_port or find_available_port() redirect_uri = f"http://localhost:{self.redirect_port}/callback" scopes_str: str if isinstance(self._scopes, list): scopes_str = " ".join(self._scopes) elif self._scopes is not None: scopes_str = str(self._scopes) else: scopes_str = "" client_metadata = OAuthClientMetadata( client_name=self._client_name, redirect_uris=[AnyHttpUrl(redirect_uri)], grant_types=["authorization_code", "refresh_token"], response_types=["code"], scope=scopes_str, **(self._additional_client_metadata or {}), ) if self._client_id: # Create the full static client info directly which will avoid DCR. # Spread client_metadata so redirect_uris, grant_types, response_types, # scope, etc. are included — servers may validate these fields. metadata = client_metadata.model_dump(exclude_none=True) # Default token_endpoint_auth_method based on whether a secret is # provided, unless the caller already set it via additional_client_metadata. if "token_endpoint_auth_method" not in metadata: metadata["token_endpoint_auth_method"] = ( "client_secret_post" if self._client_secret else "none" ) self._static_client_info = OAuthClientInformationFull( client_id=self._client_id, client_secret=self._client_secret, **metadata, ) token_storage = self._token_storage or MemoryStore() if isinstance(token_storage, MemoryStore): from warnings import warn warn( message="Using in-memory token storage -- tokens will be lost when the client restarts. " + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. " + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.", stacklevel=2, ) # Use full URL for token storage to properly separate tokens per MCP endpoint self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( async_key_value=token_storage, server_url=mcp_url ) self.mcp_url = mcp_url super().__init__( server_url=mcp_url, client_metadata=client_metadata, storage=self.token_storage_adapter, redirect_handler=self.redirect_handler, callback_handler=self.callback_handler, client_metadata_url=self._client_metadata_url, ) self._bound = True async def _initialize(self) -> None: """Load stored tokens and client info, properly setting token expiry.""" await super()._initialize() if self._static_client_info is not None: self.context.client_info = self._static_client_info await self.token_storage_adapter.set_client_info(self._static_client_info) if self.context.current_tokens and self.context.current_tokens.expires_in: self.context.update_token_expiry(self.context.current_tokens) async def redirect_handler(self, authorization_url: str) -> None: """Open browser for authorization, with pre-flight check for invalid client.""" # Pre-flight check to detect invalid client_id before opening browser async with self.httpx_client_factory() as client: response = await client.get(authorization_url, follow_redirects=False) # Check for client not found error (400 typically means bad client_id) if response.status_code == 400: raise ClientNotFoundError( "OAuth client not found - cached credentials may be stale" ) # OAuth typically returns redirects, but some providers return 200 with HTML login pages if response.status_code not in (200, 302, 303, 307, 308): raise RuntimeError( f"Unexpected authorization response: {response.status_code}" ) logger.info(f"OAuth authorization URL: {authorization_url}") webbrowser.open(authorization_url) async def callback_handler(self) -> tuple[str, str | None]: """Handle OAuth callback and return (auth_code, state).""" # Create result container and event to capture the OAuth response result = OAuthCallbackResult() result_ready = anyio.Event() # Create server with result tracking server: Server = create_oauth_callback_server( port=self.redirect_port, server_url=self.mcp_url, result_container=result, result_ready=result_ready, ) # Run server until response is received with timeout logic async with anyio.create_task_group() as tg: tg.start_soon(server.serve) logger.info( f"🎧 OAuth callback server started on http://localhost:{self.redirect_port}" ) TIMEOUT = 300.0 # 5 minute timeout try: with anyio.fail_after(TIMEOUT): await result_ready.wait() if result.error: raise result.error return result.code, result.state # type: ignore except TimeoutError as e: raise TimeoutError( f"OAuth callback timed out after {TIMEOUT} seconds" ) from e finally: server.should_exit = True await anyio.sleep(0.1) # Allow server to shut down gracefully tg.cancel_scope.cancel() raise RuntimeError("OAuth callback handler could not be started") async def async_auth_flow( self, request: httpx.Request ) -> AsyncGenerator[httpx.Request, httpx.Response]: """HTTPX auth flow with automatic retry on stale cached credentials. If the OAuth flow fails due to invalid/stale client credentials, clears the cache and retries once with fresh registration. """ if not self._bound: raise RuntimeError( "OAuth provider has no server URL. Either pass mcp_url to OAuth() " "or use it with Client(auth=...) which provides the URL automatically." ) try: # First attempt with potentially cached credentials async with aclosing(super().async_auth_flow(request)) as gen: response = None while True: try: # First iteration sends None, subsequent iterations send response yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type] response = yield yielded_request except StopAsyncIteration: break except ClientNotFoundError: # Static credentials are fixed — retrying won't help. Surface the # error so the user can correct their client_id / client_secret. if self._static_client_info is not None: raise ClientNotFoundError( "OAuth server rejected the static client credentials. " "Verify that the client_id (and client_secret, if provided) " "are correct and that the client is registered with the server." ) from None logger.debug( "OAuth client not found on server, clearing cache and retrying..." ) # Clear cached state and retry once self._initialized = False await self.token_storage_adapter.clear() # Retry with fresh registration async with aclosing(super().async_auth_flow(request)) as gen: response = None while True: try: yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type] response = yield yielded_request except StopAsyncIteration: break ================================================ FILE: src/fastmcp/client/client.py ================================================ from __future__ import annotations import asyncio import copy import datetime import secrets import ssl import weakref from collections.abc import Coroutine from contextlib import AsyncExitStack, asynccontextmanager, suppress from dataclasses import dataclass, field from pathlib import Path from typing import Any, Generic, Literal, TypeVar, cast, overload import anyio import httpx import mcp.types from exceptiongroup import catch from mcp import ClientSession, McpError from mcp.types import GetTaskResult, TaskStatusNotification from pydantic import AnyUrl import fastmcp from fastmcp.client.auth.oauth import OAuth from fastmcp.client.elicitation import ElicitationHandler, create_elicitation_callback from fastmcp.client.logging import ( LogHandler, create_log_callback, default_log_handler, ) from fastmcp.client.messages import MessageHandler, MessageHandlerT from fastmcp.client.mixins import ( ClientPromptsMixin, ClientResourcesMixin, ClientTaskManagementMixin, ClientToolsMixin, ) from fastmcp.client.progress import ProgressHandler, default_progress_handler from fastmcp.client.roots import ( RootsHandler, RootsList, create_roots_callback, ) from fastmcp.client.sampling import ( SamplingHandler, create_sampling_callback, ) from fastmcp.client.tasks import ( PromptTask, ResourceTask, TaskNotificationHandler, ToolTask, ) from fastmcp.mcp_config import MCPConfig from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers from fastmcp.utilities.logging import get_logger from fastmcp.utilities.timeout import ( normalize_timeout_to_seconds, normalize_timeout_to_timedelta, ) from .transports import ( ClientTransport, ClientTransportT, FastMCP1Server, FastMCPTransport, MCPConfigTransport, NodeStdioTransport, PythonStdioTransport, SessionKwargs, SSETransport, StdioTransport, StreamableHttpTransport, infer_transport, ) __all__ = [ "Client", "ElicitationHandler", "LogHandler", "MessageHandler", "ProgressHandler", "RootsHandler", "RootsList", "SamplingHandler", "SessionKwargs", ] logger = get_logger(__name__) T = TypeVar("T", bound="ClientTransport") ResultT = TypeVar("ResultT") @dataclass class ClientSessionState: """Holds all session-related state for a Client instance. This allows clean separation of configuration (which is copied) from session state (which should be fresh for each new client instance). """ session: ClientSession | None = None nesting_counter: int = 0 lock: anyio.Lock = field(default_factory=anyio.Lock) session_task: asyncio.Task | None = None ready_event: anyio.Event = field(default_factory=anyio.Event) stop_event: anyio.Event = field(default_factory=anyio.Event) initialize_result: mcp.types.InitializeResult | None = None @dataclass class CallToolResult: """Parsed result from a tool call.""" content: list[mcp.types.ContentBlock] structured_content: dict[str, Any] | None meta: dict[str, Any] | None data: Any = None is_error: bool = False class Client( Generic[ClientTransportT], ClientResourcesMixin, ClientPromptsMixin, ClientToolsMixin, ClientTaskManagementMixin, ): """ MCP client that delegates connection management to a Transport instance. The Client class is responsible for MCP protocol logic, while the Transport handles connection establishment and management. Client provides methods for working with resources, prompts, tools and other MCP capabilities. This client supports reentrant context managers (multiple concurrent `async with client:` blocks) using reference counting and background session management. This allows efficient session reuse in any scenario with nested or concurrent client usage. MCP SDK 1.10 introduced automatic list_tools() calls during call_tool() execution. This created a race condition where events could be reset while other tasks were waiting on them, causing deadlocks. The issue was exposed in proxy scenarios but affects any reentrant usage. The solution uses reference counting to track active context managers, a background task to manage the session lifecycle, events to coordinate between tasks, and ensures all session state changes happen within a lock. Events are only created when needed, never reset outside locks. This design prevents race conditions where tasks wait on events that get replaced by other tasks, ensuring reliable coordination in concurrent scenarios. Args: transport: Connection source specification, which can be: - ClientTransport: Direct transport instance - FastMCP: In-process FastMCP server - AnyUrl or str: URL to connect to - Path: File path for local socket - MCPConfig: MCP server configuration - dict: Transport configuration roots: Optional RootsList or RootsHandler for filesystem access sampling_handler: Optional handler for sampling requests log_handler: Optional handler for log messages message_handler: Optional handler for protocol messages progress_handler: Optional handler for progress notifications timeout: Optional timeout for requests (seconds or timedelta) init_timeout: Optional timeout for initial connection (seconds or timedelta). Set to 0 to disable. If None, uses the value in the FastMCP global settings. Examples: ```python # Connect to FastMCP server client = Client("http://localhost:8080") async with client: # List available resources resources = await client.list_resources() # Call a tool result = await client.call_tool("my_tool", {"param": "value"}) ``` """ @overload def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ... @overload def __init__( self: Client[SSETransport | StreamableHttpTransport], transport: AnyUrl, *args: Any, **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[FastMCPTransport], transport: FastMCP | FastMCP1Server, *args: Any, **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[PythonStdioTransport | NodeStdioTransport], transport: Path, *args: Any, **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[MCPConfigTransport], transport: MCPConfig | dict[str, Any], *args: Any, **kwargs: Any, ) -> None: ... @overload def __init__( self: Client[ PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport ], transport: str, *args: Any, **kwargs: Any, ) -> None: ... def __init__( self, transport: ( ClientTransportT | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str ), name: str | None = None, roots: RootsList | RootsHandler | None = None, sampling_handler: SamplingHandler | None = None, sampling_capabilities: mcp.types.SamplingCapability | None = None, elicitation_handler: ElicitationHandler | None = None, log_handler: LogHandler | None = None, message_handler: MessageHandlerT | MessageHandler | None = None, progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, auto_initialize: bool = True, init_timeout: datetime.timedelta | float | int | None = None, client_info: mcp.types.Implementation | None = None, auth: httpx.Auth | Literal["oauth"] | str | None = None, verify: ssl.SSLContext | bool | str | None = None, ) -> None: self.name = name or self.generate_name() self.transport = cast(ClientTransportT, infer_transport(transport)) if verify is not None: from fastmcp.client.transports.http import StreamableHttpTransport from fastmcp.client.transports.sse import SSETransport if isinstance(self.transport, StreamableHttpTransport | SSETransport): self.transport.verify = verify # Re-sync existing OAuth auth with the new verify setting, # but only if the transport doesn't have a custom factory # (which takes precedence and was already applied to OAuth). if ( isinstance(self.transport.auth, OAuth) and auth is None and self.transport.httpx_client_factory is None ): verify_factory = self.transport._make_verify_factory() if verify_factory is not None: self.transport.auth.httpx_client_factory = verify_factory else: raise ValueError( "The 'verify' parameter is only supported for HTTP transports." ) if auth is not None: self.transport._set_auth(auth) if log_handler is None: log_handler = default_log_handler if progress_handler is None: progress_handler = default_progress_handler self._progress_handler = progress_handler # Convert timeout to timedelta if needed timeout = normalize_timeout_to_timedelta(timeout) # handle init handshake timeout (0 means disabled) if init_timeout is None: init_timeout = fastmcp.settings.client_init_timeout self._init_timeout = normalize_timeout_to_seconds(init_timeout) self.auto_initialize = auto_initialize self._session_kwargs: SessionKwargs = { "sampling_callback": None, "list_roots_callback": None, "logging_callback": create_log_callback(log_handler), "message_handler": message_handler or TaskNotificationHandler(self), "read_timeout_seconds": timeout, "client_info": client_info, } if roots is not None: self.set_roots(roots) if sampling_handler is not None: self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_handler ) self._session_kwargs["sampling_capabilities"] = ( sampling_capabilities if sampling_capabilities is not None else mcp.types.SamplingCapability() ) if elicitation_handler is not None: self._session_kwargs["elicitation_callback"] = create_elicitation_callback( elicitation_handler ) # Maximum time to wait for a clean disconnect before giving up. # Normally disconnects complete in <100ms; this is a safety net for # unresponsive servers. self._disconnect_timeout: float = fastmcp.settings.client_disconnect_timeout # Session context management - see class docstring for detailed explanation self._session_state = ClientSessionState() # Track task IDs submitted by this client (for list_tasks support) self._submitted_task_ids: set[str] = set() # Registry for routing notifications/tasks/status to Task objects self._task_registry: dict[ str, weakref.ref[ToolTask | PromptTask | ResourceTask] ] = {} def _reset_session_state(self, full: bool = False) -> None: """Reset session state after disconnect or cancellation. Args: full: If True, also resets session_task and nesting_counter. Use full=True for cancellation cleanup where the session task was started but never completed normally. """ self._session_state.session = None self._session_state.initialize_result = None if full: self._session_state.session_task = None self._session_state.nesting_counter = 0 @property def session(self) -> ClientSession: """Get the current active session. Raises RuntimeError if not connected.""" if self._session_state.session is None: raise RuntimeError( "Client is not connected. Use the 'async with client:' context manager first." ) return self._session_state.session @property def initialize_result(self) -> mcp.types.InitializeResult | None: """Get the result of the initialization request.""" return self._session_state.initialize_result def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) def set_sampling_callback( self, sampling_callback: SamplingHandler, sampling_capabilities: mcp.types.SamplingCapability | None = None, ) -> None: """Set the sampling callback for the client.""" self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_callback ) self._session_kwargs["sampling_capabilities"] = ( sampling_capabilities if sampling_capabilities is not None else mcp.types.SamplingCapability() ) def set_elicitation_callback( self, elicitation_callback: ElicitationHandler ) -> None: """Set the elicitation callback for the client.""" self._session_kwargs["elicitation_callback"] = create_elicitation_callback( elicitation_callback ) def is_connected(self) -> bool: """Check if the client is currently connected.""" return self._session_state.session is not None def new(self) -> Client[ClientTransportT]: """Create a new client instance with the same configuration but fresh session state. This creates a new client with the same transport, handlers, and configuration, but with no active session. Useful for creating independent sessions that don't share state with the original client. Returns: A new Client instance with the same configuration but disconnected state. Example: ```python # Create a fresh client for each concurrent operation fresh_client = client.new() async with fresh_client: await fresh_client.call_tool("some_tool", {}) ``` """ new_client = copy.copy(self) if not isinstance(self.transport, StdioTransport): # Reset session state to fresh state new_client._session_state = ClientSessionState() new_client.name += f":{secrets.token_hex(2)}" return new_client @asynccontextmanager async def _context_manager(self): with catch(get_catch_handlers()): async with self.transport.connect_session( **self._session_kwargs ) as session: self._session_state.session = session # Initialize the session if auto_initialize is enabled try: if self.auto_initialize: await self.initialize() yield except anyio.ClosedResourceError as e: raise RuntimeError("Server session was closed unexpectedly") from e finally: self._reset_session_state() async def initialize( self, timeout: datetime.timedelta | float | int | None = None, ) -> mcp.types.InitializeResult: """Send an initialize request to the server. This method performs the MCP initialization handshake with the server, exchanging capabilities and server information. It is idempotent - calling it multiple times returns the cached result from the first call. The initialization happens automatically when entering the client context manager unless `auto_initialize=False` was set during client construction. Manual calls to this method are only needed when auto-initialization is disabled. Args: timeout: Optional timeout for the initialization request (seconds or timedelta). If None, uses the client's init_timeout setting. Returns: InitializeResult: The server's initialization response containing server info, capabilities, protocol version, and optional instructions. Raises: RuntimeError: If the client is not connected or initialization times out. Example: ```python # With auto-initialization disabled client = Client(server, auto_initialize=False) async with client: result = await client.initialize() print(f"Server: {result.serverInfo.name}") print(f"Instructions: {result.instructions}") ``` """ if self.initialize_result is not None: return self.initialize_result if timeout is None: timeout = self._init_timeout else: timeout = normalize_timeout_to_seconds(timeout) try: with anyio.fail_after(timeout): self._session_state.initialize_result = await self.session.initialize() return self._session_state.initialize_result except TimeoutError as e: raise RuntimeError("Failed to initialize server session") from e async def __aenter__(self): return await self._connect() async def __aexit__(self, exc_type, exc_val, exc_tb): # Use a timeout to prevent hanging during cleanup if the connection is in a bad # state (e.g., rate-limited). The MCP SDK's transport may try to terminate the # session which can hang if the server is unresponsive. with anyio.move_on_after(self._disconnect_timeout): await self._disconnect() async def _connect(self): """ Establish or reuse a session connection. This method implements the reentrant context manager pattern: - First call: Creates background session task and waits for it to be ready - Subsequent calls: Increments reference counter and reuses existing session - All operations protected by _context_lock to prevent race conditions The critical fix: Events are only created when starting a new session, never reset outside the lock, preventing the deadlock scenario where tasks wait on events that get replaced by other tasks. """ # ensure only one session is running at a time to avoid race conditions async with self._session_state.lock: need_to_start = ( self._session_state.session_task is None or self._session_state.session_task.done() ) if need_to_start: if self._session_state.nesting_counter != 0: raise RuntimeError( f"Internal error: nesting counter should be 0 when starting new session, got {self._session_state.nesting_counter}" ) self._session_state.stop_event = anyio.Event() self._session_state.ready_event = anyio.Event() self._session_state.session_task = asyncio.create_task( self._session_runner() ) try: await self._session_state.ready_event.wait() except asyncio.CancelledError: # Cancellation during initial connection startup can leave the # background session task running because __aexit__ is never invoked # when __aenter__ is cancelled. Since we hold the session lock here # and we know we started the session task, it's safe to tear it down # without impacting other active contexts. # # Note: session_task is an asyncio.Task (not anyio) because it needs # to outlive individual context manager scopes - anyio's structured # concurrency doesn't allow tasks to escape their task group. session_task = self._session_state.session_task if session_task is not None: # Request a graceful stop if the runner has already reached # its stop_event wait. self._session_state.stop_event.set() session_task.cancel() with anyio.CancelScope(shield=True): with anyio.move_on_after(3): try: await session_task except asyncio.CancelledError: pass except Exception as e: logger.debug( f"Error during cancelled session cleanup: {e}" ) # Reset session state so future callers can reconnect cleanly. self._reset_session_state(full=True) with anyio.CancelScope(shield=True): with anyio.move_on_after(3): try: await self.transport.close() except Exception as e: logger.debug( f"Error closing transport after cancellation: {e}" ) raise if self._session_state.session_task.done(): exception = self._session_state.session_task.exception() if exception is None: raise RuntimeError( "Session task completed without exception but connection failed" ) # Preserve specific exception types that clients may want to handle if isinstance(exception, httpx.HTTPStatusError | McpError): raise exception raise RuntimeError( f"Client failed to connect: {exception}" ) from exception self._session_state.nesting_counter += 1 return self async def _disconnect(self, force: bool = False): """ Disconnect from session using reference counting. This method implements proper cleanup for reentrant context managers: - Decrements reference counter for normal exits - Only stops session when counter reaches 0 (no more active contexts) - Force flag bypasses reference counting for immediate shutdown - Session cleanup happens inside the lock to ensure atomicity Key fix: Removed the problematic "Reset for future reconnects" logic that was resetting events outside the lock, causing race conditions. Event recreation now happens only in _connect() when actually needed. """ # ensure only one session is running at a time to avoid race conditions async with self._session_state.lock: # if we are forcing a disconnect, reset the nesting counter if force: self._session_state.nesting_counter = 0 # otherwise decrement to check if we are done nesting else: self._session_state.nesting_counter = max( 0, self._session_state.nesting_counter - 1 ) # if we are still nested, return if self._session_state.nesting_counter > 0: return # stop the active session if self._session_state.session_task is None: return self._session_state.stop_event.set() # wait for session to finish to ensure state has been reset await self._session_state.session_task self._session_state.session_task = None async def _session_runner(self): """ Background task that manages the actual session lifecycle. This task runs in the background and: 1. Establishes the transport connection via _context_manager() 2. Signals that the session is ready via _ready_event.set() 3. Waits for disconnect signal via _stop_event.wait() 4. Ensures _ready_event is always set, even on failures The simplified error handling (compared to the original) removes redundant exception re-raising while ensuring waiting tasks are always unblocked via the finally block. """ try: async with AsyncExitStack() as stack: await stack.enter_async_context(self._context_manager()) # Session/context is now ready self._session_state.ready_event.set() # Wait until disconnect/stop is requested await self._session_state.stop_event.wait() finally: # Ensure ready event is set even if context manager entry fails self._session_state.ready_event.set() async def _await_with_session_monitoring( self, coro: Coroutine[Any, Any, ResultT] ) -> ResultT: """Await a coroutine while monitoring the session task for errors. When using HTTP transports, server errors (4xx/5xx) are raised in the background session task, not in the coroutine waiting for a response. This causes the client to hang indefinitely since the response never arrives. This method monitors the session task and propagates any exceptions that occur, preventing the client from hanging. Args: coro: The coroutine to await (typically a session method call) Returns: The result of the coroutine Raises: The exception from the session task if it fails, or RuntimeError if the session task completes unexpectedly without an exception. """ session_task = self._session_state.session_task # If no session task, just await directly if session_task is None: return await coro # If session task already failed, raise immediately if session_task.done(): # Close the coroutine to avoid "was never awaited" warning coro.close() exc = session_task.exception() if exc: raise exc raise RuntimeError("Session task completed unexpectedly") # Create task for our call call_task = asyncio.create_task(coro) try: done, _ = await asyncio.wait( {call_task, session_task}, return_when=asyncio.FIRST_COMPLETED, ) if session_task in done: # Session task completed (likely errored) before our call finished call_task.cancel() with anyio.CancelScope(shield=True), suppress(asyncio.CancelledError): await call_task # Raise the session task exception exc = session_task.exception() if exc: raise exc raise RuntimeError("Session task completed unexpectedly") # Our call completed first - get the result return call_task.result() except asyncio.CancelledError: call_task.cancel() with anyio.CancelScope(shield=True), suppress(asyncio.CancelledError): await call_task raise def _handle_task_status_notification( self, notification: TaskStatusNotification ) -> None: """Route task status notification to appropriate Task object. Called when notifications/tasks/status is received from server. Updates Task object's cache and triggers events/callbacks. """ # Extract task ID from notification params task_id = notification.params.taskId if not task_id: return # Look up task in registry (weakref) task_ref = self._task_registry.get(task_id) if task_ref: task = task_ref() # Dereference weakref if task: # Convert notification params to GetTaskResult (they share the same fields via Task) status = GetTaskResult.model_validate(notification.params.model_dump()) task._handle_status_notification(status) async def close(self): await self._disconnect(force=True) await self.transport.close() # --- MCP Client Methods --- async def ping(self) -> bool: """Send a ping request.""" result = await self._await_with_session_monitoring(self.session.send_ping()) return isinstance(result, mcp.types.EmptyResult) async def cancel( self, request_id: str | int, reason: str | None = None, ) -> None: """Send a cancellation notification for an in-progress request.""" notification = mcp.types.ClientNotification( root=mcp.types.CancelledNotification( method="notifications/cancelled", params=mcp.types.CancelledNotificationParams( requestId=request_id, reason=reason, ), ) ) await self.session.send_notification(notification) async def progress( self, progress_token: str | int, progress: float, total: float | None = None, message: str | None = None, ) -> None: """Send a progress notification.""" await self.session.send_progress_notification( progress_token, progress, total, message ) async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None: """Send a logging/setLevel request.""" await self._await_with_session_monitoring(self.session.set_logging_level(level)) async def send_roots_list_changed(self) -> None: """Send a roots/list_changed notification.""" await self.session.send_roots_list_changed() # --- Completion --- async def complete_mcp( self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None, ) -> mcp.types.CompleteResult: """Send a completion request and return the complete MCP protocol result. Args: ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. context_arguments (dict[str, Any] | None, optional): Optional context arguments to include with the completion request. Defaults to None. Returns: mcp.types.CompleteResult: The complete response object from the protocol, containing the completion and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ logger.debug(f"[{self.name}] called complete: {ref}") result = await self._await_with_session_monitoring( self.session.complete( ref=ref, argument=argument, context_arguments=context_arguments ) ) return result async def complete( self, ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], context_arguments: dict[str, Any] | None = None, ) -> mcp.types.Completion: """Send a completion request to the server. Args: ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. context_arguments (dict[str, Any] | None, optional): Optional context arguments to include with the completion request. Defaults to None. Returns: mcp.types.Completion: The completion object. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ result = await self.complete_mcp( ref=ref, argument=argument, context_arguments=context_arguments ) return result.completion @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)}" ================================================ FILE: src/fastmcp/client/elicitation.py ================================================ from __future__ import annotations from collections.abc import Awaitable, Callable from typing import Any, Generic, TypeAlias import mcp.types from mcp import ClientSession from mcp.client.session import ElicitationFnT from mcp.shared.context import LifespanContextT, RequestContext from mcp.types import ElicitRequestFormParams, ElicitRequestParams from mcp.types import ElicitResult as MCPElicitResult from pydantic_core import to_jsonable_python from typing_extensions import TypeVar from fastmcp.utilities.json_schema_type import json_schema_to_type __all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"] T = TypeVar("T", default=Any) class ElicitResult(MCPElicitResult, Generic[T]): content: T | None = None ElicitationHandler: TypeAlias = Callable[ [ str, # message type[T] | None, # a class for creating a structured response (None for URL elicitation) ElicitRequestParams, RequestContext[ClientSession, LifespanContextT], ], Awaitable[T | dict[str, Any] | ElicitResult[T | dict[str, Any]]], ] def create_elicitation_callback( elicitation_handler: ElicitationHandler, ) -> ElicitationFnT: async def _elicitation_handler( context: RequestContext[ClientSession, LifespanContextT], params: ElicitRequestParams, ) -> MCPElicitResult | mcp.types.ErrorData: try: # requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams if isinstance(params, ElicitRequestFormParams): if params.requestedSchema == {"type": "object", "properties": {}}: response_type = None else: response_type = json_schema_to_type(params.requestedSchema) else: # URL-based elicitation doesn't have a schema response_type = None result = await elicitation_handler( params.message, response_type, params, context ) # if the user returns data, we assume they've accepted the elicitation if not isinstance(result, ElicitResult): result = ElicitResult(action="accept", content=result) content = to_jsonable_python(result.content) if not isinstance(content, dict | None): raise ValueError( "Elicitation responses must be serializable as a JSON object (dict). Received: " f"{result.content!r}" ) return MCPElicitResult( _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field action=result.action, content=content, ) except Exception as e: return mcp.types.ErrorData( code=mcp.types.INTERNAL_ERROR, message=str(e), ) return _elicitation_handler ================================================ FILE: src/fastmcp/client/logging.py ================================================ from collections.abc import Awaitable, Callable from logging import Logger from typing import TypeAlias from mcp.client.session import LoggingFnT from mcp.types import LoggingMessageNotificationParams from fastmcp.utilities.logging import get_logger logger: Logger = get_logger(name=__name__) from_server_logger: Logger = get_logger(name="fastmcp.client.from_server") LogMessage: TypeAlias = LoggingMessageNotificationParams LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]] async def default_log_handler(message: LogMessage) -> None: """Default handler that properly routes server log messages to appropriate log levels.""" # data can be any JSON-serializable type, not just a dict data = message.data # Map MCP log levels to Python logging levels level_map = { "debug": from_server_logger.debug, "info": from_server_logger.info, "notice": from_server_logger.info, # Python doesn't have 'notice', map to info "warning": from_server_logger.warning, "error": from_server_logger.error, "critical": from_server_logger.critical, "alert": from_server_logger.critical, # Map alert to critical "emergency": from_server_logger.critical, # Map emergency to critical } # Get the appropriate logging function based on the message level log_fn = level_map.get(message.level.lower(), logger.info) # Include logger name if available msg_prefix: str = f"Received {message.level.upper()} from server" if message.logger: msg_prefix += f" ({message.logger})" # Log with appropriate level and data log_fn(msg=f"{msg_prefix}: {data}") def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT: if handler is None: handler = default_log_handler async def log_callback(params: LoggingMessageNotificationParams) -> None: await handler(params) return log_callback ================================================ FILE: src/fastmcp/client/messages.py ================================================ from typing import TypeAlias import mcp.types from mcp.client.session import MessageHandlerFnT from mcp.shared.session import RequestResponder Message: TypeAlias = ( RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] | mcp.types.ServerNotification | Exception ) MessageHandlerT: TypeAlias = MessageHandlerFnT class MessageHandler: """ This class is used to handle MCP messages sent to the client. It is used to handle all messages, requests, notifications, and exceptions. Users can override any of the hooks """ async def __call__( self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] | mcp.types.ServerNotification | Exception, ) -> None: return await self.dispatch(message) async def dispatch(self, message: Message) -> None: # handle all messages await self.on_message(message) match message: # requests case RequestResponder(): # handle all requests # TODO(ty): remove when ty supports match statement narrowing await self.on_request(message) # type: ignore[arg-type] # handle specific requests # TODO(ty): remove type ignores when ty supports match statement narrowing match message.request.root: # type: ignore[union-attr] case mcp.types.PingRequest(): await self.on_ping(message.request.root) # type: ignore[union-attr] case mcp.types.ListRootsRequest(): await self.on_list_roots(message.request.root) # type: ignore[union-attr] case mcp.types.CreateMessageRequest(): await self.on_create_message(message.request.root) # type: ignore[union-attr] # notifications case mcp.types.ServerNotification(): # handle all notifications await self.on_notification(message) # handle specific notifications match message.root: case mcp.types.CancelledNotification(): await self.on_cancelled(message.root) case mcp.types.ProgressNotification(): await self.on_progress(message.root) case mcp.types.LoggingMessageNotification(): await self.on_logging_message(message.root) case mcp.types.ToolListChangedNotification(): await self.on_tool_list_changed(message.root) case mcp.types.ResourceListChangedNotification(): await self.on_resource_list_changed(message.root) case mcp.types.PromptListChangedNotification(): await self.on_prompt_list_changed(message.root) case mcp.types.ResourceUpdatedNotification(): await self.on_resource_updated(message.root) case Exception(): await self.on_exception(message) async def on_message(self, message: Message) -> None: pass async def on_request( self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] ) -> None: pass async def on_ping(self, message: mcp.types.PingRequest) -> None: pass async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None: pass async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None: pass async def on_notification(self, message: mcp.types.ServerNotification) -> None: pass async def on_exception(self, message: Exception) -> None: pass async def on_progress(self, message: mcp.types.ProgressNotification) -> None: pass async def on_logging_message( self, message: mcp.types.LoggingMessageNotification ) -> None: pass async def on_tool_list_changed( self, message: mcp.types.ToolListChangedNotification ) -> None: pass async def on_resource_list_changed( self, message: mcp.types.ResourceListChangedNotification ) -> None: pass async def on_prompt_list_changed( self, message: mcp.types.PromptListChangedNotification ) -> None: pass async def on_resource_updated( self, message: mcp.types.ResourceUpdatedNotification ) -> None: pass async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None: pass ================================================ FILE: src/fastmcp/client/mixins/__init__.py ================================================ """Client mixins for FastMCP.""" from fastmcp.client.mixins.prompts import ClientPromptsMixin from fastmcp.client.mixins.resources import ClientResourcesMixin from fastmcp.client.mixins.task_management import ClientTaskManagementMixin from fastmcp.client.mixins.tools import ClientToolsMixin __all__ = [ "ClientPromptsMixin", "ClientResourcesMixin", "ClientTaskManagementMixin", "ClientToolsMixin", ] ================================================ FILE: src/fastmcp/client/mixins/prompts.py ================================================ """Prompt-related methods for FastMCP Client.""" from __future__ import annotations import uuid import weakref from typing import TYPE_CHECKING, Any, Literal, overload import mcp.types import pydantic_core from pydantic import RootModel if TYPE_CHECKING: from fastmcp.client.client import Client from fastmcp.client.tasks import PromptTask from fastmcp.client.telemetry import client_span from fastmcp.telemetry import inject_trace_context from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 # Type alias for task response union (SEP-1686 graceful degradation) PromptTaskResponseUnion = RootModel[ mcp.types.CreateTaskResult | mcp.types.GetPromptResult ] class ClientPromptsMixin: """Mixin providing prompt-related methods for Client.""" # --- Prompts --- async def list_prompts_mcp( self: Client, *, cursor: str | None = None ) -> mcp.types.ListPromptsResult: """Send a prompts/list request and return the complete MCP protocol result. Args: cursor: Optional pagination cursor from a previous request's nextCursor. Returns: mcp.types.ListPromptsResult: The complete response object from the protocol, containing the list of prompts and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ logger.debug(f"[{self.name}] called list_prompts") result = await self._await_with_session_monitoring( self.session.list_prompts(cursor=cursor) ) return result async def list_prompts( self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES, ) -> list[mcp.types.Prompt]: """Retrieve all prompts available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_prompts_mcp() with the cursor parameter. Args: max_pages: Maximum number of pages to fetch before raising. Defaults to 250. Returns: list[mcp.types.Prompt]: A list of all Prompt objects. Raises: RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_prompts: list[mcp.types.Prompt] = [] cursor: str | None = None seen_cursors: set[str] = set() for _ in range(max_pages): result = await self.list_prompts_mcp(cursor=cursor) all_prompts.extend(result.prompts) if not result.nextCursor: break if result.nextCursor in seen_cursors: logger.warning( f"[{self.name}] Server returned duplicate pagination cursor" f" {result.nextCursor!r} for list_prompts; stopping pagination" ) break seen_cursors.add(result.nextCursor) cursor = result.nextCursor else: raise RuntimeError( f"[{self.name}] Reached auto-pagination limit" f" ({max_pages} pages) for list_prompts." " Use list_prompts_mcp() with cursor for manual pagination," " or increase max_pages." ) return all_prompts # --- Prompt --- async def get_prompt_mcp( self: Client, name: str, arguments: dict[str, Any] | None = None, meta: dict[str, Any] | None = None, ) -> mcp.types.GetPromptResult: """Send a prompts/get request and return the complete MCP protocol result. Args: name (str): The name of the prompt to retrieve. arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None. meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None. Returns: mcp.types.GetPromptResult: The complete response object from the protocol, containing the prompt messages and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ with client_span( f"prompts/get {name}", "prompts/get", name, session_id=self.transport.get_session_id(), ): 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: serialized_arguments = {} for key, value in arguments.items(): if isinstance(value, str): serialized_arguments[key] = value else: # Use pydantic_core.to_json for consistent serialization serialized_arguments[key] = pydantic_core.to_json(value).decode( "utf-8" ) # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) # If meta provided, use send_request for SEP-1686 task support if propagated_meta: task_dict = propagated_meta.get("modelcontextprotocol.io/task") request = mcp.types.GetPromptRequest( params=mcp.types.GetPromptRequestParams( name=name, arguments=serialized_arguments, task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias ) ) result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=mcp.types.GetPromptResult, ) ) else: result = await self._await_with_session_monitoring( self.session.get_prompt(name=name, arguments=serialized_arguments) ) return result @overload async def get_prompt( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, meta: dict[str, Any] | None = None, task: Literal[False] = False, ) -> mcp.types.GetPromptResult: ... @overload async def get_prompt( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, meta: dict[str, Any] | None = None, task: Literal[True], task_id: str | None = None, ttl: int = 60000, ) -> PromptTask: ... async def get_prompt( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, meta: dict[str, Any] | None = None, task: bool = False, task_id: str | None = None, ttl: int = 60000, ) -> mcp.types.GetPromptResult | PromptTask: """Retrieve a rendered prompt message list from the server. Args: name (str): The name of the prompt to retrieve. arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None. version (str | None, optional): Specific prompt version to get. If None, gets highest version. meta (dict[str, Any] | None): Optional request-level metadata. task (bool): If True, execute as background task (SEP-1686). Defaults to False. task_id (str | None): Optional client-provided task ID (auto-generated if not provided). ttl (int): Time to keep results available in milliseconds (default 60s). Returns: mcp.types.GetPromptResult | PromptTask: The complete response object if task=False, or a PromptTask object if task=True. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ # Merge version into request-level meta (not arguments) request_meta = dict(meta) if meta else {} if version is not None: request_meta["fastmcp"] = { **request_meta.get("fastmcp", {}), "version": version, } if task: return await self._get_prompt_as_task( name, arguments, task_id, ttl, meta=request_meta or None ) result = await self.get_prompt_mcp( name=name, arguments=arguments, meta=request_meta or None ) return result async def _get_prompt_as_task( self: Client, name: str, arguments: dict[str, Any] | None = None, task_id: str | None = None, ttl: int = 60000, meta: dict[str, Any] | None = None, ) -> PromptTask: """Get a prompt for background execution (SEP-1686). Returns a PromptTask object that handles both background and immediate execution. Args: name: Prompt name to get arguments: Prompt arguments task_id: Optional client-provided task ID (ignored, for backward compatibility) ttl: Time to keep results available in milliseconds (default 60s) meta: Optional request metadata (e.g., version info) Returns: PromptTask: Future-like object for accessing task status and results """ # Per SEP-1686 final spec: client sends only ttl, server generates taskId # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) # Serialize arguments for MCP protocol serialized_arguments: dict[str, str] | None = None if arguments: serialized_arguments = {} for key, value in arguments.items(): if isinstance(value, str): serialized_arguments[key] = value else: serialized_arguments[key] = pydantic_core.to_json(value).decode( "utf-8" ) request = mcp.types.GetPromptRequest( params=mcp.types.GetPromptRequestParams( name=name, arguments=serialized_arguments, task=mcp.types.TaskMetadata(ttl=ttl), _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias ) ) # Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=PromptTaskResponseUnion, ) ) raw_result = wrapped_result.root if isinstance(raw_result, mcp.types.CreateTaskResult): # Task was accepted - extract task info from CreateTaskResult server_task_id = raw_result.task.taskId self._submitted_task_ids.add(server_task_id) task_obj = PromptTask( self, server_task_id, prompt_name=name, immediate_result=None ) self._task_registry[server_task_id] = weakref.ref(task_obj) return task_obj else: # Graceful degradation - server returned GetPromptResult synthetic_task_id = task_id or str(uuid.uuid4()) return PromptTask( self, synthetic_task_id, prompt_name=name, immediate_result=raw_result ) ================================================ FILE: src/fastmcp/client/mixins/resources.py ================================================ """Resource-related methods for FastMCP Client.""" from __future__ import annotations import uuid import weakref from typing import TYPE_CHECKING, Any, Literal, overload import mcp.types from pydantic import AnyUrl, RootModel if TYPE_CHECKING: from fastmcp.client.client import Client from fastmcp.client.tasks import ResourceTask from fastmcp.client.telemetry import client_span from fastmcp.telemetry import inject_trace_context from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 # Type alias for task response union (SEP-1686 graceful degradation) ResourceTaskResponseUnion = RootModel[ mcp.types.CreateTaskResult | mcp.types.ReadResourceResult ] class ClientResourcesMixin: """Mixin providing resource-related methods for Client.""" # --- Resources --- async def list_resources_mcp( self: Client, *, cursor: str | None = None ) -> mcp.types.ListResourcesResult: """Send a resources/list request and return the complete MCP protocol result. Args: cursor: Optional pagination cursor from a previous request's nextCursor. Returns: mcp.types.ListResourcesResult: The complete response object from the protocol, containing the list of resources and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ logger.debug(f"[{self.name}] called list_resources") result = await self._await_with_session_monitoring( self.session.list_resources(cursor=cursor) ) return result async def list_resources( self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES, ) -> list[mcp.types.Resource]: """Retrieve all resources available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resources_mcp() with the cursor parameter. Args: max_pages: Maximum number of pages to fetch before raising. Defaults to 250. Returns: list[mcp.types.Resource]: A list of all Resource objects. Raises: RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_resources: list[mcp.types.Resource] = [] cursor: str | None = None seen_cursors: set[str] = set() for _ in range(max_pages): result = await self.list_resources_mcp(cursor=cursor) all_resources.extend(result.resources) if not result.nextCursor: break if result.nextCursor in seen_cursors: logger.warning( f"[{self.name}] Server returned duplicate pagination cursor" f" {result.nextCursor!r} for list_resources; stopping pagination" ) break seen_cursors.add(result.nextCursor) cursor = result.nextCursor else: raise RuntimeError( f"[{self.name}] Reached auto-pagination limit" f" ({max_pages} pages) for list_resources." " Use list_resources_mcp() with cursor for manual pagination," " or increase max_pages." ) return all_resources async def list_resource_templates_mcp( self: Client, *, cursor: str | None = None ) -> mcp.types.ListResourceTemplatesResult: """Send a resources/listResourceTemplates request and return the complete MCP protocol result. Args: cursor: Optional pagination cursor from a previous request's nextCursor. Returns: mcp.types.ListResourceTemplatesResult: The complete response object from the protocol, containing the list of resource templates and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ logger.debug(f"[{self.name}] called list_resource_templates") result = await self._await_with_session_monitoring( self.session.list_resource_templates(cursor=cursor) ) return result async def list_resource_templates( self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES, ) -> list[mcp.types.ResourceTemplate]: """Retrieve all resource templates available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_resource_templates_mcp() with the cursor parameter. Args: max_pages: Maximum number of pages to fetch before raising. Defaults to 250. Returns: list[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects. Raises: RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_templates: list[mcp.types.ResourceTemplate] = [] cursor: str | None = None seen_cursors: set[str] = set() for _ in range(max_pages): result = await self.list_resource_templates_mcp(cursor=cursor) all_templates.extend(result.resourceTemplates) if not result.nextCursor: break if result.nextCursor in seen_cursors: logger.warning( f"[{self.name}] Server returned duplicate pagination cursor" f" {result.nextCursor!r} for list_resource_templates;" " stopping pagination" ) break seen_cursors.add(result.nextCursor) cursor = result.nextCursor else: raise RuntimeError( f"[{self.name}] Reached auto-pagination limit" f" ({max_pages} pages) for list_resource_templates." " Use list_resource_templates_mcp() with cursor for manual pagination," " or increase max_pages." ) return all_templates async def read_resource_mcp( self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None ) -> mcp.types.ReadResourceResult: """Send a resources/read request and return the complete MCP protocol result. Args: uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object. meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None. Returns: mcp.types.ReadResourceResult: The complete response object from the protocol, containing the resource contents and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ uri_str = str(uri) with client_span( f"resources/read {uri_str}", "resources/read", uri_str, session_id=self.transport.get_session_id(), resource_uri=uri_str, ): logger.debug(f"[{self.name}] called read_resource: {uri}") if isinstance(uri, str): uri = AnyUrl(uri) # Ensure AnyUrl # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) # If meta provided, use send_request for SEP-1686 task support if propagated_meta: task_dict = propagated_meta.get("modelcontextprotocol.io/task") request = mcp.types.ReadResourceRequest( params=mcp.types.ReadResourceRequestParams( uri=uri, task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias ) ) result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=mcp.types.ReadResourceResult, ) ) else: result = await self._await_with_session_monitoring( self.session.read_resource(uri) ) return result @overload async def read_resource( self: Client, uri: AnyUrl | str, *, version: str | None = None, meta: dict[str, Any] | None = None, task: Literal[False] = False, ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ... @overload async def read_resource( self: Client, uri: AnyUrl | str, *, version: str | None = None, meta: dict[str, Any] | None = None, task: Literal[True], task_id: str | None = None, ttl: int = 60000, ) -> ResourceTask: ... async def read_resource( self: Client, uri: AnyUrl | str, *, version: str | None = None, meta: dict[str, Any] | None = None, task: bool = False, task_id: str | None = None, ttl: int = 60000, ) -> ( list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask ): """Read the contents of a resource or resolved template. Args: uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object. version (str | None): Specific version to read. If None, reads highest version. meta (dict[str, Any] | None): Optional request-level metadata. task (bool): If True, execute as background task (SEP-1686). Defaults to False. task_id (str | None): Optional client-provided task ID (auto-generated if not provided). ttl (int): Time to keep results available in milliseconds (default 60s). Returns: list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask: A list of content objects if task=False, or a ResourceTask object if task=True. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ # Merge version into request-level meta (not arguments) request_meta = dict(meta) if meta else {} if version is not None: request_meta["fastmcp"] = { **request_meta.get("fastmcp", {}), "version": version, } if task: return await self._read_resource_as_task( uri, task_id, ttl, meta=request_meta or None ) if isinstance(uri, str): try: uri = AnyUrl(uri) # Ensure AnyUrl except Exception as e: raise ValueError( f"Provided resource URI is invalid: {str(uri)!r}" ) from e result = await self.read_resource_mcp(uri, meta=request_meta or None) return result.contents async def _read_resource_as_task( self: Client, uri: AnyUrl | str, task_id: str | None = None, ttl: int = 60000, meta: dict[str, Any] | None = None, ) -> ResourceTask: """Read a resource for background execution (SEP-1686). Returns a ResourceTask object that handles both background and immediate execution. Args: uri: Resource URI to read task_id: Optional client-provided task ID (ignored, for backward compatibility) ttl: Time to keep results available in milliseconds (default 60s) meta: Optional metadata to pass with the request (e.g., version info) Returns: ResourceTask: Future-like object for accessing task status and results """ # Per SEP-1686 final spec: client sends only ttl, server generates taskId # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) if isinstance(uri, str): uri = AnyUrl(uri) request = mcp.types.ReadResourceRequest( params=mcp.types.ReadResourceRequestParams( uri=uri, task=mcp.types.TaskMetadata(ttl=ttl), _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias ) ) # Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=ResourceTaskResponseUnion, ) ) raw_result = wrapped_result.root if isinstance(raw_result, mcp.types.CreateTaskResult): # Task was accepted - extract task info from CreateTaskResult server_task_id = raw_result.task.taskId self._submitted_task_ids.add(server_task_id) task_obj = ResourceTask( self, server_task_id, uri=str(uri), immediate_result=None ) self._task_registry[server_task_id] = weakref.ref(task_obj) return task_obj else: # Graceful degradation - server returned ReadResourceResult synthetic_task_id = task_id or str(uuid.uuid4()) return ResourceTask( self, synthetic_task_id, uri=str(uri), immediate_result=raw_result.contents, ) ================================================ FILE: src/fastmcp/client/mixins/task_management.py ================================================ """Task management methods for FastMCP Client.""" from __future__ import annotations from typing import TYPE_CHECKING, Any import mcp.types from mcp import McpError if TYPE_CHECKING: from fastmcp.client.client import Client from mcp.types import ( CancelTaskRequest, CancelTaskRequestParams, GetTaskPayloadRequest, GetTaskPayloadRequestParams, GetTaskPayloadResult, GetTaskRequest, GetTaskRequestParams, GetTaskResult, ListTasksRequest, PaginatedRequestParams, ) from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class ClientTaskManagementMixin: """Mixin providing task management methods for Client.""" async def get_task_status(self: Client, task_id: str) -> GetTaskResult: """Query the status of a background task. Sends a 'tasks/get' MCP protocol request over the existing transport. Args: task_id: The task ID returned from call_tool_as_task Returns: GetTaskResult: Status information including taskId, status, pollInterval, etc. Raises: RuntimeError: If client not connected McpError: If the request results in a TimeoutError | JSONRPCError """ request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) return await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=GetTaskResult, ) ) async def get_task_result(self: Client, task_id: str) -> Any: """Retrieve the raw result of a completed background task. Sends a 'tasks/result' MCP protocol request over the existing transport. Returns the raw result - callers should parse it appropriately. Args: task_id: The task ID returned from call_tool_as_task Returns: Any: The raw result (could be tool, prompt, or resource result) Raises: RuntimeError: If client not connected, task not found, or task failed McpError: If the request results in a TimeoutError | JSONRPCError """ request = GetTaskPayloadRequest( params=GetTaskPayloadRequestParams(taskId=task_id) ) # Return raw result - Task classes handle type-specific parsing result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=GetTaskPayloadResult, ) ) # Return as dict for compatibility with Task class parsing return result.model_dump(exclude_none=True, by_alias=True) async def list_tasks( self: Client, cursor: str | None = None, limit: int = 50, ) -> dict[str, Any]: """List background tasks. Sends a 'tasks/list' MCP protocol request to the server. If the server returns an empty list (indicating client-side tracking), falls back to querying status for locally tracked task IDs. Args: cursor: Optional pagination cursor limit: Maximum number of tasks to return (default 50) Returns: dict: Response with structure: - tasks: List of task status dicts with taskId, status, etc. - nextCursor: Optional cursor for next page Raises: RuntimeError: If client not connected McpError: If the request results in a TimeoutError | JSONRPCError """ # Send protocol request params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK request = ListTasksRequest(params=params) server_response = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[invalid-argument-type] result_type=mcp.types.ListTasksResult, ) ) # If server returned tasks, use those if server_response.tasks: return server_response.model_dump(by_alias=True) # Server returned empty - fall back to client-side tracking tasks = [] for task_id in list(self._submitted_task_ids)[:limit]: try: status = await self.get_task_status(task_id) tasks.append(status.model_dump(by_alias=True)) except McpError: # Task may have expired or been deleted, skip it continue return {"tasks": tasks, "nextCursor": None} async def cancel_task(self: Client, task_id: str) -> mcp.types.CancelTaskResult: """Cancel a task, transitioning it to cancelled state. Sends a 'tasks/cancel' MCP protocol request. Task will halt execution and transition to cancelled state. Args: task_id: The task ID to cancel Returns: CancelTaskResult: The task status showing cancelled state Raises: RuntimeError: If task doesn't exist McpError: If the request results in a TimeoutError | JSONRPCError """ request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id)) return await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[invalid-argument-type] result_type=mcp.types.CancelTaskResult, ) ) ================================================ FILE: src/fastmcp/client/mixins/tools.py ================================================ """Tool-related methods for FastMCP Client.""" from __future__ import annotations import uuid import weakref from typing import TYPE_CHECKING, Any, Literal, cast, overload import mcp.types from pydantic import RootModel if TYPE_CHECKING: import datetime from fastmcp.client.client import CallToolResult, Client from fastmcp.client.progress import ProgressHandler from fastmcp.client.tasks import ToolTask from fastmcp.client.telemetry import client_span from fastmcp.exceptions import ToolError from fastmcp.telemetry import inject_trace_context from fastmcp.utilities.json_schema_type import json_schema_to_type from fastmcp.utilities.logging import get_logger from fastmcp.utilities.timeout import normalize_timeout_to_timedelta from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger(__name__) AUTO_PAGINATION_MAX_PAGES = 250 # Type alias for task response union (SEP-1686 graceful degradation) ToolTaskResponseUnion = RootModel[mcp.types.CreateTaskResult | mcp.types.CallToolResult] class ClientToolsMixin: """Mixin providing tool-related methods for Client.""" # --- Tools --- async def list_tools_mcp( self: Client, *, cursor: str | None = None ) -> mcp.types.ListToolsResult: """Send a tools/list request and return the complete MCP protocol result. Args: cursor: Optional pagination cursor from a previous request's nextCursor. Returns: mcp.types.ListToolsResult: The complete response object from the protocol, containing the list of tools and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ logger.debug(f"[{self.name}] called list_tools") result = await self._await_with_session_monitoring( self.session.list_tools(cursor=cursor) ) return result async def list_tools( self: Client, max_pages: int = AUTO_PAGINATION_MAX_PAGES, ) -> list[mcp.types.Tool]: """Retrieve all tools available on the server. This method automatically fetches all pages if the server paginates results, returning the complete list. For manual pagination control (e.g., to handle large result sets incrementally), use list_tools_mcp() with the cursor parameter. Args: max_pages: Maximum number of pages to fetch before raising. Defaults to 250. Returns: list[mcp.types.Tool]: A list of all Tool objects. Raises: RuntimeError: If the page limit is reached before pagination completes. McpError: If the request results in a TimeoutError | JSONRPCError """ all_tools: list[mcp.types.Tool] = [] cursor: str | None = None seen_cursors: set[str] = set() for _ in range(max_pages): result = await self.list_tools_mcp(cursor=cursor) all_tools.extend(result.tools) if not result.nextCursor: break if result.nextCursor in seen_cursors: logger.warning( f"[{self.name}] Server returned duplicate pagination cursor" f" {result.nextCursor!r} for list_tools; stopping pagination" ) break seen_cursors.add(result.nextCursor) cursor = result.nextCursor else: raise RuntimeError( f"[{self.name}] Reached auto-pagination limit" f" ({max_pages} pages) for list_tools." " Use list_tools_mcp() with cursor for manual pagination," " or increase max_pages." ) return all_tools # --- Call Tool --- async def call_tool_mcp( self: Client, name: str, arguments: dict[str, Any], progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, meta: dict[str, Any] | None = None, ) -> mcp.types.CallToolResult: """Send a tools/call request and return the complete MCP protocol result. This method returns the raw CallToolResult object, which includes an isError flag and other metadata. It does not raise an exception if the tool call results in an error. Args: name (str): The name of the tool to call. arguments (dict[str, Any]): Arguments to pass to the tool. timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. meta (dict[str, Any] | None, optional): Additional metadata to include with the request. This is useful for passing contextual information (like user IDs, trace IDs, or preferences) that shouldn't be tool arguments but may influence server-side processing. The server can access this via `context.request_context.meta`. Defaults to None. Returns: mcp.types.CallToolResult: The complete response object from the protocol, containing the tool result and any additional metadata. Raises: RuntimeError: If called while the client is not connected. McpError: If the tool call requests results in a TimeoutError | JSONRPCError """ with client_span( f"tools/call {name}", "tools/call", name, session_id=self.transport.get_session_id(), ): logger.debug(f"[{self.name}] called call_tool: {name}") # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) result = await self._await_with_session_monitoring( self.session.call_tool( name=name, arguments=arguments, read_timeout_seconds=normalize_timeout_to_timedelta(timeout), progress_callback=progress_handler or self._progress_handler, meta=propagated_meta if propagated_meta else None, ) ) return result async def _parse_call_tool_result( self: Client, name: str, result: mcp.types.CallToolResult, raise_on_error: bool = False, ) -> CallToolResult: """Parse an mcp.types.CallToolResult into our CallToolResult dataclass. Args: name: Tool name (for schema lookup) result: Raw MCP protocol result raise_on_error: Whether to raise ToolError on errors Returns: CallToolResult: Parsed result with structured data """ return await _parse_call_tool_result( name=name, result=result, tool_output_schemas=self.session._tool_output_schemas, list_tools_fn=self.session.list_tools, client_name=self.name, raise_on_error=raise_on_error, ) @overload async def call_tool( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None, task: Literal[False] = False, ) -> CallToolResult: ... @overload async def call_tool( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None, task: Literal[True], task_id: str | None = None, ttl: int = 60000, ) -> ToolTask: ... async def call_tool( self: Client, name: str, arguments: dict[str, Any] | None = None, *, version: str | None = None, timeout: datetime.timedelta | float | int | None = None, progress_handler: ProgressHandler | None = None, raise_on_error: bool = True, meta: dict[str, Any] | None = None, task: bool = False, task_id: str | None = None, ttl: int = 60000, ) -> CallToolResult | ToolTask: """Call a tool on the server. Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. Args: name (str): The name of the tool to call. arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None. version (str | None, optional): Specific tool version to call. If None, calls highest version. timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True. meta (dict[str, Any] | None, optional): Additional metadata to include with the request. This is useful for passing contextual information (like user IDs, trace IDs, or preferences) that shouldn't be tool arguments but may influence server-side processing. The server can access this via `context.request_context.meta`. Defaults to None. task (bool): If True, execute as background task (SEP-1686). Defaults to False. task_id (str | None): Optional client-provided task ID (auto-generated if not provided). ttl (int): Time to keep results available in milliseconds (default 60s). Returns: CallToolResult | ToolTask: The content returned by the tool if task=False, or a ToolTask object if task=True. If the tool returns structured outputs, they are returned as a dataclass (if an output schema is available) or a dictionary; otherwise, a list of content blocks is returned. Note: to receive both structured and unstructured outputs, use call_tool_mcp instead and access the raw result object. Raises: ToolError: If the tool call results in an error. McpError: If the tool call request results in a TimeoutError | JSONRPCError RuntimeError: If called while the client is not connected. """ # Merge version into request-level meta (not arguments) request_meta = dict(meta) if meta else {} if version is not None: request_meta["fastmcp"] = { **request_meta.get("fastmcp", {}), "version": version, } if task: return await self._call_tool_as_task( name, arguments, task_id, ttl, meta=request_meta or None ) result = await self.call_tool_mcp( name=name, arguments=arguments or {}, timeout=timeout, progress_handler=progress_handler, meta=request_meta or None, ) return await self._parse_call_tool_result( name, result, raise_on_error=raise_on_error ) async def _call_tool_as_task( self: Client, name: str, arguments: dict[str, Any] | None = None, task_id: str | None = None, ttl: int = 60000, meta: dict[str, Any] | None = None, ) -> ToolTask: """Call a tool for background execution (SEP-1686). Returns a ToolTask object that handles both background and immediate execution. If the server accepts background execution, ToolTask will poll for results. If the server declines (graceful degradation), ToolTask wraps the immediate result. Args: name: Tool name to call arguments: Tool arguments task_id: Optional client-provided task ID (ignored, for backward compatibility) ttl: Time to keep results available in milliseconds (default 60s) meta: Optional request metadata (e.g., version info) Returns: ToolTask: Future-like object for accessing task status and results """ # Per SEP-1686 final spec: client sends only ttl, server generates taskId # Inject trace context into meta for propagation to server propagated_meta = inject_trace_context(meta) # Build request with task metadata request = mcp.types.CallToolRequest( params=mcp.types.CallToolRequestParams( name=name, arguments=arguments or {}, task=mcp.types.TaskMetadata(ttl=ttl), _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias ) ) # Server returns CreateTaskResult (task accepted) or CallToolResult (graceful degradation) # Use RootModel with Union to handle both response types (SDK calls model_validate) wrapped_result = await self._await_with_session_monitoring( self.session.send_request( request=request, # type: ignore[arg-type] result_type=ToolTaskResponseUnion, ) ) raw_result = wrapped_result.root if isinstance(raw_result, mcp.types.CreateTaskResult): # Task was accepted - extract task info from CreateTaskResult server_task_id = raw_result.task.taskId self._submitted_task_ids.add(server_task_id) task_obj = ToolTask( self, server_task_id, tool_name=name, immediate_result=None ) self._task_registry[server_task_id] = weakref.ref(task_obj) return task_obj else: # Graceful degradation - server returned CallToolResult parsed_result = await self._parse_call_tool_result(name, raw_result) synthetic_task_id = task_id or str(uuid.uuid4()) return ToolTask( self, synthetic_task_id, tool_name=name, immediate_result=parsed_result, ) async def _parse_call_tool_result( name: str, result: mcp.types.CallToolResult, tool_output_schemas: dict[str, dict[str, Any] | None], list_tools_fn: Any, # Callable[[], Awaitable[None]] client_name: str | None = None, raise_on_error: bool = False, ) -> CallToolResult: """Parse an mcp.types.CallToolResult into our CallToolResult dataclass. Args: name: Tool name (for schema lookup) result: Raw MCP protocol result tool_output_schemas: Dictionary mapping tool names to their output schemas list_tools_fn: Async function to refresh tool schemas if needed client_name: Optional client name for logging raise_on_error: Whether to raise ToolError on errors Returns: CallToolResult: Parsed result with structured data """ # Local import: CallToolResult is under TYPE_CHECKING at module level to # avoid a circular import (client.client -> mixins.tools -> client.client), # but we need the concrete class here to construct the return value. from fastmcp.client.client import CallToolResult data = None if result.isError and raise_on_error: msg = cast(mcp.types.TextContent, result.content[0]).text raise ToolError(msg) elif result.structuredContent: try: raw_fastmcp_meta = (result.meta or {}).get("fastmcp") fastmcp_meta = ( raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {} ) wrap_from_meta = fastmcp_meta.get("wrap_result", False) # Ensure the schema cache is populated for type validation. # When meta tells us the result is wrapped we can skip the # schema check for *wrap detection*, but we still need the # schema for proper type coercion (e.g. list → set, str → datetime). if name not in tool_output_schemas: await list_tools_fn() if wrap_from_meta: # Meta tells us the result is wrapped — unwrap and validate. structured_content = result.structuredContent.get("result") elif name in tool_output_schemas: output_schema = tool_output_schemas.get(name) if output_schema and output_schema.get("x-fastmcp-wrap-result"): structured_content = result.structuredContent.get("result") else: structured_content = result.structuredContent else: structured_content = result.structuredContent # Type-validate through the schema if available. output_schema = tool_output_schemas.get(name) if output_schema: if wrap_from_meta or output_schema.get("x-fastmcp-wrap-result"): output_schema = output_schema.get("properties", {}).get( "result", output_schema ) output_type = json_schema_to_type(output_schema) type_adapter = get_cached_typeadapter(output_type) data = type_adapter.validate_python(structured_content) else: data = structured_content except Exception as e: logger.error( f"[{client_name or 'client'}] Error parsing structured content: {e}" ) return CallToolResult( content=result.content, structured_content=result.structuredContent, meta=result.meta, data=data, is_error=result.isError, ) ================================================ FILE: src/fastmcp/client/oauth_callback.py ================================================ """ OAuth callback server for handling authorization code flows. This module provides a reusable callback server that can handle OAuth redirects and display styled responses to users. """ from __future__ import annotations from dataclasses import dataclass import anyio from starlette.applications import Starlette from starlette.requests import Request from starlette.routing import Route from uvicorn import Config, Server from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import ( HELPER_TEXT_STYLES, INFO_BOX_STYLES, STATUS_MESSAGE_STYLES, create_info_box, create_logo, create_page, create_secure_html_response, create_status_message, ) logger = get_logger(__name__) def create_callback_html( message: str, is_success: bool = True, title: str = "FastMCP OAuth", server_url: str | None = None, ) -> str: """Create a styled HTML response for OAuth callbacks.""" # Build the main status message status_title = ( "Authentication successful" if is_success else "Authentication failed" ) # Add detail info box for both success and error cases detail_info = "" if is_success and server_url: detail_info = create_info_box( f"Connected to: {server_url}", centered=True, monospace=True ) elif not is_success: detail_info = create_info_box( message, is_error=True, centered=True, monospace=True ) # Build the page content content = f"""
{create_logo()} {create_status_message(status_title, is_success=is_success)} {detail_info}
You can safely close this tab now.
""" # Additional styles needed for this page additional_styles = STATUS_MESSAGE_STYLES + INFO_BOX_STYLES + HELPER_TEXT_STYLES return create_page( content=content, title=title, additional_styles=additional_styles, ) @dataclass class CallbackResponse: code: str | None = None state: str | None = None error: str | None = None error_description: str | None = None @classmethod def from_dict(cls, data: dict[str, str]) -> CallbackResponse: return cls(**{k: v for k, v in data.items() if k in cls.__annotations__}) def to_dict(self) -> dict[str, str]: return {k: v for k, v in self.__dict__.items() if v is not None} @dataclass class OAuthCallbackResult: """Container for OAuth callback results, used with anyio.Event for async coordination.""" code: str | None = None state: str | None = None error: Exception | None = None def create_oauth_callback_server( port: int, callback_path: str = "/callback", server_url: str | None = None, result_container: OAuthCallbackResult | None = None, result_ready: anyio.Event | None = None, ) -> Server: """ Create an OAuth callback server. Args: port: The port to run the server on callback_path: The path to listen for OAuth redirects on server_url: Optional server URL to display in success messages result_container: Optional container to store callback results result_ready: Optional event to signal when callback is received Returns: Configured uvicorn Server instance (not yet running) """ def store_result_once( *, code: str | None = None, state: str | None = None, error: Exception | None = None, ) -> None: """Store the first callback result and ignore subsequent requests.""" if result_container is None or result_ready is None or result_ready.is_set(): return result_container.code = code result_container.state = state result_container.error = error result_ready.set() async def callback_handler(request: Request): """Handle OAuth callback requests with proper HTML responses.""" query_params = dict(request.query_params) callback_response = CallbackResponse.from_dict(query_params) if callback_response.error: error_desc = callback_response.error_description or "Unknown error" # Create user-friendly error messages if callback_response.error == "access_denied": user_message = "Access was denied by the authorization server." else: user_message = f"Authorization failed: {error_desc}" # Store error and signal completion if result tracking provided store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( user_message, is_success=False, ), status_code=400, ) if not callback_response.code: user_message = "No authorization code was received from the server." # Store error and signal completion if result tracking provided store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( user_message, is_success=False, ), status_code=400, ) # Check for missing state parameter (indicates OAuth flow issue) if callback_response.state is None: user_message = ( "The OAuth server did not return the expected state parameter." ) # Store error and signal completion if result tracking provided store_result_once(error=RuntimeError(user_message)) return create_secure_html_response( create_callback_html( user_message, is_success=False, ), status_code=400, ) # Success case - store result and signal completion if result tracking provided store_result_once( code=callback_response.code, state=callback_response.state, ) return create_secure_html_response( create_callback_html("", is_success=True, server_url=server_url) ) app = Starlette(routes=[Route(callback_path, callback_handler)]) return Server( Config( app=app, host="127.0.0.1", port=port, lifespan="off", log_level="warning", ws="websockets-sansio", ) ) if __name__ == "__main__": """Run a test server when executed directly.""" import webbrowser import uvicorn port = find_available_port() print("🎭 OAuth Callback Test Server") print("📍 Test URLs:") print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz") print( f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied" ) print(f" Missing: http://localhost:{port}/callback") print("🛑 Press Ctrl+C to stop") print() # Create test server without future (just for testing HTML responses) server = create_oauth_callback_server( port=port, server_url="https://fastmcp-test-server.example.com" ) # Open browser to success example webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz") # Run with uvicorn directly uvicorn.run( server.config.app, host="127.0.0.1", port=port, log_level="warning", access_log=False, ) ================================================ FILE: src/fastmcp/client/progress.py ================================================ from typing import TypeAlias from mcp.shared.session import ProgressFnT from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) ProgressHandler: TypeAlias = ProgressFnT async def default_progress_handler( progress: float, total: float | None, message: str | None ) -> None: """Default handler for progress notifications. Logs progress updates at debug level, properly handling missing total or message values. Args: progress: Current progress value total: Optional total expected value message: Optional status message """ if total not in (None, 0): # We have both progress and total percent = (progress / total) * 100 progress_str = f"{progress}/{total} ({percent:.1f}%)" elif total == 0: # Avoid division by zero when a server reports an invalid total. progress_str = f"{progress}/{total}" else: # We only have progress progress_str = f"{progress}" # Include message if available if message: log_msg = f"Progress: {progress_str} - {message}" else: log_msg = f"Progress: {progress_str}" logger.debug(log_msg) ================================================ FILE: src/fastmcp/client/roots.py ================================================ import inspect from collections.abc import Awaitable, Callable from typing import TypeAlias, cast import mcp.types import pydantic from mcp import ClientSession from mcp.client.session import ListRootsFnT from mcp.shared.context import LifespanContextT, RequestContext RootsList: TypeAlias = list[str] | list[mcp.types.Root] | list[str | mcp.types.Root] RootsHandler: TypeAlias = ( Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]] ) def convert_roots_list(roots: RootsList) -> list[mcp.types.Root]: roots_list = [] for r in roots: if isinstance(r, mcp.types.Root): roots_list.append(r) elif isinstance(r, pydantic.FileUrl): roots_list.append(mcp.types.Root(uri=r)) elif isinstance(r, str): roots_list.append(mcp.types.Root(uri=pydantic.FileUrl(r))) else: raise ValueError(f"Invalid root: {r}") return roots_list def create_roots_callback( handler: RootsList | RootsHandler, ) -> ListRootsFnT: if isinstance(handler, list): # TODO(ty): remove when ty supports isinstance union narrowing return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] elif inspect.isfunction(handler): return _create_roots_callback_from_fn(handler) else: raise ValueError(f"Invalid roots handler: {handler}") def _create_roots_callback_from_roots( roots: RootsList, ) -> ListRootsFnT: roots = convert_roots_list(roots) async def _roots_callback( context: RequestContext[ClientSession, LifespanContextT], ) -> mcp.types.ListRootsResult: return mcp.types.ListRootsResult(roots=roots) return _roots_callback def _create_roots_callback_from_fn( fn: Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]], ) -> ListRootsFnT: async def _roots_callback( context: RequestContext[ClientSession, LifespanContextT], ) -> mcp.types.ListRootsResult | mcp.types.ErrorData: try: roots = fn(context) if inspect.isawaitable(roots): roots = await roots return mcp.types.ListRootsResult( roots=convert_roots_list(cast(RootsList, roots)) ) except Exception as e: return mcp.types.ErrorData( code=mcp.types.INTERNAL_ERROR, message=str(e), ) return _roots_callback ================================================ FILE: src/fastmcp/client/sampling/__init__.py ================================================ import inspect from collections.abc import Awaitable, Callable from typing import TypeAlias, TypeVar, cast import mcp.types from mcp import ClientSession, CreateMessageResult from mcp.client.session import SamplingFnT from mcp.server.session import ServerSession from mcp.shared.context import LifespanContextT, RequestContext from mcp.types import CreateMessageRequestParams as SamplingParams from mcp.types import CreateMessageResultWithTools, SamplingMessage # Result type that handlers can return SamplingHandlerResult: TypeAlias = ( str | CreateMessageResult | CreateMessageResultWithTools ) # Session type for sampling handlers - works with both client and server sessions SessionT = TypeVar("SessionT", ClientSession, ServerSession) # Unified sampling handler type that works for both clients and servers. # Handlers receive messages and parameters from the MCP sampling flow # and return LLM responses. SamplingHandler: TypeAlias = Callable[ [ list[SamplingMessage], SamplingParams, RequestContext[SessionT, LifespanContextT], ], SamplingHandlerResult | Awaitable[SamplingHandlerResult], ] __all__ = [ "RequestContext", "SamplingHandler", "SamplingHandlerResult", "SamplingMessage", "SamplingParams", "create_sampling_callback", ] def create_sampling_callback( sampling_handler: SamplingHandler, ) -> SamplingFnT: async def _sampling_handler( context, params: SamplingParams, ) -> CreateMessageResult | CreateMessageResultWithTools | mcp.types.ErrorData: try: result = sampling_handler(params.messages, params, context) if inspect.isawaitable(result): result = await result result = cast(SamplingHandlerResult, result) if isinstance(result, str): result = CreateMessageResult( role="assistant", model="fastmcp-client", content=mcp.types.TextContent(type="text", text=result), ) return result except Exception as e: return mcp.types.ErrorData( code=mcp.types.INTERNAL_ERROR, message=str(e), ) return _sampling_handler ================================================ FILE: src/fastmcp/client/sampling/handlers/__init__.py ================================================ ================================================ FILE: src/fastmcp/client/sampling/handlers/anthropic.py ================================================ """Anthropic sampling handler for FastMCP.""" from collections.abc import Iterator, Sequence from typing import Any from mcp.types import ( AudioContent, CreateMessageResult, CreateMessageResultWithTools, ImageContent, ModelPreferences, SamplingMessage, SamplingMessageContentBlock, StopReason, TextContent, Tool, ToolChoice, ToolResultContent, ToolUseContent, ) from mcp.types import CreateMessageRequestParams as SamplingParams try: from anthropic import AsyncAnthropic from anthropic.types import ( Base64ImageSourceParam, ImageBlockParam, Message, MessageParam, TextBlock, TextBlockParam, ToolParam, ToolResultBlockParam, ToolUseBlock, ToolUseBlockParam, ) from anthropic.types.model_param import ModelParam from anthropic.types.tool_choice_any_param import ToolChoiceAnyParam from anthropic.types.tool_choice_auto_param import ToolChoiceAutoParam from anthropic.types.tool_choice_param import ToolChoiceParam except ImportError as e: raise ImportError( "The `anthropic` package is not installed. " "Install it with `pip install fastmcp[anthropic]` or add `anthropic` to your dependencies." ) from e __all__ = ["AnthropicSamplingHandler"] # Anthropic supports these image MIME types _ANTHROPIC_IMAGE_MEDIA_TYPES = frozenset( {"image/jpeg", "image/png", "image/gif", "image/webp"} ) def _image_content_to_anthropic_block(content: ImageContent) -> ImageBlockParam: """Convert MCP ImageContent to Anthropic ImageBlockParam.""" if content.mimeType not in _ANTHROPIC_IMAGE_MEDIA_TYPES: raise ValueError( f"Unsupported image MIME type for Anthropic: {content.mimeType!r}. " f"Supported types: {', '.join(sorted(_ANTHROPIC_IMAGE_MEDIA_TYPES))}" ) return ImageBlockParam( type="image", source=Base64ImageSourceParam( type="base64", media_type=content.mimeType, # type: ignore[arg-type] data=content.data, ), ) class AnthropicSamplingHandler: """Sampling handler that uses the Anthropic API. Example: ```python from anthropic import AsyncAnthropic from fastmcp import FastMCP from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler handler = AnthropicSamplingHandler( default_model="claude-sonnet-4-5", client=AsyncAnthropic(), ) server = FastMCP(sampling_handler=handler) ``` """ def __init__( self, default_model: ModelParam, client: AsyncAnthropic | None = None ) -> None: self.client: AsyncAnthropic = client or AsyncAnthropic() self.default_model: ModelParam = default_model async def __call__( self, messages: list[SamplingMessage], params: SamplingParams, context: Any, ) -> CreateMessageResult | CreateMessageResultWithTools: anthropic_messages: list[MessageParam] = self._convert_to_anthropic_messages( messages=messages, ) model: ModelParam = self._select_model_from_preferences(params.modelPreferences) # Convert MCP tools to Anthropic format anthropic_tools: list[ToolParam] | None = None if params.tools: anthropic_tools = self._convert_tools_to_anthropic(params.tools) # Convert tool_choice to Anthropic format # Returns None if mode is "none", signaling tools should be omitted anthropic_tool_choice: ToolChoiceParam | None = None if params.toolChoice: converted = self._convert_tool_choice_to_anthropic(params.toolChoice) if converted is None: # tool_choice="none" means don't use tools anthropic_tools = None else: anthropic_tool_choice = converted # Build kwargs to avoid sentinel type compatibility issues across # anthropic SDK versions (NotGiven vs Omit) kwargs: dict[str, Any] = { "model": model, "messages": anthropic_messages, "max_tokens": params.maxTokens, } if params.systemPrompt is not None: kwargs["system"] = params.systemPrompt if params.temperature is not None: kwargs["temperature"] = params.temperature if params.stopSequences is not None: kwargs["stop_sequences"] = params.stopSequences if anthropic_tools is not None: kwargs["tools"] = anthropic_tools if anthropic_tool_choice is not None: kwargs["tool_choice"] = anthropic_tool_choice response = await self.client.messages.create(**kwargs) # Return appropriate result type based on whether tools were provided if params.tools: return self._message_to_result_with_tools(response) return self._message_to_create_message_result(response) @staticmethod def _iter_models_from_preferences( model_preferences: ModelPreferences | str | list[str] | None, ) -> Iterator[str]: if model_preferences is None: return if isinstance(model_preferences, str): yield model_preferences elif isinstance(model_preferences, list): yield from model_preferences elif isinstance(model_preferences, ModelPreferences): if not (hints := model_preferences.hints): return for hint in hints: if not (name := hint.name): continue yield name @staticmethod def _convert_to_anthropic_messages( messages: Sequence[SamplingMessage], ) -> list[MessageParam]: anthropic_messages: list[MessageParam] = [] for message in messages: content = message.content # Handle list content (from CreateMessageResultWithTools) if isinstance(content, list): content_blocks: list[ TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam ] = [] for item in content: if isinstance(item, ToolUseContent): content_blocks.append( ToolUseBlockParam( type="tool_use", id=item.id, name=item.name, input=item.input, ) ) elif isinstance(item, TextContent): content_blocks.append( TextBlockParam(type="text", text=item.text) ) elif isinstance(item, ImageContent): if message.role != "user": raise ValueError( "ImageContent is only supported in user messages " "for Anthropic" ) content_blocks.append(_image_content_to_anthropic_block(item)) elif isinstance(item, AudioContent): raise ValueError( "AudioContent is not supported by the Anthropic API" ) elif isinstance(item, ToolResultContent): # Extract text content from the result result_content: str | list[TextBlockParam] = "" if item.content: text_blocks: list[TextBlockParam] = [] for sub_item in item.content: if isinstance(sub_item, TextContent): text_blocks.append( TextBlockParam(type="text", text=sub_item.text) ) if len(text_blocks) == 1: result_content = text_blocks[0]["text"] elif text_blocks: result_content = text_blocks content_blocks.append( ToolResultBlockParam( type="tool_result", tool_use_id=item.toolUseId, content=result_content, is_error=item.isError if item.isError else False, ) ) if content_blocks: anthropic_messages.append( MessageParam( role=message.role, content=content_blocks, ) ) continue # Handle ToolUseContent (assistant's tool calls) if isinstance(content, ToolUseContent): anthropic_messages.append( MessageParam( role="assistant", content=[ ToolUseBlockParam( type="tool_use", id=content.id, name=content.name, input=content.input, ) ], ) ) continue # Handle ToolResultContent (user's tool results) if isinstance(content, ToolResultContent): result_content_str: str | list[TextBlockParam] = "" if content.content: text_parts: list[TextBlockParam] = [] for item in content.content: if isinstance(item, TextContent): text_parts.append( TextBlockParam(type="text", text=item.text) ) if len(text_parts) == 1: result_content_str = text_parts[0]["text"] elif text_parts: result_content_str = text_parts anthropic_messages.append( MessageParam( role="user", content=[ ToolResultBlockParam( type="tool_result", tool_use_id=content.toolUseId, content=result_content_str, is_error=content.isError if content.isError else False, ) ], ) ) continue # Handle TextContent if isinstance(content, TextContent): anthropic_messages.append( MessageParam( role=message.role, content=content.text, ) ) continue # Handle ImageContent if isinstance(content, ImageContent): if message.role != "user": raise ValueError( "ImageContent is only supported in user messages for Anthropic" ) anthropic_messages.append( MessageParam( role="user", content=[_image_content_to_anthropic_block(content)], ) ) continue # Handle AudioContent - not supported by Anthropic if isinstance(content, AudioContent): raise ValueError("AudioContent is not supported by the Anthropic API") raise ValueError(f"Unsupported content type: {type(content)}") return anthropic_messages @staticmethod def _message_to_create_message_result( message: Message, ) -> CreateMessageResult: if len(message.content) == 0: raise ValueError("No content in response from Anthropic") # Join all text blocks to avoid dropping content text = "".join( block.text for block in message.content if isinstance(block, TextBlock) ) if text: return CreateMessageResult( content=TextContent(type="text", text=text), role="assistant", model=message.model, ) raise ValueError( f"No text content in response from Anthropic: {[type(b).__name__ for b in message.content]}" ) def _select_model_from_preferences( self, model_preferences: ModelPreferences | str | list[str] | None ) -> ModelParam: for model_option in self._iter_models_from_preferences(model_preferences): # Accept any model that starts with "claude" if model_option.startswith("claude"): return model_option return self.default_model @staticmethod def _convert_tools_to_anthropic(tools: list[Tool]) -> list[ToolParam]: """Convert MCP tools to Anthropic tool format.""" anthropic_tools: list[ToolParam] = [] for tool in tools: # Build input_schema dict, ensuring required fields input_schema: dict[str, Any] = dict(tool.inputSchema) if "type" not in input_schema: input_schema["type"] = "object" anthropic_tools.append( ToolParam( name=tool.name, description=tool.description or "", input_schema=input_schema, ) ) return anthropic_tools @staticmethod def _convert_tool_choice_to_anthropic( tool_choice: ToolChoice, ) -> ToolChoiceParam | None: """Convert MCP tool_choice to Anthropic format. Returns None for "none" mode, signaling that tools should be omitted from the request entirely (Anthropic doesn't have an explicit "none" option). """ if tool_choice.mode == "auto": return ToolChoiceAutoParam(type="auto") elif tool_choice.mode == "required": return ToolChoiceAnyParam(type="any") elif tool_choice.mode == "none": # Anthropic doesn't have a "none" option - return None to signal # that tools should be omitted from the request entirely return None else: raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}") @staticmethod def _message_to_result_with_tools( message: Message, ) -> CreateMessageResultWithTools: """Convert Anthropic response to CreateMessageResultWithTools.""" if len(message.content) == 0: raise ValueError("No content in response from Anthropic") # Determine stop reason stop_reason: StopReason if message.stop_reason == "tool_use": stop_reason = "toolUse" elif message.stop_reason == "end_turn": stop_reason = "endTurn" elif message.stop_reason == "max_tokens": stop_reason = "maxTokens" elif message.stop_reason == "stop_sequence": stop_reason = "endTurn" else: stop_reason = "endTurn" # Build content list content: list[SamplingMessageContentBlock] = [] for block in message.content: if isinstance(block, TextBlock): content.append(TextContent(type="text", text=block.text)) elif isinstance(block, ToolUseBlock): # Anthropic returns input as dict directly arguments = block.input if isinstance(block.input, dict) else {} content.append( ToolUseContent( type="tool_use", id=block.id, name=block.name, input=arguments, ) ) # Must have at least some content if not content: raise ValueError("No content in response from Anthropic") return CreateMessageResultWithTools( content=content, role="assistant", model=message.model, stopReason=stop_reason, ) ================================================ FILE: src/fastmcp/client/sampling/handlers/google_genai.py ================================================ """Google GenAI sampling handler with tool support for FastMCP 3.0.""" import base64 from collections.abc import Sequence from uuid import uuid4 try: from google.genai import Client as GoogleGenaiClient from google.genai.types import ( Blob, Candidate, Content, FunctionCall, FunctionCallingConfig, FunctionCallingConfigMode, FunctionDeclaration, FunctionResponse, GenerateContentConfig, GenerateContentResponse, ModelContent, Part, ThinkingConfig, ToolConfig, UserContent, ) from google.genai.types import Tool as GoogleTool except ImportError as e: raise ImportError( "The `google-genai` package is not installed. " "Install it with `pip install fastmcp[gemini]` or add `google-genai` " "to your dependencies." ) from e from mcp import ClientSession, ServerSession from mcp.shared.context import LifespanContextT, RequestContext from mcp.types import ( AudioContent, CreateMessageResult, CreateMessageResultWithTools, ImageContent, ModelPreferences, SamplingMessage, SamplingMessageContentBlock, StopReason, TextContent, ToolChoice, ToolResultContent, ToolUseContent, ) from mcp.types import CreateMessageRequestParams as SamplingParams from mcp.types import Tool as MCPTool __all__ = ["GoogleGenaiSamplingHandler"] class GoogleGenaiSamplingHandler: """Sampling handler that uses the Google GenAI API with tool support. Example: ```python from google.genai import Client from fastmcp import FastMCP from fastmcp.client.sampling.handlers.google_genai import ( GoogleGenaiSamplingHandler, ) handler = GoogleGenaiSamplingHandler( default_model="gemini-2.0-flash", client=Client(), ) server = FastMCP(sampling_handler=handler) ``` """ def __init__( self, default_model: str, client: GoogleGenaiClient | None = None, thinking_budget: int | None = None, ) -> None: self.client: GoogleGenaiClient = client or GoogleGenaiClient() self.default_model: str = default_model self.thinking_budget: int | None = thinking_budget async def __call__( self, messages: list[SamplingMessage], params: SamplingParams, context: RequestContext[ServerSession, LifespanContextT] | RequestContext[ClientSession, LifespanContextT], ) -> CreateMessageResult | CreateMessageResultWithTools: contents: list[Content] = _convert_messages_to_google_genai_content(messages) # Convert MCP tools to Google GenAI format google_tools: list[GoogleTool] | None = None tool_config: ToolConfig | None = None if params.tools: google_tools = [ _convert_tool_to_google_genai(tool) for tool in params.tools ] tool_config = _convert_tool_choice_to_google_genai(params.toolChoice) # Select the model based on preferences selected_model = self._get_model(model_preferences=params.modelPreferences) # Configure thinking if a budget is specified thinking_config = ( ThinkingConfig(thinking_budget=self.thinking_budget) if self.thinking_budget is not None else None ) response: GenerateContentResponse = ( await self.client.aio.models.generate_content( model=selected_model, contents=contents, config=GenerateContentConfig( system_instruction=params.systemPrompt, temperature=params.temperature, max_output_tokens=params.maxTokens, stop_sequences=params.stopSequences, thinking_config=thinking_config, tools=google_tools, # ty: ignore[invalid-argument-type] tool_config=tool_config, ), ) ) # Return appropriate result type based on whether tools were provided if params.tools: return _response_to_result_with_tools(response, selected_model) return _response_to_create_message_result(response, selected_model) def _get_model(self, model_preferences: ModelPreferences | None) -> str: if model_preferences and model_preferences.hints: for hint in model_preferences.hints: if hint.name and hint.name.startswith("gemini"): return hint.name return self.default_model def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool: """Convert an MCP Tool to Google GenAI format. Google's parameters_json_schema accepts standard JSON Schema format, so we pass tool.inputSchema directly without conversion. """ return GoogleTool( function_declarations=[ FunctionDeclaration( name=tool.name, description=tool.description or "", parameters_json_schema=tool.inputSchema, ) ] ) def _convert_tool_choice_to_google_genai(tool_choice: ToolChoice | None) -> ToolConfig: """Convert MCP ToolChoice to Google GenAI ToolConfig.""" if tool_choice is None: return ToolConfig( function_calling_config=FunctionCallingConfig( mode=FunctionCallingConfigMode.AUTO ) ) if tool_choice.mode == "required": return ToolConfig( function_calling_config=FunctionCallingConfig( mode=FunctionCallingConfigMode.ANY ) ) if tool_choice.mode == "none": return ToolConfig( function_calling_config=FunctionCallingConfig( mode=FunctionCallingConfigMode.NONE ) ) # Default to AUTO for "auto" or any other value return ToolConfig( function_calling_config=FunctionCallingConfig( mode=FunctionCallingConfigMode.AUTO ) ) def _sampling_content_to_google_genai_part( content: TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent, ) -> Part: """Convert MCP content to Google GenAI Part.""" if isinstance(content, TextContent): return Part(text=content.text) if isinstance(content, ImageContent): return Part( inline_data=Blob( data=base64.b64decode(content.data), mime_type=content.mimeType, ) ) if isinstance(content, AudioContent): return Part( inline_data=Blob( data=base64.b64decode(content.data), mime_type=content.mimeType, ) ) if isinstance(content, ToolUseContent): # Note: thought_signature bypass is required for manually constructed tool calls. # Google's Gemini 3+ models enforce thought signature validation for function calls. # Since we're constructing these Parts from MCP protocol data (not from model responses), # they lack legitimate signatures. The bypass value allows validation to pass. # See: https://ai.google.dev/gemini-api/docs/thought-signatures return Part( function_call=FunctionCall( name=content.name, args=content.input, ), thought_signature=b"skip_thought_signature_validator", ) if isinstance(content, ToolResultContent): # Extract text from tool result content result_parts: list[str] = [] if content.content: for item in content.content: if isinstance(item, TextContent): result_parts.append(item.text) else: msg = f"Unsupported tool result content type: {type(item).__name__}" raise ValueError(msg) result_text = "".join(result_parts) # Extract function name from toolUseId # Our IDs are formatted as "{function_name}_{uuid8}", so extract the name. # Note: This is a limitation of MCP's ToolResultContent which only carries # toolUseId, while Google's FunctionResponse requires the function name. tool_use_id = content.toolUseId if "_" in tool_use_id: # Split and rejoin all but the last part (the UUID suffix) parts = tool_use_id.rsplit("_", 1) function_name = parts[0] else: # Fallback: use the full ID as the name function_name = tool_use_id return Part( function_response=FunctionResponse( name=function_name, response={"result": result_text}, ) ) msg = f"Unsupported content type: {type(content)}" raise ValueError(msg) def _convert_messages_to_google_genai_content( messages: Sequence[SamplingMessage], ) -> list[Content]: """Convert MCP messages to Google GenAI content.""" google_messages: list[Content] = [] for message in messages: content = message.content # Handle list content (tool calls + results) if isinstance(content, list): parts: list[Part] = [] for item in content: parts.append(_sampling_content_to_google_genai_part(item)) if message.role == "user": google_messages.append(UserContent(parts=parts)) elif message.role == "assistant": google_messages.append(ModelContent(parts=parts)) else: msg = f"Invalid message role: {message.role}" raise ValueError(msg) continue # Handle single content item part = _sampling_content_to_google_genai_part(content) if message.role == "user": google_messages.append(UserContent(parts=[part])) elif message.role == "assistant": google_messages.append(ModelContent(parts=[part])) else: msg = f"Invalid message role: {message.role}" raise ValueError(msg) return google_messages def _get_candidate_from_response(response: GenerateContentResponse) -> Candidate: """Extract the first candidate from a response.""" if response.candidates and response.candidates[0]: return response.candidates[0] msg = "No candidate in response from completion." raise ValueError(msg) def _response_to_create_message_result( response: GenerateContentResponse, model: str, ) -> CreateMessageResult: """Convert Google GenAI response to CreateMessageResult (no tools).""" if not (text := response.text): candidate = _get_candidate_from_response(response) msg = f"No content in response: {candidate.finish_reason}" raise ValueError(msg) return CreateMessageResult( content=TextContent(type="text", text=text), role="assistant", model=model, ) def _response_to_result_with_tools( response: GenerateContentResponse, model: str, ) -> CreateMessageResultWithTools: """Convert Google GenAI response to CreateMessageResultWithTools.""" candidate = _get_candidate_from_response(response) # Determine stop reason and check for function calls stop_reason: StopReason finish_reason = candidate.finish_reason has_function_calls = False if candidate.content and candidate.content.parts: for part in candidate.content.parts: if part.function_call is not None: has_function_calls = True break if has_function_calls: stop_reason = "toolUse" elif finish_reason == "STOP": stop_reason = "endTurn" elif finish_reason == "MAX_TOKENS": stop_reason = "maxTokens" else: stop_reason = "endTurn" # Build content list content: list[SamplingMessageContentBlock] = [] if candidate.content and candidate.content.parts: for part in candidate.content.parts: # Note: Skip thought parts from thinking_config - not relevant for MCP responses if part.text: content.append(TextContent(type="text", text=part.text)) elif part.function_call is not None: fc = part.function_call fc_name: str = fc.name or "unknown" content.append( ToolUseContent( type="tool_use", id=f"{fc_name}_{uuid4().hex[:8]}", # Generate unique ID name=fc_name, input=dict(fc.args) if fc.args else {}, ) ) if not content: raise ValueError("No content in response from completion") return CreateMessageResultWithTools( content=content, role="assistant", model=model, stopReason=stop_reason, ) ================================================ FILE: src/fastmcp/client/sampling/handlers/openai.py ================================================ """OpenAI sampling handler for FastMCP.""" import json from collections.abc import Iterator, Sequence from typing import Any, get_args from mcp import ClientSession, ServerSession from mcp.shared.context import LifespanContextT, RequestContext from mcp.types import ( AudioContent, CreateMessageResult, CreateMessageResultWithTools, ImageContent, ModelPreferences, SamplingMessage, StopReason, TextContent, Tool, ToolChoice, ToolResultContent, ToolUseContent, ) from mcp.types import CreateMessageRequestParams as SamplingParams try: from openai import AsyncOpenAI from openai.types.chat import ( ChatCompletion, ChatCompletionAssistantMessageParam, ChatCompletionContentPartImageParam, ChatCompletionContentPartInputAudioParam, ChatCompletionContentPartParam, ChatCompletionContentPartTextParam, ChatCompletionMessageParam, ChatCompletionMessageToolCallParam, ChatCompletionSystemMessageParam, ChatCompletionToolChoiceOptionParam, ChatCompletionToolMessageParam, ChatCompletionToolParam, ChatCompletionUserMessageParam, ) from openai.types.shared.chat_model import ChatModel from openai.types.shared_params import FunctionDefinition except ImportError as e: raise ImportError( "The `openai` package is not installed. " "Please install `fastmcp[openai]` or add `openai` to your dependencies manually." ) from e # OpenAI only supports wav and mp3 for input audio _OPENAI_AUDIO_FORMATS: dict[str, str] = { "audio/wav": "wav", "audio/x-wav": "wav", "audio/mp3": "mp3", "audio/mpeg": "mp3", } _OPENAI_IMAGE_MEDIA_TYPES: frozenset[str] = frozenset( {"image/jpeg", "image/png", "image/gif", "image/webp"} ) def _image_content_to_openai_part( content: ImageContent, ) -> ChatCompletionContentPartImageParam: """Convert MCP ImageContent to OpenAI image_url content part.""" if content.mimeType not in _OPENAI_IMAGE_MEDIA_TYPES: raise ValueError( f"Unsupported image MIME type for OpenAI: {content.mimeType!r}. " f"Supported types: {', '.join(sorted(_OPENAI_IMAGE_MEDIA_TYPES))}" ) data_url = f"data:{content.mimeType};base64,{content.data}" return ChatCompletionContentPartImageParam( type="image_url", image_url={"url": data_url}, ) def _audio_content_to_openai_part( content: AudioContent, ) -> ChatCompletionContentPartInputAudioParam: """Convert MCP AudioContent to OpenAI input_audio content part.""" audio_format = _OPENAI_AUDIO_FORMATS.get(content.mimeType) if audio_format is None: raise ValueError( f"Unsupported audio MIME type for OpenAI: {content.mimeType!r}. " f"Supported types: {', '.join(sorted(_OPENAI_AUDIO_FORMATS))}" ) return ChatCompletionContentPartInputAudioParam( type="input_audio", input_audio={"data": content.data, "format": audio_format}, ) class OpenAISamplingHandler: """Sampling handler that uses the OpenAI API.""" def __init__( self, default_model: ChatModel, client: AsyncOpenAI | None = None, ) -> None: self.client: AsyncOpenAI = client or AsyncOpenAI() self.default_model: ChatModel = default_model async def __call__( self, messages: list[SamplingMessage], params: SamplingParams, context: RequestContext[ServerSession, LifespanContextT] | RequestContext[ClientSession, LifespanContextT], ) -> CreateMessageResult | CreateMessageResultWithTools: openai_messages: list[ChatCompletionMessageParam] = ( self._convert_to_openai_messages( system_prompt=params.systemPrompt, messages=messages, ) ) model: ChatModel = self._select_model_from_preferences(params.modelPreferences) # Convert MCP tools to OpenAI format openai_tools: list[ChatCompletionToolParam] | None = None if params.tools: openai_tools = self._convert_tools_to_openai(params.tools) # Convert tool_choice to OpenAI format openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None if params.toolChoice: openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice) # Build kwargs to avoid sentinel type compatibility issues across # openai SDK versions (NotGiven vs Omit) kwargs: dict[str, Any] = { "model": model, "messages": openai_messages, } if params.maxTokens is not None: kwargs["max_completion_tokens"] = params.maxTokens if params.temperature is not None: kwargs["temperature"] = params.temperature if params.stopSequences: kwargs["stop"] = params.stopSequences if openai_tools is not None: kwargs["tools"] = openai_tools if openai_tool_choice is not None: kwargs["tool_choice"] = openai_tool_choice response = await self.client.chat.completions.create(**kwargs) # Return appropriate result type based on whether tools were provided if params.tools: return self._chat_completion_to_result_with_tools(response) return self._chat_completion_to_create_message_result(response) @staticmethod def _iter_models_from_preferences( model_preferences: ModelPreferences | str | list[str] | None, ) -> Iterator[str]: if model_preferences is None: return if isinstance(model_preferences, str) and model_preferences in get_args( ChatModel ): yield model_preferences elif isinstance(model_preferences, list): yield from model_preferences elif isinstance(model_preferences, ModelPreferences): if not (hints := model_preferences.hints): return for hint in hints: if not (name := hint.name): continue yield name @staticmethod def _convert_to_openai_messages( system_prompt: str | None, messages: Sequence[SamplingMessage] ) -> list[ChatCompletionMessageParam]: openai_messages: list[ChatCompletionMessageParam] = [] if system_prompt: openai_messages.append( ChatCompletionSystemMessageParam( role="system", content=system_prompt, ) ) for message in messages: content = message.content # Handle list content (from CreateMessageResultWithTools) if isinstance(content, list): # Collect tool calls, content parts, and text from the list tool_calls: list[ChatCompletionMessageToolCallParam] = [] content_parts: list[ChatCompletionContentPartParam] = [] text_parts: list[str] = [] # Collect tool results separately to maintain correct ordering tool_messages: list[ChatCompletionToolMessageParam] = [] for item in content: if isinstance(item, ToolUseContent): tool_calls.append( ChatCompletionMessageToolCallParam( id=item.id, type="function", function={ "name": item.name, "arguments": json.dumps(item.input), }, ) ) elif isinstance(item, TextContent): text_parts.append(item.text) content_parts.append( ChatCompletionContentPartTextParam( type="text", text=item.text ) ) elif isinstance(item, ImageContent): content_parts.append(_image_content_to_openai_part(item)) elif isinstance(item, AudioContent): content_parts.append(_audio_content_to_openai_part(item)) elif isinstance(item, ToolResultContent): # Collect tool results (added after assistant message) content_text = "" if item.content: result_texts = [] for sub_item in item.content: if isinstance(sub_item, TextContent): result_texts.append(sub_item.text) content_text = "\n".join(result_texts) tool_messages.append( ChatCompletionToolMessageParam( role="tool", tool_call_id=item.toolUseId, content=content_text, ) ) # Add assistant message with tool calls if present # OpenAI requires: assistant (with tool_calls) -> tool messages if tool_calls or content_parts: if tool_calls: has_multimodal = len(content_parts) > len(text_parts) if has_multimodal: raise ValueError( "ImageContent/AudioContent is only supported " "in user messages for OpenAI" ) text_str = "\n".join(text_parts) or None openai_messages.append( ChatCompletionAssistantMessageParam( role="assistant", content=text_str, tool_calls=tool_calls, ) ) # Add tool messages AFTER assistant message openai_messages.extend(tool_messages) elif content_parts: if message.role == "user": openai_messages.append( ChatCompletionUserMessageParam( role="user", content=content_parts, ) ) else: has_multimodal = len(content_parts) > len(text_parts) if has_multimodal: raise ValueError( "ImageContent/AudioContent is only supported " "in user messages for OpenAI" ) assistant_text = "\n".join(text_parts) if assistant_text: openai_messages.append( ChatCompletionAssistantMessageParam( role="assistant", content=assistant_text, ) ) elif tool_messages: # Tool results only (assistant message was in previous message) openai_messages.extend(tool_messages) continue # Handle ToolUseContent (assistant's tool calls) if isinstance(content, ToolUseContent): openai_messages.append( ChatCompletionAssistantMessageParam( role="assistant", tool_calls=[ ChatCompletionMessageToolCallParam( id=content.id, type="function", function={ "name": content.name, "arguments": json.dumps(content.input), }, ) ], ) ) continue # Handle ToolResultContent (user's tool results) if isinstance(content, ToolResultContent): # Extract text parts from the content list result_texts: list[str] = [] if content.content: for item in content.content: if isinstance(item, TextContent): result_texts.append(item.text) openai_messages.append( ChatCompletionToolMessageParam( role="tool", tool_call_id=content.toolUseId, content="\n".join(result_texts), ) ) continue # Handle TextContent if isinstance(content, TextContent): if message.role == "user": openai_messages.append( ChatCompletionUserMessageParam( role="user", content=content.text, ) ) else: openai_messages.append( ChatCompletionAssistantMessageParam( role="assistant", content=content.text, ) ) continue # Handle ImageContent if isinstance(content, ImageContent): if message.role != "user": raise ValueError( "ImageContent is only supported in user messages for OpenAI" ) openai_messages.append( ChatCompletionUserMessageParam( role="user", content=[_image_content_to_openai_part(content)], ) ) continue # Handle AudioContent if isinstance(content, AudioContent): if message.role != "user": raise ValueError( "AudioContent is only supported in user messages for OpenAI" ) openai_messages.append( ChatCompletionUserMessageParam( role="user", content=[_audio_content_to_openai_part(content)], ) ) continue raise ValueError(f"Unsupported content type: {type(content)}") return openai_messages @staticmethod def _chat_completion_to_create_message_result( chat_completion: ChatCompletion, ) -> CreateMessageResult: if len(chat_completion.choices) == 0: raise ValueError("No response for completion") first_choice = chat_completion.choices[0] if content := first_choice.message.content: return CreateMessageResult( content=TextContent(type="text", text=content), role="assistant", model=chat_completion.model, ) raise ValueError("No content in response from completion") def _select_model_from_preferences( self, model_preferences: ModelPreferences | str | list[str] | None ) -> ChatModel: for model_option in self._iter_models_from_preferences(model_preferences): if model_option in get_args(ChatModel): chosen_model: ChatModel = model_option # type: ignore[assignment] return chosen_model return self.default_model @staticmethod def _convert_tools_to_openai(tools: list[Tool]) -> list[ChatCompletionToolParam]: """Convert MCP tools to OpenAI tool format.""" openai_tools: list[ChatCompletionToolParam] = [] for tool in tools: # Build parameters dict, ensuring required fields parameters: dict[str, Any] = dict(tool.inputSchema) if "type" not in parameters: parameters["type"] = "object" openai_tools.append( ChatCompletionToolParam( type="function", function=FunctionDefinition( name=tool.name, description=tool.description or "", parameters=parameters, ), ) ) return openai_tools @staticmethod def _convert_tool_choice_to_openai( tool_choice: ToolChoice, ) -> ChatCompletionToolChoiceOptionParam: """Convert MCP tool_choice to OpenAI format.""" if tool_choice.mode == "auto": return "auto" elif tool_choice.mode == "required": return "required" elif tool_choice.mode == "none": return "none" else: raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}") @staticmethod def _chat_completion_to_result_with_tools( chat_completion: ChatCompletion, ) -> CreateMessageResultWithTools: """Convert OpenAI response to CreateMessageResultWithTools.""" if len(chat_completion.choices) == 0: raise ValueError("No response for completion") first_choice = chat_completion.choices[0] message = first_choice.message # Determine stop reason stop_reason: StopReason if first_choice.finish_reason == "tool_calls": stop_reason = "toolUse" elif first_choice.finish_reason == "stop": stop_reason = "endTurn" elif first_choice.finish_reason == "length": stop_reason = "maxTokens" else: stop_reason = "endTurn" # Build content list content: list[TextContent | ToolUseContent] = [] # Add text content if present if message.content: content.append(TextContent(type="text", text=message.content)) # Add tool calls if present if message.tool_calls: for tool_call in message.tool_calls: # Skip non-function tool calls if not hasattr(tool_call, "function"): continue func = tool_call.function # Parse the arguments JSON string try: arguments = json.loads(func.arguments) # type: ignore[union-attr] except json.JSONDecodeError as e: raise ValueError( f"Invalid JSON in tool arguments for " f"'{func.name}': {func.arguments}" # type: ignore[union-attr] ) from e content.append( ToolUseContent( type="tool_use", id=tool_call.id, name=func.name, # type: ignore[union-attr] input=arguments, ) ) # Must have at least some content if not content: raise ValueError("No content in response from completion") return CreateMessageResultWithTools( content=content, # type: ignore[arg-type] role="assistant", model=chat_completion.model, stopReason=stop_reason, ) ================================================ FILE: src/fastmcp/client/tasks.py ================================================ """SEP-1686 client Task classes.""" from __future__ import annotations import abc import asyncio import inspect import time import weakref from collections.abc import Awaitable, Callable from datetime import datetime, timezone from typing import TYPE_CHECKING, Generic, TypeVar import mcp.types from mcp.types import GetTaskResult, TaskStatusNotification from fastmcp.client.messages import Message, MessageHandler from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) if TYPE_CHECKING: from fastmcp.client.client import CallToolResult, Client class TaskNotificationHandler(MessageHandler): """MessageHandler that routes task status notifications to Task objects.""" def __init__(self, client: Client): super().__init__() self._client_ref: weakref.ref[Client] = weakref.ref(client) async def dispatch(self, message: Message) -> None: """Dispatch messages, including task status notifications.""" if isinstance(message, mcp.types.ServerNotification): if isinstance(message.root, TaskStatusNotification): client = self._client_ref() if client: client._handle_task_status_notification(message.root) await super().dispatch(message) TaskResultT = TypeVar("TaskResultT") class Task(abc.ABC, Generic[TaskResultT]): """ Abstract base class for MCP background tasks (SEP-1686). Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). Subclasses: - ToolTask: For tool calls (result type: CallToolResult) - PromptTask: For prompts (future, result type: GetPromptResult) - ResourceTask: For resources (future, result type: ReadResourceResult) """ def __init__( self, client: Client, task_id: str, immediate_result: TaskResultT | None = None, ): """ Create a Task wrapper. Args: client: The FastMCP client task_id: The task identifier immediate_result: If server executed synchronously, the immediate result """ self._client = client self._task_id = task_id self._immediate_result = immediate_result self._is_immediate = immediate_result is not None # Notification-based optimization (SEP-1686 notifications/tasks/status) self._status_cache: GetTaskResult | None = None self._status_event: asyncio.Event | None = None # Lazy init self._status_callbacks: list[ Callable[[GetTaskResult], None | Awaitable[None]] ] = [] self._cached_result: TaskResultT | None = None def _check_client_connected(self) -> None: """Validate that client context is still active. Raises: RuntimeError: If accessed outside client context (unless immediate) """ if self._is_immediate: return # Already resolved, no client needed try: _ = self._client.session except RuntimeError as e: raise RuntimeError( "Cannot access task results outside client context. " "Task futures must be used within 'async with client:' block." ) from e @property def task_id(self) -> str: """Get the task ID.""" return self._task_id @property def returned_immediately(self) -> bool: """Check if server executed the task immediately. Returns: True if server executed synchronously (graceful degradation or no task support) False if server accepted background execution """ return self._is_immediate def _handle_status_notification(self, status: GetTaskResult) -> None: """Process incoming notifications/tasks/status (internal). Called by Client when a notification is received for this task. Updates cache, triggers events, and invokes user callbacks. Args: status: Task status from notification """ # Update cache for next status() call self._status_cache = status # Wake up any wait() calls if self._status_event is not None: self._status_event.set() # Invoke user callbacks for callback in self._status_callbacks: try: result = callback(status) if inspect.isawaitable(result): # Fire and forget async callbacks asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 except Exception as e: logger.warning(f"Task callback error: {e}", exc_info=True) def on_status_change( self, callback: Callable[[GetTaskResult], None | Awaitable[None]], ) -> None: """Register callback for status change notifications. The callback will be invoked when a notifications/tasks/status is received for this task (optional server feature per SEP-1686 lines 436-444). Supports both sync and async callbacks (auto-detected). Args: callback: Function to call with GetTaskResult when status changes. Can return None (sync) or Awaitable[None] (async). Example: >>> task = await client.call_tool("slow_operation", {}, task=True) >>> >>> def on_update(status: GetTaskResult): ... print(f"Task {status.taskId} is now {status.status}") >>> >>> task.on_status_change(on_update) >>> result = await task # Callback fires when status changes """ self._status_callbacks.append(callback) async def status(self) -> GetTaskResult: """Get current task status. If server executed immediately, returns synthetic completed status. Otherwise queries the server for current status. """ self._check_client_connected() if self._is_immediate: # Return synthetic completed status now = datetime.now(timezone.utc) return GetTaskResult( taskId=self._task_id, status="completed", createdAt=now, lastUpdatedAt=now, ttl=None, pollInterval=1000, ) # Return cached status if available (from notification) if self._status_cache is not None: cached = self._status_cache # Don't clear cache - keep it for next call return cached # Query server and cache the result self._status_cache = await self._client.get_task_status(self._task_id) return self._status_cache @abc.abstractmethod async def result(self) -> TaskResultT: """Wait for and return the task result. Must be implemented by subclasses to return the appropriate result type. """ ... async def wait( self, *, state: str | None = None, timeout: float = 300.0 ) -> GetTaskResult: """Wait for task to reach a specific state or complete. Uses event-based waiting when notifications are available (fast), with fallback to polling (reliable). Optimally wakes up immediately on status changes when server sends notifications/tasks/status. Args: state: Desired state ('submitted', 'working', 'completed', 'failed'). If None, waits for any terminal state (completed/failed) timeout: Maximum time to wait in seconds Returns: GetTaskResult: Final task status Raises: TimeoutError: If desired state not reached within timeout """ self._check_client_connected() if self._is_immediate: # Already done return await self.status() # Initialize event for notification wake-ups if self._status_event is None: self._status_event = asyncio.Event() start = time.time() terminal_states = {"completed", "failed", "cancelled"} poll_interval = 0.5 # Fallback polling interval (500ms) while True: # Check cached status first (updated by notifications) if self._status_cache: current = self._status_cache.status if state is None: if current in terminal_states: return self._status_cache elif current == state: return self._status_cache # Check timeout elapsed = time.time() - start if elapsed >= timeout: raise TimeoutError( f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s" ) remaining = timeout - elapsed # Wait for notification event OR poll timeout try: await asyncio.wait_for( self._status_event.wait(), timeout=min(poll_interval, remaining) ) self._status_event.clear() except asyncio.TimeoutError: # Fallback: poll server (notification didn't arrive in time) self._status_cache = await self._client.get_task_status(self._task_id) async def cancel(self) -> None: """Cancel this task, transitioning it to cancelled state. Sends a tasks/cancel protocol request. The server will attempt to halt execution and move the task to cancelled state. Note: If server executed immediately (graceful degradation), this is a no-op as there's no server-side task to cancel. """ if self._is_immediate: # No server-side task to cancel return self._check_client_connected() await self._client.cancel_task(self._task_id) # Invalidate cache to force fresh status fetch self._status_cache = None def __await__(self): """Allow 'await task' to get result.""" return self.result().__await__() class ToolTask(Task["CallToolResult"]): """ Represents a tool call that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). Usage: task = await client.call_tool_as_task("analyze", args) # Check status status = await task.status() # Wait for completion await task.wait() # Get result (waits if needed) result = await task.result() # Returns CallToolResult # Or just await the task directly result = await task """ def __init__( self, client: Client, task_id: str, tool_name: str, immediate_result: CallToolResult | None = None, ): """ Create a ToolTask wrapper. Args: client: The FastMCP client task_id: The task identifier tool_name: Name of the tool being executed immediate_result: If server executed synchronously, the immediate result """ super().__init__(client, task_id, immediate_result) self._tool_name = tool_name async def result(self) -> CallToolResult: """Wait for and return the tool result. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. Returns: CallToolResult: The parsed tool result (same as call_tool returns) """ # Check cache first if self._cached_result is not None: return self._cached_result if self._is_immediate: assert self._immediate_result is not None # Type narrowing result = self._immediate_result else: # Check client connected self._check_client_connected() # Wait for completion using event-based wait (respects notifications) await self.wait() # Get the raw result (dict or CallToolResult) raw_result = await self._client.get_task_result(self._task_id) # Convert to CallToolResult if needed and parse if isinstance(raw_result, dict): # Raw dict from get_task_result - parse as CallToolResult mcp_result = mcp.types.CallToolResult.model_validate(raw_result) result = await self._client._parse_call_tool_result( self._tool_name, mcp_result, raise_on_error=True ) elif isinstance(raw_result, mcp.types.CallToolResult): # Already a CallToolResult from MCP protocol - parse it result = await self._client._parse_call_tool_result( self._tool_name, raw_result, raise_on_error=True ) else: # Legacy ToolResult format - convert to MCP type if hasattr(raw_result, "content") and hasattr( raw_result, "structured_content" ): mcp_result = mcp.types.CallToolResult( content=raw_result.content, structuredContent=raw_result.structured_content, _meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) result = await self._client._parse_call_tool_result( self._tool_name, mcp_result, raise_on_error=True ) else: # Unknown type - just return it result = raw_result # Cache before returning self._cached_result = result return result class PromptTask(Task[mcp.types.GetPromptResult]): """ Represents a prompt call that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). Usage: task = await client.get_prompt_as_task("analyze", args) result = await task # Returns GetPromptResult """ def __init__( self, client: Client, task_id: str, prompt_name: str, immediate_result: mcp.types.GetPromptResult | None = None, ): """ Create a PromptTask wrapper. Args: client: The FastMCP client task_id: The task identifier prompt_name: Name of the prompt being executed immediate_result: If server executed synchronously, the immediate result """ super().__init__(client, task_id, immediate_result) self._prompt_name = prompt_name async def result(self) -> mcp.types.GetPromptResult: """Wait for and return the prompt result. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. Returns: GetPromptResult: The prompt result with messages and description """ # Check cache first if self._cached_result is not None: return self._cached_result if self._is_immediate: assert self._immediate_result is not None result = self._immediate_result else: # Check client connected self._check_client_connected() # Wait for completion using event-based wait (respects notifications) await self.wait() # Get the raw MCP result mcp_result = await self._client.get_task_result(self._task_id) # Parse as GetPromptResult result = mcp.types.GetPromptResult.model_validate(mcp_result) # Cache before returning self._cached_result = result return result class ResourceTask( Task[list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]] ): """ Represents a resource read that may execute in background or immediately. Provides a uniform API whether the server accepts background execution or executes synchronously (graceful degradation per SEP-1686). Usage: task = await client.read_resource_as_task("file://data.txt") contents = await task # Returns list[ReadResourceContents] """ def __init__( self, client: Client, task_id: str, uri: str, immediate_result: list[ mcp.types.TextResourceContents | mcp.types.BlobResourceContents ] | None = None, ): """ Create a ResourceTask wrapper. Args: client: The FastMCP client task_id: The task identifier uri: URI of the resource being read immediate_result: If server executed synchronously, the immediate result """ super().__init__(client, task_id, immediate_result) self._uri = uri async def result( self, ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: """Wait for and return the resource contents. If server executed immediately, returns the immediate result. Otherwise waits for background task to complete and retrieves result. Returns: list[ReadResourceContents]: The resource contents """ # Check cache first if self._cached_result is not None: return self._cached_result if self._is_immediate: assert self._immediate_result is not None result = self._immediate_result else: # Check client connected self._check_client_connected() # Wait for completion using event-based wait (respects notifications) await self.wait() # Get the raw MCP result mcp_result = await self._client.get_task_result(self._task_id) # Parse as ReadResourceResult or extract contents if isinstance(mcp_result, mcp.types.ReadResourceResult): # Already parsed by TasksResponse - extract contents result = list(mcp_result.contents) elif isinstance(mcp_result, dict) and "contents" in mcp_result: # Dict format - parse each content item parsed_contents = [] for item in mcp_result["contents"]: if isinstance(item, dict): if "blob" in item: parsed_contents.append( mcp.types.BlobResourceContents.model_validate(item) ) else: parsed_contents.append( mcp.types.TextResourceContents.model_validate(item) ) else: parsed_contents.append(item) result = parsed_contents else: # Fallback - might be the list directly result = mcp_result if isinstance(mcp_result, list) else [mcp_result] # Cache before returning self._cached_result = result return result ================================================ FILE: src/fastmcp/client/telemetry.py ================================================ """Client-side telemetry helpers.""" from collections.abc import Generator from contextlib import contextmanager from opentelemetry.trace import Span, SpanKind, Status, StatusCode from fastmcp.telemetry import get_tracer @contextmanager def client_span( name: str, method: str, component_key: str, session_id: str | None = None, resource_uri: str | None = None, ) -> Generator[Span, None, None]: """Create a CLIENT span with standard MCP attributes. Automatically records any exception on the span and sets error status. """ tracer = get_tracer() with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: attrs: dict[str, str] = { # RPC semantic conventions "rpc.system": "mcp", "rpc.method": method, # MCP semantic conventions "mcp.method.name": method, # FastMCP-specific attributes "fastmcp.component.key": component_key, } if session_id: attrs["mcp.session.id"] = session_id if resource_uri: attrs["mcp.resource.uri"] = resource_uri span.set_attributes(attrs) try: yield span except Exception as e: span.record_exception(e) span.set_status(Status(StatusCode.ERROR)) raise __all__ = ["client_span"] ================================================ FILE: src/fastmcp/client/transports/__init__.py ================================================ # Re-export all public APIs for backward compatibility from mcp.server.fastmcp import FastMCP as FastMCP1Server from fastmcp.client.transports.base import ( ClientTransport, ClientTransportT, SessionKwargs, ) from fastmcp.client.transports.config import MCPConfigTransport from fastmcp.client.transports.http import StreamableHttpTransport from fastmcp.client.transports.inference import infer_transport from fastmcp.client.transports.sse import SSETransport from fastmcp.client.transports.memory import FastMCPTransport from fastmcp.client.transports.stdio import ( FastMCPStdioTransport, NodeStdioTransport, NpxStdioTransport, PythonStdioTransport, StdioTransport, UvStdioTransport, UvxStdioTransport, ) from fastmcp.server.server import FastMCP __all__ = [ "ClientTransport", "FastMCPStdioTransport", "FastMCPTransport", "NodeStdioTransport", "NpxStdioTransport", "PythonStdioTransport", "SSETransport", "StdioTransport", "StreamableHttpTransport", "UvStdioTransport", "UvxStdioTransport", "infer_transport", ] ================================================ FILE: src/fastmcp/client/transports/base.py ================================================ import abc import contextlib import datetime from collections.abc import AsyncIterator from typing import Literal, TypeVar import httpx import mcp.types from mcp import ClientSession from mcp.client.session import ( ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT, ) from typing_extensions import TypedDict, Unpack # TypeVar for preserving specific ClientTransport subclass types ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport") class SessionKwargs(TypedDict, total=False): """Keyword arguments for the MCP ClientSession constructor.""" read_timeout_seconds: datetime.timedelta | None sampling_callback: SamplingFnT | None sampling_capabilities: mcp.types.SamplingCapability | None list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None elicitation_callback: ElicitationFnT | None message_handler: MessageHandlerFnT | None client_info: mcp.types.Implementation | None class ClientTransport(abc.ABC): """ Abstract base class for different MCP client transport mechanisms. A Transport is responsible for establishing and managing connections to an MCP server, and providing a ClientSession within an async context. """ @abc.abstractmethod @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: """ Establishes a connection and yields an active ClientSession. The ClientSession is *not* expected to be initialized in this context manager. The session is guaranteed to be valid only within the scope of the async context manager. Connection setup and teardown are handled within this context. Args: **session_kwargs: Keyword arguments to pass to the ClientSession constructor (e.g., callbacks, timeouts). Yields: A mcp.ClientSession instance. """ raise NotImplementedError yield def __repr__(self) -> str: # Basic representation for subclasses return f"<{self.__class__.__name__}>" async def close(self): # noqa: B027 """Close the transport.""" def get_session_id(self) -> str | None: """Get the session ID for this transport, if available.""" return None def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): if auth is not None: raise ValueError("This transport does not support auth") ================================================ FILE: src/fastmcp/client/transports/config.py ================================================ import contextlib import datetime from collections.abc import AsyncIterator from typing import Any from mcp import ClientSession from typing_extensions import Unpack from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.client.transports.memory import FastMCPTransport from fastmcp.mcp_config import ( MCPConfig, MCPServerTypes, RemoteMCPServer, StdioMCPServer, TransformingRemoteMCPServer, TransformingStdioMCPServer, ) from fastmcp.server.server import FastMCP, create_proxy from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class MCPConfigTransport(ClientTransport): """Transport for connecting to one or more MCP servers defined in an MCPConfig. This transport provides a unified interface to multiple MCP servers defined in an MCPConfig object or dictionary matching the MCPConfig schema. It supports two key scenarios: 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server. 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting all servers on a single FastMCP instance, with each server's name, by default, used as its mounting prefix. In the multiserver case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` and resources with the pattern `protocol://{server_name}/path/to/resource`. This is particularly useful for creating clients that need to interact with multiple specialized MCP servers through a single interface, simplifying client code. Examples: ```python from fastmcp import Client # Create a config with multiple servers config = { "mcpServers": { "weather": { "url": "https://weather-api.example.com/mcp", "transport": "http" }, "calendar": { "url": "https://calendar-api.example.com/mcp", "transport": "http" } } } # Create a client with the config client = Client(config) async with client: # Access tools with prefixes weather = await client.call_tool("weather_get_forecast", {"city": "London"}) events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"}) # Access resources with prefixed URIs icons = await client.read_resource("weather://weather/icons/sunny") ``` """ def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True): if isinstance(config, dict): config = MCPConfig.from_dict(config) self.config = config self.name_as_prefix = name_as_prefix self._transports: list[ClientTransport] = [] if not self.config.mcpServers: raise ValueError("No MCP servers defined in the config") # For single server, create transport eagerly so it can be inspected if len(self.config.mcpServers) == 1: self.transport = next(iter(self.config.mcpServers.values())).to_transport() self._transports.append(self.transport) @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: # Single server - delegate directly to pre-created transport if len(self.config.mcpServers) == 1: async with self.transport.connect_session(**session_kwargs) as session: yield session return # Multiple servers - create composite with mounted proxies, connecting # each ProxyClient so its underlying transport session stays alive for # the duration of this context (fixes session persistence for # streamable-http backends — see #2790). timeout = session_kwargs.get("read_timeout_seconds") composite = FastMCP[Any](name="MCPRouter") async with contextlib.AsyncExitStack() as stack: # Close any previous transports from prior connections to avoid leaking for t in self._transports: await t.close() self._transports = [] for name, server_config in self.config.mcpServers.items(): try: transport, _client, proxy = await self._create_proxy( name, server_config, timeout, stack ) except Exception: # Broad catch is intentional: failure modes # are diverse (OSError, TimeoutError, RuntimeError, etc.) # and the whole point is to skip any server that can't connect. logger.warning( "Failed to connect to MCP server %r, skipping", name, exc_info=True, ) continue self._transports.append(transport) composite.mount(proxy, namespace=name if self.name_as_prefix else None) if not self._transports: raise ConnectionError("All MCP servers failed to connect") async with FastMCPTransport(mcp=composite).connect_session( **session_kwargs ) as session: yield session async def _create_proxy( self, name: str, config: MCPServerTypes, timeout: datetime.timedelta | None, stack: contextlib.AsyncExitStack, ) -> tuple[ClientTransport, Any, FastMCP[Any]]: """Create underlying transport, proxy client, and proxy server for a single backend. The ProxyClient is connected via the AsyncExitStack *before* being passed to create_proxy so the factory sees it as connected and reuses the same session for all tool calls (instead of creating fresh copies). Returns a tuple of (transport, proxy_client, proxy_server). """ # Import here to avoid circular dependency from fastmcp.server.providers.proxy import StatefulProxyClient tool_transforms = None include_tags = None exclude_tags = None # Handle transforming servers - call base class to_transport() for underlying transport if isinstance(config, TransformingStdioMCPServer): transport = StdioMCPServer.to_transport(config) tool_transforms = config.tools include_tags = config.include_tags exclude_tags = config.exclude_tags elif isinstance(config, TransformingRemoteMCPServer): transport = RemoteMCPServer.to_transport(config) tool_transforms = config.tools include_tags = config.include_tags exclude_tags = config.exclude_tags else: transport = config.to_transport() client = StatefulProxyClient(transport=transport, timeout=timeout) # Connect the client *before* create_proxy so _create_client_factory # detects it as connected and reuses it for all tool calls, preserving # the session ID across requests. StatefulProxyClient is used instead # of ProxyClient because its context-restoring handler wrappers prevent # stale ContextVars in the reused session's receive loop. # # StatefulProxyClient.__aexit__ is a no-op (by design, for the # new_stateful() use case), so we cannot rely on enter_async_context # alone to clean up. Instead we connect manually and push an # explicit force-disconnect callback so the subprocess is terminated # when the AsyncExitStack unwinds. await client.__aenter__() # Callbacks run LIFO: transport.close() must run *after* # client._disconnect so push it first. stack.push_async_callback(transport.close) stack.push_async_callback(client._disconnect, force=True) # Create proxy without include_tags/exclude_tags - we'll add them after tool transforms proxy = create_proxy( client, name=f"Proxy-{name}", ) # Add tool transforms FIRST - they may add/modify tags if tool_transforms: from fastmcp.server.transforms import ToolTransform proxy.add_transform(ToolTransform(tool_transforms)) # Then add enabled filters - they filter based on tags if include_tags: proxy.enable(tags=set(include_tags), only=True) if exclude_tags: proxy.disable(tags=set(exclude_tags)) return transport, client, proxy async def close(self): for transport in self._transports: await transport.close() def __repr__(self) -> str: return f"" ================================================ FILE: src/fastmcp/client/transports/http.py ================================================ """Streamable HTTP transport for FastMCP Client.""" from __future__ import annotations import contextlib import datetime import ssl from collections.abc import AsyncIterator, Callable from typing import Any, Literal, cast import httpx from mcp import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client from pydantic import AnyUrl from typing_extensions import Unpack import fastmcp from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.auth.oauth import OAuth from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.server.dependencies import get_http_headers from fastmcp.utilities.timeout import normalize_timeout_to_timedelta class StreamableHttpTransport(ClientTransport): """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" def __init__( self, url: str | AnyUrl, headers: dict[str, str] | None = None, auth: httpx.Auth | Literal["oauth"] | str | None = None, sse_read_timeout: datetime.timedelta | float | int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, verify: ssl.SSLContext | bool | str | None = None, ): """Initialize a Streamable HTTP transport. Args: url: The MCP server endpoint URL. headers: Optional headers to include in requests. auth: Authentication method - httpx.Auth, "oauth" for OAuth flow, or a bearer token string. sse_read_timeout: Deprecated. Use read_timeout_seconds in session_kwargs. httpx_client_factory: Optional factory for creating httpx.AsyncClient. If provided, must accept keyword arguments: headers, auth, follow_redirects, and optionally timeout. Using **kwargs is recommended to ensure forward compatibility. verify: SSL certificate verification. Accepts False to disable verification, a path to a CA bundle, or an ssl.SSLContext for full control. None (default) uses httpx defaults (verification enabled). Ignored when httpx_client_factory is provided. """ if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("http"): raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") # Don't modify the URL path - respect the exact URL provided by the user # Some servers are strict about trailing slashes (e.g., PayPal MCP) self.url: str = url self.headers = headers or {} self.httpx_client_factory = httpx_client_factory self.verify: ssl.SSLContext | bool | str | None = verify if httpx_client_factory is not None and verify is not None: import warnings warnings.warn( "Both 'httpx_client_factory' and 'verify' were provided. " "The 'verify' parameter will be ignored because " "'httpx_client_factory' takes precedence. Configure SSL " "verification directly in your httpx_client_factory instead.", UserWarning, stacklevel=2, ) self._set_auth(auth) if sse_read_timeout is not None: if fastmcp.settings.deprecation_warnings: import warnings warnings.warn( "The `sse_read_timeout` parameter is deprecated and no longer used. " "The new streamable_http_client API does not support this parameter. " "Use `read_timeout_seconds` in session_kwargs or configure timeout on " "the httpx client via `httpx_client_factory` instead.", DeprecationWarning, stacklevel=2, ) self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) self._get_session_id_cb: Callable[[], str | None] | None = None def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): resolved: httpx.Auth | None if auth == "oauth": resolved = OAuth( self.url, httpx_client_factory=self.httpx_client_factory or self._make_verify_factory(), ) elif isinstance(auth, OAuth): auth._bind(self.url) # Only inject the transport's factory into OAuth if OAuth still # has the bare default — preserve any factory the caller attached if auth.httpx_client_factory is httpx.AsyncClient: factory = self.httpx_client_factory or self._make_verify_factory() if factory is not None: auth.httpx_client_factory = factory resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) else: resolved = auth self.auth: httpx.Auth | None = resolved def _make_verify_factory(self) -> McpHttpClientFactory | None: if self.verify is None: return None verify = self.verify def factory( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None, ) -> httpx.AsyncClient: if timeout is None: timeout = httpx.Timeout(30.0, read=300.0) kwargs: dict[str, Any] = { "follow_redirects": True, "timeout": timeout, "verify": verify, } if headers is not None: kwargs["headers"] = headers if auth is not None: kwargs["auth"] = auth return httpx.AsyncClient(**kwargs) return cast(McpHttpClientFactory, factory) @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: # Load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. headers = get_http_headers(include={"authorization"}) | self.headers # Configure timeout if provided, preserving MCP's 30s connect default timeout: httpx.Timeout | None = None if session_kwargs.get("read_timeout_seconds") is not None: read_timeout_seconds = cast( datetime.timedelta, session_kwargs.get("read_timeout_seconds") ) timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds()) # Create httpx client from factory or use default with MCP-appropriate # timeouts. Note: create_mcp_http_client enables follow_redirects, but # httpx automatically strips Authorization headers on cross-origin # redirects to prevent credential leakage. verify_factory = self._make_verify_factory() if self.httpx_client_factory is not None: http_client = self.httpx_client_factory( headers=headers, auth=self.auth, follow_redirects=True, # type: ignore[call-arg] **({"timeout": timeout} if timeout else {}), ) elif verify_factory is not None: http_client = verify_factory( headers=headers, timeout=timeout, auth=self.auth, ) else: http_client = create_mcp_http_client( headers=headers, timeout=timeout, auth=self.auth, ) # Ensure httpx client is closed after use async with ( http_client, streamable_http_client(self.url, http_client=http_client) as transport, ): read_stream, write_stream, get_session_id = transport self._get_session_id_cb = get_session_id async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: yield session def get_session_id(self) -> str | None: if self._get_session_id_cb: try: return self._get_session_id_cb() except Exception: return None return None async def close(self): # Reset the session id callback self._get_session_id_cb = None def __repr__(self) -> str: return f"" ================================================ FILE: src/fastmcp/client/transports/inference.py ================================================ from pathlib import Path from typing import TYPE_CHECKING, Any, cast, overload from mcp.server.fastmcp import FastMCP as FastMCP1Server from pydantic import AnyUrl from fastmcp.client.transports.base import ClientTransport, ClientTransportT from fastmcp.client.transports.config import MCPConfigTransport from fastmcp.client.transports.http import StreamableHttpTransport from fastmcp.client.transports.memory import FastMCPTransport from fastmcp.client.transports.sse import SSETransport from fastmcp.client.transports.stdio import NodeStdioTransport, PythonStdioTransport from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: pass logger = get_logger(__name__) @overload def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... @overload def infer_transport(transport: FastMCP) -> FastMCPTransport: ... @overload def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ... @overload def infer_transport(transport: MCPConfig) -> MCPConfigTransport: ... @overload def infer_transport(transport: dict[str, Any]) -> MCPConfigTransport: ... @overload def infer_transport( transport: AnyUrl, ) -> SSETransport | StreamableHttpTransport: ... @overload def infer_transport( transport: str, ) -> ( PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport ): ... @overload def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTransport: ... def infer_transport( transport: ClientTransport | FastMCP | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str, ) -> ClientTransport: """ Infer the appropriate transport type from the given transport argument. This function attempts to infer the correct transport type from the provided argument, handling various input types and converting them to the appropriate ClientTransport subclass. The function supports these input types: - ClientTransport: Used directly without modification - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. For MCPConfig with multiple servers, a composite client is created where each server is mounted with its name as prefix. This allows accessing tools and resources from multiple servers through a single unified client interface, using naming patterns like `servername_toolname` for tools and `protocol://servername/path` for resources. If the MCPConfig contains only one server, a direct connection is established without prefixing. Examples: ```python # Connect to a local Python script transport = infer_transport("my_script.py") # Connect to a remote server via HTTP transport = infer_transport("http://example.com/mcp") # Connect to multiple servers using MCPConfig config = { "mcpServers": { "weather": {"url": "http://weather.example.com/mcp"}, "calendar": {"url": "http://calendar.example.com/mcp"} } } transport = infer_transport(config) ``` """ # the transport is already a ClientTransport if isinstance(transport, ClientTransport): return transport # the transport is a FastMCP server (2.x or 1.0) elif isinstance(transport, FastMCP | FastMCP1Server): 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=cast(Path, transport)) elif str(transport).endswith(".js"): 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( cast(AnyUrl | str, transport) ) if inferred_transport_type == "sse": inferred_transport = SSETransport(url=cast(AnyUrl | str, transport)) else: 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=cast(dict | MCPConfig, transport) ) # the transport is an unknown type else: raise ValueError(f"Could not infer a valid transport from: {transport}") logger.debug(f"Inferred transport: {inferred_transport}") return inferred_transport ================================================ FILE: src/fastmcp/client/transports/memory.py ================================================ import contextlib from collections.abc import AsyncIterator import anyio from mcp import ClientSession from mcp.server.fastmcp import FastMCP as FastMCP1Server from mcp.shared.memory import create_client_server_memory_streams from typing_extensions import Unpack from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.server.server import FastMCP class FastMCPTransport(ClientTransport): """In-memory transport for FastMCP servers. This transport connects directly to a FastMCP server instance in the same Python process. It works with both FastMCP 2.x servers and FastMCP 1.0 servers from the low-level MCP SDK. This is particularly useful for unit tests or scenarios where client and server run in the same runtime. """ def __init__(self, mcp: FastMCP | FastMCP1Server, raise_exceptions: bool = False): """Initialize a FastMCPTransport from a FastMCP server instance.""" # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a # ``_mcp_server`` attribute pointing to the underlying MCP server # implementation, so we can treat them identically. self.server = mcp self.raise_exceptions = raise_exceptions @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: async with create_client_server_memory_streams() as ( client_streams, server_streams, ): client_read, client_write = client_streams server_read, server_write = server_streams # Capture exceptions to re-raise after task group cleanup. # anyio task groups can suppress exceptions when cancel_scope.cancel() # is called during cleanup, so we capture and re-raise manually. exception_to_raise: BaseException | None = None # IMPORTANT: The lifespan MUST be the outer context and the task # group MUST be the inner context. This ensures the task group # (containing the server's run() and all its pub/sub subscriptions) # is cancelled and fully drained BEFORE the lifespan tears down # the Docket Worker and closes Redis connections. Reversing this # order (e.g. via `async with (tg, lifespan):`) causes the Worker # shutdown to hang for 5 seconds per test because fakeredis # blocking operations hold references that prevent clean # cancellation. async with _enter_server_lifespan(server=self.server): # noqa: SIM117 async with anyio.create_task_group() as tg: tg.start_soon( lambda: self.server._mcp_server.run( server_read, server_write, self.server._mcp_server.create_initialization_options(), raise_exceptions=self.raise_exceptions, ) ) try: async with ClientSession( read_stream=client_read, write_stream=client_write, **session_kwargs, ) as client_session: yield client_session except BaseException as e: exception_to_raise = e finally: tg.cancel_scope.cancel() # Re-raise after task group has exited cleanly if exception_to_raise is not None: raise exception_to_raise def __repr__(self) -> str: return f"" @contextlib.asynccontextmanager async def _enter_server_lifespan( server: FastMCP | FastMCP1Server, ) -> AsyncIterator[None]: """Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers.""" if isinstance(server, FastMCP): async with server._lifespan_manager(): yield else: yield ================================================ FILE: src/fastmcp/client/transports/sse.py ================================================ """Server-Sent Events (SSE) transport for FastMCP Client.""" from __future__ import annotations import contextlib import datetime import ssl from collections.abc import AsyncIterator from typing import Any, Literal, cast import httpx from mcp import ClientSession from mcp.client.sse import sse_client from mcp.shared._httpx_utils import McpHttpClientFactory from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.auth.oauth import OAuth from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.server.dependencies import get_http_headers from fastmcp.utilities.timeout import normalize_timeout_to_timedelta class SSETransport(ClientTransport): """Transport implementation that connects to an MCP server via Server-Sent Events.""" def __init__( self, url: str | AnyUrl, headers: dict[str, str] | None = None, auth: httpx.Auth | Literal["oauth"] | str | None = None, sse_read_timeout: datetime.timedelta | float | int | None = None, httpx_client_factory: McpHttpClientFactory | None = None, verify: ssl.SSLContext | bool | str | None = None, ): if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("http"): raise ValueError("Invalid HTTP/S URL provided for SSE.") # Don't modify the URL path - respect the exact URL provided by the user # Some servers are strict about trailing slashes (e.g., PayPal MCP) self.url: str = url self.headers = headers or {} self.httpx_client_factory = httpx_client_factory self.verify: ssl.SSLContext | bool | str | None = verify if httpx_client_factory is not None and verify is not None: import warnings warnings.warn( "Both 'httpx_client_factory' and 'verify' were provided. " "The 'verify' parameter will be ignored because " "'httpx_client_factory' takes precedence. Configure SSL " "verification directly in your httpx_client_factory instead.", UserWarning, stacklevel=2, ) self._set_auth(auth) self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): resolved: httpx.Auth | None if auth == "oauth": resolved = OAuth( self.url, httpx_client_factory=self.httpx_client_factory or self._make_verify_factory(), ) elif isinstance(auth, OAuth): auth._bind(self.url) # Only inject the transport's factory into OAuth if OAuth still # has the bare default — preserve any factory the caller attached if auth.httpx_client_factory is httpx.AsyncClient: factory = self.httpx_client_factory or self._make_verify_factory() if factory is not None: auth.httpx_client_factory = factory resolved = auth elif isinstance(auth, str): resolved = BearerAuth(auth) else: resolved = auth self.auth: httpx.Auth | None = resolved def _make_verify_factory(self) -> McpHttpClientFactory | None: if self.verify is None: return None verify = self.verify def factory( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, auth: httpx.Auth | None = None, ) -> httpx.AsyncClient: if timeout is None: timeout = httpx.Timeout(30.0, read=300.0) kwargs: dict[str, Any] = { "follow_redirects": True, "timeout": timeout, "verify": verify, } if headers is not None: kwargs["headers"] = headers if auth is not None: kwargs["auth"] = auth return httpx.AsyncClient(**kwargs) return cast(McpHttpClientFactory, factory) @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: client_kwargs: dict[str, Any] = {} # load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. client_kwargs["headers"] = ( get_http_headers(include={"authorization"}) | self.headers ) # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided if self.sse_read_timeout is not None: client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() if session_kwargs.get("read_timeout_seconds") is not None: read_timeout_seconds = cast( datetime.timedelta, session_kwargs.get("read_timeout_seconds") ) client_kwargs["timeout"] = read_timeout_seconds.total_seconds() if self.httpx_client_factory is not None: client_kwargs["httpx_client_factory"] = self.httpx_client_factory else: verify_factory = self._make_verify_factory() if verify_factory is not None: client_kwargs["httpx_client_factory"] = verify_factory async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport: read_stream, write_stream = transport async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: yield session def __repr__(self) -> str: return f"" ================================================ FILE: src/fastmcp/client/transports/stdio.py ================================================ import asyncio import contextlib import os import shutil import sys from collections.abc import AsyncIterator from pathlib import Path from typing import TextIO, cast import anyio from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from typing_extensions import Unpack from fastmcp.client.transports.base import ClientTransport, SessionKwargs from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment logger = get_logger(__name__) class StdioTransport(ClientTransport): """ Base transport for connecting to an MCP server via subprocess with stdio. This is a base class that can be subclassed for specific command-based transports like Python, Node, Uvx, etc. """ def __init__( self, command: str, args: list[str], env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, log_file: Path | TextIO | None = None, ): """ Initialize a Stdio transport. Args: command: The command to run (e.g., "python", "node", "uvx") args: The arguments to pass to the command env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess keep_alive: Whether to keep the subprocess alive between connections. Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. log_file: Optional path or file-like object where subprocess stderr will be written. Can be a Path or TextIO object. Defaults to sys.stderr if not provided. When a Path is provided, the file will be created if it doesn't exist, or appended to if it does. When set, server errors will be written to this file instead of appearing in the console. """ self.command = command self.args = args self.env = env self.cwd = cwd if keep_alive is None: keep_alive = True self.keep_alive = keep_alive self.log_file = log_file self._session: ClientSession | None = None self._connect_task: asyncio.Task | None = None self._ready_event = anyio.Event() self._stop_event = anyio.Event() @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: try: await self.connect(**session_kwargs) yield cast(ClientSession, self._session) finally: if not self.keep_alive: await self.disconnect() else: logger.debug("Stdio transport has keep_alive=True, not disconnecting") async def connect( self, **session_kwargs: Unpack[SessionKwargs] ) -> ClientSession | None: if self._connect_task is not None: return session_future: asyncio.Future[ClientSession] = asyncio.Future() # start the connection task self._connect_task = asyncio.create_task( _stdio_transport_connect_task( command=self.command, args=self.args, env=self.env, cwd=self.cwd, log_file=self.log_file, # TODO(ty): remove when ty supports Unpack[TypedDict] inference session_kwargs=session_kwargs, # type: ignore[arg-type] ready_event=self._ready_event, stop_event=self._stop_event, session_future=session_future, ) ) # wait for the client to be ready before returning await self._ready_event.wait() # Check if connect task completed with an exception (early failure) if self._connect_task.done(): exception = self._connect_task.exception() if exception is not None: raise exception self._session = await session_future return self._session async def disconnect(self): if self._connect_task is None: return # signal the connection task to stop self._stop_event.set() # wait for the connection task to finish cleanly await self._connect_task # reset variables and events for potential future reconnects self._connect_task = None self._stop_event = anyio.Event() self._ready_event = anyio.Event() async def close(self): await self.disconnect() def __del__(self): """Ensure that we send a disconnection signal to the transport task if we are being garbage collected.""" if not self._stop_event.is_set(): self._stop_event.set() def __repr__(self) -> str: return ( f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>" ) async def _stdio_transport_connect_task( command: str, args: list[str], env: dict[str, str] | None, cwd: str | None, log_file: Path | TextIO | None, session_kwargs: SessionKwargs, ready_event: anyio.Event, stop_event: anyio.Event, session_future: asyncio.Future[ClientSession], ): """A standalone connection task for a stdio transport. It is not a part of the StdioTransport class to ensure that the connection task does not hold a reference to the Transport object.""" try: async with contextlib.AsyncExitStack() as stack: try: server_params = StdioServerParameters( command=command, args=args, env=env, cwd=cwd, ) # Handle log_file: Path needs to be opened, TextIO used as-is if log_file is None: log_file_handle = sys.stderr elif isinstance(log_file, Path): log_file_handle = stack.enter_context(log_file.open("a")) else: # Must be TextIO - use it directly log_file_handle = log_file transport = await stack.enter_async_context( stdio_client(server_params, errlog=log_file_handle) ) read_stream, write_stream = transport session_future.set_result( await stack.enter_async_context( ClientSession(read_stream, write_stream, **session_kwargs) ) ) logger.debug("Stdio transport connected") ready_event.set() # Wait until disconnect is requested (stop_event is set) await stop_event.wait() finally: # Clean up client on exit logger.debug("Stdio transport disconnected") except Exception: # Ensure ready event is set even if connection fails ready_event.set() raise class PythonStdioTransport(StdioTransport): """Transport for running Python scripts.""" def __init__( self, script_path: str | Path, args: list[str] | None = None, env: dict[str, str] | None = None, cwd: str | None = None, python_cmd: str = sys.executable, keep_alive: bool | None = None, log_file: Path | TextIO | None = None, ): """ Initialize a Python transport. Args: script_path: Path to the Python script to run args: Additional arguments to pass to the script env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess python_cmd: Python command to use (default: "python") keep_alive: Whether to keep the subprocess alive between connections. Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. log_file: Optional path or file-like object where subprocess stderr will be written. Can be a Path or TextIO object. Defaults to sys.stderr if not provided. When a Path is provided, the file will be created if it doesn't exist, or appended to if it does. When set, server errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): raise FileNotFoundError(f"Script not found: {script_path}") if not str(script_path).endswith(".py"): raise ValueError(f"Not a Python script: {script_path}") full_args = [str(script_path)] if args: full_args.extend(args) super().__init__( command=python_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive, log_file=log_file, ) self.script_path = script_path class FastMCPStdioTransport(StdioTransport): """Transport for running FastMCP servers using the FastMCP CLI.""" def __init__( self, script_path: str | Path, args: list[str] | None = None, env: dict[str, str] | None = None, cwd: str | None = None, keep_alive: bool | None = None, log_file: Path | TextIO | None = None, ): script_path = Path(script_path).resolve() if not script_path.is_file(): raise FileNotFoundError(f"Script not found: {script_path}") if not str(script_path).endswith(".py"): raise ValueError(f"Not a Python script: {script_path}") super().__init__( command="fastmcp", args=["run", str(script_path)], env=env, cwd=cwd, keep_alive=keep_alive, log_file=log_file, ) self.script_path = script_path class NodeStdioTransport(StdioTransport): """Transport for running Node.js scripts.""" def __init__( self, script_path: str | Path, args: list[str] | None = None, env: dict[str, str] | None = None, cwd: str | None = None, node_cmd: str = "node", keep_alive: bool | None = None, log_file: Path | TextIO | None = None, ): """ Initialize a Node transport. Args: script_path: Path to the Node.js script to run args: Additional arguments to pass to the script env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess node_cmd: Node.js command to use (default: "node") keep_alive: Whether to keep the subprocess alive between connections. Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. log_file: Optional path or file-like object where subprocess stderr will be written. Can be a Path or TextIO object. Defaults to sys.stderr if not provided. When a Path is provided, the file will be created if it doesn't exist, or appended to if it does. When set, server errors will be written to this file instead of appearing in the console. """ script_path = Path(script_path).resolve() if not script_path.is_file(): raise FileNotFoundError(f"Script not found: {script_path}") if not str(script_path).endswith(".js"): raise ValueError(f"Not a JavaScript script: {script_path}") full_args = [str(script_path)] if args: full_args.extend(args) super().__init__( command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive, log_file=log_file, ) self.script_path = script_path class UvStdioTransport(StdioTransport): """Transport for running commands via the uv tool.""" def __init__( self, command: str, args: list[str] | None = None, module: bool = False, project_directory: Path | None = None, python_version: str | None = None, with_packages: list[str] | None = None, with_requirements: Path | None = None, env_vars: dict[str, str] | None = None, keep_alive: bool | None = None, ): # Basic validation if project_directory and not project_directory.exists(): raise NotADirectoryError( f"Project directory not found: {project_directory}" ) # Create Environment from provided parameters (internal use) env_config = UVEnvironment( python=python_version, dependencies=with_packages, requirements=with_requirements, project=project_directory, editable=None, # Not exposed in this transport ) # Build uv arguments using the config uv_args: list[str] = [] # Check if we need any environment setup if env_config._must_run_with_uv(): # Use the config to build args, but we need to handle the command differently # since transport has specific needs uv_args = ["run"] if python_version: uv_args.extend(["--python", python_version]) if project_directory: uv_args.extend(["--directory", str(project_directory)]) # Note: Don't add fastmcp as dependency here, transport is for general use for pkg in with_packages or []: uv_args.extend(["--with", pkg]) if with_requirements: uv_args.extend(["--with-requirements", str(with_requirements)]) else: # No environment setup needed uv_args = ["run"] if module: uv_args.append("--module") if not args: args = [] uv_args.extend([command, *args]) # Get environment with any additional variables env: dict[str, str] | None = None if env_vars or project_directory: env = os.environ.copy() if project_directory: env["UV_PROJECT_DIR"] = str(project_directory) if env_vars: env.update(env_vars) super().__init__( command="uv", args=uv_args, env=env, cwd=None, # Use --directory flag instead of cwd keep_alive=keep_alive, ) class UvxStdioTransport(StdioTransport): """Transport for running commands via the uvx tool.""" def __init__( self, tool_name: str, tool_args: list[str] | None = None, project_directory: str | None = None, python_version: str | None = None, with_packages: list[str] | None = None, from_package: str | None = None, env_vars: dict[str, str] | None = None, keep_alive: bool | None = None, ): """ Initialize a Uvx transport. Args: tool_name: Name of the tool to run via uvx tool_args: Arguments to pass to the tool project_directory: Project directory (for package resolution) python_version: Python version to use with_packages: Additional packages to include from_package: Package to install the tool from env_vars: Additional environment variables keep_alive: Whether to keep the subprocess alive between connections. Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. """ # Basic validation if project_directory and not Path(project_directory).exists(): raise NotADirectoryError( f"Project directory not found: {project_directory}" ) # Build uvx arguments uvx_args: list[str] = [] if python_version: uvx_args.extend(["--python", python_version]) if from_package: uvx_args.extend(["--from", from_package]) for pkg in with_packages or []: uvx_args.extend(["--with", pkg]) # Add the tool name and tool args uvx_args.append(tool_name) if tool_args: uvx_args.extend(tool_args) env: dict[str, str] | None = None if env_vars: env = os.environ.copy() env.update(env_vars) super().__init__( command="uvx", args=uvx_args, env=env, cwd=project_directory, keep_alive=keep_alive, ) self.tool_name: str = tool_name class NpxStdioTransport(StdioTransport): """Transport for running commands via the npx tool.""" def __init__( self, package: str, args: list[str] | None = None, project_directory: str | None = None, env_vars: dict[str, str] | None = None, use_package_lock: bool = True, keep_alive: bool | None = None, ): """ Initialize an Npx transport. Args: package: Name of the npm package to run args: Arguments to pass to the package command project_directory: Project directory with package.json env_vars: Additional environment variables use_package_lock: Whether to use package-lock.json (--prefer-offline) keep_alive: Whether to keep the subprocess alive between connections. Defaults to True. When True, the subprocess remains active after the connection context exits, allowing reuse in subsequent connections. """ # verify npx is installed if shutil.which("npx") is None: raise ValueError("Command 'npx' not found") # Basic validation if project_directory and not Path(project_directory).exists(): raise NotADirectoryError( f"Project directory not found: {project_directory}" ) # Build npx arguments npx_args = [] if use_package_lock: npx_args.append("--prefer-offline") # Add the package name and args npx_args.append(package) if args: npx_args.extend(args) # Get environment with any additional variables env = None if env_vars: env = os.environ.copy() env.update(env_vars) super().__init__( command="npx", args=npx_args, env=env, cwd=project_directory, keep_alive=keep_alive, ) self.package = package ================================================ FILE: src/fastmcp/contrib/README.md ================================================ # FastMCP Contrib Modules This directory holds community-contributed modules for FastMCP. These modules extend FastMCP's functionality but are not officially maintained by the core team. **Guarantees:** * Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. * Changes to the core FastMCP library might break modules in `contrib` without explicit warnings in the main changelog. Use these modules at your own discretion. Contributions are welcome, but please include tests and documentation. ## Usage To use a contrib module, import it from the `fastmcp.contrib` package. ```python from fastmcp.contrib import my_module ``` Note that the contrib modules may have different dependencies than the core library, which can be noted in their respective README's or even separate requirements / dependency files. ================================================ FILE: src/fastmcp/contrib/bulk_tool_caller/README.md ================================================ # Bulk Tool Caller This module provides the `BulkToolCaller` class, which extends the `MCPMixin` to offer tools for performing multiple tool calls in a single request to a FastMCP server. This can be useful for optimizing interactions with the server by reducing the overhead of individual tool calls. ## Usage To use the `BulkToolCaller`, see the example [example.py](./example.py) file. The `BulkToolCaller` can be instantiated and then registered with a FastMCP server URL. It provides methods to call multiple tools in bulk, either different tools or the same tool with different arguments. ## Provided Tools The `BulkToolCaller` provides the following tools: ### `call_tools_bulk` Calls multiple different tools registered on the MCP server in a single request. - **Arguments:** - `tool_calls` (list of `CallToolRequest`): A list of objects, where each object specifies the `tool` name and `arguments` for an individual tool call. - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. - **Returns:** A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. ### `call_tool_bulk` Calls a single tool registered on the MCP server multiple times with different arguments in a single request. - **Arguments:** - `tool` (str): The name of the tool to call. - `tool_arguments` (list of dict): A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. - **Returns:** A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. ================================================ FILE: src/fastmcp/contrib/bulk_tool_caller/__init__.py ================================================ from .bulk_tool_caller import BulkToolCaller __all__ = ["BulkToolCaller"] ================================================ FILE: src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py ================================================ from typing import Any from mcp.types import CallToolResult, TextContent from pydantic import BaseModel, Field from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.contrib.mcp_mixin.mcp_mixin import ( _DEFAULT_SEPARATOR_TOOL, MCPMixin, mcp_tool, ) class CallToolRequest(BaseModel): """A class to represent a request to call a tool with specific arguments.""" tool: str = Field(description="The name of the tool to call.") arguments: dict[str, Any] = Field( description="A dictionary containing the arguments for the tool call." ) class CallToolRequestResult(CallToolResult): """ A class to represent the result of a bulk tool call. It extends CallToolResult to include information about the requested tool call. """ tool: str = Field(description="The name of the tool that was called.") arguments: dict[str, Any] = Field( description="The arguments used for the tool call." ) @classmethod def from_call_tool_result( cls, result: CallToolResult, tool: str, arguments: dict[str, Any] ) -> "CallToolRequestResult": """ Create a CallToolRequestResult from a CallToolResult. """ return cls( tool=tool, arguments=arguments, isError=result.isError, content=result.content, ) class BulkToolCaller(MCPMixin): """ A class to provide a "bulk tool call" tool for a FastMCP server """ _BULK_TOOL_NAMES: frozenset[str] = frozenset({"call_tools_bulk", "call_tool_bulk"}) def register_tools( self, mcp_server: "FastMCP", prefix: str | None = None, separator: str = _DEFAULT_SEPARATOR_TOOL, ) -> None: """ Register the tools provided by this class with the given MCP server. """ self.connection = FastMCPTransport(mcp_server) super().register_tools(mcp_server=mcp_server) @mcp_tool() async def call_tools_bulk( self, tool_calls: list[CallToolRequest], continue_on_error: bool = True ) -> list[CallToolRequestResult]: """ Call multiple tools registered on this MCP server in a single request. Each call can be for a different tool and can include different arguments. Useful for speeding up what would otherwise take several individual tool calls. """ results = [] for tool_call in tool_calls: result = await self._call_tool(tool_call.tool, tool_call.arguments) results.append(result) if result.isError and not continue_on_error: return results return results @mcp_tool() async def call_tool_bulk( self, tool: str, tool_arguments: list[dict[str, str | int | float | bool | None]], continue_on_error: bool = True, ) -> list[CallToolRequestResult]: """ Call a single tool registered on this MCP server multiple times with a single request. Each call can include different arguments. Useful for speeding up what would otherwise take several individual tool calls. Args: tool: The name of the tool to call. tool_arguments: A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. """ results = [] for tool_call_arguments in tool_arguments: result = await self._call_tool(tool, tool_call_arguments) results.append(result) if result.isError and not continue_on_error: return results return results async def _call_tool( self, tool: str, arguments: dict[str, Any] ) -> CallToolRequestResult: """ Helper method to call a tool with the provided arguments. """ if tool in self._BULK_TOOL_NAMES: return CallToolRequestResult( tool=tool, arguments=arguments, isError=True, content=[ TextContent( type="text", text=( "BulkToolCaller cannot call itself. " "The tools 'call_tools_bulk' and 'call_tool_bulk' are disallowed." ), ) ], ) async with Client(self.connection) as client: result = await client.call_tool_mcp(name=tool, arguments=arguments) return CallToolRequestResult( tool=tool, arguments=arguments, isError=result.isError, content=result.content, ) ================================================ FILE: src/fastmcp/contrib/bulk_tool_caller/example.py ================================================ """Sample code for FastMCP using MCPMixin.""" from fastmcp import FastMCP from fastmcp.contrib.bulk_tool_caller import BulkToolCaller mcp = FastMCP() @mcp.tool def echo_tool(text: str) -> str: """Echo the input text""" return text bulk_tool_caller = BulkToolCaller() bulk_tool_caller.register_tools(mcp) ================================================ FILE: src/fastmcp/contrib/component_manager/README.md ================================================ # Component Manager – Contrib Module for FastMCP The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows. --- ## 🔧 Features - Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints. - Supports **local** and **mounted (server)** components. - Customizable **API root path**. - Optional **Auth scopes** for secured access. - Fully integrates with FastMCP with minimal configuration. --- ## 📦 Installation This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**. --- ## 🚀 Usage ### Basic Setup ```python from fastmcp import FastMCP from fastmcp.contrib.component_manager import set_up_component_manager mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.") set_up_component_manager(server=mcp) ``` --- ## 🔗 API Endpoints All endpoints are registered at `/` by default, or under the custom path if one is provided. ### Tools ```http POST /tools/{tool_name}/enable POST /tools/{tool_name}/disable ``` ### Resources ```http POST /resources/{uri:path}/enable POST /resources/{uri:path}/disable ``` * Supports template URIs as well ```http POST /resources/example://test/{id}/enable POST /resources/example://test/{id}/disable ``` ### Prompts ```http POST /prompts/{prompt_name}/enable POST /prompts/{prompt_name}/disable ``` --- #### 🧪 Example Response ```http HTTP/1.1 200 OK Content-Type: application/json { "message": "Disabled tool: example_tool" } ``` --- ## ⚙️ Configuration Options ### Custom Root Path To mount the API under a different path: ```python set_up_component_manager(server=mcp, path="/admin") ``` ### Securing Endpoints with Auth Scopes If your server uses authentication: ```python mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mcp, required_scopes=["write", "read"]) ``` --- ## 🧪 Example: Enabling a Tool with Curl ```bash curl -X POST \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ http://localhost:8001/tools/example_tool/enable ``` --- ## 🧱 Working with Mounted Servers You can also combine different configurations when working with mounted servers — for example, using different scopes: ```python mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) mcp.mount(server=mounted, namespace="mo") ``` This allows you to grant different levels of access: ```bash # Accessing the main server gives you control over both local and mounted components curl -X POST \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ http://localhost:8001/tools/mo_example_tool/enable # Accessing the mounted server gives you control only over its own components curl -X POST \ -H "Authorization: Bearer YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ http://localhost:8002/tools/example_tool/enable ``` --- ## ⚙️ How It Works - `set_up_component_manager()` registers HTTP routes for tools, resources, and prompts. - Each endpoint calls `server.enable()` or `server.disable()` with the component name. - Returns a success message in JSON. --- ## Maintenance Notice This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode). If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date. ## 📄 License This module follows the license of the main [FastMCP](https://github.com/PrefectHQ/fastmcp) project. ================================================ FILE: src/fastmcp/contrib/component_manager/__init__.py ================================================ from .component_manager import set_up_component_manager __all__ = ["set_up_component_manager"] ================================================ FILE: src/fastmcp/contrib/component_manager/component_manager.py ================================================ """ HTTP routes for enabling/disabling components in FastMCP. Provides REST endpoints for controlling component enabled state with optional authentication scopes. """ from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount, Route from fastmcp.server.server import FastMCP def set_up_component_manager( server: FastMCP, path: str = "/", required_scopes: list[str] | None = None ) -> None: """Set up HTTP routes for enabling/disabling tools, resources, and prompts. Args: server: The FastMCP server instance. path: Base path for component management routes. required_scopes: Optional list of scopes required for these routes. Applies only if authentication is enabled. Routes created: POST /tools/{name}/enable[?version=v1] POST /tools/{name}/disable[?version=v1] POST /resources/{uri}/enable[?version=v1] POST /resources/{uri}/disable[?version=v1] POST /prompts/{name}/enable[?version=v1] POST /prompts/{name}/disable[?version=v1] """ if required_scopes is None: # No auth - include path prefix in routes routes = _build_routes(server, path) server._additional_http_routes.extend(routes) else: # With auth - Mount handles path prefix, routes shouldn't have it routes = _build_routes(server, "/") mount = Mount( path if path != "/" else "", app=RequireAuthMiddleware(Starlette(routes=routes), required_scopes), ) server._additional_http_routes.append(mount) def _build_routes(server: FastMCP, base_path: str) -> list[Route]: """Build all component management routes.""" prefix = base_path.rstrip("/") if base_path != "/" else "" return [ # Tools Route( f"{prefix}/tools/{{name}}/enable", endpoint=_make_endpoint(server, "tool", "enable"), methods=["POST"], ), Route( f"{prefix}/tools/{{name}}/disable", endpoint=_make_endpoint(server, "tool", "disable"), methods=["POST"], ), # Resources Route( f"{prefix}/resources/{{uri:path}}/enable", endpoint=_make_endpoint(server, "resource", "enable"), methods=["POST"], ), Route( f"{prefix}/resources/{{uri:path}}/disable", endpoint=_make_endpoint(server, "resource", "disable"), methods=["POST"], ), # Prompts Route( f"{prefix}/prompts/{{name}}/enable", endpoint=_make_endpoint(server, "prompt", "enable"), methods=["POST"], ), Route( f"{prefix}/prompts/{{name}}/disable", endpoint=_make_endpoint(server, "prompt", "disable"), methods=["POST"], ), ] def _make_endpoint(server: FastMCP, component_type: str, action: str): """Create an endpoint function for enabling/disabling a component type.""" async def endpoint(request: Request) -> JSONResponse: # Get name from path params (tools/prompts use 'name', resources use 'uri') name = request.path_params.get("name") or request.path_params.get("uri") version = request.query_params.get("version") # Map component type to components list # Note: "resource" in the route can refer to either a resource or template # We need to check if it's a template (contains {}) and use "template" if so if component_type == "resource" and name is not None and "{" in name: components = ["template"] elif component_type == "resource": components = ["resource"] else: component_map = { "tool": ["tool"], "prompt": ["prompt"], } components = component_map[component_type] # Call server.enable() or server.disable() method = getattr(server, action) method(names={name} if name else None, version=version, components=components) return JSONResponse( {"message": f"{action.capitalize()}d {component_type}: {name}"} ) return endpoint ================================================ FILE: src/fastmcp/contrib/component_manager/example.py ================================================ from fastmcp import FastMCP from fastmcp.contrib.component_manager import set_up_component_manager from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair key_pair = RSAKeyPair.generate() auth = JWTVerifier( public_key=key_pair.public_key, issuer="https://dev.example.com", audience="my-dev-server", required_scopes=["mcp:read"], ) # Build main server mcp_token = key_pair.create_token( subject="dev-user", issuer="https://dev.example.com", audience="my-dev-server", scopes=["mcp:write", "mcp:read"], ) mcp = FastMCP( name="Component Manager", instructions="This is a test server with component manager.", auth=auth, ) # Set up main server component manager set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) # Build mounted server mounted_token = key_pair.create_token( subject="dev-user", issuer="https://dev.example.com", audience="my-dev-server", scopes=["mounted:write", "mcp:read"], ) mounted = FastMCP( name="Component Manager", instructions="This is a test server with component manager.", auth=auth, ) # Set up mounted server component manager set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) # Mount mcp.mount(server=mounted, namespace="mo") @mcp.resource("resource://greeting") def get_greeting() -> str: """Provides a simple greeting message.""" return "Hello from FastMCP Resources!" @mounted.tool("greeting") def get_info() -> str: """Provides a simple info.""" return "You are using component manager contrib module!" ================================================ FILE: src/fastmcp/contrib/mcp_mixin/README.md ================================================ from mcp.types import ToolAnnotations # MCP Mixin This module provides the `MCPMixin` base class and associated decorators (`@mcp_tool`, `@mcp_resource`, `@mcp_prompt`). It allows developers to easily define classes whose methods can be registered as tools, resources, or prompts with a `FastMCP` server instance using the `register_all()`, `register_tools()`, `register_resources()`, or `register_prompts()` methods provided by the mixin. Includes support for Tools: * [enable/disable](https://gofastmcp.com/servers/tools#disabling-tools) * [annotations](https://gofastmcp.com/servers/tools#annotations-2) * [excluded arguments](https://gofastmcp.com/servers/tools#excluding-arguments) * [meta](https://gofastmcp.com/servers/tools#param-meta) Prompts: * [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts) * [meta](https://gofastmcp.com/servers/prompts#param-meta) Resources: * [enable/disable](https://gofastmcp.com/servers/resources#disabling-resources) * [meta](https://gofastmcp.com/servers/resources#param-meta) ## Usage Inherit from `MCPMixin` and use the decorators on the methods you want to register. ```python from mcp.types import ToolAnnotations from fastmcp import FastMCP from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt class MyComponent(MCPMixin): @mcp_tool(name="my_tool", description="Does something cool.") def tool_method(self): return "Tool executed!" # example of disabled tool @mcp_tool(name="my_tool", description="Does something cool.", enabled=False) def disabled_tool_method(self): # This function can't be called by client because it's disabled return "You'll never get here!" # example of excluded parameter tool @mcp_tool( name="my_tool", description="Does something cool.", enabled=False, exclude_args=['delete_everything'], ) def excluded_param_tool_method(self, delete_everything=False): # MCP tool calls can't pass the "delete_everything" argument if delete_everything: return "Nothing to delete, I bet you're not a tool :)" return "You might be a tool if..." # example tool w/annotations @mcp_tool( name="my_tool", description="Does something cool.", annotations=ToolAnnotations( title="Attn LLM, use this tool first!", readOnlyHint=False, destructiveHint=False, idempotentHint=False, ) ) def tool_method(self): return "Tool executed!" # example tool w/everything @mcp_tool( name="my_tool", description="Does something cool.", enabled=True, exclude_args=['delete_all'], annotations=ToolAnnotations( title="Attn LLM, use this tool first!", readOnlyHint=False, destructiveHint=False, idempotentHint=False, ) ) def tool_method(self, delete_all=False): if delete_all: return "99 records deleted. I bet you're not a tool :)" return "Tool executed, but you might be a tool!" # example tool w/ meta @mcp_tool( name="data_tool", description="Fetches user data from database", meta={"version": "2.0", "category": "database", "author": "dev-team"} ) def data_tool_method(self, user_id: int): return f"Fetching data for user {user_id}" @mcp_resource(uri="component://data") def resource_method(self): return {"data": "some data"} # Disabled resource @mcp_resource(uri="component://data", enabled=False) def resource_method(self): return {"data": "some data"} # example resource w/meta and title @mcp_resource( uri="component://config", title="Data resource Title, meta={"internal": True, "cache_ttl": 3600, "priority": "high"} ) def config_resource_method(self): return {"config": "data"} # prompt @mcp_prompt(name="A prompt") def prompt_method(self, name): return f"What's up {name}?" # disabled prompt @mcp_prompt(name="A prompt", enabled=False) def prompt_method(self, name): return f"What's up {name}?" # example prompt w/title and meta @mcp_prompt( name="analysis_prompt", title="Data Analysis Prompt", description="Analyzes data patterns", meta={"complexity": "high", "domain": "analytics", "requires_context": True} ) def analysis_prompt_method(self, dataset: str): return f"Analyze the patterns in {dataset}" mcp_server = FastMCP() component = MyComponent() # Register all decorated methods with a prefix # Useful if you will have multiple instantiated objects of the same class # and want to avoid name collisions. component.register_all(mcp_server, prefix="my_comp") # Register without a prefix # component.register_all(mcp_server) # Now 'my_comp_my_tool' tool and 'my_comp+component://data' resource are registered (if prefix used) # Or 'my_tool' and 'component://data' are registered (if no prefix used) ``` The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types. ================================================ FILE: src/fastmcp/contrib/mcp_mixin/__init__.py ================================================ from .mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt __all__ = [ "MCPMixin", "mcp_prompt", "mcp_resource", "mcp_tool", ] ================================================ FILE: src/fastmcp/contrib/mcp_mixin/example.py ================================================ """Sample code for FastMCP using MCPMixin.""" import asyncio from fastmcp import FastMCP from fastmcp.contrib.mcp_mixin import ( MCPMixin, mcp_prompt, mcp_resource, mcp_tool, ) mcp = FastMCP() class Sample(MCPMixin): def __init__(self, name): self.name = name @mcp_tool() def first_tool(self): """First tool description.""" return f"Executed tool {self.name}." @mcp_resource(uri="test://test") def first_resource(self): """First resource description.""" return f"Executed resource {self.name}." @mcp_prompt() def first_prompt(self): """First prompt description.""" return f"here's a prompt! {self.name}." first_sample = Sample("First") second_sample = Sample("Second") first_sample.register_all(mcp_server=mcp, prefix="first") second_sample.register_all(mcp_server=mcp, prefix="second") async def list_components() -> None: print("MCP Server running with registered components...") print("Tools:", list(await mcp.list_tools())) print("Resources:", list(await mcp.list_resources())) print("Prompts:", list(await mcp.list_prompts())) if __name__ == "__main__": asyncio.run(list_components()) mcp.run() ================================================ FILE: src/fastmcp/contrib/mcp_mixin/mcp_mixin.py ================================================ """Provides a base mixin class and decorators for easy registration of class methods with FastMCP.""" import inspect import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Any import fastmcp from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.tools.base import Tool from fastmcp.utilities.types import get_fn_name if TYPE_CHECKING: from fastmcp.server import FastMCP _MCP_REGISTRATION_TOOL_ATTR = "_mcp_tool_registration" _MCP_REGISTRATION_RESOURCE_ATTR = "_mcp_resource_registration" _MCP_REGISTRATION_PROMPT_ATTR = "_mcp_prompt_registration" _DEFAULT_SEPARATOR_TOOL = "_" _DEFAULT_SEPARATOR_RESOURCE = "+" _DEFAULT_SEPARATOR_PROMPT = "_" # Sentinel key stored in registration dicts for the mixin-only `enabled` flag. # Prefixed with an underscore to avoid collisions with any from_function parameter. _MIXIN_ENABLED_KEY = "_mixin_enabled" # Valid keyword arguments for each from_function, derived once at import time # directly from the live signatures. They stay in sync automatically whenever # the underlying signatures gain or lose parameters — no manual updates needed. _TOOL_VALID_KWARGS: frozenset[str] = frozenset( p for p in inspect.signature(Tool.from_function).parameters if p != "fn" ) _RESOURCE_VALID_KWARGS: frozenset[str] = frozenset( p for p in inspect.signature(Resource.from_function).parameters if p not in ("fn", "uri") ) _PROMPT_VALID_KWARGS: frozenset[str] = frozenset( p for p in inspect.signature(Prompt.from_function).parameters if p != "fn" ) def mcp_tool( name: str | None = None, *, enabled: bool | None = None, **kwargs: Any, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP tool for later registration. Accepts all parameters supported by ``Tool.from_function``. Any new parameters added to ``Tool.from_function`` are automatically forwarded without requiring changes here. Args: name: Tool name. Defaults to the decorated method name. enabled: If ``False``, the tool is skipped during registration. **kwargs: Additional keyword arguments forwarded verbatim to ``Tool.from_function`` (e.g. ``description``, ``tags``, ``annotations``, ``auth``, ``timeout``, ``version``, …). Raises: TypeError: If an unrecognised keyword argument is supplied. The error is raised immediately at decoration time rather than later. """ unknown = set(kwargs) - _TOOL_VALID_KWARGS if unknown: raise TypeError( f"mcp_tool() got unexpected keyword argument(s): {sorted(unknown)!r}. " f"Valid keyword arguments are: {sorted(_TOOL_VALID_KWARGS)}" ) if "serializer" in kwargs and fastmcp.settings.deprecation_warnings: warnings.warn( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", DeprecationWarning, stacklevel=2, ) def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs} if enabled is not None: call_args[_MIXIN_ENABLED_KEY] = enabled setattr(func, _MCP_REGISTRATION_TOOL_ATTR, call_args) return func return decorator def mcp_resource( uri: str, *, name: str | None = None, enabled: bool | None = None, **kwargs: Any, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP resource for later registration. Accepts all parameters supported by ``Resource.from_function``. Any new parameters added to ``Resource.from_function`` are automatically forwarded without requiring changes here. Args: uri: Resource URI (required). name: Resource name. Defaults to the decorated method name. enabled: If ``False``, the resource is skipped during registration. **kwargs: Additional keyword arguments forwarded verbatim to ``Resource.from_function`` (e.g. ``description``, ``tags``, ``mime_type``, ``auth``, ``version``, …). Raises: TypeError: If an unrecognised keyword argument is supplied. The error is raised immediately at decoration time rather than later. """ unknown = set(kwargs) - _RESOURCE_VALID_KWARGS if unknown: raise TypeError( f"mcp_resource() got unexpected keyword argument(s): {sorted(unknown)!r}. " f"Valid keyword arguments are: {sorted(_RESOURCE_VALID_KWARGS)}" ) def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args: dict[str, Any] = { "uri": uri, "name": name or get_fn_name(func), **kwargs, } if enabled is not None: call_args[_MIXIN_ENABLED_KEY] = enabled setattr(func, _MCP_REGISTRATION_RESOURCE_ATTR, call_args) return func return decorator def mcp_prompt( name: str | None = None, *, enabled: bool | None = None, **kwargs: Any, ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """Decorator to mark a method as an MCP prompt for later registration. Accepts all parameters supported by ``Prompt.from_function``. Any new parameters added to ``Prompt.from_function`` are automatically forwarded without requiring changes here. Args: name: Prompt name. Defaults to the decorated method name. enabled: If ``False``, the prompt is skipped during registration. **kwargs: Additional keyword arguments forwarded verbatim to ``Prompt.from_function`` (e.g. ``description``, ``tags``, ``auth``, ``version``, …). Raises: TypeError: If an unrecognised keyword argument is supplied. The error is raised immediately at decoration time rather than later. """ unknown = set(kwargs) - _PROMPT_VALID_KWARGS if unknown: raise TypeError( f"mcp_prompt() got unexpected keyword argument(s): {sorted(unknown)!r}. " f"Valid keyword arguments are: {sorted(_PROMPT_VALID_KWARGS)}" ) def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs} if enabled is not None: call_args[_MIXIN_ENABLED_KEY] = enabled setattr(func, _MCP_REGISTRATION_PROMPT_ATTR, call_args) return func return decorator class MCPMixin: """Base mixin class for objects that can register tools, resources, and prompts with a FastMCP server instance using decorators. This mixin provides methods like ``register_all``, ``register_tools``, etc., which iterate over the methods of the inheriting class, find methods decorated with ``@mcp_tool``, ``@mcp_resource``, or ``@mcp_prompt``, and register them with the provided FastMCP server instance. """ def _get_methods_to_register(self, registration_type: str): """Retrieves all methods marked for a specific registration type.""" return [ ( getattr(self, method_name), getattr(getattr(self, method_name), registration_type).copy(), ) for method_name in dir(self) if callable(getattr(self, method_name)) and hasattr(getattr(self, method_name), registration_type) ] def register_tools( self, mcp_server: "FastMCP", prefix: str | None = None, separator: str = _DEFAULT_SEPARATOR_TOOL, ) -> None: """Registers all methods marked with @mcp_tool with the FastMCP server. Args: mcp_server: The FastMCP server instance to register tools with. prefix: Optional prefix to prepend to tool names. If provided, the final name will be ``f"{prefix}{separator}{original_name}"``. separator: The separator string used between prefix and original name. Defaults to ``'_'``. """ for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_TOOL_ATTR ): if prefix: registration_info["name"] = ( f"{prefix}{separator}{registration_info['name']}" ) enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) if enabled is False: continue tool = Tool.from_function(fn=method, **registration_info) mcp_server.add_tool(tool) def register_resources( self, mcp_server: "FastMCP", prefix: str | None = None, separator: str = _DEFAULT_SEPARATOR_RESOURCE, ) -> None: """Registers all methods marked with @mcp_resource with the FastMCP server. Args: mcp_server: The FastMCP server instance to register resources with. prefix: Optional prefix to prepend to resource names and URIs. If provided, the final name will be ``f"{prefix}{separator}{original_name}"`` and the final URI will be ``f"{prefix}{separator}{original_uri}"``. separator: The separator string used between prefix and original name/URI. Defaults to ``'+'``. """ for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_RESOURCE_ATTR ): if prefix: registration_info["name"] = ( f"{prefix}{separator}{registration_info['name']}" ) registration_info["uri"] = ( f"{prefix}{separator}{registration_info['uri']}" ) enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) if enabled is False: continue resource = Resource.from_function(fn=method, **registration_info) mcp_server.add_resource(resource) def register_prompts( self, mcp_server: "FastMCP", prefix: str | None = None, separator: str = _DEFAULT_SEPARATOR_PROMPT, ) -> None: """Registers all methods marked with @mcp_prompt with the FastMCP server. Args: mcp_server: The FastMCP server instance to register prompts with. prefix: Optional prefix to prepend to prompt names. If provided, the final name will be ``f"{prefix}{separator}{original_name}"``. separator: The separator string used between prefix and original name. Defaults to ``'_'``. """ for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_PROMPT_ATTR ): if prefix: registration_info["name"] = ( f"{prefix}{separator}{registration_info['name']}" ) enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) if enabled is False: continue prompt = Prompt.from_function(fn=method, **registration_info) mcp_server.add_prompt(prompt) def register_all( self, mcp_server: "FastMCP", prefix: str | None = None, tool_separator: str = _DEFAULT_SEPARATOR_TOOL, resource_separator: str = _DEFAULT_SEPARATOR_RESOURCE, prompt_separator: str = _DEFAULT_SEPARATOR_PROMPT, ) -> None: """Registers all marked tools, resources, and prompts with the server. This method calls ``register_tools``, ``register_resources``, and ``register_prompts`` internally, passing the provided prefix and separators. Args: mcp_server: The FastMCP server instance to register with. prefix: Optional prefix applied to all registered items. tool_separator: Separator for tool names (defaults to ``'_'``). resource_separator: Separator for resource names/URIs (defaults to ``'+'``). prompt_separator: Separator for prompt names (defaults to ``'_'``). """ self.register_tools(mcp_server, prefix=prefix, separator=tool_separator) self.register_resources(mcp_server, prefix=prefix, separator=resource_separator) self.register_prompts(mcp_server, prefix=prefix, separator=prompt_separator) ================================================ FILE: src/fastmcp/decorators.py ================================================ """Shared decorator utilities for FastMCP.""" from __future__ import annotations import inspect from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: from fastmcp.prompts.function_prompt import PromptMeta from fastmcp.resources.function_resource import ResourceMeta from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.function_tool import ToolMeta FastMCPMeta = ToolMeta | ResourceMeta | PromptMeta def resolve_task_config(task: bool | TaskConfig | None) -> bool | TaskConfig: """Resolve task config, defaulting None to False.""" return task if task is not None else False @runtime_checkable class HasFastMCPMeta(Protocol): """Protocol for callables decorated with FastMCP metadata.""" __fastmcp__: Any def get_fastmcp_meta(fn: Any) -> Any | None: """Extract FastMCP metadata from a function, handling bound methods and wrappers.""" if hasattr(fn, "__fastmcp__"): return fn.__fastmcp__ if hasattr(fn, "__func__") and hasattr(fn.__func__, "__fastmcp__"): return fn.__func__.__fastmcp__ try: unwrapped = inspect.unwrap(fn) if unwrapped is not fn and hasattr(unwrapped, "__fastmcp__"): return unwrapped.__fastmcp__ except ValueError: pass return None ================================================ FILE: src/fastmcp/dependencies.py ================================================ """Dependency injection exports for FastMCP. This module re-exports dependency injection symbols to provide a clean, centralized import location for all dependency-related functionality. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, CurrentWorker) and background task execution require fastmcp[tasks]. """ from uncalled_for import Dependency, Depends, Shared from fastmcp.server.dependencies import ( CurrentAccessToken, CurrentContext, CurrentDocket, CurrentFastMCP, CurrentHeaders, CurrentRequest, CurrentWorker, Progress, ProgressLike, TokenClaim, ) __all__ = [ "CurrentAccessToken", "CurrentContext", "CurrentDocket", "CurrentFastMCP", "CurrentHeaders", "CurrentRequest", "CurrentWorker", "Dependency", "Depends", "Progress", "ProgressLike", "Shared", "TokenClaim", ] ================================================ FILE: src/fastmcp/exceptions.py ================================================ """Custom exceptions for FastMCP.""" from mcp import McpError # noqa: F401 class FastMCPError(Exception): """Base error for FastMCP.""" class ValidationError(FastMCPError): """Error in validating parameters or return values.""" class ResourceError(FastMCPError): """Error in resource operations.""" class ToolError(FastMCPError): """Error in tool operations.""" class PromptError(FastMCPError): """Error in prompt operations.""" class InvalidSignature(Exception): """Invalid signature for use with FastMCP.""" class ClientError(Exception): """Error in client operations.""" class NotFoundError(Exception): """Object not found.""" class DisabledError(Exception): """Object is disabled.""" class AuthorizationError(FastMCPError): """Error when authorization check fails.""" ================================================ FILE: src/fastmcp/experimental/__init__.py ================================================ ================================================ FILE: src/fastmcp/experimental/sampling/__init__.py ================================================ ================================================ FILE: src/fastmcp/experimental/sampling/handlers/__init__.py ================================================ # Re-export for backwards compatibility # The canonical location is now fastmcp.client.sampling.handlers from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler __all__ = ["OpenAISamplingHandler"] ================================================ FILE: src/fastmcp/experimental/sampling/handlers/openai.py ================================================ # Re-export for backwards compatibility # The canonical location is now fastmcp.client.sampling.handlers.openai from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler __all__ = ["OpenAISamplingHandler"] ================================================ FILE: src/fastmcp/experimental/server/openapi/__init__.py ================================================ """Deprecated: Import from fastmcp.server.providers.openapi instead.""" import warnings # Deprecated in 2.14 when OpenAPI support was promoted out of experimental warnings.warn( "Importing from fastmcp.experimental.server.openapi is deprecated. " "Import from fastmcp.server.providers.openapi instead.", DeprecationWarning, stacklevel=2, ) # Import from canonical location from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402 from fastmcp.server.providers.openapi import ( # noqa: E402 ComponentFn as ComponentFn, MCPType as MCPType, OpenAPIResource as OpenAPIResource, OpenAPIResourceTemplate as OpenAPIResourceTemplate, OpenAPITool as OpenAPITool, RouteMap as RouteMap, RouteMapFn as RouteMapFn, ) from fastmcp.server.providers.openapi.routing import ( # noqa: E402 DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, _determine_route_type as _determine_route_type, ) __all__ = [ "DEFAULT_ROUTE_MAPPINGS", "ComponentFn", "FastMCPOpenAPI", "MCPType", "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", "RouteMap", "RouteMapFn", "_determine_route_type", ] ================================================ FILE: src/fastmcp/experimental/transforms/__init__.py ================================================ ================================================ FILE: src/fastmcp/experimental/transforms/code_mode.py ================================================ import importlib import json from collections.abc import Awaitable, Callable, Sequence from typing import Annotated, Any, Literal, Protocol from mcp.types import TextContent from pydantic import Field from fastmcp.exceptions import NotFoundError from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform from fastmcp.server.transforms.search.base import ( serialize_tools_for_output_json, serialize_tools_for_output_markdown, ) from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.async_utils import is_coroutine_function from fastmcp.utilities.versions import VersionSpec # --------------------------------------------------------------------------- # Type aliases # --------------------------------------------------------------------------- GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]] """Async callable that returns the auth-filtered tool catalog.""" SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]] """Async callable that searches a tool sequence by query string.""" DiscoveryToolFactory = Callable[[GetToolCatalog], Tool] """Factory that receives catalog access and returns a synthetic Tool.""" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: if is_coroutine_function(fn): return fn async def wrapper(*args: Any, **kwargs: Any) -> Any: return fn(*args, **kwargs) return wrapper def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str: """Convert a ToolResult for use in the sandbox. - Output schema present → structured_content dict (matches the schema) - Otherwise → concatenated text content as a string """ if result.structured_content is not None: return result.structured_content parts: list[str] = [] for content in result.content: if isinstance(content, TextContent): parts.append(content.text) else: parts.append(str(content)) return "\n".join(parts) # --------------------------------------------------------------------------- # Sandbox providers # --------------------------------------------------------------------------- class SandboxProvider(Protocol): """Interface for executing LLM-generated Python code in a sandbox. WARNING: The ``code`` parameter passed to ``run`` contains untrusted, LLM-generated Python. Implementations MUST execute it in an isolated sandbox — never with plain ``exec()``. Use ``MontySandboxProvider`` (backed by ``pydantic-monty``) for production workloads. """ async def run( self, code: str, *, inputs: dict[str, Any] | None = None, external_functions: dict[str, Callable[..., Any]] | None = None, ) -> Any: ... class MontySandboxProvider: """Sandbox provider backed by `pydantic-monty`. Args: limits: Resource limits for sandbox execution. Supported keys: ``max_duration_secs`` (float), ``max_allocations`` (int), ``max_memory`` (int), ``max_recursion_depth`` (int), ``gc_interval`` (int). All are optional; omit a key to leave that limit uncapped. """ def __init__( self, *, limits: dict[str, Any] | None = None, ) -> None: self.limits = limits async def run( self, code: str, *, inputs: dict[str, Any] | None = None, external_functions: dict[str, Callable[..., Any]] | None = None, ) -> Any: try: pydantic_monty = importlib.import_module("pydantic_monty") except ModuleNotFoundError as exc: raise ImportError( "CodeMode requires pydantic-monty for the Monty sandbox provider. " "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider." ) from exc inputs = inputs or {} async_functions = { key: _ensure_async(value) for key, value in (external_functions or {}).items() } monty = pydantic_monty.Monty( code, inputs=list(inputs.keys()), ) run_kwargs: dict[str, Any] = {"external_functions": async_functions} if inputs: run_kwargs["inputs"] = inputs if self.limits is not None: run_kwargs["limits"] = self.limits return await pydantic_monty.run_monty_async(monty, **run_kwargs) # --------------------------------------------------------------------------- # Built-in discovery tools # --------------------------------------------------------------------------- ToolDetailLevel = Literal["brief", "detailed", "full"] """Detail level for discovery tool output. - ``"brief"``: tool names and one-line descriptions - ``"detailed"``: compact markdown with parameter names, types, and required markers - ``"full"``: complete JSON schema """ def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str: """Render tools at the requested detail level. The same detail value produces the same output format regardless of which discovery tool calls this, so ``detail="detailed"`` on Search gives identical formatting to ``detail="detailed"`` on GetSchemas. """ if not tools: if detail == "full": return json.dumps([], indent=2) return "No tools matched the query." if detail == "full": return json.dumps(serialize_tools_for_output_json(tools), indent=2) if detail == "detailed": return serialize_tools_for_output_markdown(tools) # brief lines: list[str] = [] for tool in tools: desc = f": {tool.description}" if tool.description else "" lines.append(f"- {tool.name}{desc}") return "\n".join(lines) class Search: """Discovery tool factory that searches the catalog by query. Args: search_fn: Async callable ``(tools, query) -> matching_tools``. Defaults to BM25 ranking. name: Name of the synthetic tool exposed to the LLM. default_detail: Default detail level for search results. ``"brief"`` returns tool names and descriptions only. ``"detailed"`` returns compact markdown with parameter schemas. ``"full"`` returns complete JSON tool definitions. default_limit: Maximum number of results to return. The LLM can override this per call. ``None`` means no limit. """ def __init__( self, *, search_fn: SearchFn | None = None, name: str = "search", default_detail: ToolDetailLevel | None = None, default_limit: int | None = None, ) -> None: if search_fn is None: from fastmcp.server.transforms.search.bm25 import BM25SearchTransform _bm25 = BM25SearchTransform(max_results=default_limit or 50) search_fn = _bm25._search self._search_fn = search_fn self._name = name self._default_detail: ToolDetailLevel = default_detail or "brief" self._default_limit = default_limit def __call__(self, get_catalog: GetToolCatalog) -> Tool: search_fn = self._search_fn default_detail = self._default_detail default_limit = self._default_limit async def search( query: Annotated[str, "Search query to find available tools"], tags: Annotated[ list[str] | None, "Filter to tools with any of these tags before searching", ] = None, detail: Annotated[ ToolDetailLevel, "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", ] = default_detail, limit: Annotated[ int | None, "Maximum number of results to return", ] = default_limit, ctx: Context = None, # type: ignore[assignment] ) -> str: """Search for available tools by query. Returns matching tools ranked by relevance. """ catalog = await get_catalog(ctx) catalog_size = len(catalog) tools: Sequence[Tool] = catalog if tags: tag_set = set(tags) has_untagged = "untagged" in tag_set real_tags = tag_set - {"untagged"} tools = [ t for t in tools if (t.tags & real_tags) or (has_untagged and not t.tags) ] results = await search_fn(tools, query) if limit is not None: results = results[:limit] rendered = _render_tools(results, detail) if len(results) < catalog_size and detail != "full": n = len(results) rendered = f"{n} of {catalog_size} tools:\n\n{rendered}" return rendered return Tool.from_function(fn=search, name=self._name) class GetSchemas: """Discovery tool factory that returns schemas for tools by name. Args: name: Name of the synthetic tool exposed to the LLM. default_detail: Default detail level for schema results. ``"brief"`` returns tool names and descriptions only. ``"detailed"`` renders compact markdown with parameter names, types, and required markers. ``"full"`` returns the complete JSON schema. """ def __init__( self, *, name: str = "get_schema", default_detail: ToolDetailLevel | None = None, ) -> None: self._name = name self._default_detail: ToolDetailLevel = default_detail or "detailed" def __call__(self, get_catalog: GetToolCatalog) -> Tool: default_detail = self._default_detail async def get_schema( tools: Annotated[ list[str], "List of tool names to get schemas for", ], detail: Annotated[ ToolDetailLevel, "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", ] = default_detail, ctx: Context = None, # type: ignore[assignment] ) -> str: """Get parameter schemas for specific tools. Use after searching to get the detail needed to call a tool. """ catalog = await get_catalog(ctx) catalog_by_name = {t.name: t for t in catalog} matched = [catalog_by_name[n] for n in tools if n in catalog_by_name] not_found = [n for n in tools if n not in catalog_by_name] if not matched and not_found: return f"Tools not found: {', '.join(not_found)}" if detail == "full": data = serialize_tools_for_output_json(matched) if not_found: data.append({"not_found": not_found}) return json.dumps(data, indent=2) result = _render_tools(matched, detail) if not_found: result += f"\n\nTools not found: {', '.join(not_found)}" return result return Tool.from_function(fn=get_schema, name=self._name) class GetTags: """Discovery tool factory that lists tool tags from the catalog. Reads ``tool.tags`` from the catalog and groups tools by tag. Tools without tags appear under ``"untagged"``. Args: name: Name of the synthetic tool exposed to the LLM. default_detail: Default detail level. ``"brief"`` returns tag names with tool counts. ``"full"`` lists all tools under each tag. """ def __init__( self, *, name: str = "tags", default_detail: Literal["brief", "full"] | None = None, ) -> None: self._name = name self._default_detail: Literal["brief", "full"] = default_detail or "brief" def __call__(self, get_catalog: GetToolCatalog) -> Tool: default_detail = self._default_detail async def tags( detail: Annotated[ Literal["brief", "full"], "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag", ] = default_detail, ctx: Context = None, # type: ignore[assignment] ) -> str: """List available tool tags. Use to browse available tools by tag before searching. """ catalog = await get_catalog(ctx) by_tag: dict[str, list[Tool]] = {} for tool in catalog: if tool.tags: for tag in tool.tags: by_tag.setdefault(tag, []).append(tool) else: by_tag.setdefault("untagged", []).append(tool) if not by_tag: return "No tools available." if detail == "brief": lines = [ f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})" for tag, tools in sorted(by_tag.items()) ] return "\n".join(lines) blocks: list[str] = [] for tag, tools in sorted(by_tag.items()): lines = [f"### {tag}"] for tool in tools: desc = f": {tool.description}" if tool.description else "" lines.append(f"- {tool.name}{desc}") blocks.append("\n".join(lines)) return "\n\n".join(blocks) return Tool.from_function(fn=tags, name=self._name) class ListTools: """Discovery tool factory that lists all tools in the catalog. Args: name: Name of the synthetic tool exposed to the LLM. default_detail: Default detail level. ``"brief"`` returns tool names and one-line descriptions. ``"detailed"`` returns compact markdown with parameter schemas. ``"full"`` returns the complete JSON schema. """ def __init__( self, *, name: str = "list_tools", default_detail: ToolDetailLevel | None = None, ) -> None: self._name = name self._default_detail: ToolDetailLevel = default_detail or "brief" def __call__(self, get_catalog: GetToolCatalog) -> Tool: default_detail = self._default_detail async def list_tools( detail: Annotated[ ToolDetailLevel, "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", ] = default_detail, ctx: Context = None, # type: ignore[assignment] ) -> str: """List all available tools. Use to see the full catalog before searching or calling tools. """ catalog = await get_catalog(ctx) return _render_tools(catalog, detail) return Tool.from_function(fn=list_tools, name=self._name) # --------------------------------------------------------------------------- # CodeMode # --------------------------------------------------------------------------- def _default_discovery_tools() -> list[DiscoveryToolFactory]: return [Search(), GetSchemas()] class CodeMode(CatalogTransform): """Transform that collapses all tools into discovery + execute meta-tools. Discovery tools are composable via the ``discovery_tools`` parameter. Each is a callable that receives catalog access and returns a ``Tool``. By default, ``Search`` and ``GetSchemas`` are included for progressive disclosure: search finds candidates, get_schema retrieves parameter details, and execute runs code. The ``execute`` tool is always present and provides a sandboxed Python environment with ``call_tool(name, params)`` in scope. """ def __init__( self, *, sandbox_provider: SandboxProvider | None = None, discovery_tools: list[DiscoveryToolFactory] | None = None, execute_tool_name: str = "execute", execute_description: str | None = None, ) -> None: super().__init__() self.execute_tool_name = execute_tool_name self.execute_description = execute_description self.sandbox_provider = sandbox_provider or MontySandboxProvider() self._discovery_factories = ( discovery_tools if discovery_tools is not None else _default_discovery_tools() ) self._built_discovery_tools: list[Tool] | None = None self._cached_execute_tool: Tool | None = None def _build_discovery_tools(self) -> list[Tool]: if self._built_discovery_tools is None: tools = [ factory(self.get_tool_catalog) for factory in self._discovery_factories ] names = {t.name for t in tools} if self.execute_tool_name in names: raise ValueError( f"Discovery tool name '{self.execute_tool_name}' " f"collides with execute_tool_name." ) if len(names) != len(tools): raise ValueError("Discovery tools must have unique names.") self._built_discovery_tools = tools return self._built_discovery_tools async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: return [*self._build_discovery_tools(), self._get_execute_tool()] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None, ) -> Tool | None: for tool in self._build_discovery_tools(): if tool.name == name: return tool if name == self.execute_tool_name: return self._get_execute_tool() return await call_next(name, version=version) def _build_execute_description(self) -> str: if self.execute_description is not None: return self.execute_description return ( "Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n" "Use `return` to produce output.\n" "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope." ) @staticmethod def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None: """Find a tool by name from a pre-fetched list.""" for tool in tools: if tool.name == name: return tool return None def _get_execute_tool(self) -> Tool: if self._cached_execute_tool is None: self._cached_execute_tool = self._make_execute_tool() return self._cached_execute_tool def _make_execute_tool(self) -> Tool: transform = self async def execute( code: Annotated[ str, Field( description=( "Python async code to execute tool calls via call_tool(name, arguments)" ) ), ], ctx: Context = None, # type: ignore[assignment] ) -> Any: """Execute tool calls using Python code.""" async def call_tool(tool_name: str, params: dict[str, Any]) -> Any: backend_tools = await transform.get_tool_catalog(ctx) tool = transform._find_tool(tool_name, backend_tools) if tool is None: raise NotFoundError(f"Unknown tool: {tool_name}") result = await ctx.fastmcp.call_tool(tool.name, params) return _unwrap_tool_result(result) return await transform.sandbox_provider.run( code, external_functions={"call_tool": call_tool}, ) return Tool.from_function( fn=execute, name=self.execute_tool_name, description=self._build_execute_description(), ) __all__ = [ "CodeMode", "GetSchemas", "GetTags", "GetToolCatalog", "ListTools", "MontySandboxProvider", "SandboxProvider", "Search", ] ================================================ FILE: src/fastmcp/experimental/utilities/openapi/__init__.py ================================================ """Deprecated: Import from fastmcp.utilities.openapi instead.""" import warnings from fastmcp.utilities.openapi import ( HTTPRoute, HttpMethod, ParameterInfo, ParameterLocation, RequestBodyInfo, ResponseInfo, extract_output_schema_from_responses, parse_openapi_to_http_routes, _combine_schemas, ) # Deprecated in 2.14 when OpenAPI support was promoted out of experimental warnings.warn( "Importing from fastmcp.experimental.utilities.openapi is deprecated. " "Import from fastmcp.utilities.openapi instead.", DeprecationWarning, stacklevel=2, ) __all__ = [ "HTTPRoute", "HttpMethod", "ParameterInfo", "ParameterLocation", "RequestBodyInfo", "ResponseInfo", "_combine_schemas", "extract_output_schema_from_responses", "parse_openapi_to_http_routes", ] ================================================ FILE: src/fastmcp/mcp_config.py ================================================ """Canonical MCP Configuration Format. This module defines the standard configuration format for Model Context Protocol (MCP) servers. It provides a client-agnostic, extensible format that can be used across all MCP implementations. The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive field definitions for server metadata, authentication, and execution parameters. Example configuration: ```json { "mcpServers": { "my-server": { "command": "npx", "args": ["-y", "@my/mcp-server"], "env": {"API_KEY": "secret"}, "timeout": 30000, "description": "My MCP server" } } } ``` """ from __future__ import annotations import datetime import re from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any, Literal, cast from urllib.parse import urlparse import httpx from pydantic import ( AnyUrl, BaseModel, ConfigDict, Field, model_validator, ) from typing_extensions import Self, override from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.types import FastMCPBaseModel if TYPE_CHECKING: from fastmcp.client.transports import ( ClientTransport, SSETransport, StdioTransport, StreamableHttpTransport, ) from fastmcp.server.server import FastMCP def infer_transport_type_from_url( url: str | AnyUrl, ) -> Literal["http", "sse"]: """ Infer the appropriate transport type from the given URL. """ url = str(url) if not url.startswith("http"): raise ValueError(f"Invalid URL: {url}") parsed_url = urlparse(url) path = parsed_url.path # Match /sse followed by /, ?, &, or end of string if re.search(r"/sse(/|\?|&|$)", path): return "sse" else: return "http" class _TransformingMCPServerMixin(FastMCPBaseModel): """A mixin that enables wrapping an MCP Server with tool transforms.""" tools: dict[str, ToolTransformConfig] = Field(default_factory=dict) """The multi-tool transform to apply to the tools.""" include_tags: set[str] | None = Field( default=None, description="The tags to include in the proxy.", ) exclude_tags: set[str] | None = Field( default=None, description="The tags to exclude in the proxy.", ) @model_validator(mode="before") @classmethod def _require_at_least_one_transform_field( cls, values: dict[str, Any] ) -> dict[str, Any]: """Reject if none of the transforming fields are set. This ensures that plain server configs (without tools, include_tags, or exclude_tags) fall through to the base server types during union validation, avoiding unnecessary proxy wrapping. """ if isinstance(values, dict): has_tools = bool(values.get("tools")) has_include = values.get("include_tags") is not None has_exclude = values.get("exclude_tags") is not None if not (has_tools or has_include or has_exclude): raise ValueError( "At least one of 'tools', 'include_tags', or 'exclude_tags' is required" ) return values 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.client import Client from fastmcp.client.transports import ( ClientTransport, # pyright: ignore[reportUnusedImport] ) from fastmcp.server import create_proxy transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] # ty: ignore[unresolved-attribute] transport = cast(ClientTransport, transport) client: Client[ClientTransport] = Client(transport=transport, name=client_name) wrapped_mcp_server = create_proxy( client, name=server_name, ) if self.include_tags is not None: wrapped_mcp_server.enable(tags=self.include_tags, only=True) if self.exclude_tags is not None: wrapped_mcp_server.disable(tags=self.exclude_tags) # Apply tool transforms if configured if self.tools: from fastmcp.server.transforms import ToolTransform wrapped_mcp_server.add_transform(ToolTransform(self.tools)) return wrapped_mcp_server, transport def to_transport(self) -> ClientTransport: """Get the transport for the transforming MCP server.""" from fastmcp.client.transports import FastMCPTransport return FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0]) class StdioMCPServer(BaseModel): """MCP server configuration for stdio transport. This is the canonical configuration format for MCP servers using stdio transport. """ # Required fields command: str # Common optional fields args: list[str] = Field(default_factory=list) env: dict[str, Any] = Field(default_factory=dict) # Transport specification transport: Literal["stdio"] = "stdio" type: Literal["stdio"] | None = None # Alternative transport field name # Execution context cwd: str | None = None # Working directory for command execution timeout: int | None = None # Maximum response time in milliseconds keep_alive: bool | None = ( None # Whether to keep the subprocess alive between connections ) # Metadata description: str | None = None # Human-readable server description icon: str | None = None # Icon path or URL for UI display # Authentication configuration authentication: dict[str, Any] | None = None # Auth configuration object model_config = ConfigDict(extra="allow") # Preserve unknown fields def to_transport(self) -> StdioTransport: from fastmcp.client.transports import StdioTransport return StdioTransport( command=self.command, args=self.args, env=self.env, cwd=self.cwd, keep_alive=self.keep_alive, ) class TransformingStdioMCPServer(_TransformingMCPServerMixin, StdioMCPServer): """A Stdio server with tool transforms.""" class RemoteMCPServer(BaseModel): """MCP server configuration for HTTP/SSE transport. This is the canonical configuration format for MCP servers using remote transports. """ # Required fields url: str # Transport configuration transport: Literal["http", "streamable-http", "sse"] | None = None headers: dict[str, str] = Field(default_factory=dict) # Authentication auth: Annotated[ str | Literal["oauth"] | httpx.Auth | None, Field( description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.', ), ] = None # Timeout configuration sse_read_timeout: datetime.timedelta | int | float | None = None timeout: int | None = None # Maximum response time in milliseconds # Metadata description: str | None = None # Human-readable server description icon: str | None = None # Icon path or URL for UI display # Authentication configuration authentication: dict[str, Any] | None = None # Auth configuration object model_config = ConfigDict( extra="allow", arbitrary_types_allowed=True ) # Preserve unknown fields def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport if self.transport is None: transport = infer_transport_type_from_url(self.url) else: transport = self.transport if transport == "sse": return SSETransport( self.url, headers=self.headers, auth=self.auth, sse_read_timeout=self.sse_read_timeout, ) else: # Both "http" and "streamable-http" map to StreamableHttpTransport return StreamableHttpTransport( self.url, headers=self.headers, auth=self.auth, sse_read_timeout=self.sse_read_timeout, ) class TransformingRemoteMCPServer(_TransformingMCPServerMixin, RemoteMCPServer): """A Remote server with tool transforms.""" TransformingMCPServerTypes = TransformingStdioMCPServer | TransformingRemoteMCPServer CanonicalMCPServerTypes = StdioMCPServer | RemoteMCPServer MCPServerTypes = TransformingMCPServerTypes | CanonicalMCPServerTypes class MCPConfig(BaseModel): """A configuration object for MCP Servers that conforms to the canonical MCP configuration format while adding additional fields for enabling FastMCP-specific features like tool transformations and filtering by tags. For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. """ mcpServers: dict[str, MCPServerTypes] = Field(default_factory=dict) model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields @model_validator(mode="before") @classmethod def wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any]: """If there's no mcpServers key but there are server configs at root, wrap them.""" if "mcpServers" not in values: # Check if any values look like server configs has_servers = any( isinstance(v, dict) and ("command" in v or "url" in v) for v in values.values() ) if has_servers: # Move all server-like configs under mcpServers return {"mcpServers": values} return values def add_server(self, name: str, server: MCPServerTypes) -> None: """Add or update a server in the configuration.""" self.mcpServers[name] = server @classmethod def from_dict(cls, config: dict[str, Any]) -> Self: """Parse MCP configuration from dictionary format.""" return cls.model_validate(config) def to_dict(self) -> dict[str, Any]: """Convert MCPConfig to dictionary format, preserving all fields.""" return self.model_dump(exclude_none=True) def write_to_file(self, file_path: Path) -> None: """Write configuration to JSON file.""" file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(self.model_dump_json(indent=2)) @classmethod def from_file(cls, file_path: Path) -> Self: """Load configuration from JSON file.""" if file_path.exists() and (content := file_path.read_text().strip()): return cls.model_validate_json(content) raise ValueError(f"No MCP servers defined in the config: {file_path}") class CanonicalMCPConfig(MCPConfig): """Canonical MCP configuration format. This defines the standard configuration format for Model Context Protocol servers. The format is designed to be client-agnostic and extensible for future use cases. """ mcpServers: dict[str, CanonicalMCPServerTypes] = Field(default_factory=dict) @override def add_server(self, name: str, server: CanonicalMCPServerTypes) -> None: """Add or update a server in the configuration.""" self.mcpServers[name] = server def update_config_file( file_path: Path, server_name: str, server_config: CanonicalMCPServerTypes, ) -> None: """Update an MCP configuration file from a server object, preserving existing fields. This is used for updating the mcpServer configurations of third-party tools so we do not worry about transforming server objects here.""" config = MCPConfig.from_file(file_path) # If updating an existing server, merge with existing configuration # to preserve any unknown fields if existing_server := config.mcpServers.get(server_name): # Get the raw dict representation of both servers existing_dict = existing_server.model_dump() new_dict = server_config.model_dump(exclude_none=True) # Merge, with new values taking precedence merged_config = server_config.model_validate({**existing_dict, **new_dict}) config.add_server(server_name, merged_config) else: config.add_server(server_name, server_config) config.write_to_file(file_path) ================================================ FILE: src/fastmcp/prompts/__init__.py ================================================ import sys from .function_prompt import FunctionPrompt, prompt from .base import Message, Prompt, PromptArgument, PromptMessage, PromptResult # Backward compat: prompt.py was renamed to base.py to stop Pyright from resolving # `from fastmcp.prompts import prompt` as the submodule instead of the decorator function. # This shim keeps `from fastmcp.prompts.prompt import Prompt` working at runtime. # Safe to remove once we're confident no external code imports from the old path. sys.modules[f"{__name__}.prompt"] = sys.modules[f"{__name__}.base"] __all__ = [ "FunctionPrompt", "Message", "Prompt", "PromptArgument", "PromptMessage", "PromptResult", "prompt", ] ================================================ FILE: src/fastmcp/prompts/base.py ================================================ """Base classes for FastMCP prompts.""" from __future__ import annotations as _annotations import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload import pydantic import pydantic_core if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from fastmcp.prompts.function_prompt import FunctionPrompt import mcp.types from mcp import GetPromptResult from mcp.types import ( AudioContent, EmbeddedResource, Icon, ImageContent, PromptMessage, TextContent, ) from mcp.types import Prompt as SDKPrompt from mcp.types import PromptArgument as SDKPromptArgument from pydantic import Field from pydantic.json_schema import SkipJsonSchema from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( FastMCPBaseModel, ) logger = get_logger(__name__) class Message(pydantic.BaseModel): """Wrapper for prompt message with auto-serialization. Accepts any content - strings pass through, other types (dict, list, BaseModel) are JSON-serialized to text. Example: ```python from fastmcp.prompts import Message # String content (user role by default) Message("Hello, world!") # Explicit role Message("I can help with that.", role="assistant") # Auto-serialized to JSON Message({"key": "value"}) Message(["item1", "item2"]) ``` """ role: Literal["user", "assistant"] content: TextContent | ImageContent | AudioContent | EmbeddedResource def __init__( self, content: Any, role: Literal["user", "assistant"] = "user", ): """Create Message with automatic serialization. Args: content: The message content. str passes through directly. TextContent, ImageContent, AudioContent, and EmbeddedResource pass through. Other types (dict, list, BaseModel) are JSON-serialized. role: The message role, either "user" or "assistant". """ # Handle already-wrapped content types if isinstance( content, (TextContent, ImageContent, AudioContent, EmbeddedResource) ): normalized_content: ( TextContent | ImageContent | AudioContent | EmbeddedResource ) = content elif isinstance(content, str): normalized_content = TextContent(type="text", text=content) else: # dict, list, BaseModel → JSON string serialized = pydantic_core.to_json(content, fallback=str).decode() normalized_content = TextContent(type="text", text=serialized) super().__init__(role=role, content=normalized_content) def to_mcp_prompt_message(self) -> PromptMessage: """Convert to MCP PromptMessage.""" return PromptMessage(role=self.role, content=self.content) class PromptArgument(FastMCPBaseModel): """An argument that can be passed to a prompt.""" name: str = Field(description="Name of the argument") description: str | None = Field( default=None, description="Description of what the argument does" ) required: bool = Field( default=False, description="Whether the argument is required" ) class PromptResult(pydantic.BaseModel): """Canonical result type for prompt rendering. Provides explicit control over prompt responses: multiple messages, roles, and metadata at both the message and result level. Accepts: - str: Wrapped as single Message (user role) - list[Message]: Used directly for multiple messages or custom roles Example: ```python from fastmcp import FastMCP from fastmcp.prompts import PromptResult, Message mcp = FastMCP() # Simple string content @mcp.prompt def greet() -> PromptResult: return PromptResult("Hello!") # Multiple messages with roles @mcp.prompt def conversation() -> PromptResult: return PromptResult([ Message("What's the weather?"), Message("It's sunny today.", role="assistant"), ]) ``` """ messages: list[Message] description: str | None = None meta: dict[str, Any] | None = None def __init__( self, messages: str | list[Message], description: str | None = None, meta: dict[str, Any] | None = None, ): """Create PromptResult. Args: messages: String or list of Message objects. description: Optional description of the prompt result. meta: Optional metadata about the prompt result. """ normalized = self._normalize_messages(messages) super().__init__(messages=normalized, description=description, meta=meta) @staticmethod def _normalize_messages( messages: str | list[Message], ) -> list[Message]: """Normalize input to list[Message].""" if isinstance(messages, str): return [Message(messages)] if isinstance(messages, list): # Validate all items are Message for i, item in enumerate(messages): if not isinstance(item, Message): raise TypeError( f"messages[{i}] must be Message, got {type(item).__name__}. " f"Use Message({item!r}) to wrap the value." ) return messages raise TypeError( f"messages must be str or list[Message], got {type(messages).__name__}" ) def to_mcp_prompt_result(self) -> GetPromptResult: """Convert to MCP GetPromptResult.""" mcp_messages = [m.to_mcp_prompt_message() for m in self.messages] return GetPromptResult( description=self.description, messages=mcp_messages, _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) class Prompt(FastMCPComponent): """A prompt template that can be rendered with parameters.""" KEY_PREFIX: ClassVar[str] = "prompt" arguments: list[PromptArgument] | None = Field( default=None, description="Arguments that can be passed to the prompt" ) auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( default=None, description="Authorization checks for this prompt", exclude=True ) def to_mcp_prompt( self, **overrides: Any, ) -> SDKPrompt: """Convert the prompt to an MCP prompt.""" arguments = [ SDKPromptArgument( name=arg.name, description=arg.description, required=arg.required, ) for arg in self.arguments or [] ] return SDKPrompt( name=overrides.get("name", self.name), description=overrides.get("description", self.description), arguments=arguments, title=overrides.get("title", self.title), icons=overrides.get("icons", self.icons), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() ), ) @classmethod def from_function( cls, fn: Callable[..., Any], *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. The function can return: - str: wrapped as single user Message - list[Message | str]: converted to list[Message] - PromptResult: used directly """ from fastmcp.prompts.function_prompt import FunctionPrompt return FunctionPrompt.from_function( fn=fn, name=name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=task, auth=auth, ) async def render( self, arguments: dict[str, Any] | None = None, ) -> str | list[Message | str] | PromptResult: """Render the prompt with arguments. Subclasses must implement this method. Return one of: - str: Wrapped as single user Message - list[Message | str]: Converted to list[Message] - PromptResult: Used directly """ raise NotImplementedError("Subclasses must implement render()") def convert_result(self, raw_value: Any) -> PromptResult: """Convert a raw return value to PromptResult. Accepts: - PromptResult: passed through - str: wrapped as single Message - list[Message | str]: converted to list[Message] Raises: TypeError: for unsupported types """ if isinstance(raw_value, PromptResult): return raw_value if isinstance(raw_value, str): return PromptResult(raw_value, description=self.description, meta=self.meta) if isinstance(raw_value, list | tuple): messages: list[Message] = [] for i, item in enumerate(raw_value): if isinstance(item, Message): messages.append(item) elif isinstance(item, str): messages.append(Message(item)) else: raise TypeError( f"messages[{i}] must be Message or str, got {type(item).__name__}. " f"Use Message({item!r}) to wrap the value." ) return PromptResult(messages, description=self.description, meta=self.meta) raise TypeError( f"Prompt must return str, list[Message], or PromptResult, " f"got {type(raw_value).__name__}" ) @overload async def _render( self, arguments: dict[str, Any] | None = None, task_meta: None = None, ) -> PromptResult: ... @overload async def _render( self, arguments: dict[str, Any] | None, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def _render( self, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY Prompt subclass to support background execution by setting task_config.mode to "supported" or "required". The server calls this method instead of render() directly. Args: arguments: Prompt arguments task_meta: If provided, execute as background task and return CreateTaskResult. If None (default), execute synchronously and return PromptResult. Returns: PromptResult when task_meta is None. CreateTaskResult when task_meta is provided. Subclasses can override this to customize task routing behavior. For example, FastMCPProviderPrompt overrides to delegate to child middleware without submitting to Docket. """ from fastmcp.server.tasks.routing import check_background_task task_result = await check_background_task( component=self, task_type="prompt", arguments=arguments, task_meta=task_meta, ) if task_result: return task_result # Synchronous execution result = await self.render(arguments) return self.convert_result(result) def register_with_docket(self, docket: Docket) -> None: """Register this prompt with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.render, names=[self.key]) async def add_to_docket( # type: ignore[override] self, docket: Docket, arguments: dict[str, Any] | None, *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this prompt for background execution via docket. Args: docket: The Docket instance arguments: Prompt arguments fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(arguments) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "prompt", "fastmcp.provider.type": "LocalProvider", } __all__ = [ "Message", "Prompt", "PromptArgument", "PromptResult", ] def __getattr__(name: str) -> Any: """Deprecated re-exports for backwards compatibility.""" deprecated_exports = { "FunctionPrompt": "FunctionPrompt", "prompt": "prompt", } if name in deprecated_exports: import fastmcp if fastmcp.settings.deprecation_warnings: warnings.warn( f"Importing {name} from fastmcp.prompts.prompt is deprecated. " f"Import from fastmcp.prompts.function_prompt instead.", DeprecationWarning, stacklevel=2, ) from fastmcp.prompts import function_prompt return getattr(function_prompt, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ================================================ FILE: src/fastmcp/prompts/function_prompt.py ================================================ """Standalone @prompt decorator for FastMCP.""" from __future__ import annotations import functools import inspect import json import warnings from collections.abc import Callable from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, Any, Literal, Protocol, TypeVar, overload, runtime_checkable, ) import pydantic_core from mcp.types import Icon from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.exceptions import PromptError from fastmcp.prompts.base import Prompt, PromptArgument, PromptResult from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter if TYPE_CHECKING: from docket import Docket from docket.execution import Execution F = TypeVar("F", bound=Callable[..., Any]) logger = get_logger(__name__) @runtime_checkable class DecoratedPrompt(Protocol): """Protocol for functions decorated with @prompt.""" __fastmcp__: PromptMeta def __call__(self, *args: Any, **kwargs: Any) -> Any: ... @dataclass(frozen=True, kw_only=True) class PromptMeta: """Metadata attached to functions by the @prompt decorator.""" type: Literal["prompt"] = field(default="prompt", init=False) name: str | None = None version: str | int | None = None title: str | None = None description: str | None = None icons: list[Icon] | None = None tags: set[str] | None = None meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True class FunctionPrompt(Prompt): """A prompt that is a function.""" fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( cls, fn: Callable[..., Any], *, metadata: PromptMeta | None = None, # Keep individual params for backwards compat name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionPrompt: """Create a Prompt from a function. Args: fn: The function to wrap metadata: PromptMeta object with all configuration. If provided, individual parameters must not be passed. name, title, etc.: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. The function can return: - str: wrapped as single user Message - list[Message | str]: converted to list[Message] - PromptResult: used directly """ # Check mutual exclusion individual_params_provided = any( x is not None for x in [name, version, title, description, icons, tags, meta, task, auth] ) if metadata is not None and individual_params_provided: raise TypeError( "Cannot pass both 'metadata' and individual parameters to from_function(). " "Use metadata alone or individual parameters alone." ) # Build metadata from kwargs if not provided if metadata is None: metadata = PromptMeta( name=name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=task, auth=auth, ) func_name = ( metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__ ) if func_name == "": raise ValueError("You must provide a name for lambda functions") # Reject functions with *args or **kwargs sig = inspect.signature(fn) for param in sig.parameters.values(): if param.kind == inspect.Parameter.VAR_POSITIONAL: raise ValueError("Functions with *args are not supported as prompts") if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as prompts") description = metadata.description or inspect.getdoc(fn) # Normalize task to TaskConfig and validate task_value = metadata.task if task_value is None: task_config = TaskConfig(mode="forbidden") elif isinstance(task_value, bool): task_config = TaskConfig.from_bool(task_value) else: task_config = task_value task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): fn = fn.__func__ # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) # Wrap fn to handle dependency resolution internally wrapped_fn = without_injected_parameters(fn) type_adapter = get_cached_typeadapter(wrapped_fn) parameters = type_adapter.json_schema() parameters = compress_schema(parameters, prune_titles=True) # Convert parameters to PromptArguments arguments: list[PromptArgument] = [] if "properties" in parameters: for param_name, param in parameters["properties"].items(): arg_description = param.get("description") # For non-string parameters, append JSON schema info to help users # understand the expected format when passing as strings (MCP requirement) if param_name in sig.parameters: sig_param = sig.parameters[param_name] if ( sig_param.annotation != inspect.Parameter.empty and sig_param.annotation is not str ): # Get the JSON schema for this specific parameter type try: param_adapter = get_cached_typeadapter(sig_param.annotation) param_schema = param_adapter.json_schema() # Create compact schema representation schema_str = json.dumps(param_schema, separators=(",", ":")) # Append schema info to description schema_note = f"Provide as a JSON string matching the following schema: {schema_str}" if arg_description: arg_description = f"{arg_description}\n\n{schema_note}" else: arg_description = schema_note except Exception as e: # If schema generation fails, skip enhancement logger.debug( "Failed to generate schema for prompt argument %s: %s", param_name, e, ) arguments.append( PromptArgument( name=param_name, description=arg_description, required=param_name in parameters.get("required", []), ) ) return cls( name=func_name, version=str(metadata.version) if metadata.version is not None else None, title=metadata.title, description=description, icons=metadata.icons, arguments=arguments, tags=metadata.tags or set(), fn=wrapped_fn, meta=metadata.meta, task_config=task_config, auth=metadata.auth, ) def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Convert string arguments to expected types based on function signature.""" from fastmcp.server.dependencies import without_injected_parameters wrapper_fn = without_injected_parameters(self.fn) sig = inspect.signature(wrapper_fn) converted_kwargs = {} for param_name, param_value in kwargs.items(): if param_name in sig.parameters: param = sig.parameters[param_name] # If parameter has no annotation or annotation is str, pass as-is if ( param.annotation == inspect.Parameter.empty or param.annotation is str ) or not isinstance(param_value, str): converted_kwargs[param_name] = param_value else: # Try to convert string argument using type adapter try: adapter = get_cached_typeadapter(param.annotation) # Try JSON parsing first for complex types try: converted_kwargs[param_name] = adapter.validate_json( param_value ) except (ValueError, TypeError, pydantic_core.ValidationError): # Fallback to direct validation converted_kwargs[param_name] = adapter.validate_python( param_value ) except (ValueError, TypeError, pydantic_core.ValidationError) as e: # If conversion fails, provide informative error raise PromptError( f"Could not convert argument '{param_name}' with value '{param_value}' " f"to expected type {param.annotation}. Error: {e}" ) from e else: # Parameter not in function signature, pass as-is converted_kwargs[param_name] = param_value return converted_kwargs async def render( self, arguments: dict[str, Any] | None = None, ) -> PromptResult: """Render the prompt with arguments.""" # Validate required arguments if self.arguments: required = {arg.name for arg in self.arguments if arg.required} provided = set(arguments or {}) missing = required - provided if missing: raise ValueError(f"Missing required arguments: {missing}") try: # Prepare arguments kwargs = arguments.copy() if arguments else {} # Convert string arguments to expected types BEFORE validation kwargs = self._convert_string_arguments(kwargs) # Filter out arguments that aren't in the function signature # This is important for security: dependencies should not be overridable # from external callers. self.fn is wrapped by without_injected_parameters, # so we only accept arguments that are in the wrapped function's signature. sig = inspect.signature(self.fn) valid_params = set(sig.parameters.keys()) kwargs = {k: v for k, v in kwargs.items() if k in valid_params} # Use type adapter to validate arguments and handle Field() defaults # This matches the behavior of tools in function_tool type_adapter = get_cached_typeadapter(self.fn) # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally if is_coroutine_function(self.fn): result = await type_adapter.validate_python(kwargs) else: # Run sync functions in threadpool to avoid blocking the event loop result = await call_sync_fn_in_threadpool( type_adapter.validate_python, kwargs ) # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) if inspect.isawaitable(result): result = await result return self.convert_result(result) except Exception as e: logger.exception(f"Error rendering prompt {self.name}") raise PromptError(f"Error rendering prompt {self.name}.") from e def register_with_docket(self, docket: Docket) -> None: """Register this prompt with docket for background execution. FunctionPrompt registers the underlying function, which has the user's Depends parameters for docket to resolve. """ if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) async def add_to_docket( self, docket: Docket, arguments: dict[str, Any] | None, *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this prompt for background execution via docket. FunctionPrompt splats the arguments dict since .fn expects **kwargs. Args: docket: The Docket instance arguments: Prompt arguments fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(**(arguments or {})) @overload def prompt(fn: F) -> F: ... @overload def prompt( name_or_fn: str, *, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @overload def prompt( name_or_fn: None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... def prompt( name_or_fn: str | Callable[..., Any] | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Any: """Standalone decorator to mark a function as an MCP prompt. Returns the original function with metadata attached. Register with a server using mcp.add_prompt(). """ if isinstance(name_or_fn, classmethod): raise TypeError( "To decorate a classmethod, use @classmethod above @prompt. " "See https://gofastmcp.com/servers/prompts#using-with-methods" ) def create_prompt( fn: Callable[..., Any], prompt_name: str | None ) -> FunctionPrompt: # Create metadata first, then pass it prompt_meta = PromptMeta( name=prompt_name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=resolve_task_config(task), auth=auth, ) return FunctionPrompt.from_function(fn, metadata=prompt_meta) def attach_metadata(fn: F, prompt_name: str | None) -> F: metadata = PromptMeta( name=prompt_name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=task, auth=auth, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata return fn def decorator(fn: F, prompt_name: str | None) -> F: if fastmcp.settings.decorator_mode == "object": warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", DeprecationWarning, stacklevel=4, ) return create_prompt(fn, prompt_name) # type: ignore[return-value] return attach_metadata(fn, prompt_name) if inspect.isroutine(name_or_fn): return decorator(name_or_fn, name) elif isinstance(name_or_fn, str): if name is not None: raise TypeError("Cannot specify name both as first argument and keyword") prompt_name = name_or_fn elif name_or_fn is None: prompt_name = name else: raise TypeError(f"Invalid first argument: {type(name_or_fn)}") def wrapper(fn: F) -> F: return decorator(fn, prompt_name) return wrapper ================================================ FILE: src/fastmcp/py.typed ================================================ ================================================ FILE: src/fastmcp/resources/__init__.py ================================================ import sys from .function_resource import FunctionResource, resource from .base import Resource, ResourceContent, ResourceResult from .template import ResourceTemplate from .types import ( BinaryResource, DirectoryResource, FileResource, HttpResource, TextResource, ) __all__ = [ "BinaryResource", "DirectoryResource", "FileResource", "FunctionResource", "HttpResource", "Resource", "ResourceContent", "ResourceResult", "ResourceTemplate", "TextResource", "resource", ] # Backward compat: resource.py was renamed to base.py to stop Pyright from resolving # `from fastmcp.resources import resource` as the submodule instead of the decorator function. # This shim keeps `from fastmcp.resources.resource import Resource` working at runtime. # Safe to remove once we're confident no external code imports from the old path. sys.modules[f"{__name__}.resource"] = sys.modules[f"{__name__}.base"] ================================================ FILE: src/fastmcp/resources/base.py ================================================ """Base classes and interfaces for FastMCP resources.""" from __future__ import annotations import base64 from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload import mcp.types if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from fastmcp.resources.function_resource import FunctionResource import pydantic import pydantic_core from mcp.types import Annotations, Icon from mcp.types import Resource as SDKResource from pydantic import ( AnyUrl, ConfigDict, Field, UrlConstraints, field_validator, model_validator, ) from pydantic.json_schema import SkipJsonSchema from typing_extensions import Self from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent class ResourceContent(pydantic.BaseModel): """Wrapper for resource content with optional MIME type and metadata. Accepts any value for content - strings and bytes pass through directly, other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. Example: ```python from fastmcp.resources import ResourceContent # String content ResourceContent("plain text") # Binary content ResourceContent(b"binary data", mime_type="application/octet-stream") # Auto-serialized to JSON ResourceContent({"key": "value"}) ResourceContent(["a", "b", "c"]) ``` """ content: str | bytes mime_type: str | None = None meta: dict[str, Any] | None = None def __init__( self, content: Any, mime_type: str | None = None, meta: dict[str, Any] | None = None, ): """Create ResourceContent with automatic serialization. Args: content: The content value. str and bytes pass through directly. Other types (dict, list, BaseModel) are JSON-serialized. mime_type: Optional MIME type. Defaults based on content type: str → "text/plain", bytes → "application/octet-stream", other → "application/json" meta: Optional metadata dictionary. """ if isinstance(content, str): normalized_content: str | bytes = content mime_type = mime_type or "text/plain" elif isinstance(content, bytes): normalized_content = content mime_type = mime_type or "application/octet-stream" else: # dict, list, BaseModel, etc → JSON normalized_content = pydantic_core.to_json(content, fallback=str).decode() mime_type = mime_type or "application/json" super().__init__(content=normalized_content, mime_type=mime_type, meta=meta) def to_mcp_resource_contents( self, uri: AnyUrl | str ) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents: """Convert to MCP resource contents type. Args: uri: The URI of the resource (required by MCP types) Returns: TextResourceContents for str content, BlobResourceContents for bytes """ if isinstance(self.content, str): return mcp.types.TextResourceContents( uri=AnyUrl(uri) if isinstance(uri, str) else uri, text=self.content, mimeType=self.mime_type or "text/plain", _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) else: return mcp.types.BlobResourceContents( uri=AnyUrl(uri) if isinstance(uri, str) else uri, blob=base64.b64encode(self.content).decode(), mimeType=self.mime_type or "application/octet-stream", _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) class ResourceResult(pydantic.BaseModel): """Canonical result type for resource reads. Provides explicit control over resource responses: multiple content items, per-item MIME types, and metadata at both the item and result level. Accepts: - str: Wrapped as single ResourceContent (text/plain) - bytes: Wrapped as single ResourceContent (application/octet-stream) - list[ResourceContent]: Used directly for multiple items or custom MIME types Example: ```python from fastmcp import FastMCP from fastmcp.resources import ResourceResult, ResourceContent mcp = FastMCP() # Simple string content @mcp.resource("data://simple") def get_simple() -> ResourceResult: return ResourceResult("hello world") # Multiple items with custom MIME types @mcp.resource("data://items") def get_items() -> ResourceResult: return ResourceResult( contents=[ ResourceContent({"key": "value"}), # auto-serialized to JSON ResourceContent(b"binary data"), ], meta={"count": 2} ) ``` """ contents: list[ResourceContent] meta: dict[str, Any] | None = None def __init__( self, contents: str | bytes | list[ResourceContent], meta: dict[str, Any] | None = None, ): """Create ResourceResult. Args: contents: String, bytes, or list of ResourceContent objects. meta: Optional metadata about the resource result. """ normalized = self._normalize_contents(contents) super().__init__(contents=normalized, meta=meta) @staticmethod def _normalize_contents( contents: str | bytes | list[ResourceContent], ) -> list[ResourceContent]: """Normalize input to list[ResourceContent].""" if isinstance(contents, str): return [ResourceContent(contents)] if isinstance(contents, bytes): return [ResourceContent(contents)] if isinstance(contents, list): # Validate all items are ResourceContent for i, item in enumerate(contents): if not isinstance(item, ResourceContent): raise TypeError( f"contents[{i}] must be ResourceContent, got {type(item).__name__}. " f"Use ResourceContent({item!r}) to wrap the value." ) return contents raise TypeError( f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}" ) def to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult: """Convert to MCP ReadResourceResult. Args: uri: The URI of the resource (required by MCP types) Returns: MCP ReadResourceResult with converted contents """ mcp_contents = [item.to_mcp_resource_contents(uri) for item in self.contents] return mcp.types.ReadResourceResult( contents=mcp_contents, _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) class Resource(FastMCPComponent): """Base class for all resources.""" KEY_PREFIX: ClassVar[str] = "resource" model_config = ConfigDict(validate_default=True) uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( default=..., description="URI of the resource" ) name: str = Field(default="", description="Name of the resource") mime_type: str = Field( default="text/plain", description="MIME type of the resource content", ) annotations: Annotated[ Annotations | None, Field(description="Optional annotations about the resource's behavior"), ] = None auth: Annotated[ SkipJsonSchema[AuthCheck | list[AuthCheck] | None], Field(description="Authorization checks for this resource", exclude=True), ] = None @classmethod def from_function( cls, fn: Callable[..., Any], uri: str | AnyUrl, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: from fastmcp.resources.function_resource import ( FunctionResource, ) return FunctionResource.from_function( fn=fn, uri=uri, name=name, version=version, title=title, description=description, icons=icons, mime_type=mime_type, tags=tags, annotations=annotations, meta=meta, task=task, auth=auth, ) @field_validator("mime_type", mode="before") @classmethod def set_default_mime_type(cls, mime_type: str | None) -> str: """Set default MIME type if not provided.""" if mime_type: return mime_type return "text/plain" @model_validator(mode="after") def set_default_name(self) -> Self: """Set default name from URI if not provided.""" if self.name: pass elif self.uri: self.name = str(self.uri) else: raise ValueError("Either name or uri must be provided") return self async def read( self, ) -> str | bytes | ResourceResult: """Read the resource content. Subclasses implement this to return resource data. Supported return types: - str: Text content - bytes: Binary content - ResourceResult: Full control over contents and result-level meta """ raise NotImplementedError("Subclasses must implement read()") def convert_result(self, raw_value: Any) -> ResourceResult: """Convert a raw result to ResourceResult. This is used in two contexts: 1. In _read() to convert user function return values to ResourceResult 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. When the raw value is a plain string or bytes, the resource's own ``mime_type`` is forwarded so that ``ui://`` resources (and others with non-default MIME types) don't fall back to ``text/plain``. The resource's component-level ``meta`` (e.g. ``ui`` metadata for MCP Apps CSP/permissions) is propagated to each content item so that hosts can read it from the ``resources/read`` response. """ if isinstance(raw_value, ResourceResult): return raw_value # For plain str/bytes returns, wrap in ResourceContent with the # resource's MIME type and component meta so the wire response # carries the correct type and metadata (e.g. CSP for MCP Apps). if isinstance(raw_value, (str, bytes)): return ResourceResult( [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] ) # ResourceResult.__init__ handles all other normalization return ResourceResult(raw_value) @overload async def _read(self, task_meta: None = None) -> ResourceResult: ... @overload async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ... async def _read( self, task_meta: TaskMeta | None = None ) -> ResourceResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY Resource subclass to support background execution by setting task_config.mode to "supported" or "required". The server calls this method instead of read() directly. Args: task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ResourceResult. Returns: ResourceResult when task_meta is None. CreateTaskResult when task_meta is provided. Subclasses can override this to customize task routing behavior. For example, FastMCPProviderResource overrides to delegate to child middleware without submitting to Docket. """ from fastmcp.server.tasks.routing import check_background_task task_result = await check_background_task( component=self, task_type="resource", arguments=None, task_meta=task_meta ) if task_result: return task_result # Synchronous execution - convert result to ResourceResult result = await self.read() return self.convert_result(result) def to_mcp_resource( self, **overrides: Any, ) -> SDKResource: """Convert the resource to an SDKResource.""" return SDKResource( name=overrides.get("name", self.name), uri=overrides.get("uri", self.uri), description=overrides.get("description", self.description), mimeType=overrides.get("mimeType", self.mime_type), title=overrides.get("title", self.title), icons=overrides.get("icons", self.icons), annotations=overrides.get("annotations", self.annotations), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() ), ) def __repr__(self) -> str: return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" @property def key(self) -> str: """The globally unique lookup key for this resource.""" base_key = self.make_key(str(self.uri)) return f"{base_key}@{self.version or ''}" def register_with_docket(self, docket: Docket) -> None: """Register this resource with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.read, names=[self.key]) async def add_to_docket( # type: ignore[override] self, docket: Docket, *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this resource for background execution via docket. Args: docket: The Docket instance fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)() def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "resource", "fastmcp.provider.type": "LocalProvider", } __all__ = [ "Resource", "ResourceContent", "ResourceResult", ] def __getattr__(name: str) -> Any: """Deprecated re-exports for backwards compatibility.""" deprecated_exports = { "FunctionResource": "FunctionResource", "resource": "resource", } if name in deprecated_exports: import warnings import fastmcp if fastmcp.settings.deprecation_warnings: warnings.warn( f"Importing {name} from fastmcp.resources.resource is deprecated. " f"Import from fastmcp.resources.function_resource instead.", DeprecationWarning, stacklevel=2, ) from fastmcp.resources import function_resource return getattr(function_resource, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ================================================ FILE: src/fastmcp/resources/function_resource.py ================================================ """Standalone @resource decorator for FastMCP.""" from __future__ import annotations import functools import inspect import warnings from collections.abc import Callable from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable from mcp.types import Annotations, Icon from pydantic import AnyUrl from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.resources.base import Resource, ResourceResult from fastmcp.server.apps import resolve_ui_mime_type from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) if TYPE_CHECKING: from docket import Docket from fastmcp.resources.template import ResourceTemplate F = TypeVar("F", bound=Callable[..., Any]) @runtime_checkable class DecoratedResource(Protocol): """Protocol for functions decorated with @resource.""" __fastmcp__: ResourceMeta def __call__(self, *args: Any, **kwargs: Any) -> Any: ... @dataclass(frozen=True, kw_only=True) class ResourceMeta: """Metadata attached to functions by the @resource decorator.""" type: Literal["resource"] = field(default="resource", init=False) uri: str name: str | None = None version: str | int | None = None title: str | None = None description: str | None = None icons: list[Icon] | None = None tags: set[str] | None = None mime_type: str | None = None annotations: Annotations | None = None meta: dict[str, Any] | None = None task: bool | TaskConfig | None = None auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True class FunctionResource(Resource): """A resource that defers data loading by wrapping a function. The function is only called when the resource is read, allowing for lazy loading of potentially expensive data. This is particularly useful when listing resources, as the function won't be called until the resource is actually accessed. The function can return: - str for text content (default) - bytes for binary content - other types will be converted to JSON """ fn: SkipJsonSchema[Callable[..., Any]] @classmethod def from_function( cls, fn: Callable[..., Any], uri: str | AnyUrl | None = None, *, metadata: ResourceMeta | None = None, # Keep individual params for backwards compat name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResource: """Create a FunctionResource from a function. Args: fn: The function to wrap uri: The URI for the resource (required if metadata not provided) metadata: ResourceMeta object with all configuration. If provided, individual parameters must not be passed. name, title, etc.: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. """ # Check mutual exclusion individual_params_provided = ( any( x is not None for x in [ name, version, title, description, icons, mime_type, tags, annotations, meta, task, auth, ] ) or uri is not None ) if metadata is not None and individual_params_provided: raise TypeError( "Cannot pass both 'metadata' and individual parameters to from_function(). " "Use metadata alone or individual parameters alone." ) # Build metadata from kwargs if not provided if metadata is None: if uri is None: raise TypeError("uri is required when metadata is not provided") metadata = ResourceMeta( uri=str(uri), name=name, version=version, title=title, description=description, icons=icons, tags=tags, mime_type=mime_type, annotations=annotations, meta=meta, task=task, auth=auth, ) uri_obj = AnyUrl(metadata.uri) # Get function name - use class name for callable objects func_name = ( metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__ ) # Normalize task to TaskConfig and validate task_value = metadata.task if task_value is None: task_config = TaskConfig(mode="forbidden") elif isinstance(task_value, bool): task_config = TaskConfig.from_bool(task_value) else: task_config = task_value task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): fn = fn.__func__ # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) # Wrap fn to handle dependency resolution internally wrapped_fn = without_injected_parameters(fn) # Apply ui:// MIME default, then fall back to text/plain resolved_mime = resolve_ui_mime_type(metadata.uri, metadata.mime_type) return cls( fn=wrapped_fn, uri=uri_obj, name=func_name, version=str(metadata.version) if metadata.version is not None else None, title=metadata.title, description=metadata.description or inspect.getdoc(fn), icons=metadata.icons, mime_type=resolved_mime or "text/plain", tags=metadata.tags or set(), annotations=metadata.annotations, meta=metadata.meta, task_config=task_config, auth=metadata.auth, ) async def read( self, ) -> str | bytes | ResourceResult: """Read the resource by calling the wrapped function.""" # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally if is_coroutine_function(self.fn): result = await self.fn() else: # Run sync functions in threadpool to avoid blocking the event loop result = await call_sync_fn_in_threadpool(self.fn) # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) if inspect.isawaitable(result): result = await result # If user returned another Resource, read it recursively if isinstance(result, Resource): return await result.read() return result def register_with_docket(self, docket: Docket) -> None: """Register this resource with docket for background execution. FunctionResource registers the underlying function, which has the user's Depends parameters for docket to resolve. """ if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) def resource( uri: str, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: """Standalone decorator to mark a function as an MCP resource. Returns the original function with metadata attached. Register with a server using mcp.add_resource(). """ if isinstance(annotations, dict): annotations = Annotations(**annotations) if inspect.isroutine(uri): raise TypeError( "The @resource decorator requires a URI. " "Use @resource('uri') instead of @resource" ) def create_resource(fn: Callable[..., Any]) -> FunctionResource | ResourceTemplate: from fastmcp.resources.template import ResourceTemplate from fastmcp.server.dependencies import without_injected_parameters resolved = resolve_task_config(task) has_uri_params = "{" in uri and "}" in uri wrapper_fn = without_injected_parameters(fn) has_func_params = bool(inspect.signature(wrapper_fn).parameters) # Create metadata first resource_meta = ResourceMeta( uri=uri, name=name, version=version, title=title, description=description, icons=icons, tags=tags, mime_type=mime_type, annotations=annotations, meta=meta, task=resolved, auth=auth, ) if has_uri_params or has_func_params: # ResourceTemplate doesn't have metadata support yet, so pass individual params return ResourceTemplate.from_function( fn=fn, uri_template=uri, name=name, version=version, title=title, description=description, icons=icons, mime_type=mime_type, tags=tags, annotations=annotations, meta=meta, task=resolved, auth=auth, ) else: return FunctionResource.from_function(fn, metadata=resource_meta) def attach_metadata(fn: F) -> F: metadata = ResourceMeta( uri=uri, name=name, version=version, title=title, description=description, icons=icons, tags=tags, mime_type=mime_type, annotations=annotations, meta=meta, task=task, auth=auth, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata return fn def decorator(fn: F) -> F: if fastmcp.settings.decorator_mode == "object": warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", DeprecationWarning, stacklevel=3, ) return create_resource(fn) # type: ignore[return-value] return attach_metadata(fn) return decorator ================================================ FILE: src/fastmcp/resources/template.py ================================================ """Resource template functionality.""" from __future__ import annotations import functools import inspect import re from collections.abc import Callable from typing import TYPE_CHECKING, Any, ClassVar, overload from urllib.parse import parse_qs, unquote import mcp.types from mcp.types import Annotations, Icon from pydantic.json_schema import SkipJsonSchema if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from mcp.types import ResourceTemplate as SDKResourceTemplate from pydantic import ( Field, field_validator, validate_call, ) from fastmcp.resources.base import Resource, ResourceResult from fastmcp.server.apps import resolve_ui_mime_type from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import get_cached_typeadapter def extract_query_params(uri_template: str) -> set[str]: """Extract query parameter names from RFC 6570 `{?param1,param2}` syntax.""" match = re.search(r"\{\?([^}]+)\}", uri_template) if match: return {p.strip() for p in match.group(1).split(",")} return set() def build_regex(template: str) -> re.Pattern[str] | None: """Build regex pattern for URI template, handling RFC 6570 syntax. Supports: - `{var}` - simple path parameter - `{var*}` - wildcard path parameter (captures multiple segments) - `{?var1,var2}` - query parameters (ignored in path matching) Returns None if the template produces an invalid regex (e.g. parameter names with hyphens, leading digits, or duplicates from a remote server). """ # Remove query parameter syntax for path matching template_without_query = re.sub(r"\{\?[^}]+\}", "", template) parts = re.split(r"(\{[^}]+\})", template_without_query) pattern = "" for part in parts: if part.startswith("{") and part.endswith("}"): name = part[1:-1] if name.endswith("*"): name = name[:-1] pattern += f"(?P<{name}>.+)" else: pattern += f"(?P<{name}>[^/]+)" else: pattern += re.escape(part) try: return re.compile(f"^{pattern}$") except re.error: return None def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: """Match URI against template and extract both path and query parameters. Supports RFC 6570 URI templates: - Path params: `{var}`, `{var*}` - Query params: `{?var1,var2}` """ # Split URI into path and query parts uri_path, _, query_string = uri.partition("?") # Match path parameters regex = build_regex(uri_template) if regex is None: return None match = regex.match(uri_path) if not match: return None params = {k: unquote(v) for k, v in match.groupdict().items()} # Extract query parameters if present in URI and template if query_string: query_param_names = extract_query_params(uri_template) parsed_query = parse_qs(query_string) for name in query_param_names: if name in parsed_query: # Take first value if multiple provided params[name] = parsed_query[name][0] return params class ResourceTemplate(FastMCPComponent): """A template for dynamically creating resources.""" KEY_PREFIX: ClassVar[str] = "template" uri_template: str = Field( description="URI template with parameters (e.g. weather://{city}/current)" ) mime_type: str = Field( default="text/plain", description="MIME type of the resource content" ) parameters: dict[str, Any] = Field( description="JSON schema for function parameters" ) annotations: Annotations | None = Field( default=None, description="Optional annotations about the resource's behavior" ) auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( default=None, description="Authorization checks for this resource template", exclude=True, ) def __repr__(self) -> str: return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" @staticmethod def from_function( fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResourceTemplate: return FunctionResourceTemplate.from_function( fn=fn, uri_template=uri_template, name=name, version=version, title=title, description=description, icons=icons, mime_type=mime_type, tags=tags, annotations=annotations, meta=meta, task=task, auth=auth, ) @field_validator("mime_type", mode="before") @classmethod def set_default_mime_type(cls, mime_type: str | None) -> str: """Set default MIME type if not provided.""" if mime_type: return mime_type return "text/plain" def matches(self, uri: str) -> dict[str, Any] | None: """Check if URI matches template and extract parameters.""" return match_uri_template(uri, self.uri_template) async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content.""" raise NotImplementedError( "Subclasses must implement read() or override create_resource()" ) def convert_result(self, raw_value: Any) -> ResourceResult: """Convert a raw result to ResourceResult. This is used in two contexts: 1. In _read() to convert user function return values to ResourceResult 2. In tasks_result_handler() to convert Docket task results to ResourceResult Handles ResourceResult passthrough and converts raw values using ResourceResult's normalization. """ if isinstance(raw_value, ResourceResult): return raw_value # ResourceResult.__init__ handles all normalization return ResourceResult(raw_value) @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: None = None ) -> ResourceResult: ... @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta ) -> mcp.types.CreateTaskResult: ... async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None ) -> ResourceResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY ResourceTemplate subclass to support background execution by setting task_config.mode to "supported" or "required". The server calls this method instead of create_resource()/read() directly. Args: uri: The concrete URI being read params: Template parameters extracted from the URI task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ResourceResult. Returns: ResourceResult when task_meta is None. CreateTaskResult when task_meta is provided. Subclasses can override this to customize task routing behavior. For example, FastMCPProviderResourceTemplate overrides to delegate to child middleware without submitting to Docket. """ from fastmcp.server.tasks.routing import check_background_task task_result = await check_background_task( component=self, task_type="template", arguments=params, task_meta=task_meta ) if task_result: return task_result # Synchronous execution - create resource and read directly # Call resource.read() not resource._read() to avoid task routing on ephemeral resource resource = await self.create_resource(uri, params) result = await resource.read() return self.convert_result(result) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters. The base implementation does not support background tasks. Use FunctionResourceTemplate for task support. """ raise NotImplementedError( "Subclasses must implement create_resource(). " "Use FunctionResourceTemplate for task support." ) def to_mcp_template( self, **overrides: Any, ) -> SDKResourceTemplate: """Convert the resource template to an SDKResourceTemplate.""" return SDKResourceTemplate( name=overrides.get("name", self.name), uriTemplate=overrides.get("uriTemplate", self.uri_template), description=overrides.get("description", self.description), mimeType=overrides.get("mimeType", self.mime_type), title=overrides.get("title", self.title), icons=overrides.get("icons", self.icons), annotations=overrides.get("annotations", self.annotations), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() ), ) @classmethod def from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate: """Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object.""" # Note: This creates a simple ResourceTemplate instance. For function-based templates, # the original function is lost, which is expected for remote templates. return cls( uri_template=mcp_template.uriTemplate, name=mcp_template.name, description=mcp_template.description, mime_type=mcp_template.mimeType or "text/plain", parameters={}, # Remote templates don't have local parameters ) @property def key(self) -> str: """The globally unique lookup key for this template.""" base_key = self.make_key(self.uri_template) return f"{base_key}@{self.version or ''}" def register_with_docket(self, docket: Docket) -> None: """Register this template with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.read, names=[self.key]) async def add_to_docket( # type: ignore[override] self, docket: Docket, params: dict[str, Any], *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this template for background execution via docket. Args: docket: The Docket instance params: Template parameters fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(params) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "resource_template", "fastmcp.provider.type": "LocalProvider", } class FunctionResourceTemplate(ResourceTemplate): """A template for dynamically creating resources.""" fn: SkipJsonSchema[Callable[..., Any]] @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: None = None ) -> ResourceResult: ... @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta ) -> mcp.types.CreateTaskResult: ... async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None ) -> ResourceResult | mcp.types.CreateTaskResult: """Optimized server entry point that skips ephemeral resource creation. For FunctionResourceTemplate, we can call read() directly instead of creating a temporary resource, which is more efficient. Args: uri: The concrete URI being read params: Template parameters extracted from the URI task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ResourceResult. Returns: ResourceResult when task_meta is None. CreateTaskResult when task_meta is provided. """ from fastmcp.server.tasks.routing import check_background_task task_result = await check_background_task( component=self, task_type="template", arguments=params, task_meta=task_meta ) if task_result: return task_result # Synchronous execution - call read() directly, skip resource creation result = await self.read(arguments=params) return self.convert_result(result) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters.""" async def resource_read_fn() -> str | bytes | ResourceResult: # Call function and check if result is a coroutine result = await self.read(arguments=params) return result return Resource.from_function( fn=resource_read_fn, uri=uri, name=self.name, description=self.description, mime_type=self.mime_type, tags=self.tags, task=self.task_config, auth=self.auth, ) async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content.""" # Type coercion for query parameters (which arrive as strings) kwargs = arguments.copy() sig = inspect.signature(self.fn) for param_name, param_value in list(kwargs.items()): if param_name in sig.parameters and isinstance(param_value, str): param = sig.parameters[param_name] annotation = param.annotation if annotation is inspect.Parameter.empty or annotation is str: continue try: if annotation is int: kwargs[param_name] = int(param_value) elif annotation is float: kwargs[param_name] = float(param_value) elif annotation is bool: lower = param_value.lower() if lower in ("true", "1", "yes"): kwargs[param_name] = True elif lower in ("false", "0", "no"): kwargs[param_name] = False else: raise ValueError( f"Invalid boolean value for {param_name}: {param_value!r}" ) except (ValueError, AttributeError): raise # self.fn is wrapped by without_injected_parameters which handles # dependency resolution internally, so we call it directly result = self.fn(**kwargs) if inspect.isawaitable(result): result = await result return result def register_with_docket(self, docket: Docket) -> None: """Register this template with docket for background execution. FunctionResourceTemplate registers the underlying function, which has the user's Depends parameters for docket to resolve. """ if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) async def add_to_docket( self, docket: Docket, params: dict[str, Any], *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this template for background execution via docket. FunctionResourceTemplate splats the params dict since .fn expects **kwargs. Args: docket: The Docket instance params: Template parameters fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(**params) @classmethod def from_function( cls, fn: Callable[..., Any], uri_template: str, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionResourceTemplate: """Create a template from a function.""" func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") # Reject functions with *args # (**kwargs is allowed because the URI will define the parameter names) sig = inspect.signature(fn) for param in sig.parameters.values(): if param.kind == inspect.Parameter.VAR_POSITIONAL: raise ValueError( "Functions with *args are not supported as resource templates" ) # Extract path and query parameters from URI template path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) query_params = extract_query_params(uri_template) all_uri_params = path_params | query_params if not all_uri_params: raise ValueError("URI template must contain at least one parameter") # Use wrapper to get user-facing parameters (excludes injected params) wrapper_fn = without_injected_parameters(fn) user_sig = inspect.signature(wrapper_fn) func_params = set(user_sig.parameters.keys()) # Get required and optional function parameters required_params = { p for p in func_params if user_sig.parameters[p].default is inspect.Parameter.empty and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD } optional_params = { p for p in func_params if user_sig.parameters[p].default is not inspect.Parameter.empty and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD } # Validate RFC 6570 query parameters # Query params must be optional (have defaults) if query_params: invalid_query_params = query_params - optional_params if invalid_query_params: raise ValueError( f"Query parameters {invalid_query_params} must be optional function parameters with default values" ) # Check if required parameters are a subset of the path parameters if not required_params.issubset(path_params): raise ValueError( f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}" ) # Check if all URI parameters are valid function parameters (skip if **kwargs present) if not any( param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values() ): if not all_uri_params.issubset(func_params): raise ValueError( f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}" ) description = description or inspect.getdoc(fn) # Normalize task to TaskConfig and validate if task is None: task_config = TaskConfig(mode="forbidden") elif isinstance(task, bool): task_config = TaskConfig.from_bool(task) else: task_config = task task_config.validate_function(fn, func_name) # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): fn = fn.__func__ # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) wrapper_fn = without_injected_parameters(fn) type_adapter = get_cached_typeadapter(wrapper_fn) parameters = type_adapter.json_schema() parameters = compress_schema(parameters, prune_titles=True) # Use validate_call on wrapper for runtime type coercion fn = validate_call(wrapper_fn) # Apply ui:// MIME default, then fall back to text/plain resolved_mime = resolve_ui_mime_type(uri_template, mime_type) return cls( uri_template=uri_template, name=func_name, version=str(version) if version is not None else None, title=title, description=description, icons=icons, mime_type=resolved_mime or "text/plain", fn=fn, parameters=parameters, tags=tags or set(), annotations=annotations, meta=meta, task_config=task_config, auth=auth, ) ================================================ FILE: src/fastmcp/resources/types.py ================================================ """Concrete resource implementations.""" from __future__ import annotations import json from pathlib import Path import httpx import pydantic.json from anyio import Path as AsyncPath from pydantic import Field, ValidationInfo from typing_extensions import override from fastmcp.exceptions import ResourceError from fastmcp.resources.base import Resource, ResourceContent, ResourceResult from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class TextResource(Resource): """A resource that reads from a string.""" text: str = Field(description="Text content of the resource") async def read(self) -> ResourceResult: """Read the text content.""" return ResourceResult( contents=[ ResourceContent( content=self.text, mime_type=self.mime_type, meta=self.meta ) ] ) class BinaryResource(Resource): """A resource that reads from bytes.""" data: bytes = Field(description="Binary content of the resource") async def read(self) -> ResourceResult: """Read the binary content.""" return ResourceResult( contents=[ ResourceContent( content=self.data, mime_type=self.mime_type, meta=self.meta ) ] ) class FileResource(Resource): """A resource that reads from a file. Set is_binary=True to read file as binary data instead of text. """ path: Path = Field(description="Path to the file") is_binary: bool = Field( default=False, description="Whether to read the file as binary data", ) mime_type: str = Field( default="text/plain", description="MIME type of the resource content", ) @property def _async_path(self) -> AsyncPath: return AsyncPath(self.path) @pydantic.field_validator("path") @classmethod def validate_absolute_path(cls, path: Path) -> Path: """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") return path @pydantic.field_validator("is_binary") @classmethod def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool: """Set is_binary based on mime_type if not explicitly set.""" if is_binary: return True mime_type = info.data.get("mime_type", "text/plain") return not mime_type.startswith("text/") @override async def read(self) -> ResourceResult: """Read the file content.""" try: if self.is_binary: content: str | bytes = await self._async_path.read_bytes() else: content = await self._async_path.read_text() return ResourceResult( contents=[ResourceContent(content=content, mime_type=self.mime_type)] ) except Exception as e: raise ResourceError(f"Error reading file {self.path}") from e class HttpResource(Resource): """A resource that reads from an HTTP endpoint.""" url: str = Field(description="URL to fetch content from") mime_type: str = Field( default="application/json", description="MIME type of the resource content" ) @override async def read(self) -> ResourceResult: """Read the HTTP content.""" async with httpx.AsyncClient() as client: response = await client.get(self.url) _ = response.raise_for_status() return ResourceResult( contents=[ ResourceContent(content=response.text, mime_type=self.mime_type) ] ) class DirectoryResource(Resource): """A resource that lists files in a directory.""" path: Path = Field(description="Path to the directory") recursive: bool = Field( default=False, description="Whether to list files recursively" ) pattern: str | None = Field( default=None, description="Optional glob pattern to filter files" ) mime_type: str = Field( default="application/json", description="MIME type of the resource content" ) @property def _async_path(self) -> AsyncPath: return AsyncPath(self.path) @pydantic.field_validator("path") @classmethod def validate_absolute_path(cls, path: Path) -> Path: """Ensure path is absolute.""" if not path.is_absolute(): raise ValueError("Path must be absolute") return path async def list_files(self) -> list[Path]: """List files in the directory.""" if not await self._async_path.exists(): raise FileNotFoundError(f"Directory not found: {self.path}") if not await self._async_path.is_dir(): raise NotADirectoryError(f"Not a directory: {self.path}") pattern = self.pattern or "*" glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob try: return [Path(p) async for p in glob_fn(pattern) if await p.is_file()] except Exception as e: raise ResourceError(f"Error listing directory {self.path}") from e @override async def read(self) -> ResourceResult: """Read the directory listing.""" try: files: list[Path] = await self.list_files() file_list = [str(f.relative_to(self.path)) for f in files] content = json.dumps({"files": file_list}, indent=2) return ResourceResult( contents=[ResourceContent(content=content, mime_type=self.mime_type)] ) except Exception as e: raise ResourceError(f"Error reading directory {self.path}") from e ================================================ FILE: src/fastmcp/server/__init__.py ================================================ import importlib from .context import Context from .server import FastMCP, create_proxy def __getattr__(name: str) -> object: if name == "dependencies": return importlib.import_module("fastmcp.server.dependencies") raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = ["Context", "FastMCP", "create_proxy"] ================================================ FILE: src/fastmcp/server/app.py ================================================ """FastMCPApp — a Provider that represents a composable MCP application. FastMCPApp binds entry-point tools (model calls these) together with backend tools (the UI calls these via CallTool). Backend tools get global keys — UUID-suffixed stable identifiers that survive namespace transforms when servers are composed — so ``CallTool(save_contact)`` keeps working even when the app is mounted under a namespace. Usage:: from fastmcp import FastMCP, FastMCPApp app = FastMCPApp("Dashboard") @app.ui() def show_dashboard() -> Component: return Column(...) @app.tool() def save_contact(name: str, email: str) -> dict: return {"name": name, "email": email} server = FastMCP("Platform") server.add_provider(app) """ from __future__ import annotations import inspect import uuid from collections.abc import AsyncIterator, Callable, Sequence from contextlib import asynccontextmanager, suppress from typing import Any, Literal, TypeVar, overload from mcp.types import AnyFunction, Icon, ToolAnnotations from fastmcp.decorators import get_fastmcp_meta from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.providers.base import Provider from fastmcp.server.providers.local_provider import LocalProvider from fastmcp.tools.base import Tool from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) F = TypeVar("F", bound=Callable[..., Any]) # --------------------------------------------------------------------------- # Process-level registries # --------------------------------------------------------------------------- # Global key → Tool object. FastMCP.call_tool checks this before normal # provider resolution so that CallTool("save_contact-a1b2c3d4") reaches the # right tool regardless of namespace transforms. _APP_TOOL_REGISTRY: dict[str, Tool] = {} # id(original_fn) → global key. Used by the CallTool callable resolver to # translate ``CallTool(save_contact)`` → ``"save_contact-a1b2c3d4"``. _FN_TO_GLOBAL_KEY: dict[int, str] = {} def get_global_tool(name: str) -> Tool | None: """Look up a tool by its global key, or return None.""" return _APP_TOOL_REGISTRY.get(name) # --------------------------------------------------------------------------- # Global key helpers # --------------------------------------------------------------------------- def _make_global_key(name: str) -> str: """Generate a global key: ``{name}-{8_hex_chars}``.""" return f"{name}-{uuid.uuid4().hex[:8]}" def _register_global_key(tool: Tool, fn: Any, global_key: str) -> None: """Register a tool in both process-level registries.""" _APP_TOOL_REGISTRY[global_key] = tool _FN_TO_GLOBAL_KEY[id(fn)] = global_key def _stamp_global_key(tool: Tool, global_key: str) -> None: """Write the global key into the tool's ``meta["ui"]["globalKey"]``.""" meta = dict(tool.meta) if tool.meta else {} ui = dict(meta.get("ui", {})) if isinstance(meta.get("ui"), dict) else {} ui["globalKey"] = global_key meta["ui"] = ui tool.meta = meta # --------------------------------------------------------------------------- # CallTool callable resolver # --------------------------------------------------------------------------- def _resolve_tool_ref(fn: Any) -> Any: """Resolve a callable to a ``ResolvedTool`` for CallTool serialization. Always returns a ``ResolvedTool`` with the resolved name and any metadata the renderer needs (e.g. ``unwrap_result``). Resolution order: 1. Global key registry (FastMCPApp tools) — includes metadata 2. ``__fastmcp__`` metadata (decorated but not on a FastMCPApp) 3. ``fn.__name__`` (bare function — works for standalone servers) """ from prefab_ui.app import ResolvedTool global_key = _FN_TO_GLOBAL_KEY.get(id(fn)) if global_key is not None: tool = _APP_TOOL_REGISTRY.get(global_key) unwrap = bool( tool is not None and tool.output_schema and tool.output_schema.get("x-fastmcp-wrap-result") ) return ResolvedTool(name=global_key, unwrap_result=unwrap) fmeta = get_fastmcp_meta(fn) if fmeta is not None: name: str | None = getattr(fmeta, "name", None) if name is not None: return ResolvedTool(name=name) fn_name = getattr(fn, "__name__", None) if fn_name is not None: return ResolvedTool(name=fn_name) raise ValueError(f"Cannot resolve tool reference: {fn!r}") def _dispatch_decorator( name_or_fn: str | AnyFunction | None, name: str | None, register: Callable[[Any, str | None], Any], decorator_name: str, ) -> Any: """Shared dispatch logic for @app.tool() and @app.ui() calling patterns.""" if inspect.isroutine(name_or_fn): return register(name_or_fn, name) if isinstance(name_or_fn, str): if name is not None: raise TypeError( "Cannot specify both a name as first argument and as keyword argument." ) tool_name: str | None = name_or_fn elif name_or_fn is None: tool_name = name else: raise TypeError( f"First argument to @{decorator_name} must be a function, string, or None, " f"got {type(name_or_fn)}" ) def decorator(fn: F) -> F: return register(fn, tool_name) return decorator # --------------------------------------------------------------------------- # FastMCPApp # --------------------------------------------------------------------------- class FastMCPApp(Provider): """A Provider that represents an MCP application. Binds together entry-point tools (``@app.ui``), backend tools (``@app.tool``), the Prefab renderer resource, and global-key infrastructure so that composed/namespaced servers can still reach backend tools by stable identifiers. """ def __init__(self, name: str) -> None: super().__init__() self.name = name self._local = LocalProvider(on_duplicate="error") def __repr__(self) -> str: return f"FastMCPApp({self.name!r})" # ------------------------------------------------------------------ # @app.tool() — backend tools called by the UI # ------------------------------------------------------------------ @overload def tool( self, name_or_fn: F, *, name: str | None = None, description: str | None = None, model: bool = False, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> F: ... @overload def tool( self, name_or_fn: str | None = None, *, name: str | None = None, description: str | None = None, model: bool = False, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> Callable[[F], F]: ... def tool( self, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, description: str | None = None, model: bool = False, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> Any: """Register a backend tool that the UI calls via CallTool. Backend tools get a global key for composition safety and default to ``visibility=["app"]``. Pass ``model=True`` to also expose the tool to the model (``visibility=["app", "model"]``). Supports multiple calling patterns:: @app.tool def save(name: str): ... @app.tool() def save(name: str): ... @app.tool("custom_name") def save(name: str): ... """ visibility: list[Literal["app", "model"]] = ( ["app", "model"] if model else ["app"] ) def _register(fn: F, tool_name: str | None) -> F: resolved_name = tool_name or getattr(fn, "__name__", None) if resolved_name is None: raise ValueError(f"Cannot determine tool name for {fn!r}") from fastmcp.server.apps import AppConfig, app_config_to_meta_dict global_key = _make_global_key(resolved_name) app_config = AppConfig(visibility=visibility) meta: dict[str, Any] = {"ui": app_config_to_meta_dict(app_config)} meta["ui"]["globalKey"] = global_key tool_obj = Tool.from_function( fn, name=resolved_name, description=description, meta=meta, timeout=timeout, auth=auth, ) self._local._add_component(tool_obj) _register_global_key(tool_obj, fn, global_key) return fn return _dispatch_decorator(name_or_fn, name, _register, "tool") # ------------------------------------------------------------------ # @app.ui() — entry-point tools the model calls to open the app # ------------------------------------------------------------------ @overload def ui( self, name_or_fn: F, *, name: str | None = None, description: str | None = None, title: str | None = None, tags: set[str] | None = None, icons: list[Icon] | None = None, annotations: ToolAnnotations | None = None, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> F: ... @overload def ui( self, name_or_fn: str | None = None, *, name: str | None = None, description: str | None = None, title: str | None = None, tags: set[str] | None = None, icons: list[Icon] | None = None, annotations: ToolAnnotations | None = None, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> Callable[[F], F]: ... def ui( self, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, description: str | None = None, title: str | None = None, tags: set[str] | None = None, icons: list[Icon] | None = None, annotations: ToolAnnotations | None = None, auth: AuthCheck | list[AuthCheck] | None = None, timeout: float | None = None, ) -> Any: """Register a UI entry-point tool that the model calls. Entry-point tools default to ``visibility=["model"]`` and auto-wire the Prefab renderer resource and CSP. They do NOT get a global key — the model resolves them through the normal transform chain. Supports multiple calling patterns:: @app.ui def dashboard() -> Component: ... @app.ui() def dashboard() -> Component: ... @app.ui("my_dashboard") def dashboard() -> Component: ... """ def _register(fn: F, tool_name: str | None) -> F: from fastmcp.server.apps import AppConfig, app_config_to_meta_dict from fastmcp.server.providers.local_provider.decorators.tools import ( PREFAB_RENDERER_URI, _ensure_prefab_renderer, ) try: from prefab_ui.renderer import get_renderer_csp from fastmcp.server.apps import ResourceCSP csp = get_renderer_csp() app_config = AppConfig( resource_uri=PREFAB_RENDERER_URI, visibility=["model"], csp=ResourceCSP( resource_domains=csp.get("resource_domains"), connect_domains=csp.get("connect_domains"), ), ) except ImportError: app_config = AppConfig( resource_uri=PREFAB_RENDERER_URI, visibility=["model"], ) meta: dict[str, Any] = {"ui": app_config_to_meta_dict(app_config)} tool_obj = Tool.from_function( fn, name=tool_name, description=description, title=title, tags=tags, icons=icons, annotations=annotations, meta=meta, timeout=timeout, auth=auth, ) self._local._add_component(tool_obj) # Register the Prefab renderer resource on the internal provider with suppress(ImportError): _ensure_prefab_renderer(self._local) return fn return _dispatch_decorator(name_or_fn, name, _register, "ui") # ------------------------------------------------------------------ # Programmatic tool addition # ------------------------------------------------------------------ def add_tool( self, tool: Tool | Callable[..., Any], *, fn: Any | None = None, ) -> Tool: """Add a tool to this app programmatically. If the tool has ``meta["ui"]["globalKey"]``, it is assumed to already be configured (but still registered for lookup). Otherwise it is treated as a backend tool and gets a global key assigned automatically. Pass ``fn`` to register the original callable in the resolver so that ``CallTool(fn)`` can resolve to the global key. """ if not isinstance(tool, Tool): fn = fn or tool tool = Tool._ensure_tool(tool) meta = tool.meta or {} ui = meta.get("ui", {}) if isinstance(ui, dict) and "globalKey" in ui: global_key = ui["globalKey"] else: global_key = _make_global_key(tool.name) _stamp_global_key(tool, global_key) self._local._add_component(tool) _APP_TOOL_REGISTRY[global_key] = tool if fn is not None: _FN_TO_GLOBAL_KEY[id(fn)] = global_key return tool # ------------------------------------------------------------------ # Provider interface — delegate to internal LocalProvider # ------------------------------------------------------------------ async def _list_tools(self) -> Sequence[Tool]: return await self._local._list_tools() async def _get_tool(self, name: str, version: Any = None) -> Tool | None: return await self._local._get_tool(name, version) async def _list_resources(self) -> Sequence[Any]: return await self._local._list_resources() async def _get_resource(self, uri: str, version: Any = None) -> Any | None: return await self._local._get_resource(uri, version) async def _list_resource_templates(self) -> Sequence[Any]: return await self._local._list_resource_templates() async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: return await self._local._get_resource_template(uri, version) async def _list_prompts(self) -> Sequence[Any]: return await self._local._list_prompts() async def _get_prompt(self, name: str, version: Any = None) -> Any | None: return await self._local._get_prompt(name, version) @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: async with self._local.lifespan(): yield # ------------------------------------------------------------------ # Convenience runner # ------------------------------------------------------------------ def run( self, transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, **kwargs: Any, ) -> None: """Create a temporary FastMCP server and run this app standalone.""" from fastmcp.server.server import FastMCP server = FastMCP(self.name) server.add_provider(self) server.run(transport=transport, **kwargs) ================================================ FILE: src/fastmcp/server/apps.py ================================================ """MCP Apps support — extension negotiation and typed UI metadata models. Provides constants and Pydantic models for the MCP Apps extension (io.modelcontextprotocol/ui), enabling tools and resources to carry UI metadata for clients that support interactive app rendering. """ from __future__ import annotations from typing import Any, Literal from pydantic import BaseModel, Field UI_EXTENSION_ID = "io.modelcontextprotocol/ui" UI_MIME_TYPE = "text/html;profile=mcp-app" class ResourceCSP(BaseModel): """Content Security Policy for MCP App resources. Declares which external origins the app is allowed to connect to or load resources from. Hosts use these declarations to build the ``Content-Security-Policy`` header for the sandboxed iframe. """ connect_domains: list[str] | None = Field( default=None, alias="connectDomains", description="Origins allowed for fetch/XHR/WebSocket (connect-src)", ) resource_domains: list[str] | None = Field( default=None, alias="resourceDomains", description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", ) frame_domains: list[str] | None = Field( default=None, alias="frameDomains", description="Origins allowed for nested iframes (frame-src)", ) base_uri_domains: list[str] | None = Field( default=None, alias="baseUriDomains", description="Allowed base URIs for the document (base-uri)", ) model_config = {"populate_by_name": True, "extra": "allow"} class ResourcePermissions(BaseModel): """Iframe sandbox permissions for MCP App resources. Each field, when set (typically to ``{}``), requests that the host grant the corresponding Permission Policy feature to the sandboxed iframe. Hosts MAY honour these; apps should use JS feature detection as a fallback. """ camera: dict[str, Any] | None = Field( default=None, description="Request camera access" ) microphone: dict[str, Any] | None = Field( default=None, description="Request microphone access" ) geolocation: dict[str, Any] | None = Field( default=None, description="Request geolocation access" ) clipboard_write: dict[str, Any] | None = Field( default=None, alias="clipboardWrite", description="Request clipboard-write access", ) model_config = {"populate_by_name": True, "extra": "allow"} class AppConfig(BaseModel): """Configuration for MCP App tools and resources. Controls how a tool or resource participates in the MCP Apps extension. On tools, ``resource_uri`` and ``visibility`` specify which UI resource to render and where the tool appears. On resources, those fields must be left unset (the resource itself is the UI). All fields use ``exclude_none`` serialization so only explicitly-set values appear on the wire. Aliases match the MCP Apps wire format (camelCase). """ resource_uri: str | None = Field( default=None, alias="resourceUri", description="URI of the UI resource (typically ui:// scheme). Tools only.", ) visibility: list[Literal["app", "model"]] | None = Field( default=None, description="Where this tool is visible: 'app', 'model', or both. Tools only.", ) csp: ResourceCSP | None = Field( default=None, description="Content Security Policy for the app iframe" ) permissions: ResourcePermissions | None = Field( default=None, description="Iframe sandbox permissions" ) domain: str | None = Field(default=None, description="Domain for the iframe") prefers_border: bool | None = Field( default=None, alias="prefersBorder", description="Whether the UI prefers a visible border", ) model_config = {"populate_by_name": True, "extra": "allow"} def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" if isinstance(app, AppConfig): return app.model_dump(by_alias=True, exclude_none=True) return app def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: """Return the appropriate MIME type for a resource URI. For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no explicit MIME type is provided. This ensures UI resources are correctly identified regardless of how they're registered (via FastMCP.resource, the standalone @resource decorator, or resource templates). Args: uri: The resource URI string explicit_mime_type: The MIME type explicitly provided by the user Returns: The resolved MIME type (explicit value, UI default, or None) """ if explicit_mime_type is not None: return explicit_mime_type # Case-insensitive scheme check per RFC 3986 if uri.lower().startswith("ui://"): return UI_MIME_TYPE return None ================================================ FILE: src/fastmcp/server/auth/__init__.py ================================================ from typing import TYPE_CHECKING from .auth import ( OAuthProvider, TokenVerifier, RemoteAuthProvider, MultiAuth, AccessToken, AuthProvider, ) from .authorization import ( AuthCheck, AuthContext, require_scopes, restrict_tag, run_auth_checks, ) if TYPE_CHECKING: from .oauth_proxy import OAuthProxy as OAuthProxy from .oidc_proxy import OIDCProxy as OIDCProxy from .providers.debug import DebugTokenVerifier as DebugTokenVerifier from .providers.jwt import JWTVerifier as JWTVerifier from .providers.jwt import StaticTokenVerifier as StaticTokenVerifier # --- Lazy imports for performance (see #3292) --- # These providers pull in heavy deps (authlib, cryptography, key_value.aio, # beartype) that most users never need. Keeping them behind __getattr__ # avoids ~150ms+ of import overhead for the common server-only case. # Do not convert these back to top-level imports. def __getattr__(name: str) -> object: if name == "DebugTokenVerifier": from .providers.debug import DebugTokenVerifier return DebugTokenVerifier if name == "JWTVerifier": from .providers.jwt import JWTVerifier return JWTVerifier if name == "StaticTokenVerifier": from .providers.jwt import StaticTokenVerifier return StaticTokenVerifier if name == "OAuthProxy": from .oauth_proxy import OAuthProxy return OAuthProxy if name == "OIDCProxy": from .oidc_proxy import OIDCProxy return OIDCProxy raise AttributeError(f"module {__name__!r} has no attribute {name!r}") __all__ = [ "AccessToken", "AuthCheck", "AuthContext", "AuthProvider", "DebugTokenVerifier", "JWTVerifier", "MultiAuth", "OAuthProvider", "OAuthProxy", "OIDCProxy", "RemoteAuthProvider", "StaticTokenVerifier", "TokenVerifier", "require_scopes", "restrict_tag", "run_auth_checks", ] ================================================ FILE: src/fastmcp/server/auth/auth.py ================================================ from __future__ import annotations import json from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse from mcp.server.auth.handlers.token import TokenErrorResponse from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend from mcp.server.auth.middleware.client_auth import ( AuthenticationError, ClientAuthenticator, ) from mcp.server.auth.middleware.client_auth import ( ClientAuthenticator as _SDKClientAuthenticator, ) from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) from mcp.server.auth.provider import ( AuthorizationCode, OAuthAuthorizationServerProvider, RefreshToken, ) from mcp.server.auth.provider import ( TokenVerifier as TokenVerifierProtocol, ) from mcp.server.auth.routes import ( cors_middleware, create_auth_routes, create_protected_resource_routes, ) from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyHttpUrl, Field from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.requests import Request from starlette.routing import Route from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.server.auth.cimd import CIMDClientManager logger = get_logger(__name__) class AccessToken(_SDKAccessToken): """AccessToken that includes all JWT claims.""" claims: dict[str, Any] = Field(default_factory=dict) class TokenHandler(_SDKTokenHandler): """TokenHandler that returns MCP-compliant error responses. This handler addresses two SDK issues: 1. Error code: The SDK returns `unauthorized_client` for client authentication failures, but RFC 6749 Section 5.2 requires `invalid_client` with HTTP 401. This distinction matters for client re-registration behavior. 2. Status code: The SDK returns HTTP 400 for all token errors including `invalid_grant` (expired/invalid tokens). However, the MCP spec requires: "Invalid or expired tokens MUST receive a HTTP 401 response." This handler transforms responses to be compliant with both OAuth 2.1 and MCP specs. """ async def handle(self, request: Any): """Wrap SDK handle() and transform auth error responses.""" response = await super().handle(request) # Transform 401 unauthorized_client -> invalid_client if response.status_code == 401: try: body = json.loads(response.body) if body.get("error") == "unauthorized_client": return PydanticJSONResponse( content=TokenErrorResponse( error="invalid_client", error_description=body.get("error_description"), ), status_code=401, headers={ "Cache-Control": "no-store", "Pragma": "no-cache", }, ) except (json.JSONDecodeError, AttributeError): pass # Not JSON or unexpected format, return as-is # Transform 400 invalid_grant -> 401 for expired/invalid tokens # Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." if response.status_code == 400: try: body = json.loads(response.body) if body.get("error") == "invalid_grant": return PydanticJSONResponse( content=TokenErrorResponse( error="invalid_grant", error_description=body.get("error_description"), ), status_code=401, headers={ "Cache-Control": "no-store", "Pragma": "no-cache", }, ) except (json.JSONDecodeError, AttributeError): pass # Not JSON or unexpected format, return as-is return response # Expected assertion type for private_key_jwt JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" class PrivateKeyJWTClientAuthenticator(_SDKClientAuthenticator): """Client authenticator with private_key_jwt support for CIMD clients. Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` authentication method per RFC 7523. This is required for CIMD (Client ID Metadata Document) clients that use asymmetric keys for authentication. The authenticator: 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) 2. Adds private_key_jwt handling for CIMD clients 3. Validates JWT assertions against client's JWKS """ def __init__( self, provider: OAuthAuthorizationServerProvider[Any, Any, Any], cimd_manager: CIMDClientManager, token_endpoint_url: str, ): """Initialize the authenticator. Args: provider: OAuth provider for client lookups cimd_manager: CIMD manager for private_key_jwt validation token_endpoint_url: Token endpoint URL for audience validation """ super().__init__(provider) self._cimd_manager = cimd_manager self._token_endpoint_url = token_endpoint_url async def authenticate_request( self, request: Request ) -> OAuthClientInformationFull: """Authenticate a client from an HTTP request. Extends SDK authentication to support private_key_jwt for CIMD clients. Delegates to SDK for client_secret_basic (Authorization header) and client_secret_post (form body) authentication. """ form_data = await request.form() client_id = form_data.get("client_id") # If client_id is not in form data, delegate to SDK # This handles client_secret_basic which sends credentials in Authorization header if not client_id: return await super().authenticate_request(request) client = await self.provider.get_client(str(client_id)) if not client: raise AuthenticationError("Invalid client_id") # Handle private_key_jwt authentication for CIMD clients if client.token_endpoint_auth_method == "private_key_jwt": # Validate assertion parameters assertion_type = form_data.get("client_assertion_type") assertion = form_data.get("client_assertion") if assertion_type != JWT_BEARER_ASSERTION_TYPE: raise AuthenticationError( f"Invalid client_assertion_type: expected {JWT_BEARER_ASSERTION_TYPE}" ) if not assertion or not isinstance(assertion, str): raise AuthenticationError("Missing client_assertion") # Validate the JWT assertion using CIMD manager try: await self._cimd_manager.validate_private_key_jwt( assertion=assertion, client=client, token_endpoint=self._token_endpoint_url, ) except ValueError as e: raise AuthenticationError(f"Invalid client assertion: {e}") from e return client # Delegate to SDK for other authentication methods return await super().authenticate_request(request) class AuthProvider(TokenVerifierProtocol): """Base class for all FastMCP authentication providers. This class provides a unified interface for all authentication providers, whether they are simple token verifiers or full OAuth authorization servers. All providers must be able to verify tokens and can optionally provide custom authentication routes. """ def __init__( self, base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, ): """ Initialize the auth provider. Args: base_url: The base URL of this server (e.g., http://localhost:8000). This is used for constructing .well-known endpoints and OAuth metadata. required_scopes: List of OAuth scopes required for all requests. """ if isinstance(base_url, str): base_url = AnyHttpUrl(base_url) self.base_url = base_url self.required_scopes = required_scopes or [] self._mcp_path: str | None = None self._resource_url: AnyHttpUrl | None = None async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid. All auth providers must implement token verification. Args: token: The token string to validate Returns: AccessToken object if valid, None if invalid or expired """ raise NotImplementedError("Subclasses must implement verify_token") def set_mcp_path(self, mcp_path: str | None) -> None: """Set the MCP endpoint path and compute resource URL. This method is called by get_routes() to configure the expected resource URL before route creation. Subclasses can override to perform additional initialization that depends on knowing the MCP endpoint path. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") """ self._mcp_path = mcp_path self._resource_url = self._get_resource_url(mcp_path) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get all routes for this authentication provider. This includes both well-known discovery routes and operational routes. Each provider is responsible for creating whatever routes it needs: - TokenVerifier: typically no routes (default implementation) - RemoteAuthProvider: protected resource metadata routes - OAuthProvider: full OAuth authorization server routes - Custom providers: whatever routes they need Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata, but the provider does not create the actual MCP endpoint route. Returns: List of all routes for this provider (excluding the MCP endpoint itself) """ return [] def get_well_known_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get well-known discovery routes for this authentication provider. This is a utility method that filters get_routes() to return only well-known discovery routes (those starting with /.well-known/). Well-known routes provide OAuth metadata and discovery endpoints that clients use to discover authentication capabilities. These routes should be mounted at the root level of the application to comply with RFC 8414 and RFC 9728. Common well-known routes: - /.well-known/oauth-authorization-server (authorization server metadata) - /.well-known/oauth-protected-resource/* (protected resource metadata) Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to construct path-scoped well-known URLs. Returns: List of well-known discovery routes (typically mounted at root level) """ all_routes = self.get_routes(mcp_path) return [ route for route in all_routes if isinstance(route, Route) and route.path.startswith("/.well-known/") ] def get_middleware(self) -> list: """Get HTTP application-level middleware for this auth provider. Returns: List of Starlette Middleware instances to apply to the HTTP app """ # TODO(ty): remove type ignores when ty supports Starlette Middleware typing return [ Middleware( AuthenticationMiddleware, # type: ignore[arg-type] backend=BearerAuthBackend(self), ), Middleware(AuthContextMiddleware), # type: ignore[arg-type] ] def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None: """Get the actual resource URL being protected. Args: path: The path where the resource endpoint is mounted (e.g., "/mcp") Returns: The full URL of the protected resource """ if self.base_url is None: return None if path: prefix = str(self.base_url).rstrip("/") suffix = path.lstrip("/") return AnyHttpUrl(f"{prefix}/{suffix}") return self.base_url class TokenVerifier(AuthProvider): """Base class for token verifiers (Resource Servers). This class provides token verification capability without OAuth server functionality. Token verifiers typically don't provide authentication routes by default. """ def __init__( self, base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, ): """ Initialize the token verifier. Args: base_url: The base URL of this server required_scopes: Scopes that are required for all requests """ super().__init__(base_url=base_url, required_scopes=required_scopes) @property def scopes_supported(self) -> list[str]: """Scopes to advertise in OAuth metadata. Defaults to required_scopes. Override in subclasses when the advertised scopes differ from the validation scopes (e.g., Azure AD where tokens contain short-form scopes but clients request full URI scopes). """ return self.required_scopes or [] async def verify_token(self, token: str) -> AccessToken | None: """Verify a bearer token and return access info if valid.""" raise NotImplementedError("Subclasses must implement verify_token") class RemoteAuthProvider(AuthProvider): """Authentication provider for resource servers that verify tokens from known authorization servers. This provider composes a TokenVerifier with authorization server metadata to create standardized OAuth 2.0 Protected Resource endpoints (RFC 9728). Perfect for: - JWT verification with known issuers - Remote token introspection services - Any resource server that knows where its tokens come from Use this when you have token verification logic and want to advertise the authorization servers that issue valid tokens. """ base_url: AnyHttpUrl def __init__( self, token_verifier: TokenVerifier, authorization_servers: list[AnyHttpUrl], base_url: AnyHttpUrl | str, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, ): """Initialize the remote auth provider. Args: token_verifier: TokenVerifier instance for token validation authorization_servers: List of authorization servers that issue valid tokens base_url: The base URL of this server scopes_supported: Scopes to advertise in OAuth metadata. If None, uses the token verifier's scopes_supported property. Use this when the scopes clients request differ from the scopes that appear in tokens (e.g., Azure AD full URI scopes vs short-form). resource_name: Optional name for the protected resource resource_documentation: Optional documentation URL for the protected resource """ super().__init__( base_url=base_url, required_scopes=token_verifier.required_scopes, ) self.token_verifier = token_verifier self.authorization_servers = authorization_servers self._scopes_supported = scopes_supported self.resource_name = resource_name self.resource_documentation = resource_documentation async def verify_token(self, token: str) -> AccessToken | None: """Verify token using the configured token verifier.""" return await self.token_verifier.verify_token(token) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get routes for this provider. Creates protected resource metadata routes (RFC 9728). """ routes = [] # Get the resource URL based on the MCP path resource_url = self._get_resource_url(mcp_path) if resource_url: # Add protected resource metadata routes routes.extend( create_protected_resource_routes( resource_url=resource_url, authorization_servers=self.authorization_servers, scopes_supported=( self._scopes_supported if self._scopes_supported is not None else self.token_verifier.scopes_supported ), resource_name=self.resource_name, resource_documentation=self.resource_documentation, ) ) return routes class MultiAuth(AuthProvider): """Composes an optional auth server with additional token verifiers. Use this when a single server needs to accept tokens from multiple sources. For example, an OAuth proxy for interactive clients combined with a JWT verifier for machine-to-machine tokens. Token verification tries the server first (if present), then each verifier in order, returning the first successful result. Routes and OAuth metadata come from the server; verifiers contribute only token verification. Example: ```python from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy auth = MultiAuth( server=OAuthProxy(issuer_url="https://login.example.com/..."), verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")], ) mcp = FastMCP("my-server", auth=auth) ``` """ def __init__( self, *, server: AuthProvider | None = None, verifiers: list[TokenVerifier] | TokenVerifier | None = None, base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, ): """Initialize the multi-auth provider. Args: server: Optional auth provider (e.g., OAuthProxy) that owns routes and OAuth metadata. Also participates in token verification as the first verifier tried. verifiers: One or more token verifiers to try after the server. base_url: Override the base URL. Defaults to the server's base_url. required_scopes: Override required scopes. Defaults to the server's. """ if verifiers is None: verifiers = [] elif isinstance(verifiers, TokenVerifier): verifiers = [verifiers] if server is None and not verifiers: raise ValueError("MultiAuth requires at least a server or one verifier") effective_base_url = base_url or (server.base_url if server else None) effective_scopes = ( required_scopes if required_scopes is not None else (server.required_scopes if server else None) ) super().__init__(base_url=effective_base_url, required_scopes=effective_scopes) self.server = server self.verifiers = list(verifiers) self._sources: list[AuthProvider] = [] if self.server is not None: self._sources.append(self.server) self._sources.extend(self.verifiers) async def verify_token(self, token: str) -> AccessToken | None: """Verify a token by trying the server, then each verifier in order. Each source is tried independently. If a source raises an exception, it is logged and treated as a non-match so that remaining sources still get a chance to verify the token. """ for source in self._sources: try: result = await source.verify_token(token) if result is not None: return result except Exception: logger.debug( "Token verification failed for %s, trying next source", type(source).__name__, exc_info=True, ) return None def set_mcp_path(self, mcp_path: str | None) -> None: """Propagate MCP path to the server and all verifiers.""" super().set_mcp_path(mcp_path) if self.server is not None: self.server.set_mcp_path(mcp_path) for verifier in self.verifiers: verifier.set_mcp_path(mcp_path) def get_routes(self, mcp_path: str | None = None) -> list[Route]: """Delegate route creation to the server.""" if self.server is not None: return self.server.get_routes(mcp_path) return [] def get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]: """Delegate well-known route creation to the server. This ensures that server-specific well-known route logic (e.g., OAuthProvider's RFC 8414 path-aware discovery) is preserved. """ if self.server is not None: return self.server.get_well_known_routes(mcp_path) return [] class OAuthProvider( AuthProvider, OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken], ): """OAuth Authorization Server provider. This class provides full OAuth server functionality including client registration, authorization flows, token issuance, and token verification. """ def __init__( self, *, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, required_scopes: list[str] | None = None, ): """ Initialize the OAuth provider. Args: base_url: The public URL of this FastMCP server issuer_url: The issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: The URL of the service documentation. client_registration_options: The client registration options. revocation_options: The revocation options. required_scopes: Scopes that are required for all requests. """ super().__init__(base_url=base_url, required_scopes=required_scopes) if issuer_url is None: self.issuer_url = self.base_url elif isinstance(issuer_url, str): self.issuer_url = AnyHttpUrl(issuer_url) else: self.issuer_url = issuer_url # Log if issuer_url and base_url differ (requires additional setup) if ( self.base_url is not None and self.issuer_url is not None and str(self.base_url) != str(self.issuer_url) ): logger.info( f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. " f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). " f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers" ) # Initialize OAuth Authorization Server Provider OAuthAuthorizationServerProvider.__init__(self) if isinstance(service_documentation_url, str): service_documentation_url = AnyHttpUrl(service_documentation_url) self.service_documentation_url = service_documentation_url self.client_registration_options = client_registration_options self.revocation_options = revocation_options async def verify_token(self, token: str) -> AccessToken | None: """ Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. Args: token: The token string to validate Returns: AccessToken object if valid, None if invalid or expired """ return await self.load_access_token(token) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth authorization server routes and optional protected resource routes. This method creates the full set of OAuth routes including: - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - Optional protected resource routes Returns: List of OAuth routes """ # Configure resource URL before creating routes self.set_mcp_path(mcp_path) # Create standard OAuth authorization server routes # Pass base_url as issuer_url to ensure metadata declares endpoints where # they're actually accessible (operational routes are mounted at # base_url) assert self.base_url is not None # typing check assert ( self.issuer_url is not None ) # typing check (issuer_url defaults to base_url) sdk_routes = create_auth_routes( provider=self, issuer_url=self.base_url, service_documentation_url=self.service_documentation_url, client_registration_options=self.client_registration_options, revocation_options=self.revocation_options, ) # Replace the token endpoint with our custom handler that returns # proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client) oauth_routes: list[Route] = [] for route in sdk_routes: if ( isinstance(route, Route) and route.path == "/token" and route.methods is not None and "POST" in route.methods ): # Replace with our OAuth 2.1 compliant token handler token_handler = TokenHandler( provider=self, client_authenticator=ClientAuthenticator(self) ) oauth_routes.append( Route( path="/token", endpoint=cors_middleware( token_handler.handle, ["POST", "OPTIONS"] ), methods=["POST", "OPTIONS"], ) ) else: oauth_routes.append(route) # Add protected resource routes if this server is also acting as a resource server if self._resource_url: supported_scopes = ( self.client_registration_options.valid_scopes if self.client_registration_options and self.client_registration_options.valid_scopes else self.required_scopes ) protected_routes = create_protected_resource_routes( resource_url=self._resource_url, authorization_servers=[cast(AnyHttpUrl, self.issuer_url)], scopes_supported=supported_scopes, ) oauth_routes.extend(protected_routes) # Add base routes oauth_routes.extend(super().get_routes(mcp_path)) return oauth_routes def get_well_known_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get well-known discovery routes with RFC 8414 path-aware support. Overrides the base implementation to support path-aware authorization server metadata discovery per RFC 8414. If issuer_url has a path component, the authorization server metadata route is adjusted to include that path. For example, if issuer_url is "http://example.com/api", the discovery endpoint will be at "/.well-known/oauth-authorization-server/api" instead of just "/.well-known/oauth-authorization-server". Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") Returns: List of well-known discovery routes """ routes = super().get_well_known_routes(mcp_path) # RFC 8414: If issuer_url has a path, use path-aware discovery if self.issuer_url: parsed = urlparse(str(self.issuer_url)) issuer_path = parsed.path.rstrip("/") if issuer_path and issuer_path != "/": # Replace /.well-known/oauth-authorization-server with path-aware version new_routes = [] for route in routes: if route.path == "/.well-known/oauth-authorization-server": new_path = ( f"/.well-known/oauth-authorization-server{issuer_path}" ) new_routes.append( Route( new_path, endpoint=route.endpoint, methods=route.methods, ) ) else: new_routes.append(route) return new_routes return routes ================================================ FILE: src/fastmcp/server/auth/authorization.py ================================================ """Authorization checks for FastMCP components. This module provides callable-based authorization for tools, resources, and prompts. Auth checks are functions that receive an AuthContext and return True to allow access or False to deny. Auth checks can also raise exceptions: - AuthorizationError: Propagates with the custom message for explicit denial - Other exceptions: Masked for security (logged, treated as auth failure) Example: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes mcp = FastMCP() @mcp.tool(auth=require_scopes("write")) def protected_tool(): ... @mcp.resource("data://secret", auth=require_scopes("read")) def secret_data(): ... @mcp.prompt(auth=require_scopes("admin")) def admin_prompt(): ... ``` """ from __future__ import annotations import inspect import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, cast from fastmcp.exceptions import AuthorizationError logger = logging.getLogger(__name__) if TYPE_CHECKING: from fastmcp.server.auth import AccessToken from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent @dataclass class AuthContext: """Context passed to auth check callables. This object is passed to each auth check function and provides access to the current authentication token and the component being accessed. Attributes: token: The current access token, or None if unauthenticated. component: The component (tool, resource, or prompt) being accessed. tool: Backwards-compatible alias for component when it's a Tool. """ token: AccessToken | None component: FastMCPComponent @property def tool(self) -> Tool | None: """Backwards-compatible access to the component as a Tool. Returns the component if it's a Tool, None otherwise. """ from fastmcp.tools.base import Tool return self.component if isinstance(self.component, Tool) else None # Type alias for auth check functions (sync or async) AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] def require_scopes(*scopes: str) -> AuthCheck: """Require specific OAuth scopes. Returns an auth check that requires ALL specified scopes to be present in the token (AND logic). Args: *scopes: One or more scope strings that must all be present. Example: ```python @mcp.tool(auth=require_scopes("admin")) def admin_tool(): ... @mcp.tool(auth=require_scopes("read", "write")) def read_write_tool(): ... ``` """ required = set(scopes) def check(ctx: AuthContext) -> bool: if ctx.token is None: return False return required.issubset(set(ctx.token.scopes)) return check def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: """Restrict components with a specific tag to require certain scopes. If the component has the specified tag, the token must have ALL the required scopes. If the component doesn't have the tag, access is allowed. Args: tag: The tag that triggers the scope requirement. scopes: List of scopes required when the tag is present. Example: ```python # Components tagged "admin" require the "admin" scope AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ``` """ required = set(scopes) def check(ctx: AuthContext) -> bool: if tag not in ctx.component.tags: return True # Tag not present, no restriction if ctx.token is None: return False return required.issubset(set(ctx.token.scopes)) return check async def run_auth_checks( checks: AuthCheck | list[AuthCheck], ctx: AuthContext, ) -> bool: """Run auth checks with AND logic. All checks must pass for authorization to succeed. Checks can be synchronous or asynchronous functions. Auth checks can: - Return True to allow access - Return False to deny access - Raise AuthorizationError to deny with a custom message (propagates) - Raise other exceptions (masked for security, treated as denial) Args: checks: A single check function or list of check functions. Each check can be sync (returns bool) or async (returns Awaitable[bool]). ctx: The auth context to pass to each check. Returns: True if all checks pass, False if any check fails. Raises: AuthorizationError: If an auth check explicitly raises it. """ check_list = [checks] if not isinstance(checks, list) else checks check_list = cast(list[AuthCheck], check_list) for check in check_list: try: result = check(ctx) if inspect.isawaitable(result): result = await result if not result: return False except AuthorizationError: # Let AuthorizationError propagate with its custom message raise except Exception: # Mask other exceptions for security - log and treat as auth failure logger.warning( f"Auth check {getattr(check, '__name__', repr(check))} " "raised an unexpected exception", exc_info=True, ) return False return True ================================================ FILE: src/fastmcp/server/auth/cimd.py ================================================ """CIMD (Client ID Metadata Document) support for FastMCP. .. warning:: **Beta Feature**: CIMD support is currently in beta. The API may change in future releases. Please report any issues you encounter. CIMD is a simpler alternative to Dynamic Client Registration where clients host a static JSON document at an HTTPS URL, and that URL becomes their client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document This module provides: - CIMDDocument: Pydantic model for CIMD document validation - CIMDFetcher: Fetch and validate CIMD documents with SSRF protection - CIMDClientManager: Manages CIMD client operations """ from __future__ import annotations import fnmatch import json import time from collections.abc import Mapping from dataclasses import dataclass from datetime import timezone from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING, Any, Literal from urllib.parse import urlparse from pydantic import AnyHttpUrl, BaseModel, Field, field_validator from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, ssrf_safe_fetch_response, validate_url, ) from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.server.auth.providers.jwt import JWTVerifier logger = get_logger(__name__) class CIMDDocument(BaseModel): """CIMD document per draft-parecki-oauth-client-id-metadata-document. The client metadata document is a JSON document containing OAuth client metadata. The client_id property MUST match the URL where this document is hosted. Key constraint: token_endpoint_auth_method MUST NOT use shared secrets (client_secret_post, client_secret_basic, client_secret_jwt). redirect_uris is required and must contain at least one entry. """ client_id: AnyHttpUrl = Field( ..., description="Must match the URL where this document is hosted", ) client_name: str | None = Field( default=None, description="Human-readable name of the client", ) client_uri: AnyHttpUrl | None = Field( default=None, description="URL of the client's home page", ) logo_uri: AnyHttpUrl | None = Field( default=None, description="URL of the client's logo image", ) redirect_uris: list[str] = Field( ..., description="Array of allowed redirect URIs (may include wildcards like http://localhost:*/callback)", ) token_endpoint_auth_method: Literal["none", "private_key_jwt"] = Field( default="none", description="Authentication method for token endpoint (no shared secrets allowed)", ) grant_types: list[str] = Field( default_factory=lambda: ["authorization_code"], description="OAuth grant types the client will use", ) response_types: list[str] = Field( default_factory=lambda: ["code"], description="OAuth response types the client will use", ) scope: str | None = Field( default=None, description="Space-separated list of scopes the client may request", ) contacts: list[str] | None = Field( default=None, description="Contact information for the client developer", ) tos_uri: AnyHttpUrl | None = Field( default=None, description="URL of the client's terms of service", ) policy_uri: AnyHttpUrl | None = Field( default=None, description="URL of the client's privacy policy", ) jwks_uri: AnyHttpUrl | None = Field( default=None, description="URL of the client's JSON Web Key Set (for private_key_jwt)", ) jwks: dict[str, Any] | None = Field( default=None, description="Client's JSON Web Key Set (for private_key_jwt)", ) software_id: str | None = Field( default=None, description="Unique identifier for the client software", ) software_version: str | None = Field( default=None, description="Version of the client software", ) @field_validator("token_endpoint_auth_method") @classmethod def validate_auth_method(cls, v: str) -> str: """Ensure no shared-secret auth methods are used.""" forbidden = {"client_secret_post", "client_secret_basic", "client_secret_jwt"} if v in forbidden: raise ValueError( f"CIMD documents cannot use shared-secret auth methods: {v}. " "Use 'none' or 'private_key_jwt' instead." ) return v @field_validator("redirect_uris") @classmethod def validate_redirect_uris(cls, v: list[str]) -> list[str]: """Ensure redirect_uris is non-empty and each entry is a valid URI.""" if not v: raise ValueError("CIMD documents must include at least one redirect_uri") for uri in v: if not uri or not uri.strip(): raise ValueError("CIMD redirect_uris must be non-empty strings") parsed = urlparse(uri) if not parsed.scheme: raise ValueError( f"CIMD redirect_uri must have a scheme (e.g. http:// or https://): {uri!r}" ) if not parsed.netloc and not uri.startswith("urn:"): raise ValueError(f"CIMD redirect_uri must have a host: {uri!r}") return v class CIMDValidationError(Exception): """Raised when CIMD document validation fails.""" class CIMDFetchError(Exception): """Raised when CIMD document fetching fails.""" @dataclass class _CIMDCacheEntry: """Cached CIMD document and associated HTTP cache metadata.""" doc: CIMDDocument etag: str | None last_modified: str | None expires_at: float freshness_lifetime: float must_revalidate: bool @dataclass class _CIMDCachePolicy: """Normalized cache directives parsed from HTTP response headers.""" etag: str | None last_modified: str | None expires_at: float freshness_lifetime: float no_store: bool must_revalidate: bool class CIMDFetcher: """Fetch and validate CIMD documents with SSRF protection. Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS pinning, IP validation, size limits, and timeout enforcement. Documents are cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with a TTL fallback when response headers do not define caching behavior. """ # Maximum response size (bytes) MAX_RESPONSE_SIZE = 5120 # 5KB # Default cache TTL (seconds) DEFAULT_CACHE_TTL_SECONDS = 3600 def __init__( self, timeout: float = 10.0, ): """Initialize the CIMD fetcher. Args: timeout: HTTP request timeout in seconds (default 10.0) """ self.timeout = timeout self._cache: dict[str, _CIMDCacheEntry] = {} def _parse_cache_policy( self, headers: Mapping[str, str], now: float ) -> _CIMDCachePolicy: """Parse HTTP cache headers and derive cache behavior.""" normalized = {k.lower(): v for k, v in headers.items()} cache_control = normalized.get("cache-control", "") directives = { part.strip().lower() for part in cache_control.split(",") if part.strip() } no_store = "no-store" in directives must_revalidate = "no-cache" in directives max_age: int | None = None for directive in directives: if directive.startswith("max-age="): value = directive.removeprefix("max-age=").strip() try: max_age = max(0, int(value)) except ValueError: logger.debug( "Ignoring invalid Cache-Control max-age value: %s", value ) break expires_at: float | None = None if max_age is not None: expires_at = now + max_age elif "expires" in normalized: try: dt = parsedate_to_datetime(normalized["expires"]) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) expires_at = dt.timestamp() except (TypeError, ValueError): logger.debug( "Ignoring invalid Expires header on CIMD response: %s", normalized["expires"], ) if expires_at is None: expires_at = now + self.DEFAULT_CACHE_TTL_SECONDS freshness_lifetime = max(0.0, expires_at - now) return _CIMDCachePolicy( etag=normalized.get("etag"), last_modified=normalized.get("last-modified"), expires_at=expires_at, freshness_lifetime=freshness_lifetime, no_store=no_store, must_revalidate=must_revalidate, ) def _has_freshness_headers(self, headers: Mapping[str, str]) -> bool: """Return True when response includes cache freshness directives.""" normalized = {k.lower() for k in headers} return "cache-control" in normalized or "expires" in normalized def is_cimd_client_id(self, client_id: str) -> bool: """Check if a client_id looks like a CIMD URL. CIMD URLs must be HTTPS with a host and non-root path. """ if not client_id: return False try: parsed = urlparse(client_id) return ( parsed.scheme == "https" and bool(parsed.netloc) and parsed.path not in ("", "/") ) except (ValueError, AttributeError): return False async def fetch(self, client_id_url: str) -> CIMDDocument: """Fetch and validate a CIMD document with SSRF protection. Uses ssrf_safe_fetch_response for the HTTP layer, which provides: - HTTPS only, DNS resolution with IP validation - DNS pinning (connects to validated IP directly) - Blocks private/loopback/link-local/multicast IPs - Response size limit and timeout enforcement - Redirects disabled Args: client_id_url: The URL to fetch (also the expected client_id) Returns: Validated CIMDDocument Raises: CIMDValidationError: If document is invalid or URL blocked CIMDFetchError: If document cannot be fetched """ cached = self._cache.get(client_id_url) now = time.time() request_headers: dict[str, str] | None = None allowed_status_codes = {200} if cached is not None: if not cached.must_revalidate and now < cached.expires_at: return cached.doc request_headers = {} if cached.etag: request_headers["If-None-Match"] = cached.etag if cached.last_modified: request_headers["If-Modified-Since"] = cached.last_modified if request_headers: allowed_status_codes = {200, 304} try: response = await ssrf_safe_fetch_response( client_id_url, require_path=True, max_size=self.MAX_RESPONSE_SIZE, timeout=self.timeout, overall_timeout=30.0, request_headers=request_headers, allowed_status_codes=allowed_status_codes, ) except SSRFError as e: raise CIMDValidationError(str(e)) from e except SSRFFetchError as e: raise CIMDFetchError(str(e)) from e if response.status_code == 304: if cached is None: raise CIMDFetchError( "CIMD server returned 304 Not Modified without cached document" ) now = time.time() if self._has_freshness_headers(response.headers): policy = self._parse_cache_policy(response.headers, now) else: # RFC allows 304 to omit unchanged headers. Preserve existing # cache policy rather than resetting to fallback defaults. policy = _CIMDCachePolicy( etag=None, last_modified=None, expires_at=now + cached.freshness_lifetime, freshness_lifetime=cached.freshness_lifetime, no_store=False, must_revalidate=cached.must_revalidate, ) if not policy.no_store: self._cache[client_id_url] = _CIMDCacheEntry( doc=cached.doc, etag=policy.etag or cached.etag, last_modified=policy.last_modified or cached.last_modified, expires_at=policy.expires_at, freshness_lifetime=policy.freshness_lifetime, must_revalidate=policy.must_revalidate, ) else: self._cache.pop(client_id_url, None) return cached.doc now = time.time() policy = self._parse_cache_policy(response.headers, now) try: data = json.loads(response.content) except json.JSONDecodeError as e: raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e try: doc = CIMDDocument.model_validate(data) except Exception as e: raise CIMDValidationError(f"Invalid CIMD document: {e}") from e if str(doc.client_id).rstrip("/") != client_id_url.rstrip("/"): raise CIMDValidationError( f"CIMD client_id mismatch: document says '{doc.client_id}' " f"but was fetched from '{client_id_url}'" ) # Validate jwks_uri if present (SSRF check for JWKS endpoint) if doc.jwks_uri: jwks_uri_str = str(doc.jwks_uri) try: await validate_url(jwks_uri_str) except SSRFError as e: raise CIMDValidationError( f"CIMD jwks_uri failed SSRF validation: {e}" ) from e logger.info( "CIMD document fetched and validated: %s (client_name=%s)", client_id_url, doc.client_name, ) if not policy.no_store: self._cache[client_id_url] = _CIMDCacheEntry( doc=doc, etag=policy.etag, last_modified=policy.last_modified, expires_at=policy.expires_at, freshness_lifetime=policy.freshness_lifetime, must_revalidate=policy.must_revalidate, ) else: self._cache.pop(client_id_url, None) return doc def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: """Validate that a redirect_uri is allowed by the CIMD document. Args: doc: The CIMD document redirect_uri: The redirect URI to validate Returns: True if valid, False otherwise """ if not doc.redirect_uris: # No redirect_uris specified - reject all return False # Normalize for comparison redirect_uri = redirect_uri.rstrip("/") for allowed in doc.redirect_uris: allowed_str = allowed.rstrip("/") if redirect_uri == allowed_str: return True # Check for wildcard port matching (http://localhost:*/callback) if "*" in allowed_str: if fnmatch.fnmatch(redirect_uri, allowed_str): return True return False class CIMDAssertionValidator: """Validates JWT assertions for private_key_jwt CIMD clients. Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants) for CIMD client authentication. JTI replay protection uses TTL-based caching to ensure proper security: - JTIs are cached with expiration matching the JWT's exp claim - Expired JTIs are automatically cleaned up - Maximum assertion lifetime is enforced (5 minutes) """ # Maximum allowed assertion lifetime in seconds (RFC 7523 recommends short-lived) MAX_ASSERTION_LIFETIME = 300 # 5 minutes def __init__(self): # JTI cache: maps jti -> expiration timestamp self._jti_cache: dict[str, float] = {} self._jti_cache_max_size = 10000 self._last_cleanup = time.monotonic() self._cleanup_interval = 60 # Cleanup every 60 seconds # Cache JWTVerifier per jwks_uri so JWKS keys are not re-fetched # on every token exchange self._verifier_cache: dict[str, JWTVerifier] = {} self._verifier_cache_max_size = 100 self.logger = get_logger(__name__) def _cleanup_expired_jtis(self) -> None: """Remove expired JTIs from cache.""" now = time.time() expired = [jti for jti, exp in self._jti_cache.items() if exp < now] for jti in expired: del self._jti_cache[jti] if expired: self.logger.debug("Cleaned up %d expired JTIs from cache", len(expired)) def _maybe_cleanup(self) -> None: """Periodically cleanup expired JTIs to prevent unbounded growth.""" now = time.monotonic() if now - self._last_cleanup > self._cleanup_interval: self._cleanup_expired_jtis() self._last_cleanup = now async def validate_assertion( self, assertion: str, client_id: str, token_endpoint: str, cimd_doc: CIMDDocument, ) -> bool: """Validate JWT assertion from client. Args: assertion: The JWT assertion string client_id: Expected client_id (must match iss and sub claims) token_endpoint: Token endpoint URL (must match aud claim) cimd_doc: CIMD document containing JWKS for key verification Returns: True if valid Raises: ValueError: If validation fails """ from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier # Periodic cleanup of expired JTIs self._maybe_cleanup() # 1. Validate CIMD document has key material and get/create verifier if cimd_doc.jwks_uri: jwks_uri_str = str(cimd_doc.jwks_uri) cache_key = f"{jwks_uri_str}|{client_id}|{token_endpoint}" verifier = self._verifier_cache.get(cache_key) if verifier is None: verifier = _JWTVerifier( jwks_uri=jwks_uri_str, issuer=client_id, audience=token_endpoint, ssrf_safe=True, ) if len(self._verifier_cache) >= self._verifier_cache_max_size: oldest_key = next(iter(self._verifier_cache)) del self._verifier_cache[oldest_key] self._verifier_cache[cache_key] = verifier elif cimd_doc.jwks: # Inline JWKS — no caching since the key is embedded public_key = self._extract_public_key_from_jwks(assertion, cimd_doc.jwks) verifier = _JWTVerifier( public_key=public_key, issuer=client_id, audience=token_endpoint, ) else: raise ValueError( "CIMD document must have jwks_uri or jwks for private_key_jwt" ) # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud) access_token = await verifier.load_access_token(assertion) if not access_token: raise ValueError("Invalid JWT assertion") claims = access_token.claims # 3. Validate assertion lifetime (exp and iat) now = time.time() exp = claims.get("exp") iat = claims.get("iat") if not exp: raise ValueError("Assertion must include exp claim") # Validate exp is in the future (with small clock skew tolerance) if exp < now - 30: # 30 second clock skew tolerance raise ValueError("Assertion has expired") # If iat is present, validate it and check assertion lifetime if iat: if iat > now + 30: # 30 second clock skew tolerance raise ValueError("Assertion iat is in the future") if exp - iat > self.MAX_ASSERTION_LIFETIME: raise ValueError( f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)" ) else: # No iat, enforce max lifetime from now if exp > now + self.MAX_ASSERTION_LIFETIME: raise ValueError( f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)" ) # 4. Additional RFC 7523 validation: sub claim must equal client_id if claims.get("sub") != client_id: raise ValueError(f"Assertion sub claim must be {client_id}") # 5. Check jti for replay attacks (RFC 7523 requirement) jti = claims.get("jti") if not jti: raise ValueError("Assertion must include jti claim") # Check if JTI was already used (and hasn't expired from cache) if jti in self._jti_cache: cached_exp = self._jti_cache[jti] if cached_exp > now: # Still valid in cache raise ValueError(f"Assertion replay detected: jti {jti} already used") # Expired in cache, can be reused (clean it up) del self._jti_cache[jti] # Add to cache with expiration time # Use the assertion's exp claim so it stays cached until it would expire anyway self._jti_cache[jti] = exp # Emergency size limit (shouldn't hit with proper TTL cleanup) if len(self._jti_cache) > self._jti_cache_max_size: self._cleanup_expired_jtis() # If still over limit after cleanup, reject to prevent DoS if len(self._jti_cache) > self._jti_cache_max_size: self.logger.warning( "JTI cache at max capacity (%d), possible attack", self._jti_cache_max_size, ) raise ValueError("Server overloaded, please retry") self.logger.debug( "JWT assertion validated successfully for client %s", client_id ) return True def _extract_public_key_from_jwks(self, token: str, jwks: dict) -> str: """Extract public key from inline JWKS. Args: token: JWT token to extract kid from jwks: JWKS document containing keys Returns: PEM-encoded public key Raises: ValueError: If key cannot be found or extracted """ import base64 import json from authlib.jose import JsonWebKey # Extract kid from token header try: header_b64 = token.split(".")[0] header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding header = json.loads(base64.urlsafe_b64decode(header_b64)) kid = header.get("kid") except Exception as e: raise ValueError(f"Failed to extract key ID from token: {e}") from e # Find matching key in JWKS keys = jwks.get("keys", []) if not keys: raise ValueError("JWKS document contains no keys") matching_key = None for key in keys: if kid and key.get("kid") == kid: matching_key = key break if not matching_key: # If no kid match, try first key as fallback if len(keys) == 1: matching_key = keys[0] self.logger.warning( "No matching kid in JWKS, using single available key" ) else: raise ValueError(f"No matching key found for kid={kid} in JWKS") # Convert JWK to PEM try: jwk = JsonWebKey.import_key(matching_key) return jwk.as_pem().decode("utf-8") except Exception as e: raise ValueError(f"Failed to convert JWK to PEM: {e}") from e class CIMDClientManager: """Manages all CIMD client operations for OAuth proxy. This class encapsulates: - CIMD client detection - Document fetching and validation - Synthetic OAuth client creation - Private key JWT assertion validation This allows the OAuth proxy to delegate all CIMD-specific logic to a single, focused manager class. """ def __init__( self, enable_cimd: bool = True, default_scope: str = "", allowed_redirect_uri_patterns: list[str] | None = None, ): """Initialize CIMD client manager. Args: enable_cimd: Whether CIMD support is enabled default_scope: Default scope for CIMD clients if not specified in document allowed_redirect_uri_patterns: Allowed redirect URI patterns (proxy's config) """ self.enabled = enable_cimd self.default_scope = default_scope self.allowed_redirect_uri_patterns = allowed_redirect_uri_patterns self._fetcher = CIMDFetcher() self._assertion_validator = CIMDAssertionValidator() self.logger = get_logger(__name__) def is_cimd_client_id(self, client_id: str) -> bool: """Check if client_id is a CIMD URL. Args: client_id: Client ID to check Returns: True if client_id is an HTTPS URL (CIMD format) """ return self.enabled and self._fetcher.is_cimd_client_id(client_id) async def get_client(self, client_id_url: str): """Fetch CIMD document and create synthetic OAuth client. Args: client_id_url: HTTPS URL pointing to CIMD document Returns: OAuthProxyClient with CIMD document attached, or None if fetch fails Note: Return type is left untyped to avoid circular import with oauth_proxy. Returns OAuthProxyClient instance or None. """ if not self.enabled: return None try: cimd_doc = await self._fetcher.fetch(client_id_url) except (CIMDFetchError, CIMDValidationError) as e: self.logger.warning("CIMD fetch failed for %s: %s", client_id_url, e) return None # Import here to avoid circular dependency from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient # Create synthetic client from CIMD document. # Keep CIMD redirect_uris as strings on the document itself so wildcard # patterns like http://localhost:*/callback remain valid. redirect_uris = None client = ProxyDCRClient( client_id=client_id_url, client_secret=None, redirect_uris=redirect_uris, grant_types=cimd_doc.grant_types, scope=cimd_doc.scope or self.default_scope, token_endpoint_auth_method=cimd_doc.token_endpoint_auth_method, allowed_redirect_uri_patterns=self.allowed_redirect_uri_patterns, client_name=cimd_doc.client_name, cimd_document=cimd_doc, cimd_fetched_at=time.time(), ) self.logger.debug( "CIMD client resolved: %s (name=%s)", client_id_url, cimd_doc.client_name, ) return client async def validate_private_key_jwt( self, assertion: str, client, # OAuthProxyClient, untyped to avoid circular import token_endpoint: str, ) -> bool: """Validate JWT assertion for private_key_jwt auth. Args: assertion: JWT assertion string from client client: OAuth proxy client (must have cimd_document) token_endpoint: Token endpoint URL for aud validation Returns: True if assertion is valid Raises: ValueError: If client doesn't have CIMD document or validation fails """ if not hasattr(client, "cimd_document") or not client.cimd_document: raise ValueError("Client must have CIMD document for private_key_jwt") cimd_doc = client.cimd_document if cimd_doc.token_endpoint_auth_method != "private_key_jwt": raise ValueError("CIMD document must specify private_key_jwt auth method") return await self._assertion_validator.validate_assertion( assertion, client.client_id, token_endpoint, cimd_doc ) ================================================ FILE: src/fastmcp/server/auth/handlers/authorize.py ================================================ """Enhanced authorization handler with improved error responses. This module provides an enhanced authorization handler that wraps the MCP SDK's AuthorizationHandler to provide better error messages when clients attempt to authorize with unregistered client IDs. The enhancement adds: - Content negotiation: HTML for browsers, JSON for API clients - Enhanced JSON responses with registration endpoint hints - Styled HTML error pages with registration links/forms - Link headers pointing to registration endpoints """ from __future__ import annotations import json from typing import TYPE_CHECKING from mcp.server.auth.handlers.authorize import ( AuthorizationHandler as SDKAuthorizationHandler, ) from pydantic import AnyHttpUrl from starlette.requests import Request from starlette.responses import Response from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import ( INFO_BOX_STYLES, TOOLTIP_STYLES, create_logo, create_page, create_secure_html_response, ) if TYPE_CHECKING: from mcp.server.auth.provider import OAuthAuthorizationServerProvider logger = get_logger(__name__) def create_unregistered_client_html( client_id: str, registration_endpoint: str, discovery_endpoint: str, server_name: str | None = None, server_icon_url: str | None = None, title: str = "Client Not Registered", ) -> str: """Create styled HTML error page for unregistered client attempts. Args: client_id: The unregistered client ID that was provided registration_endpoint: URL of the registration endpoint discovery_endpoint: URL of the OAuth metadata discovery endpoint server_name: Optional server name for branding server_icon_url: Optional server icon URL title: Page title Returns: HTML string for the error page """ import html as html_module client_id_escaped = html_module.escape(client_id) # Main error message error_box = f"""

The client ID {client_id_escaped} was not found in the server's client registry.

""" # What to do - yellow warning box warning_box = """

Your MCP client opened this page to complete OAuth authorization, but the server did not recognize its client ID. To fix this:

  • Close this browser window
  • Clear authentication tokens in your MCP client (or restart it)
  • Try connecting again - your client should automatically re-register
""" # Help link with tooltip (similar to consent screen) help_link = """ """ # Build page content content = f"""
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}

{title}

{error_box} {warning_box}
{help_link} """ # Use same styles as consent page additional_styles = ( INFO_BOX_STYLES + TOOLTIP_STYLES + """ /* Error variant for info-box */ .info-box.error { background: #fef2f2; border-color: #f87171; } .info-box.error strong { color: #991b1b; } /* Warning variant for info-box (yellow) */ .info-box.warning { background: #fffbeb; border-color: #fbbf24; } .info-box.warning strong { color: #92400e; } .info-box code { background: rgba(0, 0, 0, 0.05); padding: 2px 6px; border-radius: 3px; font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; font-size: 0.9em; } .info-box ul { margin: 10px 0; padding-left: 20px; } .info-box li { margin: 6px 0; } """ ) return create_page( content=content, title=title, additional_styles=additional_styles, ) class AuthorizationHandler(SDKAuthorizationHandler): """Authorization handler with enhanced error responses for unregistered clients. This handler extends the MCP SDK's AuthorizationHandler to provide better UX when clients attempt to authorize without being registered. It implements content negotiation to return: - HTML error pages for browser requests - Enhanced JSON with registration hints for API clients - Link headers pointing to registration endpoints This maintains OAuth 2.1 compliance (returns 400 for invalid client_id) while providing actionable guidance to fix the error. """ def __init__( self, provider: OAuthAuthorizationServerProvider, base_url: AnyHttpUrl | str, server_name: str | None = None, server_icon_url: str | None = None, ): """Initialize the enhanced authorization handler. Args: provider: OAuth authorization server provider base_url: Base URL of the server for constructing endpoint URLs server_name: Optional server name for branding server_icon_url: Optional server icon URL for branding """ super().__init__(provider) self._base_url = str(base_url).rstrip("/") self._server_name = server_name self._server_icon_url = server_icon_url async def handle(self, request: Request) -> Response: """Handle authorization request with enhanced error responses. This method extends the SDK's authorization handler and intercepts errors for unregistered clients to provide better error responses based on the client's Accept header. Args: request: The authorization request Returns: Response (redirect on success, error response on failure) """ # Call the SDK handler response = await super().handle(request) # Check if this is a client not found error if response.status_code == 400: # Try to extract client_id from request for enhanced error client_id: str | None = None if request.method == "GET": client_id = request.query_params.get("client_id") else: form = await request.form() client_id_value = form.get("client_id") # Ensure client_id is a string, not UploadFile if isinstance(client_id_value, str): client_id = client_id_value # If we have a client_id and the error is about it not being found, # enhance the response if client_id: try: # Check if response body contains "not found" error if hasattr(response, "body"): body = json.loads(bytes(response.body)) if ( body.get("error") == "invalid_request" and "not found" in body.get("error_description", "").lower() ): return await self._create_enhanced_error_response( request, client_id, body.get("state") ) except Exception: # If we can't parse the response, just return the original pass return response async def _create_enhanced_error_response( self, request: Request, client_id: str, state: str | None ) -> Response: """Create enhanced error response with content negotiation. Args: request: The original request client_id: The unregistered client ID state: The state parameter from the request Returns: HTML or JSON error response based on Accept header """ registration_endpoint = f"{self._base_url}/register" discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server" # Extract server metadata from app state (same pattern as consent screen) from fastmcp.server.server import FastMCP fastmcp = getattr(request.app.state, "fastmcp_server", None) if isinstance(fastmcp, FastMCP): server_name = fastmcp.name icons = fastmcp.icons server_icon_url = icons[0].src if icons else None else: server_name = self._server_name server_icon_url = self._server_icon_url # Check Accept header for content negotiation accept = request.headers.get("accept", "") # Prefer HTML for browsers if "text/html" in accept: html = create_unregistered_client_html( client_id=client_id, registration_endpoint=registration_endpoint, discovery_endpoint=discovery_endpoint, server_name=server_name, server_icon_url=server_icon_url, ) response = create_secure_html_response(html, status_code=400) else: # Return enhanced JSON for API clients from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse error_data = AuthorizationErrorResponse( error="invalid_request", error_description=( f"Client ID '{client_id}' is not registered with this server. " f"MCP clients should automatically re-register by sending a POST request to " f"the registration_endpoint and retry authorization. " f"If this persists, clear cached authentication tokens and reconnect." ), state=state, ) # Add extra fields to help clients discover registration error_dict = error_data.model_dump(exclude_none=True) error_dict["registration_endpoint"] = registration_endpoint error_dict["authorization_server_metadata"] = discovery_endpoint from starlette.responses import JSONResponse response = JSONResponse( status_code=400, content=error_dict, headers={"Cache-Control": "no-store"}, ) # Add Link header for registration endpoint discovery response.headers["Link"] = ( f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"' ) logger.info( "Unregistered client_id=%s, returned %s error response", client_id, "HTML" if "text/html" in accept else "JSON", ) return response ================================================ FILE: src/fastmcp/server/auth/jwt_issuer.py ================================================ """JWT token issuance and verification for FastMCP OAuth Proxy. This module implements the token factory pattern for OAuth proxies, where the proxy issues its own JWT tokens to clients instead of forwarding upstream provider tokens. This maintains proper OAuth 2.0 token audience boundaries. """ from __future__ import annotations import base64 import time from typing import Any, overload from authlib.jose import JsonWebToken from authlib.jose.errors import JoseError from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC import fastmcp from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) KDF_ITERATIONS = 1_000_000 KDF_ITERATIONS_TEST = 10 @overload def derive_jwt_key(*, high_entropy_material: str, salt: str) -> bytes: """Derive JWT signing key from a high-entropy key material and server salt.""" @overload def derive_jwt_key(*, low_entropy_material: str, salt: str) -> bytes: """Derive JWT signing key from a low-entropy key material and server salt.""" def derive_jwt_key( *, high_entropy_material: str | None = None, low_entropy_material: str | None = None, salt: str, ) -> bytes: """Derive JWT signing key from a high-entropy or low-entropy key material and server salt.""" if high_entropy_material is not None and low_entropy_material is not None: raise ValueError( "Either high_entropy_material or low_entropy_material must be provided, but not both" ) if high_entropy_material is not None: derived_key = HKDF( algorithm=hashes.SHA256(), length=32, salt=salt.encode(), info=b"Fernet", ).derive(key_material=high_entropy_material.encode()) return base64.urlsafe_b64encode(derived_key) if low_entropy_material is not None: iterations = ( KDF_ITERATIONS_TEST if fastmcp.settings.test_mode else KDF_ITERATIONS ) pbkdf2 = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt.encode(), iterations=iterations, ).derive(key_material=low_entropy_material.encode()) return base64.urlsafe_b64encode(pbkdf2) raise ValueError( "Either high_entropy_material or low_entropy_material must be provided" ) class JWTIssuer: """Issues and validates FastMCP-signed JWT tokens using HS256. This issuer creates JWT tokens for MCP clients with proper audience claims, maintaining OAuth 2.0 token boundaries. Tokens are signed with HS256 using a key derived from the upstream client secret. """ def __init__( self, issuer: str, audience: str, signing_key: bytes, ): """Initialize JWT issuer. Args: issuer: Token issuer (FastMCP server base URL) audience: Token audience (typically {base_url}/mcp) signing_key: HS256 signing key (32 bytes) """ self.issuer = issuer self.audience = audience self._signing_key = signing_key self._jwt = JsonWebToken(["HS256"]) def issue_access_token( self, client_id: str, scopes: list[str], jti: str, expires_in: int = 3600, upstream_claims: dict[str, Any] | None = None, ) -> str: """Issue a minimal FastMCP access token. FastMCP tokens are reference tokens containing only the minimal claims needed for validation and lookup. The JTI maps to the upstream token which contains actual user identity and authorization data. Args: client_id: MCP client ID scopes: Token scopes jti: Unique token identifier (maps to upstream token) expires_in: Token lifetime in seconds upstream_claims: Optional claims from upstream IdP token to include Returns: Signed JWT token """ now = int(time.time()) header = {"alg": "HS256", "typ": "JWT"} payload: dict[str, Any] = { "iss": self.issuer, "aud": self.audience, "client_id": client_id, "scope": " ".join(scopes), "exp": now + expires_in, "iat": now, "jti": jti, } if upstream_claims: payload["upstream_claims"] = upstream_claims token_bytes = self._jwt.encode(header, payload, self._signing_key) token = token_bytes.decode("utf-8") logger.debug( "Issued access token for client=%s jti=%s exp=%d", client_id, jti[:8], payload["exp"], ) return token def issue_refresh_token( self, client_id: str, scopes: list[str], jti: str, expires_in: int, upstream_claims: dict[str, Any] | None = None, ) -> str: """Issue a minimal FastMCP refresh token. FastMCP refresh tokens are reference tokens containing only the minimal claims needed for validation and lookup. The JTI maps to the upstream token which contains actual user identity and authorization data. Args: client_id: MCP client ID scopes: Token scopes jti: Unique token identifier (maps to upstream token) expires_in: Token lifetime in seconds (should match upstream refresh expiry) upstream_claims: Optional claims from upstream IdP token to include Returns: Signed JWT token """ now = int(time.time()) header = {"alg": "HS256", "typ": "JWT"} payload: dict[str, Any] = { "iss": self.issuer, "aud": self.audience, "client_id": client_id, "scope": " ".join(scopes), "exp": now + expires_in, "iat": now, "jti": jti, "token_use": "refresh", } if upstream_claims: payload["upstream_claims"] = upstream_claims token_bytes = self._jwt.encode(header, payload, self._signing_key) token = token_bytes.decode("utf-8") logger.debug( "Issued refresh token for client=%s jti=%s exp=%d", client_id, jti[:8], payload["exp"], ) return token def verify_token( self, token: str, expected_token_use: str = "access", ) -> dict[str, Any]: """Verify and decode a FastMCP token. Validates JWT signature, expiration, issuer, audience, and token type. Args: token: JWT token to verify expected_token_use: Expected token type ("access" or "refresh"). Defaults to "access", which rejects refresh tokens. Returns: Decoded token payload Raises: JoseError: If token is invalid, expired, or has wrong claims """ try: # Decode and verify signature payload = self._jwt.decode(token, self._signing_key) # Validate token type token_use = payload.get("token_use", "access") if token_use != expected_token_use: logger.debug( "Token type mismatch: expected %s, got %s", expected_token_use, token_use, ) raise JoseError( f"Token type mismatch: expected {expected_token_use}, " f"got {token_use}" ) # Validate expiration exp = payload.get("exp") if exp and exp < time.time(): logger.debug("Token expired") raise JoseError("Token has expired") # Validate issuer if payload.get("iss") != self.issuer: logger.debug("Token has invalid issuer") raise JoseError("Invalid token issuer") # Validate audience if payload.get("aud") != self.audience: logger.debug("Token has invalid audience") raise JoseError("Invalid token audience") logger.debug( "Token verified successfully for subject=%s", payload.get("sub") ) return payload except JoseError as e: logger.debug("Token validation failed: %s", e) raise ================================================ FILE: src/fastmcp/server/auth/middleware.py ================================================ """Enhanced authentication middleware with better error messages. This module provides enhanced versions of MCP SDK authentication middleware that return more helpful error messages for developers troubleshooting authentication issues. """ from __future__ import annotations import json from mcp.server.auth.middleware.bearer_auth import ( RequireAuthMiddleware as SDKRequireAuthMiddleware, ) from starlette.types import Send from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class RequireAuthMiddleware(SDKRequireAuthMiddleware): """Enhanced authentication middleware with detailed error messages. Extends the SDK's RequireAuthMiddleware to provide more actionable error messages when authentication fails. This helps developers understand what went wrong and how to fix it. """ async def _send_auth_error( self, send: Send, status_code: int, error: str, description: str ) -> None: """Send an authentication error response with enhanced error messages. Overrides the SDK's _send_auth_error to provide more detailed error descriptions that help developers troubleshoot authentication issues. Args: send: ASGI send callable status_code: HTTP status code (401 or 403) error: OAuth error code description: Base error description """ # Enhance error descriptions based on error type enhanced_description = description if error == "invalid_token" and status_code == 401: # This is the "Authentication required" error enhanced_description = ( "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. " "To resolve: clear authentication tokens in your MCP client and reconnect. " "Your client should automatically re-register and obtain new tokens." ) elif error == "insufficient_scope": # Scope error - already has good detail from SDK pass # Build WWW-Authenticate header value www_auth_parts = [ f'error="{error}"', f'error_description="{enhanced_description}"', ] if self.resource_metadata_url: www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') www_authenticate = f"Bearer {', '.join(www_auth_parts)}" # Send response body = {"error": error, "error_description": enhanced_description} body_bytes = json.dumps(body).encode() await send( { "type": "http.response.start", "status": status_code, "headers": [ (b"content-type", b"application/json"), (b"content-length", str(len(body_bytes)).encode()), (b"www-authenticate", www_authenticate.encode()), ], } ) await send( { "type": "http.response.body", "body": body_bytes, } ) logger.info( "Auth error returned: %s (status=%d)", error, status_code, ) ================================================ FILE: src/fastmcp/server/auth/oauth_proxy/__init__.py ================================================ """OAuth Proxy Provider for FastMCP. This package provides OAuth proxy functionality split across multiple modules: - models: Pydantic models and constants - ui: HTML generation functions - consent: Consent management mixin - proxy: Main OAuthProxy class """ from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy __all__ = [ "OAuthProxy", ] ================================================ FILE: src/fastmcp/server/auth/oauth_proxy/consent.py ================================================ """OAuth Proxy Consent Management. This module contains consent management functionality for the OAuth proxy. The ConsentMixin class provides methods for handling user consent flows, cookie management, and consent page rendering. """ from __future__ import annotations import base64 import hashlib import hmac import json import secrets import time from base64 import urlsafe_b64encode from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlparse from pydantic import AnyUrl from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient from fastmcp.server.auth.oauth_proxy.ui import create_consent_html from fastmcp.utilities.logging import get_logger from fastmcp.utilities.ui import create_secure_html_response if TYPE_CHECKING: from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy logger = get_logger(__name__) class ConsentMixin: """Mixin class providing consent management functionality for OAuthProxy. This mixin contains all methods related to: - Cookie signing and verification - Consent page rendering - Consent approval/denial handling - URI normalization for consent tracking """ def _normalize_uri(self, uri: str) -> str: """Normalize a URI to a canonical form for consent tracking.""" parsed = urlparse(uri) path = parsed.path or "" normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}" if normalized.endswith("/") and len(path) > 1: normalized = normalized[:-1] return normalized def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str: """Create a stable key for consent tracking from client_id and redirect_uri.""" normalized = self._normalize_uri(str(redirect_uri)) return f"{client_id}:{normalized}" def _cookie_name(self: OAuthProxy, base_name: str) -> str: """Return secure cookie name for HTTPS, fallback for HTTP development.""" if self._is_https: return f"__Host-{base_name}" return f"__{base_name}" def _cookie_signing_key(self: OAuthProxy) -> bytes: """Return the key used for HMAC-signing consent cookies. Uses the upstream client secret when available, falling back to the JWT signing key (which is always present — OAuthProxy requires it when no client secret is provided). """ if self._upstream_client_secret is not None: return self._upstream_client_secret.get_secret_value().encode() return self._jwt_signing_key def _sign_cookie(self: OAuthProxy, payload: str) -> str: """Sign a cookie payload with HMAC-SHA256. Returns: base64(payload).base64(signature) """ key = self._cookie_signing_key() signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() signature_b64 = base64.b64encode(signature).decode() return f"{payload}.{signature_b64}" def _verify_cookie(self: OAuthProxy, signed_value: str) -> str | None: """Verify and extract payload from signed cookie. Returns: payload if signature valid, None otherwise """ try: if "." not in signed_value: return None payload, signature_b64 = signed_value.rsplit(".", 1) # Verify signature key = self._cookie_signing_key() expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() provided_sig = base64.b64decode(signature_b64.encode()) # Constant-time comparison if not hmac.compare_digest(expected_sig, provided_sig): return None return payload except Exception: return None def _decode_list_cookie( self: OAuthProxy, request: Request, base_name: str ) -> list[str]: """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" secure_name = self._cookie_name(base_name) raw = request.cookies.get(secure_name) # Only fall back to the non-__Host- name over plain HTTP. On HTTPS, # __Host- enforces host-only scope; accepting the weaker name would # let a sibling-subdomain attacker inject a domain-scoped cookie. if not raw and not self._is_https: raw = request.cookies.get(f"__{base_name}") if not raw: return [] try: # Verify signature payload = self._verify_cookie(raw) if not payload: logger.debug("Cookie signature verification failed for %s", secure_name) return [] # Decode payload data = base64.b64decode(payload.encode()) value = json.loads(data.decode()) if isinstance(value, list): return [str(x) for x in value] except Exception: logger.debug("Failed to decode cookie %s; treating as empty", secure_name) return [] def _encode_list_cookie(self: OAuthProxy, values: list[str]) -> str: """Encode values to base64 and sign with HMAC. Returns: signed cookie value (payload.signature) """ payload = json.dumps(values, separators=(",", ":")).encode() payload_b64 = base64.b64encode(payload).decode() return self._sign_cookie(payload_b64) def _set_list_cookie( self: OAuthProxy, response: HTMLResponse | RedirectResponse, base_name: str, value_b64: str, max_age: int, ) -> None: name = self._cookie_name(base_name) response.set_cookie( name, value_b64, max_age=max_age, secure=self._is_https, httponly=True, samesite="lax", path="/", ) def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]: """Read the consent binding map from the signed cookie. Returns a dict of {txn_id: consent_token} for all pending flows. """ cookie_name = self._cookie_name("MCP_CONSENT_BINDING") raw = request.cookies.get(cookie_name) # Only fall back to the non-__Host- name over plain HTTP. On HTTPS, # __Host- enforces host-only scope; accepting the weaker name would # bypass that guarantee. if not raw and not self._is_https: raw = request.cookies.get("__MCP_CONSENT_BINDING") if not raw: return {} payload = self._verify_cookie(raw) if not payload: return {} try: data = json.loads(base64.b64decode(payload.encode()).decode()) if isinstance(data, dict): return {str(k): str(v) for k, v in data.items()} except Exception: logger.debug("Failed to decode consent binding cookie") return {} def _write_consent_bindings( self: OAuthProxy, response: HTMLResponse | RedirectResponse, bindings: dict[str, str], ) -> None: """Write the consent binding map to a signed cookie.""" name = self._cookie_name("MCP_CONSENT_BINDING") if not bindings: response.set_cookie( name, "", max_age=0, secure=self._is_https, httponly=True, samesite="lax", path="/", ) return payload_bytes = json.dumps(bindings, separators=(",", ":")).encode() payload_b64 = base64.b64encode(payload_bytes).decode() signed_value = self._sign_cookie(payload_b64) response.set_cookie( name, signed_value, max_age=15 * 60, secure=self._is_https, httponly=True, samesite="lax", path="/", ) def _set_consent_binding_cookie( self: OAuthProxy, request: Request, response: HTMLResponse | RedirectResponse, txn_id: str, consent_token: str, ) -> None: """Add a consent binding entry for a transaction. This cookie binds the browser that approved consent to the IdP callback, ensuring a different browser cannot complete the OAuth flow. Multiple concurrent flows are supported by storing a map of txn_id → consent_token. """ bindings = self._read_consent_bindings(request) bindings[txn_id] = consent_token self._write_consent_bindings(response, bindings) def _clear_consent_binding_cookie( self: OAuthProxy, request: Request, response: HTMLResponse | RedirectResponse, txn_id: str, ) -> None: """Remove a specific consent binding entry after successful callback.""" bindings = self._read_consent_bindings(request) bindings.pop(txn_id, None) self._write_consent_bindings(response, bindings) def _verify_consent_binding_cookie( self: OAuthProxy, request: Request, txn_id: str, expected_token: str, ) -> bool: """Verify the consent binding for a specific transaction.""" bindings = self._read_consent_bindings(request) actual = bindings.get(txn_id) if not actual: return False return hmac.compare_digest(actual, expected_token) def _build_upstream_authorize_url( self: OAuthProxy, txn_id: str, transaction: dict[str, Any] ) -> str: """Construct the upstream IdP authorization URL using stored transaction data.""" query_params: dict[str, Any] = { "response_type": "code", "client_id": self._upstream_client_id, "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", "state": txn_id, } scopes_to_use = transaction.get("scopes") or self.required_scopes or [] if scopes_to_use: query_params["scope"] = " ".join(scopes_to_use) # If PKCE forwarding was enabled, include the proxy challenge proxy_code_verifier = transaction.get("proxy_code_verifier") if proxy_code_verifier: challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() proxy_code_challenge = ( urlsafe_b64encode(challenge_bytes).decode().rstrip("=") ) query_params["code_challenge"] = proxy_code_challenge query_params["code_challenge_method"] = "S256" # Forward resource indicator if present in transaction if resource := transaction.get("resource"): query_params["resource"] = resource # Extra configured parameters if self._extra_authorize_params: query_params.update(self._extra_authorize_params) separator = "&" if "?" in self._upstream_authorization_endpoint else "?" return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" async def _handle_consent( self: OAuthProxy, request: Request ) -> HTMLResponse | RedirectResponse: """Handle consent page - dispatch to GET or POST handler based on method.""" if request.method == "POST": return await self._submit_consent(request) return await self._show_consent_page(request) async def _show_consent_page( self: OAuthProxy, request: Request ) -> HTMLResponse | RedirectResponse: """Display consent page or auto-approve/deny based on cookies.""" from fastmcp.server.server import FastMCP txn_id = request.query_params.get("txn_id") if not txn_id: return create_secure_html_response( "

Error

Invalid or expired transaction

", status_code=400 ) txn_model = await self._transaction_store.get(key=txn_id) if not txn_model: return create_secure_html_response( "

Error

Invalid or expired transaction

", status_code=400 ) txn = txn_model.model_dump() client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) if client_key in approved: consent_token = secrets.token_urlsafe(32) txn_model.consent_token = consent_token await self._transaction_store.put(key=txn_id, value=txn_model, ttl=15 * 60) upstream_url = self._build_upstream_authorize_url(txn_id, txn) response = RedirectResponse(url=upstream_url, status_code=302) self._set_consent_binding_cookie(request, response, txn_id, consent_token) return response if client_key in denied: callback_params = { "error": "access_denied", "state": txn.get("client_state") or "", } sep = "&" if "?" in txn["client_redirect_uri"] else "?" return RedirectResponse( url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", status_code=302, ) # Need consent: issue CSRF token and show HTML csrf_token = secrets.token_urlsafe(32) csrf_expires_at = time.time() + 15 * 60 # Update transaction with CSRF token txn_model.csrf_token = csrf_token txn_model.csrf_expires_at = csrf_expires_at await self._transaction_store.put( key=txn_id, value=txn_model, ttl=15 * 60 ) # Auto-expire after 15 minutes # Update dict for use in HTML generation txn["csrf_token"] = csrf_token txn["csrf_expires_at"] = csrf_expires_at # Load client to get client_name and CIMD info if available client = await self.get_client(txn["client_id"]) client_name = getattr(client, "client_name", None) if client else None # Detect CIMD clients for verified domain badge is_cimd_client = False cimd_domain: str | None = None if isinstance(client, ProxyDCRClient) and client.cimd_document is not None: is_cimd_client = True cimd_domain = urlparse(txn["client_id"]).hostname # Extract server metadata from app state fastmcp = getattr(request.app.state, "fastmcp_server", None) if isinstance(fastmcp, FastMCP): server_name = fastmcp.name icons = fastmcp.icons server_icon_url = icons[0].src if icons else None server_website_url = fastmcp.website_url else: server_name = None server_icon_url = None server_website_url = None html = create_consent_html( client_id=txn["client_id"], redirect_uri=txn["client_redirect_uri"], scopes=txn.get("scopes") or [], txn_id=txn_id, csrf_token=csrf_token, client_name=client_name, server_name=server_name, server_icon_url=server_icon_url, server_website_url=server_website_url, csp_policy=self._consent_csp_policy, is_cimd_client=is_cimd_client, cimd_domain=cimd_domain, ) response = create_secure_html_response(html) # Merge new CSRF token with any existing ones (supports concurrent flows) existing_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") existing_tokens.append(csrf_token) self._set_list_cookie( response, "MCP_CONSENT_STATE", self._encode_list_cookie(existing_tokens), max_age=15 * 60, ) return response async def _submit_consent( self: OAuthProxy, request: Request ) -> RedirectResponse | HTMLResponse: """Handle consent approval/denial, set cookies, and redirect appropriately.""" form = await request.form() txn_id = str(form.get("txn_id", "")) action = str(form.get("action", "")) csrf_token = str(form.get("csrf_token", "")) if not txn_id: return create_secure_html_response( "

Error

Invalid or expired transaction

", status_code=400 ) txn_model = await self._transaction_store.get(key=txn_id) if not txn_model: return create_secure_html_response( "

Error

Invalid or expired transaction

", status_code=400 ) txn = txn_model.model_dump() expected_csrf = txn.get("csrf_token") expires_at = float(txn.get("csrf_expires_at") or 0) if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at: return create_secure_html_response( "

Error

Invalid or expired consent token

", status_code=400 ) # Double-submit CSRF check: verify the form token matches the cookie. # Without this, an attacker who knows their own tx_id/csrf_token can # CSRF the victim's browser into approving consent, bypassing the # consent binding cookie protection. cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") if csrf_token not in cookie_csrf_tokens: logger.warning( "CSRF double-submit check failed for transaction %s " "(possible cross-site consent forgery)", txn_id, ) return create_secure_html_response( "

Error

Authorization session mismatch. " "Please try authenticating again.

", status_code=403, ) client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) if action == "approve": approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) if client_key not in approved: approved.add(client_key) approved_b64 = self._encode_list_cookie(sorted(approved)) consent_token = secrets.token_urlsafe(32) txn_model.consent_token = consent_token await self._transaction_store.put(key=txn_id, value=txn_model, ttl=15 * 60) upstream_url = self._build_upstream_authorize_url(txn_id, txn) response = RedirectResponse(url=upstream_url, status_code=302) self._set_list_cookie( response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600 ) # Clear CSRF cookie by setting empty short-lived value self._set_list_cookie( response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 ) self._set_consent_binding_cookie(request, response, txn_id, consent_token) return response elif action == "deny": denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) if client_key not in denied: denied.add(client_key) denied_b64 = self._encode_list_cookie(sorted(denied)) callback_params = { "error": "access_denied", "state": txn.get("client_state") or "", } sep = "&" if "?" in txn["client_redirect_uri"] else "?" client_callback_url = ( f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" ) response = RedirectResponse(url=client_callback_url, status_code=302) self._set_list_cookie( response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600 ) self._set_list_cookie( response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 ) return response else: return create_secure_html_response( "

Error

Invalid action

", status_code=400 ) ================================================ FILE: src/fastmcp/server/auth/oauth_proxy/models.py ================================================ """OAuth Proxy Models and Constants. This module contains all Pydantic models and constants used by the OAuth proxy. """ from __future__ import annotations import hashlib from typing import Any, Final from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull from pydantic import AnyUrl, BaseModel, Field from fastmcp.server.auth.cimd import CIMDDocument from fastmcp.server.auth.redirect_validation import ( matches_allowed_pattern, validate_redirect_uri, ) # ------------------------------------------------------------------------- # Constants # ------------------------------------------------------------------------- # Default token expiration times DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = ( 60 * 60 * 24 * 365 ) # 1 year DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes # HTTP client timeout HTTP_TIMEOUT_SECONDS: Final[int] = 30 # ------------------------------------------------------------------------- # Pydantic Models # ------------------------------------------------------------------------- class OAuthTransaction(BaseModel): """OAuth transaction state for consent flow. Stored server-side to track active authorization flows with client context. Includes CSRF tokens for consent protection per MCP security best practices. """ txn_id: str client_id: str client_redirect_uri: str client_state: str code_challenge: str | None code_challenge_method: str scopes: list[str] created_at: float resource: str | None = None proxy_code_verifier: str | None = None csrf_token: str | None = None csrf_expires_at: float | None = None consent_token: str | None = None class ClientCode(BaseModel): """Client authorization code with PKCE and upstream tokens. Stored server-side after upstream IdP callback. Contains the upstream tokens bound to the client's PKCE challenge for secure token exchange. """ code: str client_id: str redirect_uri: str code_challenge: str | None code_challenge_method: str scopes: list[str] idp_tokens: dict[str, Any] expires_at: float created_at: float class UpstreamTokenSet(BaseModel): """Stored upstream OAuth tokens from identity provider. These tokens are obtained from the upstream provider (Google, GitHub, etc.) and stored in plaintext within this model. Encryption is handled transparently at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. """ upstream_token_id: str # Unique ID for this token set access_token: str # Upstream access token refresh_token: str | None # Upstream refresh token refresh_token_expires_at: ( float | None ) # Unix timestamp when refresh token expires (if known) expires_at: float # Unix timestamp when access token expires token_type: str # Usually "Bearer" scope: str # Space-separated scopes client_id: str # MCP client this is bound to created_at: float # Unix timestamp raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response class JTIMapping(BaseModel): """Maps FastMCP token JTI to upstream token ID. This allows stateless JWT validation while still being able to look up the corresponding upstream token when tools need to access upstream APIs. """ jti: str # JWT ID from FastMCP-issued token upstream_token_id: str # References UpstreamTokenSet created_at: float # Unix timestamp class RefreshTokenMetadata(BaseModel): """Metadata for a refresh token, stored keyed by token hash. We store only metadata (not the token itself) for security - if storage is compromised, attackers get hashes they can't reverse into usable tokens. """ client_id: str scopes: list[str] expires_at: int | None = None created_at: float def _hash_token(token: str) -> str: """Hash a token for secure storage lookup. Uses SHA-256 to create a one-way hash. The original token cannot be recovered from the hash, providing defense in depth if storage is compromised. """ return hashlib.sha256(token.encode()).hexdigest() class ProxyDCRClient(OAuthClientInformationFull): """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: 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 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 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 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. """ allowed_redirect_uri_patterns: list[str] | None = Field(default=None) client_name: str | None = Field(default=None) cimd_document: CIMDDocument | None = Field(default=None) cimd_fetched_at: float | None = Field(default=None) def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. For CIMD clients: validates against BOTH the CIMD document's redirect_uris AND the proxy's allowed patterns (if configured). Both must pass. For DCR clients: validates against proxy patterns first, falling back to base validation (registered redirect_uris) if patterns don't match. """ if redirect_uri is None and self.cimd_document is not None: cimd_redirect_uris = self.cimd_document.redirect_uris if len(cimd_redirect_uris) == 1: candidate = cimd_redirect_uris[0] if "*" in candidate: raise InvalidRedirectUriError( "redirect_uri must be specified when CIMD redirect_uris uses wildcards." ) try: resolved = AnyUrl(candidate) except Exception as e: raise InvalidRedirectUriError( f"Invalid CIMD redirect_uri: {e}" ) from e # Respect proxy-level redirect URI restrictions even when the # client omits redirect_uri and we fall back to CIMD defaults. if ( self.allowed_redirect_uri_patterns is not None and not validate_redirect_uri( redirect_uri=resolved, allowed_patterns=self.allowed_redirect_uri_patterns, ) ): raise InvalidRedirectUriError( f"Redirect URI '{resolved}' does not match allowed patterns." ) return resolved raise InvalidRedirectUriError( "redirect_uri must be specified when CIMD lists multiple redirect_uris." ) if redirect_uri is not None: cimd_redirect_uris = ( self.cimd_document.redirect_uris if self.cimd_document else None ) if cimd_redirect_uris: uri_str = str(redirect_uri) cimd_match = any( matches_allowed_pattern(uri_str, pattern) for pattern in cimd_redirect_uris ) if not cimd_match: raise InvalidRedirectUriError( f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." ) if self.allowed_redirect_uri_patterns is not None: if not validate_redirect_uri( redirect_uri=redirect_uri, allowed_patterns=self.allowed_redirect_uri_patterns, ): raise InvalidRedirectUriError( f"Redirect URI '{redirect_uri}' does not match allowed patterns." ) return redirect_uri pattern_matches = validate_redirect_uri( redirect_uri=redirect_uri, allowed_patterns=self.allowed_redirect_uri_patterns, ) if pattern_matches: return redirect_uri # Patterns configured but didn't match if self.allowed_redirect_uri_patterns: raise InvalidRedirectUriError( f"Redirect URI '{redirect_uri}' does not match allowed patterns." ) # No redirect_uri provided or no patterns configured — use base validation return super().validate_redirect_uri(redirect_uri) ================================================ FILE: src/fastmcp/server/auth/oauth_proxy/proxy.py ================================================ """OAuth Proxy Provider for FastMCP. This provider acts as a transparent proxy to an upstream OAuth Authorization Server, handling Dynamic Client Registration locally while forwarding all other OAuth flows. This enables authentication with upstream providers that don't support DCR or have restricted client registration policies. Key features: - Proxies authorization and token endpoints to upstream server - Implements local Dynamic Client Registration with fixed upstream credentials - Validates tokens using upstream JWKS - Maintains minimal local state for bookkeeping - Enhanced logging with request correlation This implementation is based on the OAuth 2.1 specification and is designed for production use with enterprise identity providers. """ from __future__ import annotations import hashlib import secrets import time from base64 import urlsafe_b64encode from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse import httpx from authlib.common.security import generate_token from authlib.integrations.httpx_client import AsyncOAuth2Client from cryptography.fernet import Fernet from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.stores.filetree import ( FileTreeStore, FileTreeV1CollectionSanitizationStrategy, FileTreeV1KeySanitizationStrategy, ) from key_value.aio.wrappers.encryption import FernetEncryptionWrapper from mcp.server.auth.handlers.metadata import MetadataHandler from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, RefreshToken, TokenError, ) from mcp.server.auth.routes import build_metadata, cors_middleware from mcp.server.auth.settings import ( ClientRegistrationOptions, RevocationOptions, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthToken from pydantic import AnyHttpUrl, AnyUrl, SecretStr from starlette.requests import Request from starlette.responses import HTMLResponse, RedirectResponse from starlette.routing import Route from typing_extensions import override from fastmcp import settings from fastmcp.server.auth.auth import ( OAuthProvider, PrivateKeyJWTClientAuthenticator, TokenHandler, TokenVerifier, ) from fastmcp.server.auth.cimd import CIMDClientManager from fastmcp.server.auth.handlers.authorize import AuthorizationHandler from fastmcp.server.auth.jwt_issuer import ( JWTIssuer, derive_jwt_key, ) from fastmcp.server.auth.oauth_proxy.consent import ConsentMixin from fastmcp.server.auth.oauth_proxy.models import ( DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, DEFAULT_AUTH_CODE_EXPIRY_SECONDS, HTTP_TIMEOUT_SECONDS, ClientCode, JTIMapping, OAuthTransaction, ProxyDCRClient, RefreshTokenMetadata, UpstreamTokenSet, _hash_token, ) from fastmcp.server.auth.oauth_proxy.ui import create_error_html from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) def _normalize_resource_url(url: str) -> str: """Normalize a resource URL by removing query parameters and trailing slashes. RFC 8707 allows clients to include query parameters in resource URLs, but the server's configured resource URL typically doesn't include them. This function normalizes URLs for comparison by stripping query params and fragments. Args: url: The URL to normalize Returns: Normalized URL with scheme, host, and path only (no query/fragment) """ parsed = urlparse(str(url)) return urlunparse( (parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", "") ) def _server_url_has_query(url: str) -> bool: """Check if a URL has query parameters.""" return bool(urlparse(str(url)).query) class OAuthProxy(OAuthProvider, ConsentMixin): """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. Purpose ------- MCP clients expect OAuth providers to support Dynamic Client Registration (DCR), where clients can register themselves dynamically and receive unique credentials. Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require pre-registered OAuth applications with fixed credentials. This proxy bridges that gap by: - Presenting a full DCR-compliant OAuth interface to MCP clients - Translating DCR registration requests to use pre-configured upstream credentials - Proxying all OAuth flows to the upstream IDP with appropriate translations - Managing the state and security requirements of both protocols Architecture Overview -------------------- The proxy maintains a single OAuth app registration with the upstream provider while allowing unlimited MCP clients to register and authenticate dynamically. It implements the complete OAuth 2.1 + DCR specification for clients while translating to whatever OAuth variant the upstream provider requires. Key Translation Challenges Solved --------------------------------- 1. Dynamic Client Registration: - MCP clients expect to register dynamically and get unique credentials - Upstream IDPs require pre-registered apps with fixed credentials - Solution: Accept DCR requests, return shared upstream credentials 2. Dynamic Redirect URIs: - MCP clients use random localhost ports that change between sessions - Upstream IDPs require fixed, pre-registered redirect URIs - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI 3. Authorization Code Mapping: - Upstream returns codes for the proxy's redirect URI - Clients expect codes for their own redirect URIs - Solution: Exchange upstream code server-side, issue new code to client 4. State Parameter Collision: - Both client and proxy need to maintain state through the flow - Only one state parameter available in OAuth - Solution: Use transaction ID as state with upstream, preserve client's state 5. Token Management: - Clients may expect different token formats/claims than upstream provides - Need to track tokens for revocation and refresh - Solution: Store token relationships, forward upstream tokens transparently OAuth Flow Implementation ------------------------ 1. Client Registration (DCR): - Accept any client registration request - Store ProxyDCRClient that accepts dynamic redirect URIs 2. Authorization: - Store transaction mapping client details to proxy flow - Redirect to upstream with proxy's fixed redirect URI - Use transaction ID as state parameter with upstream 3. Upstream Callback: - Exchange upstream authorization code for tokens (server-side) - Generate new authorization code bound to client's PKCE challenge - Redirect to client's original dynamic redirect URI 4. Token Exchange: - Validate client's code and PKCE verifier - Return previously obtained upstream tokens - Clean up one-time use authorization code 5. Token Refresh: - Forward refresh requests to upstream using authlib - Handle token rotation if upstream issues new refresh token - Update local token mappings State Management --------------- The proxy maintains minimal but crucial state via pluggable storage (client_storage): - _oauth_transactions: Active authorization flows with client context - _client_codes: Authorization codes with PKCE challenges and upstream tokens - _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs - _refresh_token_store: Refresh token metadata (keyed by token hash) All state is stored in the configured client_storage backend (Redis, disk, etc.) enabling horizontal scaling across multiple instances. Security Considerations ---------------------- - Refresh tokens stored by hash only (defense in depth if storage compromised) - PKCE enforced end-to-end (client to proxy, proxy to upstream) - Authorization codes are single-use with short expiry - Transaction IDs are cryptographically random - All state is cleaned up after use to prevent replay - Token validation delegates to upstream provider Provider Compatibility --------------------- Works with any OAuth 2.0 provider that supports: - Authorization code flow - Fixed redirect URI (configured in provider's app settings) - Standard token endpoint Handles provider-specific requirements: - Google: Ensures minimum scope requirements - GitHub: Compatible with OAuth Apps and GitHub Apps - Azure AD: Handles tenant-specific endpoints - Generic: Works with any spec-compliant provider """ def __init__( self, *, # Upstream server configuration upstream_authorization_endpoint: str, upstream_token_endpoint: str, upstream_client_id: str, upstream_client_secret: str | None = None, upstream_revocation_endpoint: str | None = None, # Token validation token_verifier: TokenVerifier, # FastMCP server configuration base_url: AnyHttpUrl | str, redirect_path: str | None = None, issuer_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, # Client redirect URI validation allowed_client_redirect_uris: list[str] | None = None, valid_scopes: list[str] | None = None, # PKCE configuration forward_pkce: bool = True, # Token endpoint authentication token_endpoint_auth_method: str | None = None, # Extra parameters to forward to authorization endpoint extra_authorize_params: dict[str, str] | None = None, # Extra parameters to forward to token endpoint extra_token_params: dict[str, str] | None = None, # Client storage client_storage: AsyncKeyValue | None = None, # JWT signing key jwt_signing_key: str | bytes | None = None, # Consent screen configuration require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, # CIMD (Client ID Metadata Document) support enable_cimd: bool = True, ): """Initialize the OAuth proxy provider. Args: upstream_authorization_endpoint: URL of upstream authorization endpoint upstream_token_endpoint: URL of upstream token endpoint upstream_client_id: Client ID registered with upstream server upstream_client_secret: Client secret for upstream server. Optional for PKCE public clients or when using alternative credentials (e.g., managed identity). When omitted, jwt_signing_key must be provided. upstream_revocation_endpoint: Optional upstream revocation endpoint token_verifier: Token verifier for validating access tokens base_url: Public URL of the server that exposes this FastMCP server; redirect path is relative to this URL redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") issuer_url: Issuer URL for OAuth metadata (defaults to base_url) service_documentation_url: Optional service documentation 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), all redirect URIs are allowed (for DCR compatibility). If empty list, no redirect URIs are allowed. These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. valid_scopes: List of all the possible valid scopes for a client. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided. forward_pkce: Whether to forward PKCE to upstream server (default True). Enable for providers that support/require PKCE (Google, Azure, AWS, etc.). Disable only if upstream provider doesn't support PKCE. token_endpoint_auth_method: Token endpoint authentication method for upstream server. Common values: "client_secret_basic", "client_secret_post", "none". If None, authlib will use its default (typically "client_secret_basic"). extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters like Auth0's "audience". Example: {"audience": "https://api.example.com"} extra_token_params: Additional parameters to forward to the upstream token endpoint. Useful for provider-specific parameters during token exchange. client_storage: Storage backend for OAuth state (client registrations, tokens). If None, an encrypted file store will be created in the data directory. jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as-is. If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations). If not provided, it will be derived from the upstream client secret using HKDF. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. If None (default), uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. This allows organizations with their own CSP policies to override or disable the built-in CSP directives. fallback_access_token_expiry_seconds: Expiry time to use when upstream provider doesn't return `expires_in` in the token response. If not set, uses smart defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). Set explicitly to override these defaults. enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based client IDs. When True, clients can authenticate using HTTPS URLs as client IDs, with metadata fetched from the URL. Supports private_key_jwt auth. """ # Always enable DCR since we implement it locally for MCP clients client_registration_options = ClientRegistrationOptions( enabled=True, valid_scopes=valid_scopes or token_verifier.required_scopes, ) # Enable revocation only if upstream endpoint provided revocation_options = ( RevocationOptions(enabled=True) if upstream_revocation_endpoint else None ) super().__init__( base_url=base_url, issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, required_scopes=token_verifier.required_scopes, ) # Store upstream configuration self._upstream_authorization_endpoint: str = upstream_authorization_endpoint self._upstream_token_endpoint: str = upstream_token_endpoint self._upstream_client_id: str = upstream_client_id self._upstream_client_secret: SecretStr | None = ( SecretStr(secret_value=upstream_client_secret) if upstream_client_secret is not None else None ) self._upstream_revocation_endpoint: str | None = upstream_revocation_endpoint self._default_scope_str: str = " ".join(self.required_scopes or []) # Store redirect configuration if not redirect_path: self._redirect_path = "/auth/callback" else: self._redirect_path = ( redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" ) if ( isinstance(allowed_client_redirect_uris, list) and not allowed_client_redirect_uris ): logger.warning( "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " + "This will block all OAuth clients." ) self._allowed_client_redirect_uris: list[str] | None = ( allowed_client_redirect_uris ) # PKCE configuration self._forward_pkce: bool = forward_pkce # Token endpoint authentication self._token_endpoint_auth_method: str | None = token_endpoint_auth_method # Consent screen configuration self._require_authorization_consent: bool | Literal["external"] = ( require_authorization_consent ) self._consent_csp_policy: str | None = consent_csp_policy if require_authorization_consent == "external": logger.info( "Built-in consent screen disabled; consent is handled externally." ) elif not require_authorization_consent: logger.warning( "Authorization consent screen disabled - only use for local development or testing. " + "In production, this screen protects against confused deputy attacks." ) # Extra parameters for authorization and token endpoints self._extra_authorize_params: dict[str, str] = extra_authorize_params or {} self._extra_token_params: dict[str, str] = extra_token_params or {} # Token expiry fallback (None means use smart default based on refresh token) self._fallback_access_token_expiry_seconds: int | None = ( fallback_access_token_expiry_seconds ) if jwt_signing_key is None: if upstream_client_secret is None: raise ValueError( "jwt_signing_key is required when upstream_client_secret is not provided. " "The JWT signing key cannot be derived without a client secret." ) jwt_signing_key = derive_jwt_key( high_entropy_material=upstream_client_secret, salt="fastmcp-jwt-signing-key", ) if isinstance(jwt_signing_key, str): if len(jwt_signing_key) < 12: logger.warning( "jwt_signing_key is less than 12 characters; it is recommended to use a longer. " + "string for the key derivation." ) jwt_signing_key = derive_jwt_key( low_entropy_material=jwt_signing_key, salt="fastmcp-jwt-signing-key", ) # Store JWT signing key for deferred JWTIssuer creation in set_mcp_path() self._jwt_signing_key: bytes = jwt_signing_key # JWTIssuer will be created in set_mcp_path() with correct audience self._jwt_issuer: JWTIssuer | None = None # If the user does not provide a store, we will provide an encrypted file store. # The storage directory is derived from the encryption key so that different # keys get isolated directories (e.g. two servers on the same machine with # different keys won't collide). Decryption errors are treated as cache misses # rather than hard failures, so key rotation just causes re-registration. if client_storage is None: storage_encryption_key = derive_jwt_key( high_entropy_material=jwt_signing_key.decode(), salt="fastmcp-storage-encryption-key", ) key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12] storage_dir = settings.home / "oauth-proxy" / key_fingerprint storage_dir.mkdir(parents=True, exist_ok=True) file_store = FileTreeStore( data_directory=storage_dir, key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( storage_dir ), collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( storage_dir ), ) client_storage = FernetEncryptionWrapper( key_value=file_store, fernet=Fernet(key=storage_encryption_key), raise_on_decryption_error=False, ) self._client_storage: AsyncKeyValue = client_storage # Cache HTTPS check to avoid repeated logging self._is_https: bool = str(self.base_url).startswith("https://") if not self._is_https: logger.warning( "Using non-secure cookies for development; deploy with HTTPS for production." ) self._upstream_token_store: PydanticAdapter[UpstreamTokenSet] = PydanticAdapter[ UpstreamTokenSet ]( key_value=self._client_storage, pydantic_model=UpstreamTokenSet, default_collection="mcp-upstream-tokens", raise_on_validation_error=True, ) self._client_store: PydanticAdapter[ProxyDCRClient] = PydanticAdapter[ ProxyDCRClient ]( key_value=self._client_storage, pydantic_model=ProxyDCRClient, default_collection="mcp-oauth-proxy-clients", raise_on_validation_error=True, ) # OAuth transaction storage for IdP callback forwarding # Reuse client_storage with different collections for state management self._transaction_store: PydanticAdapter[OAuthTransaction] = PydanticAdapter[ OAuthTransaction ]( key_value=self._client_storage, pydantic_model=OAuthTransaction, default_collection="mcp-oauth-transactions", raise_on_validation_error=True, ) self._code_store: PydanticAdapter[ClientCode] = PydanticAdapter[ClientCode]( key_value=self._client_storage, pydantic_model=ClientCode, default_collection="mcp-authorization-codes", raise_on_validation_error=True, ) # Storage for JTI mappings (FastMCP token -> upstream token) self._jti_mapping_store: PydanticAdapter[JTIMapping] = PydanticAdapter[ JTIMapping ]( key_value=self._client_storage, pydantic_model=JTIMapping, default_collection="mcp-jti-mappings", raise_on_validation_error=True, ) # Refresh token metadata storage, keyed by token hash for security. # We only store metadata (not the token itself) - if storage is compromised, # attackers get hashes they can't reverse into usable tokens. self._refresh_token_store: PydanticAdapter[RefreshTokenMetadata] = ( PydanticAdapter[RefreshTokenMetadata]( key_value=self._client_storage, pydantic_model=RefreshTokenMetadata, default_collection="mcp-refresh-tokens", raise_on_validation_error=True, ) ) # Use the provided token validator self._token_validator: TokenVerifier = token_verifier # CIMD (Client ID Metadata Document) support self._cimd_manager: CIMDClientManager | None = None if enable_cimd: self._cimd_manager = CIMDClientManager( enable_cimd=True, default_scope=self._default_scope_str, allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, ) logger.debug( "Initialized OAuth proxy provider with upstream server %s", self._upstream_authorization_endpoint, ) # ------------------------------------------------------------------------- # MCP Path Configuration # ------------------------------------------------------------------------- def set_mcp_path(self, mcp_path: str | None) -> None: """Set the MCP endpoint path and create JWTIssuer with correct audience. This method is called by get_routes() to configure the resource URL and create the JWTIssuer. The JWT audience is set to the full resource URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to this specific MCP endpoint. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") """ super().set_mcp_path(mcp_path) # Create JWT issuer with correct audience based on actual MCP path # This ensures tokens are bound to the specific resource URL self._jwt_issuer = JWTIssuer( issuer=str(self.base_url), audience=str(self._resource_url), signing_key=self._jwt_signing_key, ) logger.debug("Configured OAuth proxy for resource URL: %s", self._resource_url) @property def jwt_issuer(self) -> JWTIssuer: """Get the JWT issuer, ensuring it has been initialized. The JWT issuer is created when set_mcp_path() is called (via get_routes()). This property ensures a clear error if used before initialization. """ if self._jwt_issuer is None: raise RuntimeError( "JWT issuer not initialized. Ensure get_routes() is called " "before token operations." ) return self._jwt_issuer # ------------------------------------------------------------------------- # Upstream OAuth Client # ------------------------------------------------------------------------- def _create_upstream_oauth_client(self) -> AsyncOAuth2Client: """Create an OAuth2 client for communicating with the upstream IdP. This is the single point for constructing the client used in token exchange, refresh, and other upstream interactions. Subclasses can override this to provide alternative authentication methods (e.g., managed-identity client assertions instead of a static client secret). """ return AsyncOAuth2Client( client_id=self._upstream_client_id, client_secret=( self._upstream_client_secret.get_secret_value() if self._upstream_client_secret is not None else None ), token_endpoint_auth_method=self._token_endpoint_auth_method, timeout=HTTP_TIMEOUT_SECONDS, ) # ------------------------------------------------------------------------- # PKCE Helper Methods # ------------------------------------------------------------------------- def _generate_pkce_pair(self) -> tuple[str, str]: """Generate PKCE code verifier and challenge pair. Returns: Tuple of (code_verifier, code_challenge) using S256 method """ # Generate code verifier: 43-128 characters from unreserved set code_verifier = generate_token(48) # Generate code challenge using S256 (SHA256 + base64url) challenge_bytes = hashlib.sha256(code_verifier.encode()).digest() code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=") return code_verifier, code_challenge # ------------------------------------------------------------------------- # Client Registration (Local Implementation) # ------------------------------------------------------------------------- @override async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: """Get client information by ID. This is generally the random ID provided to the DCR client during registration, not the upstream client ID. For unregistered clients, returns None (which will raise an error in the SDK). CIMD clients (URL-based client IDs) are looked up and cached automatically. """ # Load from storage client = await self._client_store.get(key=client_id) if client is not None: if client.allowed_redirect_uri_patterns is None: client.allowed_redirect_uri_patterns = ( self._allowed_client_redirect_uris ) # Refresh CIMD clients using HTTP cache-aware fetcher. if self._cimd_manager is not None and client.cimd_document is not None: try: refreshed = await self._cimd_manager.get_client(client_id) if refreshed is not None: await self._client_store.put(key=client_id, value=refreshed) return refreshed except Exception as e: logger.debug( "CIMD refresh failed for %s, using cached client: %s", client_id, e, ) return client # Client not in storage — try CIMD lookup for URL-based client IDs if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id( client_id ): cimd_client = await self._cimd_manager.get_client(client_id) if cimd_client is not None: await self._client_store.put(key=client_id, value=cimd_client) return cimd_client return None @override async def register_client(self, client_info: OAuthClientInformationFull) -> None: """Register a client locally When a client registers, we create a ProxyDCRClient that is more forgiving about validating redirect URIs, since the DCR client's redirect URI will likely be localhost or unknown to the proxied IDP. The proxied IDP only knows about this server's fixed redirect URI. """ # Create a ProxyDCRClient with configured redirect URI validation if client_info.client_id is None: raise ValueError("client_id is required for client registration") # We use token_endpoint_auth_method="none" because the proxy handles # all upstream authentication. The client_secret must also be None # because the SDK requires secrets to be provided if they're set, # regardless of auth method. proxy_client: ProxyDCRClient = ProxyDCRClient( client_id=client_info.client_id, client_secret=None, redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], grant_types=client_info.grant_types or ["authorization_code", "refresh_token"], scope=client_info.scope or self._default_scope_str, token_endpoint_auth_method="none", allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, client_name=getattr(client_info, "client_name", None), ) await self._client_store.put( key=client_info.client_id, value=proxy_client, ) # Log redirect URIs to help users discover what patterns they might need if client_info.redirect_uris: for uri in client_info.redirect_uris: logger.debug( "Client registered with redirect_uri: %s - if restricting redirect URIs, " "ensure this pattern is allowed in allowed_client_redirect_uris", uri, ) logger.debug( "Registered client %s with %d redirect URIs", client_info.client_id, len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0, ) # ------------------------------------------------------------------------- # Authorization Flow (Proxy to Upstream) # ------------------------------------------------------------------------- @override async def authorize( self, client: OAuthClientInformationFull, params: AuthorizationParams, ) -> str: """Start OAuth transaction and route through consent interstitial. Flow: 1. Validate client's resource matches server's resource URL (security check) 2. Store transaction with client details and PKCE (if forwarding) 3. Return local /consent URL; browser visits consent first 4. Consent handler redirects to upstream IdP if approved/already approved If consent is disabled (require_authorization_consent=False), skip the consent screen and redirect directly to the upstream IdP. """ # Security check: validate client's requested resource matches this server # This prevents tokens intended for one server from being used on another # # Per RFC 8707, clients may include query parameters in resource URLs (e.g., # ChatGPT sends ?kb_name=X). We handle two cases: # # 1. Server URL has NO query params: normalize both URLs (strip query/fragment) # to allow clients like ChatGPT that add query params to still match. # # 2. Server URL HAS query params (e.g., multi-tenant ?tenant=X): require exact # match to prevent clients from bypassing tenant isolation by changing params. # # Claude doesn't send a resource parameter at all, so this check is skipped. client_resource = getattr(params, "resource", None) if client_resource and self._resource_url: server_url = str(self._resource_url) client_url = str(client_resource) if _server_url_has_query(server_url): # Server has query params - require exact match for security urls_match = client_url.rstrip("/") == server_url.rstrip("/") else: # Server has no query params - normalize both for comparison urls_match = _normalize_resource_url( client_url ) == _normalize_resource_url(server_url) if not urls_match: logger.warning( "Resource mismatch: client requested %s but server is %s", client_resource, self._resource_url, ) raise AuthorizeError( error="invalid_target", # type: ignore[arg-type] error_description="Resource does not match this server", ) # Generate transaction ID for this authorization request txn_id = secrets.token_urlsafe(32) # Generate proxy's own PKCE parameters if forwarding is enabled proxy_code_verifier = None proxy_code_challenge = None if self._forward_pkce and params.code_challenge: proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair() logger.debug( "Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)", txn_id, ) # Store transaction data for IdP callback processing if client.client_id is None: raise AuthorizeError( error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type error_description="Client ID is required", ) transaction = OAuthTransaction( txn_id=txn_id, client_id=client.client_id, client_redirect_uri=str(params.redirect_uri), client_state=params.state or "", code_challenge=params.code_challenge, code_challenge_method=getattr(params, "code_challenge_method", "S256"), scopes=params.scopes or [], created_at=time.time(), resource=getattr(params, "resource", None), proxy_code_verifier=proxy_code_verifier, ) await self._transaction_store.put( key=txn_id, value=transaction, ttl=15 * 60, # Auto-expire after 15 minutes ) # If consent is disabled or handled externally, skip consent screen if self._require_authorization_consent is not True: upstream_url = self._build_upstream_authorize_url( txn_id, transaction.model_dump() ) logger.debug( "Starting OAuth transaction %s for client %s, redirecting directly to upstream IdP (consent disabled, PKCE forwarding: %s)", txn_id, client.client_id, "enabled" if proxy_code_challenge else "disabled", ) return upstream_url consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}" logger.debug( "Starting OAuth transaction %s for client %s, redirecting to consent page (PKCE forwarding: %s)", txn_id, client.client_id, "enabled" if proxy_code_challenge else "disabled", ) return consent_url # ------------------------------------------------------------------------- # Authorization Code Handling # ------------------------------------------------------------------------- @override async def load_authorization_code( self, client: OAuthClientInformationFull, authorization_code: str, ) -> AuthorizationCode | None: """Load authorization code for validation. Look up our client code and return authorization code object with PKCE challenge for validation. """ # Look up client code data code_model = await self._code_store.get(key=authorization_code) if not code_model: logger.debug("Authorization code not found: %s", authorization_code) return None # Check if code expired if time.time() > code_model.expires_at: logger.debug("Authorization code expired: %s", authorization_code) _ = await self._code_store.delete(key=authorization_code) return None # Verify client ID matches if code_model.client_id != client.client_id: logger.debug( "Authorization code client ID mismatch: %s vs %s", code_model.client_id, client.client_id, ) return None # Create authorization code object with PKCE challenge if client.client_id is None: raise AuthorizeError( error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type error_description="Client ID is required", ) return AuthorizationCode( code=authorization_code, client_id=client.client_id, redirect_uri=AnyUrl(url=code_model.redirect_uri), redirect_uri_provided_explicitly=True, scopes=code_model.scopes, expires_at=code_model.expires_at, code_challenge=code_model.code_challenge or "", ) @override async def exchange_authorization_code( self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode, ) -> OAuthToken: """Exchange authorization code for FastMCP-issued tokens. Implements the token factory pattern: 1. Retrieves upstream tokens from stored authorization code 2. Extracts user identity from upstream token 3. Encrypts and stores upstream tokens 4. Issues FastMCP-signed JWT tokens 5. Returns FastMCP tokens (NOT upstream tokens) PKCE validation is handled by the MCP framework before this method is called. """ # Look up stored code data code_model = await self._code_store.get(key=authorization_code.code) if not code_model: logger.error( "Authorization code not found in client codes: %s", authorization_code.code, ) raise TokenError("invalid_grant", "Authorization code not found") # Get stored upstream tokens idp_tokens = code_model.idp_tokens # Use IdP-granted scopes when available (RFC 6749 §5.1: the IdP MUST # include a scope parameter when the granted scope differs from the # requested scope). Fall back to requested scopes only when the IdP # omits scope, meaning it granted exactly what was requested. granted_scopes: list[str] = ( parse_scopes(idp_tokens["scope"]) or [] if "scope" in idp_tokens else list(authorization_code.scopes) ) # Clean up client code (one-time use) await self._code_store.delete(key=authorization_code.code) # Generate IDs for token storage upstream_token_id = secrets.token_urlsafe(32) access_jti = secrets.token_urlsafe(32) refresh_jti = ( secrets.token_urlsafe(32) if idp_tokens.get("refresh_token") else None ) # Calculate token expiry times # If upstream provides expires_in, use it. Otherwise use fallback based on: # - User-provided fallback if set # - 1 hour if refresh token available (can refresh when expired) # - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps) if "expires_in" in idp_tokens: expires_in = int(idp_tokens["expires_in"]) logger.debug( "Access token TTL: %d seconds (from IdP expires_in)", expires_in ) elif self._fallback_access_token_expiry_seconds is not None: expires_in = self._fallback_access_token_expiry_seconds logger.debug( "Access token TTL: %d seconds (using configured fallback)", expires_in ) elif idp_tokens.get("refresh_token"): expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS logger.debug( "Access token TTL: %d seconds (default, has refresh token)", expires_in ) else: expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS logger.debug( "Access token TTL: %d seconds (default, no refresh token)", expires_in ) # Calculate refresh token expiry if provided by upstream # Some providers include refresh_expires_in, some don't refresh_expires_in = None refresh_token_expires_at = None if idp_tokens.get("refresh_token"): if "refresh_expires_in" in idp_tokens and int( idp_tokens["refresh_expires_in"] ): refresh_expires_in = int(idp_tokens["refresh_expires_in"]) refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( "Upstream refresh token expires in %d seconds", refresh_expires_in ) else: # Default to 30 days if upstream doesn't specify # This is conservative - most providers use longer expiry refresh_expires_in = 60 * 60 * 24 * 30 # 30 days refresh_token_expires_at = time.time() + refresh_expires_in logger.debug( "Upstream refresh token expiry unknown, using 30-day default" ) # Encrypt and store upstream tokens upstream_token_set = UpstreamTokenSet( upstream_token_id=upstream_token_id, access_token=idp_tokens["access_token"], refresh_token=idp_tokens["refresh_token"] if idp_tokens.get("refresh_token") else None, refresh_token_expires_at=refresh_token_expires_at, expires_at=time.time() + expires_in, token_type=idp_tokens.get("token_type", "Bearer"), scope=" ".join(granted_scopes), client_id=client.client_id or "", created_at=time.time(), raw_token_data=idp_tokens, ) await self._upstream_token_store.put( key=upstream_token_id, value=upstream_token_set, ttl=max( refresh_expires_in or 0, expires_in, 1 ), # Keep until longest-lived token expires (min 1s for safety) ) logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) # Extract upstream claims to embed in FastMCP JWT (if subclass implements) upstream_claims = await self._extract_upstream_claims(idp_tokens) # Issue minimal FastMCP access token (just a reference via JTI) if client.client_id is None: raise TokenError("invalid_client", "Client ID is required") fastmcp_access_token = self.jwt_issuer.issue_access_token( client_id=client.client_id, scopes=granted_scopes, jti=access_jti, expires_in=expires_in, upstream_claims=upstream_claims, ) # Issue minimal FastMCP refresh token if upstream provided one # Use upstream refresh token expiry to align lifetimes fastmcp_refresh_token = None if refresh_jti and refresh_expires_in: fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token( client_id=client.client_id, scopes=granted_scopes, jti=refresh_jti, expires_in=refresh_expires_in, upstream_claims=upstream_claims, ) # Store JTI mappings await self._jti_mapping_store.put( key=access_jti, value=JTIMapping( jti=access_jti, upstream_token_id=upstream_token_id, created_at=time.time(), ), ttl=expires_in, # Auto-expire with access token ) if refresh_jti: await self._jti_mapping_store.put( key=refresh_jti, value=JTIMapping( jti=refresh_jti, upstream_token_id=upstream_token_id, created_at=time.time(), ), ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days) ) # Store refresh token metadata (keyed by hash for security) if fastmcp_refresh_token and refresh_expires_in: await self._refresh_token_store.put( key=_hash_token(fastmcp_refresh_token), value=RefreshTokenMetadata( client_id=client.client_id, scopes=granted_scopes, expires_at=int(time.time()) + refresh_expires_in, created_at=time.time(), ), ttl=refresh_expires_in, ) logger.debug( "Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)", client.client_id, access_jti[:8], refresh_jti[:8] if refresh_jti else "none", ) # Return FastMCP-issued tokens (NOT upstream tokens!) return OAuthToken( access_token=fastmcp_access_token, token_type="Bearer", expires_in=expires_in, refresh_token=fastmcp_refresh_token, scope=" ".join(granted_scopes), ) # ------------------------------------------------------------------------- # Refresh Token Flow # ------------------------------------------------------------------------- def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: """Prepare scopes for initial token exchange (auth code -> tokens). Override this method to provide scopes during the authorization code exchange. Some providers (like Azure) require scopes to be sent. Args: scopes: Scopes from the authorization request Returns: List of scopes to send, or empty list to omit scope parameter """ return scopes def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: """Prepare scopes for upstream token refresh request. Override this method to transform scopes before sending to upstream provider. For example, Azure needs to prefix scopes and add additional Graph scopes. The scopes parameter represents what should be stored in the RefreshToken. This method returns what should be sent to the upstream provider. Args: scopes: Base scopes that will be stored in RefreshToken Returns: Scopes to send to upstream provider (may be transformed/augmented) """ return scopes async def _extract_upstream_claims( self, idp_tokens: dict[str, Any] ) -> dict[str, Any] | None: """Extract upstream claims to embed in FastMCP JWT. Override this method to decode upstream tokens, call userinfo endpoints, or otherwise extract claims that should be embedded in the FastMCP JWT issued to MCP clients. This enables gateways to inspect upstream identity information by decoding the JWT without server-side storage lookups. Args: idp_tokens: Full token response from upstream provider. Contains access_token, and for OIDC providers may include id_token, refresh_token, and other response fields. Returns: Dict of claims to embed in JWT under the "upstream_claims" key, or None to not embed any upstream claims. Example: For Azure/Entra ID, you might decode the access_token JWT and extract claims like sub, oid, name, preferred_username, email, roles, and groups. """ _ = idp_tokens return None async def load_refresh_token( self, client: OAuthClientInformationFull, refresh_token: str, ) -> RefreshToken | None: """Load refresh token metadata from distributed storage. Looks up by token hash and reconstructs the RefreshToken object. Validates that the token belongs to the requesting client. """ token_hash = _hash_token(refresh_token) metadata = await self._refresh_token_store.get(key=token_hash) if not metadata: return None # Verify token belongs to this client (prevents cross-client token usage) if metadata.client_id != client.client_id: logger.warning( "Refresh token client_id mismatch: expected %s, got %s", client.client_id, metadata.client_id, ) return None return RefreshToken( token=refresh_token, client_id=metadata.client_id, scopes=metadata.scopes, expires_at=metadata.expires_at, ) async def exchange_refresh_token( self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str], ) -> OAuthToken: """Exchange FastMCP refresh token for new FastMCP access token. Implements two-tier refresh: 1. Verify FastMCP refresh token 2. Look up upstream token via JTI mapping 3. Refresh upstream token with upstream provider 4. Update stored upstream token 5. Issue new FastMCP access token 6. Keep same FastMCP refresh token (unless upstream rotates) """ # Verify FastMCP refresh token try: refresh_payload = self.jwt_issuer.verify_token( refresh_token.token, expected_token_use="refresh" ) refresh_jti = refresh_payload["jti"] except Exception as e: logger.debug("FastMCP refresh token validation failed: %s", e) raise TokenError("invalid_grant", "Invalid refresh token") from e # Look up upstream token via JTI mapping jti_mapping = await self._jti_mapping_store.get(key=refresh_jti) if not jti_mapping: logger.error("JTI mapping not found for refresh token: %s", refresh_jti[:8]) raise TokenError("invalid_grant", "Refresh token mapping not found") upstream_token_set = await self._upstream_token_store.get( key=jti_mapping.upstream_token_id ) if not upstream_token_set: logger.error( "Upstream token set not found: %s", jti_mapping.upstream_token_id[:8] ) raise TokenError("invalid_grant", "Upstream token not found") # Decrypt upstream refresh token if not upstream_token_set.refresh_token: logger.error("No upstream refresh token available") raise TokenError("invalid_grant", "Refresh not supported for this token") # Refresh upstream token using authlib oauth_client = self._create_upstream_oauth_client() # Allow child classes to transform scopes before sending to upstream # This enables provider-specific scope formatting (e.g., Azure prefixing) # while keeping original scopes in storage upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes) try: logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8]) token_response: dict[str, Any] = await oauth_client.refresh_token( url=self._upstream_token_endpoint, refresh_token=upstream_token_set.refresh_token, scope=" ".join(upstream_scopes) if upstream_scopes else None, **self._extra_token_params, ) logger.debug("Successfully refreshed upstream token") except Exception as e: logger.error("Upstream token refresh failed: %s", e) raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e # Update stored upstream token # In refresh flow, we know there's a refresh token, so default to 1 hour # (user override still applies if set) if "expires_in" in token_response: new_expires_in = int(token_response["expires_in"]) logger.debug( "Refreshed access token TTL: %d seconds (from IdP expires_in)", new_expires_in, ) elif self._fallback_access_token_expiry_seconds is not None: new_expires_in = self._fallback_access_token_expiry_seconds logger.debug( "Refreshed access token TTL: %d seconds (using configured fallback)", new_expires_in, ) else: new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS logger.debug( "Refreshed access token TTL: %d seconds (default)", new_expires_in ) upstream_token_set.access_token = token_response["access_token"] upstream_token_set.expires_at = time.time() + new_expires_in # Prefer IdP-granted scopes from refresh response (RFC 6749 §5.1) refreshed_scopes: list[str] = ( parse_scopes(token_response["scope"]) or [] if "scope" in token_response else scopes ) upstream_token_set.scope = " ".join(refreshed_scopes) # Handle upstream refresh token rotation and expiry new_refresh_expires_in = None if new_upstream_refresh := token_response.get("refresh_token"): if new_upstream_refresh != upstream_token_set.refresh_token: upstream_token_set.refresh_token = new_upstream_refresh logger.debug("Upstream refresh token rotated") # Update refresh token expiry if provided if "refresh_expires_in" in token_response and int( token_response["refresh_expires_in"] ): new_refresh_expires_in = int(token_response["refresh_expires_in"]) upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) logger.debug( "Upstream refresh token expires in %d seconds", new_refresh_expires_in, ) elif upstream_token_set.refresh_token_expires_at: # Keep existing expiry if upstream doesn't provide new one new_refresh_expires_in = int( upstream_token_set.refresh_token_expires_at - time.time() ) else: # Default to 30 days if unknown new_refresh_expires_in = 60 * 60 * 24 * 30 upstream_token_set.refresh_token_expires_at = ( time.time() + new_refresh_expires_in ) upstream_token_set.raw_token_data = { **upstream_token_set.raw_token_data, **token_response, } # Calculate refresh TTL for storage refresh_ttl = new_refresh_expires_in or ( int(upstream_token_set.refresh_token_expires_at - time.time()) if upstream_token_set.refresh_token_expires_at else 60 * 60 * 24 * 30 # Default to 30 days if unknown ) await self._upstream_token_store.put( key=upstream_token_set.upstream_token_id, value=upstream_token_set, ttl=max( refresh_ttl, new_expires_in, 1 ), # Keep until longest-lived token expires (min 1s for safety) ) # Re-extract upstream claims from refreshed token response upstream_claims = await self._extract_upstream_claims( upstream_token_set.raw_token_data ) # Issue new minimal FastMCP access token (just a reference via JTI) if client.client_id is None: raise TokenError("invalid_client", "Client ID is required") new_access_jti = secrets.token_urlsafe(32) new_fastmcp_access = self.jwt_issuer.issue_access_token( client_id=client.client_id, scopes=refreshed_scopes, jti=new_access_jti, expires_in=new_expires_in, upstream_claims=upstream_claims, ) # Store new access token JTI mapping await self._jti_mapping_store.put( key=new_access_jti, value=JTIMapping( jti=new_access_jti, upstream_token_id=upstream_token_set.upstream_token_id, created_at=time.time(), ), ttl=new_expires_in, # Auto-expire with refreshed access token ) # Issue NEW minimal FastMCP refresh token (rotation for security) # Use upstream refresh token expiry to align lifetimes new_refresh_jti = secrets.token_urlsafe(32) new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token( client_id=client.client_id, scopes=refreshed_scopes, jti=new_refresh_jti, expires_in=new_refresh_expires_in or 60 * 60 * 24 * 30, # Fallback to 30 days upstream_claims=upstream_claims, ) # Store new refresh token JTI mapping with aligned expiry # (reuse refresh_ttl calculated above for upstream token store) await self._jti_mapping_store.put( key=new_refresh_jti, value=JTIMapping( jti=new_refresh_jti, upstream_token_id=upstream_token_set.upstream_token_id, created_at=time.time(), ), ttl=refresh_ttl, # Align with upstream refresh token expiry ) # Invalidate old refresh token (refresh token rotation - enforces one-time use) await self._jti_mapping_store.delete(key=refresh_jti) logger.debug( "Rotated refresh token (old JTI invalidated - one-time use enforced)" ) # Store new refresh token metadata (keyed by hash) await self._refresh_token_store.put( key=_hash_token(new_fastmcp_refresh), value=RefreshTokenMetadata( client_id=client.client_id, scopes=refreshed_scopes, expires_at=int(time.time()) + refresh_ttl, created_at=time.time(), ), ttl=refresh_ttl, ) # Delete old refresh token (by hash) await self._refresh_token_store.delete(key=_hash_token(refresh_token.token)) logger.info( "Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)", client.client_id, new_access_jti[:8], new_refresh_jti[:8], ) # Return new FastMCP tokens (both access AND refresh are new) return OAuthToken( access_token=new_fastmcp_access, token_type="Bearer", expires_in=new_expires_in, refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) scope=" ".join(refreshed_scopes), ) # ------------------------------------------------------------------------- # Token Validation # ------------------------------------------------------------------------- def _get_verification_token( self, upstream_token_set: UpstreamTokenSet ) -> str | None: """Get the token string to pass to the token verifier. Returns the upstream access token by default. Subclasses can override to verify a different token (e.g., the OIDC id_token for providers that issue opaque access tokens). """ return upstream_token_set.access_token def _uses_alternate_verification(self) -> bool: """Whether this provider verifies a different token than the access token. When True, ``load_access_token`` patches the validated result with the upstream access token, scopes, and expiry so that the returned ``AccessToken`` reflects the access token rather than the verification token. The default implementation compares token values, but subclasses should override this to use an intent-based flag so the patch is applied even when the verification token and access token happen to carry the same value (e.g., some OIDC providers issue identical JWTs for both). """ return False async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] """Validate FastMCP JWT by swapping for upstream token. This implements the token swap pattern: 1. Verify FastMCP JWT signature (proves it's our token) 2. Look up upstream token via JTI mapping 3. Decrypt upstream token 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) 5. Return upstream validation result The FastMCP JWT is a reference token - all authorization data comes from validating the upstream token via the TokenVerifier. """ try: # 1. Verify FastMCP JWT signature and claims payload = self.jwt_issuer.verify_token(token) jti = payload["jti"] # 2. Look up upstream token via JTI mapping jti_mapping = await self._jti_mapping_store.get(key=jti) if not jti_mapping: logger.info( "JTI mapping not found (token may have expired): jti=%s...", jti[:16], ) return None upstream_token_set = await self._upstream_token_store.get( key=jti_mapping.upstream_token_id ) if not upstream_token_set: logger.debug( "Upstream token not found: %s", jti_mapping.upstream_token_id ) return None # 3. Validate with upstream provider (delegated to TokenVerifier) # This calls the real token validator (GitHub API, JWKS, etc.) verification_token = self._get_verification_token(upstream_token_set) if verification_token is None: logger.debug("No verification token available") return None validated = await self._token_validator.verify_token(verification_token) if not validated: logger.debug("Upstream token validation failed") return None # When alternate verification is in use (e.g., id_token # verification in OIDCProxy), ensure the returned AccessToken # carries the upstream access token and its scopes, not the # verification token's values. We use an intent-based check # rather than value equality because some IdPs issue identical # JWTs for both access_token and id_token, which would cause # the scope patch to be skipped even though it's needed. if self._uses_alternate_verification(): validated = validated.model_copy( update={ "token": upstream_token_set.access_token, "scopes": upstream_token_set.scope.split() if upstream_token_set.scope else validated.scopes, "expires_at": int(upstream_token_set.expires_at), } ) logger.debug( "Token swap successful for JTI=%s (upstream validated)", jti[:8] ) return validated except Exception as e: logger.debug("Token swap validation failed: %s", e) return None # ------------------------------------------------------------------------- # Token Revocation # ------------------------------------------------------------------------- async def revoke_token(self, token: AccessToken | RefreshToken) -> None: """Revoke token locally and with upstream server if supported. For refresh tokens, removes from local storage by hash. For all tokens, attempts upstream revocation if endpoint is configured. Access token JTI mappings expire via TTL. """ # For refresh tokens, delete from local storage by hash if isinstance(token, RefreshToken): await self._refresh_token_store.delete(key=_hash_token(token.token)) # Attempt upstream revocation if endpoint is configured if self._upstream_revocation_endpoint: try: async with httpx.AsyncClient( timeout=HTTP_TIMEOUT_SECONDS ) as http_client: revocation_data: dict[str, str] = {"token": token.token} request_kwargs: dict[str, Any] = {"data": revocation_data} # Use the factory method when available (supports alternative auth like # client assertions for managed identity), falling back to basic auth # or client_id-only for public clients per RFC 7009 oauth_client = self._create_upstream_oauth_client() if oauth_client.client_secret is not None: # Client secret is available, use HTTP Basic auth request_kwargs["auth"] = ( self._upstream_client_id, oauth_client.client_secret, ) else: # No secret; public client must still identify itself per RFC 7009 revocation_data["client_id"] = self._upstream_client_id await http_client.post( self._upstream_revocation_endpoint, **request_kwargs, ) logger.debug("Successfully revoked token with upstream server") except Exception as e: logger.warning("Failed to revoke token with upstream server: %s", e) else: logger.debug("No upstream revocation endpoint configured") logger.debug("Token revoked successfully") def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth routes with custom handlers for better error UX. This method creates standard OAuth routes and replaces: - /authorize endpoint: Enhanced error responses for unregistered clients - /token endpoint: OAuth 2.1 compliant error codes Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ # Get standard OAuth routes from parent class # Note: parent already replaces /token with TokenHandler for proper error codes routes = super().get_routes(mcp_path) custom_routes = [] logger.debug( f"get_routes called - configuring OAuth routes in {len(routes)} routes" ) for i, route in enumerate(routes): logger.debug( f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" ) # Replace the authorize endpoint with our enhanced handler for better error UX if ( isinstance(route, Route) and route.path == "/authorize" and route.methods is not None and ("GET" in route.methods or "POST" in route.methods) ): # Replace with our enhanced authorization handler # Note: self.base_url is guaranteed to be set in parent __init__ authorize_handler = AuthorizationHandler( provider=self, base_url=self.base_url, # ty: ignore[invalid-argument-type] server_name=None, # Could be extended to pass server metadata server_icon_url=None, ) custom_routes.append( Route( path="/authorize", endpoint=authorize_handler.handle, methods=["GET", "POST"], ) ) elif ( self._cimd_manager is not None and isinstance(route, Route) and route.path == "/token" and route.methods is not None and "POST" in route.methods ): # Replace the token endpoint authenticator with one that supports # private_key_jwt for CIMD clients token_endpoint_url = f"{self.base_url}/token" cimd_authenticator = PrivateKeyJWTClientAuthenticator( provider=self, cimd_manager=self._cimd_manager, token_endpoint_url=token_endpoint_url, ) token_handler = TokenHandler( provider=self, client_authenticator=cimd_authenticator ) custom_routes.append( Route( path="/token", endpoint=cors_middleware( token_handler.handle, ["POST", "OPTIONS"] ), methods=["POST", "OPTIONS"], ) ) elif ( self._cimd_manager is not None and isinstance(route, Route) and route.path.startswith("/.well-known/oauth-authorization-server") ): client_registration_options = ( self.client_registration_options or ClientRegistrationOptions() ) revocation_options = self.revocation_options or RevocationOptions() metadata = build_metadata( self.base_url, # ty: ignore[invalid-argument-type] self.service_documentation_url, client_registration_options, revocation_options, ) metadata.client_id_metadata_document_supported = True handler = MetadataHandler(metadata) methods = route.methods or ["GET", "OPTIONS"] custom_routes.append( Route( path=route.path, endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), methods=methods, name=route.name, include_in_schema=route.include_in_schema, ) ) else: # Keep all other standard OAuth routes unchanged custom_routes.append(route) # Add OAuth callback endpoint for forwarding to client callbacks custom_routes.append( Route( path=self._redirect_path, endpoint=self._handle_idp_callback, methods=["GET"], ) ) # Add consent endpoints # Handle both GET (show page) and POST (submit) at /consent custom_routes.append( Route( path="/consent", endpoint=self._handle_consent, methods=["GET", "POST"] ) ) return custom_routes # ------------------------------------------------------------------------- # IdP Callback Forwarding # ------------------------------------------------------------------------- async def _handle_idp_callback( self, request: Request ) -> HTMLResponse | RedirectResponse: """Handle callback from upstream IdP and forward to client. This implements the DCR-compliant callback forwarding: 1. Receive IdP callback with code and txn_id as state 2. Exchange IdP code for tokens (server-side) 3. Generate our own client code bound to PKCE challenge 4. Redirect to client's callback with client code and original state """ try: idp_code = request.query_params.get("code") txn_id = request.query_params.get("state") error = request.query_params.get("error") if error: error_description = request.query_params.get("error_description") logger.error( "IdP callback error: %s - %s", error, error_description, ) # Show error page to user html_content = create_error_html( error_title="OAuth Error", error_message=f"Authentication failed: {error_description or 'Unknown error'}", error_details={"Error Code": error} if error else None, ) return HTMLResponse(content=html_content, status_code=400) if not idp_code or not txn_id: logger.error("IdP callback missing code or transaction ID") html_content = create_error_html( error_title="OAuth Error", error_message="Missing authorization code or transaction ID from the identity provider.", ) return HTMLResponse(content=html_content, status_code=400) # Look up transaction data transaction_model = await self._transaction_store.get(key=txn_id) if not transaction_model: logger.error("IdP callback with invalid transaction ID: %s", txn_id) html_content = create_error_html( error_title="OAuth Error", error_message="Invalid or expired authorization transaction. Please try authenticating again.", ) return HTMLResponse(content=html_content, status_code=400) # Verify consent binding cookie to prevent confused deputy attacks. # When consent is enabled, the browser that approved consent receives # a signed cookie. A different browser (e.g., a victim lured to the # IdP URL) won't have this cookie and will be rejected. if self._require_authorization_consent is True: consent_token = transaction_model.consent_token if not consent_token: logger.error("Transaction %s missing consent_token", txn_id) html_content = create_error_html( error_title="Authorization Error", error_message="Invalid authorization flow. Please try authenticating again.", ) return HTMLResponse(content=html_content, status_code=403) if not self._verify_consent_binding_cookie( request, txn_id, consent_token ): logger.warning( "Consent binding cookie missing or invalid for transaction %s " "(possible confused deputy attack)", txn_id, ) html_content = create_error_html( error_title="Authorization Error", error_message=( "Authorization session mismatch. This can happen if you " "followed a link from another person or your session expired. " "Please try authenticating again." ), ) return HTMLResponse(content=html_content, status_code=403) transaction = transaction_model.model_dump() # Exchange IdP code for tokens (server-side) oauth_client = self._create_upstream_oauth_client() try: idp_redirect_uri = ( f"{str(self.base_url).rstrip('/')}{self._redirect_path}" ) logger.debug( f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}" ) # Build token exchange parameters token_params = { "url": self._upstream_token_endpoint, "code": idp_code, "redirect_uri": idp_redirect_uri, } # Include proxy's code_verifier if we forwarded PKCE proxy_code_verifier = transaction.get("proxy_code_verifier") if proxy_code_verifier: token_params["code_verifier"] = proxy_code_verifier logger.debug( "Including proxy code_verifier in token exchange for transaction %s", txn_id, ) # Allow providers to specify scope for token exchange exchange_scopes = self._prepare_scopes_for_token_exchange( transaction.get("scopes") or [] ) if exchange_scopes: token_params["scope"] = " ".join(exchange_scopes) # Add any extra token parameters configured for this proxy if self._extra_token_params: token_params.update(self._extra_token_params) logger.debug( "Adding extra token parameters for transaction %s: %s", txn_id, list(self._extra_token_params.keys()), ) idp_tokens: dict[str, Any] = await oauth_client.fetch_token( **token_params ) logger.debug( f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})" ) logger.debug( "IdP token response: expires_in=%s, has_refresh_token=%s", idp_tokens.get("expires_in"), "refresh_token" in idp_tokens, ) except Exception as e: logger.error("IdP token exchange failed: %s", e) html_content = create_error_html( error_title="OAuth Error", error_message=f"Token exchange with identity provider failed: {e}", ) return HTMLResponse(content=html_content, status_code=500) # Generate our own authorization code for the client client_code = secrets.token_urlsafe(32) code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS) # Store client code with PKCE challenge and IdP tokens await self._code_store.put( key=client_code, value=ClientCode( code=client_code, client_id=transaction["client_id"], redirect_uri=transaction["client_redirect_uri"], code_challenge=transaction["code_challenge"], code_challenge_method=transaction["code_challenge_method"], scopes=transaction["scopes"], idp_tokens=idp_tokens, expires_at=code_expires_at, created_at=time.time(), ), ttl=DEFAULT_AUTH_CODE_EXPIRY_SECONDS, # Auto-expire after 5 minutes ) # Clean up transaction await self._transaction_store.delete(key=txn_id) # Build client callback URL with our code and original state client_redirect_uri = transaction["client_redirect_uri"] client_state = transaction["client_state"] callback_params = { "code": client_code, "state": client_state, } # Add query parameters to client redirect URI separator = "&" if "?" in client_redirect_uri else "?" client_callback_url = ( f"{client_redirect_uri}{separator}{urlencode(callback_params)}" ) logger.debug(f"Forwarding to client callback for transaction {txn_id}") response = RedirectResponse(url=client_callback_url, status_code=302) self._clear_consent_binding_cookie(request, response, txn_id) return response except Exception as e: logger.error("Error in IdP callback handler: %s", e, exc_info=True) html_content = create_error_html( error_title="OAuth Error", error_message="Internal server error during OAuth callback processing. Please try again.", ) return HTMLResponse(content=html_content, status_code=500) ================================================ FILE: src/fastmcp/server/auth/oauth_proxy/ui.py ================================================ """OAuth Proxy UI Generation Functions. This module contains HTML generation functions for consent and error pages. """ from __future__ import annotations from fastmcp.utilities.ui import ( BUTTON_STYLES, DETAIL_BOX_STYLES, DETAILS_STYLES, INFO_BOX_STYLES, REDIRECT_SECTION_STYLES, TOOLTIP_STYLES, create_logo, create_page, ) def create_consent_html( client_id: str, redirect_uri: str, scopes: list[str], txn_id: str, csrf_token: str, client_name: str | None = None, title: str = "Application Access Request", server_name: str | None = None, server_icon_url: str | None = None, server_website_url: str | None = None, client_website_url: str | None = None, csp_policy: str | None = None, is_cimd_client: bool = False, cimd_domain: str | None = None, ) -> str: """Create a styled HTML consent page for OAuth authorization requests. Args: csp_policy: Content Security Policy override. If None, uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. """ import html as html_module client_display = html_module.escape(client_name or client_id) server_name_escaped = html_module.escape(server_name or "FastMCP") # Make server name a hyperlink if website URL is available if server_website_url: website_url_escaped = html_module.escape(server_website_url) server_display = f'{server_name_escaped}' else: server_display = server_name_escaped # Build intro box with call-to-action intro_box = f"""

The application {client_display} wants to access the MCP server {server_display}. Please ensure you recognize the callback address below.

""" # Build CIMD verified domain badge if applicable cimd_badge = "" if is_cimd_client and cimd_domain: cimd_domain_escaped = html_module.escape(cimd_domain) cimd_badge = f"""
Verified domain: {cimd_domain_escaped}
""" # Build redirect URI section (yellow box, centered) redirect_uri_escaped = html_module.escape(redirect_uri) redirect_section = f"""
Credentials will be sent to:
{redirect_uri_escaped}
""" # Build advanced details with collapsible section detail_rows = [ ("Application Name", html_module.escape(client_name or client_id)), ("Application Website", html_module.escape(client_website_url or "N/A")), ("Application ID", html_module.escape(client_id)), ("Redirect URI", redirect_uri_escaped), ( "Requested Scopes", ", ".join(html_module.escape(s) for s in scopes) if scopes else "None", ), ] detail_rows_html = "\n".join( [ f"""
{label}:
{value}
""" for label, value in detail_rows ] ) advanced_details = f"""
Advanced Details
{detail_rows_html}
""" # Build form with buttons # Use empty action to submit to current URL (/consent or /mcp/consent) # The POST handler is registered at the same path as GET form = f"""
""" # Build help link with tooltip (identical to current implementation) help_link = """ """ # Build the page content content = f"""
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}

Application Access Request

{intro_box} {cimd_badge} {redirect_section} {advanced_details} {form}
{help_link} """ # Additional styles needed for this page cimd_badge_styles = """ .cimd-badge { background: #ecfdf5; border: 1px solid #6ee7b7; border-radius: 8px; padding: 8px 16px; margin-bottom: 16px; font-size: 14px; color: #065f46; text-align: center; } .cimd-check { color: #059669; font-weight: bold; margin-right: 4px; } """ additional_styles = ( INFO_BOX_STYLES + REDIRECT_SECTION_STYLES + DETAILS_STYLES + DETAIL_BOX_STYLES + BUTTON_STYLES + TOOLTIP_STYLES + cimd_badge_styles ) # Determine CSP policy to use # If csp_policy is None, build the default CSP policy # If csp_policy is empty string, CSP will be disabled entirely in create_page # If csp_policy is a non-empty string, use it as-is if csp_policy is None: # The consent form posts to itself (action="") and all subsequent redirects # are server-controlled. Chrome enforces form-action across the entire redirect # chain (Chromium issue #40923007), which breaks flows where an HTTPS callback # internally redirects to a custom scheme (e.g., claude:// or cursor://). # Since the form target is same-origin and we control the redirect chain, # omitting form-action is safe and avoids these browser-specific CSP issues. csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'" return create_page( content=content, title=title, additional_styles=additional_styles, csp_policy=csp_policy, ) def create_error_html( error_title: str, error_message: str, error_details: dict[str, str] | None = None, server_name: str | None = None, server_icon_url: str | None = None, ) -> str: """Create a styled HTML error page for OAuth errors. Args: error_title: The error title (e.g., "OAuth Error", "Authorization Failed") error_message: The main error message to display error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`) server_name: Optional server name to display server_icon_url: Optional URL to server icon/logo Returns: Complete HTML page as a string """ import html as html_module error_message_escaped = html_module.escape(error_message) # Build error message box error_box = f"""

{error_message_escaped}

""" # Build error details section if provided details_section = "" if error_details: detail_rows_html = "\n".join( [ f"""
{html_module.escape(label)}:
{html_module.escape(value)}
""" for label, value in error_details.items() ] ) details_section = f"""
Error Details
{detail_rows_html}
""" # Build the page content content = f"""
{create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")}

{html_module.escape(error_title)}

{error_box} {details_section}
""" # Additional styles needed for this page # Override .info-box.error to use normal text color instead of red additional_styles = ( INFO_BOX_STYLES + DETAILS_STYLES + DETAIL_BOX_STYLES + """ .info-box.error { color: #111827; } """ ) # Simple CSP policy for error pages (no forms needed) csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'" return create_page( content=content, title=error_title, additional_styles=additional_styles, csp_policy=csp_policy, ) ================================================ FILE: src/fastmcp/server/auth/oidc_proxy.py ================================================ """OIDC Proxy Provider for FastMCP. This provider acts as a transparent proxy to an upstream OIDC compliant Authorization Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and forwarding of all OAuth flows. This implementation is based on: OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 """ from collections.abc import Sequence from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl, BaseModel, model_validator from typing_extensions import Self from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class OIDCConfiguration(BaseModel): """OIDC Configuration. See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata https://datatracker.ietf.org/doc/html/rfc8414#section-2 """ strict: bool = True # OpenID Connect Discovery 1.0 issuer: AnyHttpUrl | str | None = None # Strict authorization_endpoint: AnyHttpUrl | str | None = None # Strict token_endpoint: AnyHttpUrl | str | None = None # Strict userinfo_endpoint: AnyHttpUrl | str | None = None jwks_uri: AnyHttpUrl | str | None = None # Strict registration_endpoint: AnyHttpUrl | str | None = None scopes_supported: Sequence[str] | None = None response_types_supported: Sequence[str] | None = None # Strict response_modes_supported: Sequence[str] | None = None grant_types_supported: Sequence[str] | None = None acr_values_supported: Sequence[str] | None = None subject_types_supported: Sequence[str] | None = None # Strict id_token_signing_alg_values_supported: Sequence[str] | None = None # Strict id_token_encryption_alg_values_supported: Sequence[str] | None = None id_token_encryption_enc_values_supported: Sequence[str] | None = None userinfo_signing_alg_values_supported: Sequence[str] | None = None userinfo_encryption_alg_values_supported: Sequence[str] | None = None userinfo_encryption_enc_values_supported: Sequence[str] | None = None request_object_signing_alg_values_supported: Sequence[str] | None = None request_object_encryption_alg_values_supported: Sequence[str] | None = None request_object_encryption_enc_values_supported: Sequence[str] | None = None token_endpoint_auth_methods_supported: Sequence[str] | None = None token_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None display_values_supported: Sequence[str] | None = None claim_types_supported: Sequence[str] | None = None claims_supported: Sequence[str] | None = None service_documentation: AnyHttpUrl | str | None = None claims_locales_supported: Sequence[str] | None = None ui_locales_supported: Sequence[str] | None = None claims_parameter_supported: bool | None = None request_parameter_supported: bool | None = None request_uri_parameter_supported: bool | None = None require_request_uri_registration: bool | None = None op_policy_uri: AnyHttpUrl | str | None = None op_tos_uri: AnyHttpUrl | str | None = None # OAuth 2.0 Authorization Server Metadata revocation_endpoint: AnyHttpUrl | str | None = None revocation_endpoint_auth_methods_supported: Sequence[str] | None = None revocation_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None introspection_endpoint: AnyHttpUrl | str | None = None introspection_endpoint_auth_methods_supported: Sequence[str] | None = None introspection_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = ( None ) code_challenge_methods_supported: Sequence[str] | None = None signed_metadata: str | None = None @model_validator(mode="after") def _enforce_strict(self) -> Self: """Enforce strict rules.""" if not self.strict: return self def enforce(attr: str, is_url: bool = False) -> None: value = getattr(self, attr, None) if not value: message = f"Missing required configuration metadata: {attr}" logger.error(message) raise ValueError(message) if not is_url or isinstance(value, AnyHttpUrl): return try: AnyHttpUrl(value) except Exception as e: message = f"Invalid URL for configuration metadata: {attr}" logger.error(message) raise ValueError(message) from e enforce("issuer", True) enforce("authorization_endpoint", True) enforce("token_endpoint", True) enforce("jwks_uri", True) enforce("response_types_supported") enforce("subject_types_supported") enforce("id_token_signing_alg_values_supported") return self @classmethod def get_oidc_configuration( cls, config_url: AnyHttpUrl, *, strict: bool | None, timeout_seconds: int | None ) -> Self: """Get the OIDC configuration for the specified config URL. Args: config_url: The OIDC config URL strict: The strict flag for the configuration timeout_seconds: HTTP request timeout in seconds """ get_kwargs = {} if timeout_seconds is not None: get_kwargs["timeout"] = timeout_seconds try: response = httpx.get(str(config_url), **get_kwargs) response.raise_for_status() config_data = response.json() if strict is not None: config_data["strict"] = strict return cls.model_validate(config_data) except Exception: logger.exception( f"Unable to get OIDC configuration for config url: {config_url}" ) raise class OIDCProxy(OAuthProxy): """OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. This provider makes it easier to add OAuth protection for any upstream provider that is OIDC compliant. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.oidc_proxy import OIDCProxy # Simple OIDC based protection auth = OIDCProxy( config_url="https://oidc.config.url", client_id="your-oidc-client-id", client_secret="your-oidc-client-secret", base_url="https://your.server.url", ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ oidc_config: OIDCConfiguration def __init__( self, *, # OIDC configuration config_url: AnyHttpUrl | str, strict: bool | None = None, # Upstream server configuration client_id: str, client_secret: str | None = None, audience: str | None = None, timeout_seconds: int | None = None, # Token verifier token_verifier: TokenVerifier | None = None, algorithm: str | None = None, required_scopes: list[str] | None = None, verify_id_token: bool = False, # FastMCP server configuration base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, # Client configuration allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, # JWT and encryption keys jwt_signing_key: str | bytes | None = None, # Token validation configuration token_endpoint_auth_method: str | None = None, # Consent screen configuration require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, # Extra parameters extra_authorize_params: dict[str, str] | None = None, extra_token_params: dict[str, str] | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, # CIMD configuration enable_cimd: bool = True, ) -> None: """Initialize the OIDC proxy provider. Args: config_url: URL of upstream configuration strict: Optional strict flag for the configuration client_id: Client ID registered with upstream server client_secret: Client secret for upstream server. Optional for PKCE public clients or when using alternative credentials. When omitted, jwt_signing_key must be provided. audience: Audience for upstream server timeout_seconds: HTTP request timeout in seconds token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens). If not provided, a JWTVerifier will be created using the OIDC configuration. Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead). algorithm: Token verifier algorithm (only used if token_verifier is not provided) required_scopes: Required scopes for token validation (only used if token_verifier is not provided) verify_id_token: If True, verify the OIDC id_token instead of the access_token. Useful for providers that issue opaque (non-JWT) access tokens, since the id_token is always a standard JWT verifiable via the provider's JWKS. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") 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), all redirect URIs are allowed (for DCR compatibility). If empty list, no redirect URIs are allowed. These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. token_endpoint_auth_method: Token endpoint authentication method for upstream server. Common values: "client_secret_basic", "client_secret_post", "none". If None, authlib will use its default (typically "client_secret_basic"). require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to the upstream IdP. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. consent_csp_policy: Content Security Policy for the consent page. If None (default), uses the built-in CSP policy with appropriate directives. If empty string "", disables CSP entirely (no meta tag is rendered). If a non-empty string, uses that as the CSP policy value. extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. Useful for provider-specific parameters like prompt=consent or access_type=offline. Example: {"prompt": "consent", "access_type": "offline"} extra_token_params: Additional parameters to forward to the upstream token endpoint. Useful for provider-specific parameters during token exchange. fallback_access_token_expiry_seconds: Expiry time to use when upstream provider doesn't return `expires_in` in the token response. If not set, uses smart defaults: 1 hour if a refresh token is available (since we can refresh), or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support. When True, clients can use their metadata document URL as client_id instead of Dynamic Client Registration. Default is True. """ if not config_url: raise ValueError("Missing required config URL") if not client_id: raise ValueError("Missing required client id") if not client_secret and not jwt_signing_key: raise ValueError( "Either client_secret or jwt_signing_key must be provided. " "jwt_signing_key is required when client_secret is omitted " "(e.g., for PKCE public clients)." ) if not base_url: raise ValueError("Missing required base URL") # Validate that verifier-specific parameters are not used with custom verifier if token_verifier is not None: if algorithm is not None: raise ValueError( "Cannot specify 'algorithm' when providing a custom token_verifier. " "Configure the algorithm on your token verifier instead." ) if required_scopes is not None: raise ValueError( "Cannot specify 'required_scopes' when providing a custom token_verifier. " "Configure required scopes on your token verifier instead." ) if isinstance(config_url, str): config_url = AnyHttpUrl(config_url) self.oidc_config = self.get_oidc_configuration( config_url, strict, timeout_seconds ) if ( not self.oidc_config.authorization_endpoint or not self.oidc_config.token_endpoint ): logger.debug(f"Invalid OIDC Configuration: {self.oidc_config}") raise ValueError("Missing required OIDC endpoints") revocation_endpoint = ( str(self.oidc_config.revocation_endpoint) if self.oidc_config.revocation_endpoint else None ) # Use custom verifier if provided, otherwise create default JWTVerifier if token_verifier is None: # When verifying id_tokens: # - aud is always the OAuth client_id (per OIDC Core §2), not # the API audience, so use client_id for audience validation. # - id_tokens don't carry scope/scp claims, so don't pass # required_scopes to the verifier (scope enforcement happens # at the FastMCP token level instead). verifier_audience = client_id if verify_id_token else audience verifier_scopes = None if verify_id_token else required_scopes token_verifier = self.get_token_verifier( algorithm=algorithm, audience=verifier_audience, required_scopes=verifier_scopes, timeout_seconds=timeout_seconds, ) init_kwargs: dict[str, object] = { "upstream_authorization_endpoint": str( self.oidc_config.authorization_endpoint ), "upstream_token_endpoint": str(self.oidc_config.token_endpoint), "upstream_client_id": client_id, "upstream_client_secret": client_secret, "upstream_revocation_endpoint": revocation_endpoint, "token_verifier": token_verifier, "base_url": base_url, "issuer_url": issuer_url or base_url, "service_documentation_url": self.oidc_config.service_documentation, "allowed_client_redirect_uris": allowed_client_redirect_uris, "client_storage": client_storage, "jwt_signing_key": jwt_signing_key, "token_endpoint_auth_method": token_endpoint_auth_method, "require_authorization_consent": require_authorization_consent, "consent_csp_policy": consent_csp_policy, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, "enable_cimd": enable_cimd, } if redirect_path: init_kwargs["redirect_path"] = redirect_path # Build extra params, merging audience with user-provided params # User params override audience if there's a conflict final_authorize_params: dict[str, str] = {} final_token_params: dict[str, str] = {} if audience: final_authorize_params["audience"] = audience final_token_params["audience"] = audience if extra_authorize_params: final_authorize_params.update(extra_authorize_params) if extra_token_params: final_token_params.update(extra_token_params) if final_authorize_params: init_kwargs["extra_authorize_params"] = final_authorize_params if final_token_params: init_kwargs["extra_token_params"] = final_token_params super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type] self._verify_id_token = verify_id_token # When verify_id_token strips scopes from the verifier, restore # them on the provider so they're still advertised to clients # and enforced at the FastMCP token level. We also need to # recompute derived state that OAuthProxy.__init__ already built # from the (empty) verifier scopes. if verify_id_token and required_scopes: self.required_scopes = required_scopes self._default_scope_str = " ".join(required_scopes) if self.client_registration_options: self.client_registration_options.valid_scopes = required_scopes if self._cimd_manager is not None: self._cimd_manager.default_scope = self._default_scope_str def _get_verification_token( self, upstream_token_set: UpstreamTokenSet ) -> str | None: """Get the token to verify from the upstream token set. When verify_id_token is enabled, returns the id_token from the upstream token response instead of the access_token. """ if self._verify_id_token: id_token = upstream_token_set.raw_token_data.get("id_token") if id_token is None: logger.warning( "verify_id_token is enabled but no id_token found in" " upstream token response" ) return id_token return upstream_token_set.access_token def _uses_alternate_verification(self) -> bool: """Return True when id_token verification is enabled. This ensures ``load_access_token`` always patches the validated result with upstream scopes, even when the IdP issues the same JWT for both ``access_token`` and ``id_token``. """ return self._verify_id_token def get_oidc_configuration( self, config_url: AnyHttpUrl, strict: bool | None, timeout_seconds: int | None, ) -> OIDCConfiguration: """Gets the OIDC configuration for the specified configuration URL. Args: config_url: The OIDC configuration URL strict: The strict flag for the configuration timeout_seconds: HTTP request timeout in seconds """ return OIDCConfiguration.get_oidc_configuration( config_url, strict=strict, timeout_seconds=timeout_seconds ) def get_token_verifier( self, *, algorithm: str | None = None, audience: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int | None = None, ) -> TokenVerifier: """Creates the token verifier for the specified OIDC configuration and arguments. Args: algorithm: Optional token verifier algorithm audience: Optional token verifier audience required_scopes: Optional token verifier required_scopes timeout_seconds: HTTP request timeout in seconds """ return JWTVerifier( jwks_uri=str(self.oidc_config.jwks_uri), issuer=str(self.oidc_config.issuer), algorithm=algorithm, audience=audience, required_scopes=required_scopes, ) ================================================ FILE: src/fastmcp/server/auth/providers/__init__.py ================================================ ================================================ FILE: src/fastmcp/server/auth/providers/auth0.py ================================================ """Auth0 OAuth provider for FastMCP. This module provides a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider # Simple Auth0 OAuth protection auth = Auth0Provider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", audience="your-auth0-api-audience", base_url="http://localhost:8000", ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ from typing import Literal from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class Auth0Provider(OIDCProxy): """An Auth0 provider implementation for FastMCP. This provider is a complete Auth0 integration that's ready to use with just the configuration URL, client ID, client secret, audience, and base URL. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.auth0 import Auth0Provider # Simple Auth0 OAuth protection auth = Auth0Provider( config_url="https://auth0.config.url", client_id="your-auth0-client-id", client_secret="your-auth0-client-secret", audience="your-auth0-api-audience", base_url="http://localhost:8000", ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ def __init__( self, *, config_url: AnyHttpUrl | str, client_id: str, client_secret: str, audience: str, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, redirect_path: str | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, ) -> None: """Initialize Auth0 OAuth provider. Args: config_url: Auth0 config URL client_id: Auth0 application client id client_secret: Auth0 application client secret audience: Auth0 API audience base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. required_scopes: Required Auth0 scopes (defaults to ["openid"]) redirect_path: Redirect path configured in Auth0 application allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Auth0. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. """ # Parse scopes if provided as string auth0_required_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) super().__init__( config_url=config_url, client_id=client_id, client_secret=client_secret, audience=audience, base_url=base_url, issuer_url=issuer_url, redirect_path=redirect_path, required_scopes=auth0_required_scopes, allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized Auth0 OAuth provider for client %s with scopes: %s", client_id, auth0_required_scopes, ) ================================================ FILE: src/fastmcp/server/auth/providers/aws.py ================================================ """AWS Cognito OAuth provider for FastMCP. This module provides a complete AWS Cognito OAuth integration that's ready to use with a user pool ID, domain prefix, client ID and client secret. It handles all the complexity of AWS Cognito's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider # Simple AWS Cognito OAuth protection auth = AWSCognitoProvider( user_pool_id="your-user-pool-id", aws_region="eu-central-1", client_id="your-cognito-client-id", client_secret="your-cognito-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ from __future__ import annotations from typing import Literal from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class AWSCognitoTokenVerifier(JWTVerifier): """Token verifier that filters claims to Cognito-specific subset.""" async def verify_token(self, token: str) -> AccessToken | None: """Verify token and filter claims to Cognito-specific subset.""" # Use base JWT verification access_token = await super().verify_token(token) if not access_token: return None # Filter claims to Cognito-specific subset cognito_claims = { "sub": access_token.claims.get("sub"), "username": access_token.claims.get("username"), "cognito:groups": access_token.claims.get("cognito:groups", []), } # Return new AccessToken with filtered claims return AccessToken( token=access_token.token, client_id=access_token.client_id, scopes=access_token.scopes, expires_at=access_token.expires_at, claims=cognito_claims, ) class AWSCognitoProvider(OIDCProxy): """Complete AWS Cognito OAuth provider for FastMCP. This provider makes it trivial to add AWS Cognito OAuth protection to any FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, client credentials, and a base URL, and you're ready to go. Features: - Automatic OIDC Discovery from AWS Cognito User Pool - Automatic JWT token validation via Cognito's public keys - Cognito-specific claim filtering (sub, username, cognito:groups) - Support for Cognito User Pools Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider auth = AWSCognitoProvider( user_pool_id="eu-central-1_XXXXXXXXX", aws_region="eu-central-1", client_id="your-cognito-client-id", client_secret="your-cognito-client-secret", base_url="https://my-server.com", redirect_path="/custom/callback", ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, user_pool_id: str, client_id: str, client_secret: str, base_url: AnyHttpUrl | str, aws_region: str = "eu-central-1", issuer_url: AnyHttpUrl | str | None = None, redirect_path: str = "/auth/callback", required_scopes: list[str] | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, ): """Initialize AWS Cognito OAuth provider. Args: user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX") client_id: Cognito app client ID client_secret: Cognito app client secret base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback") required_scopes: Required Cognito scopes (defaults to ["openid"]) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to AWS Cognito. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. """ # Parse scopes if provided as string required_scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) # Construct OIDC discovery URL config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration" # Store Cognito-specific info for claim filtering self.user_pool_id = user_pool_id self.aws_region = aws_region self.client_id = client_id # Initialize OIDC proxy with Cognito discovery super().__init__( config_url=config_url, client_id=client_id, client_secret=client_secret, algorithm="RS256", required_scopes=required_scopes_final, base_url=base_url, issuer_url=issuer_url, redirect_path=redirect_path, allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", client_id, required_scopes_final, ) def get_token_verifier( self, *, algorithm: str | None = None, audience: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int | None = None, ) -> AWSCognitoTokenVerifier: """Creates a Cognito-specific token verifier with claim filtering. Args: algorithm: Optional token verifier algorithm audience: Optional token verifier audience required_scopes: Optional token verifier required_scopes timeout_seconds: HTTP request timeout in seconds """ return AWSCognitoTokenVerifier( issuer=str(self.oidc_config.issuer), audience=audience or self.client_id, algorithm=algorithm, jwks_uri=str(self.oidc_config.jwks_uri), required_scopes=required_scopes, ) ================================================ FILE: src/fastmcp/server/auth/providers/azure.py ================================================ """Azure (Microsoft Entra) OAuth provider for FastMCP. This provider implements Azure/Microsoft Entra ID OAuth authentication using the OAuth Proxy pattern for non-DCR OAuth flows. """ from __future__ import annotations import hashlib from collections import OrderedDict from typing import TYPE_CHECKING, Any, Literal, cast import httpx from key_value.aio.protocols import AsyncKeyValue from fastmcp.dependencies import Dependency from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from azure.identity.aio import OnBehalfOfCredential from mcp.server.auth.provider import AuthorizationParams from mcp.shared.auth import OAuthClientInformationFull logger = get_logger(__name__) # Standard OIDC scopes that should never be prefixed with identifier_uri. # Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc # "OIDC scopes are requested as simple string identifiers without resource prefixes" OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"}) class AzureProvider(OAuthProxy): """Azure (Microsoft Entra) OAuth provider for FastMCP. This provider implements Azure/Microsoft Entra ID authentication using the OAuth Proxy pattern. It supports both organizational accounts and personal Microsoft accounts depending on the tenant configuration. Scope Handling: - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) → Automatically prefixed with identifier_uri during initialization → Validated on all tokens and advertised to MCP clients - additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) → NOT prefixed, NOT validated, NOT advertised to clients → Used to request Microsoft Graph or other upstream API permissions Features: - OAuth proxy to Azure/Microsoft identity platform - JWT validation using tenant issuer and JWKS - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - Custom API scopes and Microsoft Graph scopes in a single provider Setup: 1. Create an App registration in Azure Portal 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) 4. Add custom scopes (e.g., "read", "write") under "Expose an API" 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 6. Create a client secret 7. Get Application (client) ID, Directory (tenant) ID, and client secret Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.azure import AzureProvider # Standard Azure (Public Cloud) auth = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", tenant_id="your-tenant-id", required_scopes=["read", "write"], # Unprefixed scope names additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes base_url="http://localhost:8000", # identifier_uri defaults to api://{client_id} ) # Azure Government auth_gov = AzureProvider( client_id="your-client-id", client_secret="your-client-secret", tenant_id="your-tenant-id", required_scopes=["read", "write"], base_authority="login.microsoftonline.us", # Override for Azure Gov base_url="http://localhost:8000", ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, client_id: str, client_secret: str | None = None, tenant_id: str, required_scopes: list[str], base_url: str, identifier_uri: str | None = None, issuer_url: str | None = None, redirect_path: str | None = None, additional_authorize_scopes: list[str] | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, base_authority: str = "login.microsoftonline.com", http_client: httpx.AsyncClient | None = None, ) -> None: """Initialize Azure OAuth provider. Args: client_id: Azure application (client) ID from your App registration client_secret: Azure client secret from your App registration. Optional when using alternative credentials (e.g., managed identity with a custom _create_upstream_oauth_client override). When omitted, jwt_signing_key must be provided. tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers") identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}). This URI is automatically prefixed to all required_scopes during initialization. Example: identifier_uri="api://my-api" + required_scopes=["read"] → tokens validated for "api://my-api/read" base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). For Azure Government, use "login.microsoftonline.us". required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). - Automatically prefixed with identifier_uri during initialization - Validated on all tokens - Advertised in Protected Resource Metadata - Must match scope names defined in Azure Portal under "Expose an API" Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"] additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format. - NOT prefixed with identifier_uri - NOT validated on tokens - NOT advertised to MCP clients - Used to request additional permissions from Azure (e.g., Graph API access) Example: ["User.Read", "Mail.Read"] These scopes allow your FastMCP server to call Microsoft Graph APIs using the upstream Azure token, but MCP clients are unaware of them. Note: "offline_access" is automatically included to obtain refresh tokens. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Azure. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches. When provided, the client is reused for JWT key fetches and the caller is responsible for its lifecycle. When None (default), a fresh client is created per fetch. """ # Parse scopes if provided as string parsed_required_scopes = parse_scopes(required_scopes) parsed_additional_scopes: list[str] = ( parse_scopes(additional_authorize_scopes) or [] if additional_authorize_scopes else [] ) # Always include offline_access to get refresh tokens from Azure if "offline_access" not in parsed_additional_scopes: parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] # Store Azure-specific config for OBO credential creation self._tenant_id = tenant_id self._base_authority = base_authority # Cache of OBO credentials keyed by hash of user assertion token. # Reusing credentials allows the Azure SDK's internal token cache # to avoid redundant OBO exchanges for the same user + scopes. self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict() self._obo_max_credentials: int = 128 # Apply defaults self.identifier_uri = identifier_uri or f"api://{client_id}" self.additional_authorize_scopes: list[str] = parsed_additional_scopes # Always validate tokens against the app's API client ID using JWT issuer = f"https://{base_authority}/{tenant_id}/v2.0" jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys" # Azure access tokens only include custom API scopes in the `scp` claim, # NOT standard OIDC scopes (openid, profile, email, offline_access). # Filter out OIDC scopes from validation - they'll still be sent to Azure # during authorization (handled by _prefix_scopes_for_azure). validation_scopes = [ s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES ] if not validation_scopes: raise ValueError( "AzureProvider requires at least one non-OIDC scope in " "required_scopes (e.g., 'read', 'write'). OIDC scopes like " "'openid', 'profile', 'email', and 'offline_access' are not " "included in Azure access token claims and cannot be used for " "scope enforcement." ) token_verifier = JWTVerifier( jwks_uri=jwks_uri, issuer=issuer, audience=client_id, algorithm="RS256", required_scopes=validation_scopes, # Only validate non-OIDC scopes http_client=http_client, ) # Build Azure OAuth endpoints with tenant authorization_endpoint = ( f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize" ) token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token" # Initialize OAuth proxy with Azure endpoints # Remember there's hooks called, such as _prepare_scopes_for_token_exchange # and _prepare_scopes_for_upstream_refresh super().__init__( upstream_authorization_endpoint=authorization_endpoint, upstream_token_endpoint=token_endpoint, upstream_client_id=client_id, upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, valid_scopes=parsed_required_scopes, ) authority_info = "" if base_authority != "login.microsoftonline.com": authority_info = f" using authority {base_authority}" logger.info( "Initialized Azure OAuth provider for client %s with tenant %s%s%s", client_id, tenant_id, f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", authority_info, ) async def authorize( self, client: OAuthClientInformationFull, params: AuthorizationParams, ) -> str: """Start OAuth transaction and redirect to Azure AD. Override parent's authorize method to filter out the 'resource' parameter which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use scopes to determine the resource/audience instead of a separate parameter. Args: client: OAuth client information params: Authorization parameters from the client Returns: Authorization URL to redirect the user to Azure AD """ # Clear the resource parameter that Azure AD v2.0 doesn't support # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) # but Azure AD v2.0 uses scopes instead to determine the audience params_to_use = params if hasattr(params, "resource"): original_resource = getattr(params, "resource", None) if original_resource is not None: params_to_use = params.model_copy(update={"resource": None}) if original_resource: logger.debug( "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", original_resource, ) # Don't modify the scopes in params - they stay unprefixed for MCP clients # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url) auth_url = await super().authorize(client, params_to_use) separator = "&" if "?" in auth_url else "?" return f"{auth_url}{separator}prompt=select_account" def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]: """Prefix unprefixed custom API scopes with identifier_uri for Azure. This helper centralizes the scope prefixing logic used in both authorization and token refresh flows. Scopes that are NOT prefixed: - Standard OIDC scopes (openid, profile, email, offline_access) - Fully-qualified URIs (contain "://") - Scopes with path component (contain "/") Note: Microsoft Graph scopes (e.g., User.Read) should be passed via `additional_authorize_scopes` or use fully-qualified format (e.g., https://graph.microsoft.com/User.Read). Args: scopes: List of scopes, may be prefixed or unprefixed Returns: List of scopes with identifier_uri prefix applied where needed """ prefixed = [] for scope in scopes: if scope in OIDC_SCOPES: # Standard OIDC scopes - never prefix prefixed.append(scope) elif "://" in scope or "/" in scope: # Already fully-qualified (e.g., "api://xxx/read" or # "https://graph.microsoft.com/User.Read") prefixed.append(scope) else: # Unprefixed custom API scope - prefix with identifier_uri prefixed.append(f"{self.identifier_uri}/{scope}") return prefixed def _build_upstream_authorize_url( self, txn_id: str, transaction: dict[str, Any] ) -> str: """Build Azure authorization URL with prefixed scopes. Overrides parent to prefix scopes with identifier_uri before sending to Azure, while keeping unprefixed scopes in the transaction for MCP clients. """ # Get unprefixed scopes from transaction unprefixed_scopes = transaction.get("scopes") or self.required_scopes or [] # Prefix scopes for Azure authorization request prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes) # Add Microsoft Graph scopes (not validated, not prefixed) if self.additional_authorize_scopes: prefixed_scopes.extend(self.additional_authorize_scopes) # Temporarily modify transaction dict for parent's URL building modified_transaction = transaction.copy() modified_transaction["scopes"] = prefixed_scopes # Let parent build the URL with prefixed scopes return super()._build_upstream_authorize_url(txn_id, modified_transaction) def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: """Prepare scopes for Azure authorization code exchange. Azure requires scopes during token exchange (AADSTS28003 error if missing). Azure only allows ONE resource per token request (AADSTS28000), so we only include scopes for this API plus OIDC scopes. Args: scopes: Scopes from the authorization request (unprefixed) Returns: List of scopes for Azure token endpoint """ # Prefix scopes for this API prefixed_scopes = self._prefix_scopes_for_azure(scopes or []) # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 if self.additional_authorize_scopes: prefixed_scopes.extend( s for s in self.additional_authorize_scopes if s in OIDC_SCOPES ) deduplicated = list(dict.fromkeys(prefixed_scopes)) logger.debug("Token exchange scopes: %s", deduplicated) return deduplicated def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: """Prepare scopes for Azure token refresh. Azure requires fully-qualified scopes and only allows ONE resource per token request (AADSTS28000). We include scopes for this API plus OIDC scopes. Args: scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"]) Returns: Deduplicated list of scopes formatted for Azure token endpoint """ logger.debug("Base scopes from storage: %s", scopes) # Filter out any additional_authorize_scopes that may have been stored additional_scopes_set = set(self.additional_authorize_scopes or []) base_scopes = [s for s in scopes if s not in additional_scopes_set] # Prefix base scopes with identifier_uri for Azure prefixed_scopes = self._prefix_scopes_for_azure(base_scopes) # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 if self.additional_authorize_scopes: prefixed_scopes.extend( s for s in self.additional_authorize_scopes if s in OIDC_SCOPES ) deduplicated_scopes = list(dict.fromkeys(prefixed_scopes)) logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes) return deduplicated_scopes async def _extract_upstream_claims( self, idp_tokens: dict[str, Any] ) -> dict[str, Any] | None: """Extract claims from Azure token response to embed in FastMCP JWT. Decodes the Azure access token (which is a JWT) to extract user identity claims. This allows gateways to inspect upstream identity information by decoding the FastMCP JWT without needing server-side storage lookups. Azure access tokens contain claims like: - sub: Subject identifier (unique per user per application) - oid: Object ID (unique user identifier across Azure AD) - tid: Tenant ID - azp: Authorized party (client ID that requested the token) - name: Display name - given_name: First name - family_name: Last name - preferred_username: User principal name (email format) - upn: User Principal Name - email: Email address (if available) - roles: Application roles assigned to the user - groups: Group memberships (if configured) Args: idp_tokens: Full token response from Azure, containing access_token and potentially id_token. Returns: Dict of extracted claims, or None if extraction fails. """ access_token = idp_tokens.get("access_token") if not access_token: return None try: # Azure access tokens are JWTs - decode without verification # (already validated by token_verifier during token exchange) payload = decode_jwt_payload(access_token) # Extract useful identity claims claims: dict[str, Any] = {} claim_keys = [ "sub", "oid", "tid", "azp", "name", "given_name", "family_name", "preferred_username", "upn", "email", "roles", "groups", ] for claim in claim_keys: if claim in payload: claims[claim] = payload[claim] if claims: logger.debug( "Extracted %d Azure claims for embedding in FastMCP JWT", len(claims), ) return claims return None except Exception as e: logger.debug("Failed to extract Azure claims: %s", e) return None async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: """Get a cached or new OnBehalfOfCredential for OBO token exchange. Credentials are cached by user assertion so the Azure SDK's internal token cache can avoid redundant OBO exchanges when the same user calls multiple tools with the same scopes. Args: user_assertion: The user's access token to exchange via OBO. Returns: A configured OnBehalfOfCredential ready for get_token() calls. Raises: ImportError: If azure-identity is not installed (requires fastmcp[azure]). """ _require_azure_identity("OBO token exchange") from azure.identity.aio import OnBehalfOfCredential key = hashlib.sha256(user_assertion.encode()).hexdigest() if key in self._obo_credentials: self._obo_credentials.move_to_end(key) return self._obo_credentials[key] obo_kwargs: dict[str, Any] = { "tenant_id": self._tenant_id, "client_id": self._upstream_client_id, "user_assertion": user_assertion, "authority": f"https://{self._base_authority}", } if self._upstream_client_secret is not None: obo_kwargs["client_secret"] = ( self._upstream_client_secret.get_secret_value() ) else: raise ValueError( "OBO token exchange requires either a client_secret or a subclass " "that overrides get_obo_credential() to provide alternative credentials " "(e.g., client_assertion_func for managed identity)." ) credential = OnBehalfOfCredential(**obo_kwargs) self._obo_credentials[key] = credential # Evict oldest if over capacity while len(self._obo_credentials) > self._obo_max_credentials: _, evicted = self._obo_credentials.popitem(last=False) await evicted.close() return credential async def close_obo_credentials(self) -> None: """Close all cached OBO credentials.""" credentials = list(self._obo_credentials.values()) self._obo_credentials.clear() for credential in credentials: try: await credential.close() except Exception: logger.debug("Error closing OBO credential", exc_info=True) class AzureJWTVerifier(JWTVerifier): """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. Auto-configures JWKS URI, issuer, audience, and scope handling from your Azure app registration details. Designed for Managed Identity and other token-verification-only scenarios where AzureProvider's full OAuth proxy isn't needed. Handles Azure's scope format automatically: - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) - Advertises full-URI scopes in OAuth metadata (what clients need to request) Example:: from fastmcp.server.auth import RemoteAuthProvider from fastmcp.server.auth.providers.azure import AzureJWTVerifier from pydantic import AnyHttpUrl verifier = AzureJWTVerifier( client_id="your-client-id", tenant_id="your-tenant-id", required_scopes=["access_as_user"], ) auth = RemoteAuthProvider( token_verifier=verifier, authorization_servers=[ AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") ], base_url="https://my-server.com", ) """ def __init__( self, *, client_id: str, tenant_id: str, required_scopes: list[str] | None = None, identifier_uri: str | None = None, base_authority: str = "login.microsoftonline.com", ): """Initialize Azure JWT verifier. Args: client_id: Azure application (client) ID from your App registration tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers"). For multi-tenant apps ("organizations" or "consumers"), issuer validation is skipped since Azure tokens carry the actual tenant GUID as issuer. required_scopes: Scope names as they appear in Azure Portal under "Expose an API" (e.g., ["access_as_user", "read"]). These are validated against the short-form scopes in token ``scp`` claims, and automatically prefixed with identifier_uri for OAuth metadata. identifier_uri: Application ID URI (defaults to ``api://{client_id}``). Used to prefix scopes in OAuth metadata so clients know the full scope URIs to request from Azure. base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). For Azure Government, use "login.microsoftonline.us". """ self._identifier_uri = identifier_uri or f"api://{client_id}" # For multi-tenant apps, Azure tokens carry the actual tenant GUID as # issuer, not the literal "organizations" or "consumers" string. Skip # issuer validation for these — audience still protects against wrong-app tokens. multi_tenant_values = {"organizations", "consumers", "common"} issuer: str | None = ( None if tenant_id in multi_tenant_values else f"https://{base_authority}/{tenant_id}/v2.0" ) super().__init__( jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", issuer=issuer, audience=client_id, algorithm="RS256", required_scopes=required_scopes, ) @property def scopes_supported(self) -> list[str]: """Return scopes with Azure URI prefix for OAuth metadata. Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` claim, but clients must request full URI scopes (e.g., ``api://client-id/read``) from the Azure authorization endpoint. This property returns the full-URI form for OAuth metadata while ``required_scopes`` retains the short form for token validation. """ if not self.required_scopes: return [] prefixed = [] for scope in self.required_scopes: if scope in OIDC_SCOPES or "://" in scope or "/" in scope: prefixed.append(scope) else: prefixed.append(f"{self._identifier_uri}/{scope}") return prefixed # --- Dependency injection support --- # These require fastmcp[azure] extra for azure-identity def _require_azure_identity(feature: str) -> None: """Raise ImportError with install instructions if azure-identity is not available.""" try: import azure.identity # noqa: F401 except ImportError as e: raise ImportError( f"{feature} requires the `azure` extra. " "Install with: pip install 'fastmcp[azure]'" ) from e class _EntraOBOToken(Dependency[str]): """Dependency that performs OBO token exchange for Microsoft Entra. Uses azure.identity's OnBehalfOfCredential for async-native OBO, with automatic token caching and refresh. Credentials are cached on the AzureProvider so repeated tool calls reuse existing credentials and benefit from the Azure SDK's internal token cache. """ def __init__(self, scopes: list[str]): self.scopes = scopes async def __aenter__(self) -> str: _require_azure_identity("EntraOBOToken") from fastmcp.server.dependencies import get_access_token, get_server access_token = get_access_token() if access_token is None: raise RuntimeError( "No access token available. Cannot perform OBO exchange." ) server = get_server() if not isinstance(server.auth, AzureProvider): raise RuntimeError( "EntraOBOToken requires an AzureProvider as the auth provider. " f"Current provider: {type(server.auth).__name__}" ) credential = await server.auth.get_obo_credential( user_assertion=access_token.token, ) result = await credential.get_token(*self.scopes) return result.token def EntraOBOToken(scopes: list[str]) -> str: """Exchange the user's Entra token for a downstream API token via OBO. This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, allowing your MCP server to call downstream APIs (like Microsoft Graph) on behalf of the authenticated user. Args: scopes: The scopes to request for the downstream API. For Microsoft Graph, use scopes like ["https://graph.microsoft.com/Mail.Read"] or ["https://graph.microsoft.com/.default"]. Returns: A dependency that resolves to the downstream API access token string Raises: ImportError: If fastmcp[azure] is not installed RuntimeError: If no access token is available, provider is not Azure, or OBO exchange fails Example: ```python from fastmcp.server.auth.providers.azure import EntraOBOToken import httpx @mcp.tool() async def get_my_emails( graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) ): async with httpx.AsyncClient() as client: resp = await client.get( "https://graph.microsoft.com/v1.0/me/messages", headers={"Authorization": f"Bearer {graph_token}"} ) return resp.json() ``` Note: For OBO to work, ensure the scopes are included in the AzureProvider's `additional_authorize_scopes` parameter, and that admin consent has been granted for those scopes in your Entra app registration. """ return cast(str, _EntraOBOToken(scopes)) ================================================ FILE: src/fastmcp/server/auth/providers/debug.py ================================================ """Debug token verifier for testing and special cases. This module provides a flexible token verifier that delegates validation to a custom callable. Useful for testing, development, or scenarios where standard verification isn't possible (like opaque tokens without introspection). Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.debug import DebugTokenVerifier # Accept all tokens (default - useful for testing) auth = DebugTokenVerifier() # Custom sync validation logic auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-")) # Custom async validation logic async def check_cache(token: str) -> bool: return await redis.exists(f"token:{token}") auth = DebugTokenVerifier(validate=check_cache) mcp = FastMCP("My Server", auth=auth) ``` """ from __future__ import annotations import inspect from collections.abc import Awaitable, Callable from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class DebugTokenVerifier(TokenVerifier): """Token verifier with custom validation logic. This verifier delegates token validation to a user-provided callable. By default, it accepts all non-empty tokens (useful for testing). Use cases: - Testing: Accept any token without real verification - Development: Custom validation logic for prototyping - Opaque tokens: When you have tokens with no introspection endpoint WARNING: This bypasses standard security checks. Only use in controlled environments or when you understand the security implications. """ def __init__( self, validate: Callable[[str], bool] | Callable[[str], Awaitable[bool]] = lambda token: True, client_id: str = "debug-client", scopes: list[str] | None = None, required_scopes: list[str] | None = None, ): """Initialize the debug token verifier. Args: validate: Callable that takes a token string and returns True if valid. Can be sync or async. Default accepts all tokens. client_id: Client ID to assign to validated tokens scopes: Scopes to assign to validated tokens required_scopes: Required scopes (inherited from TokenVerifier base class) """ super().__init__(required_scopes=required_scopes) self.validate = validate self.client_id = client_id self.scopes = scopes or [] async def verify_token(self, token: str) -> AccessToken | None: """Verify token using custom validation logic. Args: token: The token string to validate Returns: AccessToken if validation succeeds, None otherwise """ # Reject empty tokens if not token or not token.strip(): logger.debug("Rejecting empty token") return None try: # Call validation function and await if result is awaitable result = self.validate(token) if inspect.isawaitable(result): is_valid = await result else: is_valid = result if not is_valid: logger.debug("Token validation failed: callable returned False") return None # Return valid AccessToken return AccessToken( token=token, client_id=self.client_id, scopes=self.scopes, expires_at=None, # No expiration claims={"token": token}, # Store original token in claims ) except Exception as e: logger.debug("Token validation error: %s", e, exc_info=True) return None ================================================ FILE: src/fastmcp/server/auth/providers/descope.py ================================================ """Descope authentication provider for FastMCP. This module provides DescopeProvider - a complete authentication solution that integrates with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) for seamless MCP client authentication. """ from __future__ import annotations from urllib.parse import urlparse import httpx from pydantic import AnyHttpUrl from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class DescopeProvider(RemoteAuthProvider): """Descope metadata provider for DCR (Dynamic Client Registration). This provider implements Descope integration using metadata forwarding. This is the recommended approach for Descope DCR as it allows Descope to handle the OAuth flow directly while FastMCP acts as a resource server. IMPORTANT SETUP REQUIREMENTS: 1. Create an MCP Server in Descope Console: - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console - Create a new MCP Server - Ensure that **Dynamic Client Registration (DCR)** is enabled - Note your Well-Known URL 2. Note your Well-Known URL: - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` For detailed setup instructions, see: https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr Example: ```python from fastmcp.server.auth.providers.descope import DescopeProvider # Create Descope metadata provider (JWT verifier created automatically) descope_auth = DescopeProvider( config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration", base_url="https://your-fastmcp-server.com", ) # Use with FastMCP mcp = FastMCP("My App", auth=descope_auth) ``` """ def __init__( self, *, base_url: AnyHttpUrl | str, config_url: AnyHttpUrl | str | None = None, project_id: str | None = None, descope_base_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Descope metadata provider. Args: base_url: Public URL of this FastMCP server config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration") This is the new recommended way. If provided, project_id and descope_base_url are ignored. project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility. descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility. required_scopes: Optional list of scopes that must be present in validated tokens. These scopes will be included in the protected resource metadata. scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Descope """ self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) # Parse scopes if provided as string parsed_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None ) # Determine which API is being used if config_url is not None: # New API: use config_url # Strip /.well-known/openid-configuration from config_url if present issuer_url = str(config_url) if issuer_url.endswith("/.well-known/openid-configuration"): issuer_url = issuer_url[: -len("/.well-known/openid-configuration")] # Parse the issuer URL to extract descope_base_url and project_id for other uses parsed_url = urlparse(issuer_url) path_parts = parsed_url.path.strip("/").split("/") # Extract project_id from path (format: /v1/apps/agentic/P.../M...) if "agentic" in path_parts: agentic_index = path_parts.index("agentic") if agentic_index + 1 < len(path_parts): self.project_id = path_parts[agentic_index + 1] else: raise ValueError( f"Could not extract project_id from config_url: {issuer_url}" ) else: raise ValueError( f"Could not find 'agentic' in config_url path: {issuer_url}" ) # Extract descope_base_url (scheme + netloc) self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip( "/" ) elif project_id is not None and descope_base_url is not None: # Old API: use project_id and descope_base_url self.project_id = project_id descope_base_url_str = str(descope_base_url).rstrip("/") # Ensure descope_base_url has a scheme if not descope_base_url_str.startswith(("http://", "https://")): descope_base_url_str = f"https://{descope_base_url_str}" self.descope_base_url = descope_base_url_str # Old issuer format issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}" else: raise ValueError( "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided" ) # Create default JWT verifier if none provided if token_verifier is None: token_verifier = JWTVerifier( jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json", issuer=issuer_url, algorithm="RS256", audience=self.project_id, required_scopes=parsed_scopes, ) # Initialize RemoteAuthProvider with Descope as the authorization server super().__init__( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(issuer_url)], base_url=self.base_url, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, ) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth routes including Descope authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Descope's OAuth metadata to clients. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server" ) response.raise_for_status() metadata = response.json() return JSONResponse(metadata) except Exception as e: return JSONResponse( { "error": "server_error", "error_description": f"Failed to fetch Descope metadata: {e}", }, status_code=500, ) # Add Descope authorization server metadata forwarding routes.append( Route( "/.well-known/oauth-authorization-server", endpoint=oauth_authorization_server_metadata, methods=["GET"], ) ) return routes ================================================ FILE: src/fastmcp/server/auth/providers/discord.py ================================================ """Discord OAuth provider for FastMCP. This module provides a complete Discord OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of Discord's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.discord import DiscordProvider # Simple Discord OAuth protection auth = DiscordProvider( client_id="your-discord-client-id", client_secret="your-discord-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ from __future__ import annotations import contextlib import time from datetime import datetime from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class DiscordTokenVerifier(TokenVerifier): """Token verifier for Discord OAuth tokens. Discord OAuth tokens are opaque (not JWTs), so we verify them by calling Discord's tokeninfo API to check if they're valid and get user info. """ def __init__( self, *, expected_client_id: str, required_scopes: list[str] | None = None, timeout_seconds: int = 10, http_client: httpx.AsyncClient | None = None, ): """Initialize the Discord token verifier. Args: expected_client_id: Expected Discord OAuth client ID for audience binding required_scopes: Required OAuth scopes (e.g., ['email']) timeout_seconds: HTTP request timeout http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ super().__init__(required_scopes=required_scopes) self.expected_client_id = expected_client_id self.timeout_seconds = timeout_seconds self._http_client = http_client async def verify_token(self, token: str) -> AccessToken | None: """Verify Discord OAuth token by calling Discord's tokeninfo API.""" try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: # Use Discord's tokeninfo endpoint to validate the token headers = { "Authorization": f"Bearer {token}", "User-Agent": "FastMCP-Discord-OAuth", } response = await client.get( "https://discord.com/api/oauth2/@me", headers=headers, ) if response.status_code != 200: logger.debug( "Discord token verification failed: %d", response.status_code, ) return None token_info = response.json() # Check if token is expired (Discord returns ISO timestamp) expires_str = token_info.get("expires") expires_at = None if expires_str: expires_dt = datetime.fromisoformat( expires_str.replace("Z", "+00:00") ) expires_at = int(expires_dt.timestamp()) if expires_at <= int(time.time()): logger.debug("Discord token has expired") return None token_scopes = token_info.get("scopes", []) # Check required scopes if self.required_scopes: token_scopes_set = set(token_scopes) required_scopes_set = set(self.required_scopes) if not required_scopes_set.issubset(token_scopes_set): logger.debug( "Discord token missing required scopes. Has %d, needs %d", len(token_scopes_set), len(required_scopes_set), ) return None user_data = token_info.get("user", {}) application = token_info.get("application") or {} client_id = str(application.get("id", "unknown")) if client_id != self.expected_client_id: logger.debug( "Discord token app ID mismatch: expected %s, got %s", self.expected_client_id, client_id, ) return None # Create AccessToken with Discord user info access_token = AccessToken( token=token, client_id=client_id, scopes=token_scopes, expires_at=expires_at, claims={ "sub": user_data.get("id"), "username": user_data.get("username"), "discriminator": user_data.get("discriminator"), "avatar": user_data.get("avatar"), "email": user_data.get("email"), "verified": user_data.get("verified"), "locale": user_data.get("locale"), "discord_user": user_data, "discord_token_info": token_info, }, ) logger.debug("Discord token verified successfully") return access_token except httpx.RequestError as e: logger.debug("Failed to verify Discord token: %s", e) return None except Exception as e: logger.debug("Discord token verification error: %s", e) return None class DiscordProvider(OAuthProxy): """Complete Discord OAuth provider for FastMCP. This provider makes it trivial to add Discord OAuth protection to any FastMCP server. Just provide your Discord OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to Discord - Automatic token validation via Discord's API - User information extraction from Discord APIs - Minimal configuration required Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.discord import DiscordProvider auth = DiscordProvider( client_id="123456789", client_secret="discord-client-secret-abc123...", base_url="https://my-server.com" ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, client_id: str, client_secret: str, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize Discord OAuth provider. Args: client_id: Discord OAuth client ID (e.g., "123456789") client_secret: Discord OAuth client secret (e.g., "S....") base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: - "identify" for profile info (default) - "email" for email access - "guilds" for server membership info timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Discord. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ # Parse scopes if provided as string required_scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else ["identify"] ) # Create Discord token verifier token_verifier = DiscordTokenVerifier( expected_client_id=client_id, required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, http_client=http_client, ) # Initialize OAuth proxy with Discord endpoints super().__init__( upstream_authorization_endpoint="https://discord.com/oauth2/authorize", upstream_token_endpoint="https://discord.com/api/oauth2/token", upstream_client_id=client_id, upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized Discord OAuth provider for client %s with scopes: %s", client_id, required_scopes_final, ) ================================================ FILE: src/fastmcp/server/auth/providers/github.py ================================================ """GitHub OAuth provider for FastMCP. This module provides a complete GitHub OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of GitHub's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider # Simple GitHub OAuth protection auth = GitHubProvider( client_id="your-github-client-id", client_secret="your-github-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ from __future__ import annotations import contextlib from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.token_cache import TokenCache logger = get_logger(__name__) class GitHubTokenVerifier(TokenVerifier): """Token verifier for GitHub OAuth tokens. GitHub OAuth tokens are opaque (not JWTs), so we verify them by calling GitHub's API to check if they're valid and get user info. Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive integer to cache successful verification results and avoid repeated GitHub API calls for the same token. """ def __init__( self, *, required_scopes: list[str] | None = None, timeout_seconds: int = 10, cache_ttl_seconds: int | None = None, max_cache_size: int | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize the GitHub token verifier. Args: required_scopes: Required OAuth scopes (e.g., ['user:email']) timeout_seconds: HTTP request timeout cache_ttl_seconds: How long to cache verification results in seconds. Caching is disabled by default (None). Set to a positive integer to enable (e.g., 300 for 5 minutes). max_cache_size: Maximum number of tokens to cache. Default: 10 000. http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ super().__init__(required_scopes=required_scopes) self.timeout_seconds = timeout_seconds self._http_client = http_client self._cache = TokenCache( ttl_seconds=cache_ttl_seconds, max_size=max_cache_size, ) async def verify_token(self, token: str) -> AccessToken | None: """Verify GitHub OAuth token by calling GitHub API.""" is_cached, cached_result = self._cache.get(token) if is_cached: logger.debug("GitHub token cache hit") return cached_result try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: # Get token info from GitHub API response = await client.get( "https://api.github.com/user", headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3+json", "User-Agent": "FastMCP-GitHub-OAuth", }, ) if response.status_code != 200: logger.debug( "GitHub token verification failed: %d - %s", response.status_code, response.text[:200], ) return None user_data = response.json() # Get token scopes from GitHub API # GitHub includes scopes in the X-OAuth-Scopes header scopes_response = await client.get( "https://api.github.com/user/repos", # Any authenticated endpoint headers={ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3+json", "User-Agent": "FastMCP-GitHub-OAuth", }, ) # Extract scopes from X-OAuth-Scopes header if available scopes_verified = scopes_response.status_code == 200 oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "") token_scopes = [ scope.strip() for scope in oauth_scopes_header.split(",") if scope.strip() ] # If no scopes in header, assume basic scopes based on successful user API call if not token_scopes: token_scopes = ["user"] # Basic scope if we can access user info # Check required scopes if self.required_scopes: token_scopes_set = set(token_scopes) required_scopes_set = set(self.required_scopes) if not required_scopes_set.issubset(token_scopes_set): logger.debug( "GitHub token missing required scopes. Has %d, needs %d", len(token_scopes_set), len(required_scopes_set), ) return None # Create AccessToken with GitHub user info result = AccessToken( token=token, client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID scopes=token_scopes, expires_at=None, # GitHub tokens don't typically expire claims={ "sub": str(user_data["id"]), "login": user_data.get("login"), "name": user_data.get("name"), "email": user_data.get("email"), "avatar_url": user_data.get("avatar_url"), "github_user_data": user_data, }, ) if scopes_verified: self._cache.set(token, result) return result except httpx.RequestError as e: logger.debug("Failed to verify GitHub token: %s", e) return None except Exception as e: logger.debug("GitHub token verification error: %s", e) return None class GitHubProvider(OAuthProxy): """Complete GitHub OAuth provider for FastMCP. This provider makes it trivial to add GitHub OAuth protection to any FastMCP server. Just provide your GitHub OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to GitHub - Automatic token validation via GitHub API - User information extraction - Minimal configuration required Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.github import GitHubProvider auth = GitHubProvider( client_id="Ov23li...", client_secret="abc123...", base_url="https://my-server.com" ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, client_id: str, client_secret: str, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int = 10, cache_ttl_seconds: int | None = None, max_cache_size: int | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize GitHub OAuth provider. Args: client_id: GitHub OAuth app client ID (e.g., "Ov23li...") client_secret: GitHub OAuth app client secret base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") required_scopes: Required GitHub scopes (defaults to ["user"]) timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10) cache_ttl_seconds: How long to cache token verification results in seconds. Caching is disabled by default (None). Set to a positive integer to enable (e.g., 300 for 5 minutes). max_cache_size: Maximum number of tokens to cache. Default: 10 000. allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to GitHub. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ # Parse scopes if provided as string required_scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else ["user"] ) # Create GitHub token verifier token_verifier = GitHubTokenVerifier( required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, cache_ttl_seconds=cache_ttl_seconds, max_cache_size=max_cache_size, http_client=http_client, ) # Initialize OAuth proxy with GitHub endpoints super().__init__( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", upstream_client_id=client_id, upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized GitHub OAuth provider for client %s with scopes: %s", client_id, required_scopes_final, ) ================================================ FILE: src/fastmcp/server/auth/providers/google.py ================================================ """Google OAuth provider for FastMCP. This module provides a complete Google OAuth integration that's ready to use with just a client ID and client secret. It handles all the complexity of Google's OAuth flow, token validation, and user management. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.google import GoogleProvider # Simple Google OAuth protection auth = GoogleProvider( client_id="your-google-client-id.apps.googleusercontent.com", client_secret="your-google-client-secret" ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ from __future__ import annotations import contextlib import time from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth import TokenVerifier from fastmcp.server.auth.auth import AccessToken from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) GOOGLE_SCOPE_ALIASES: dict[str, str] = { "email": "https://www.googleapis.com/auth/userinfo.email", "profile": "https://www.googleapis.com/auth/userinfo.profile", } def _normalize_google_scope(scope: str) -> str: """Normalize a Google scope shorthand to its canonical full URI. Google accepts shorthand scopes like "email" and "profile" in authorization requests, but returns the full URI form in token responses. This normalizes to the full URI so comparisons work regardless of which form was used. """ return GOOGLE_SCOPE_ALIASES.get(scope, scope) class GoogleTokenVerifier(TokenVerifier): """Token verifier for Google OAuth tokens. Google OAuth tokens are opaque (not JWTs), so we verify them by calling Google's tokeninfo API to check if they're valid and get user info. """ def __init__( self, *, required_scopes: list[str] | None = None, timeout_seconds: int = 10, http_client: httpx.AsyncClient | None = None, ): """Initialize the Google token verifier. Args: required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email']) timeout_seconds: HTTP request timeout http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ normalized = ( [_normalize_google_scope(s) for s in required_scopes] if required_scopes else required_scopes ) super().__init__(required_scopes=normalized) self.timeout_seconds = timeout_seconds self._http_client = http_client async def verify_token(self, token: str) -> AccessToken | None: """Verify Google OAuth token by calling Google's tokeninfo API.""" try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: # Use Google's tokeninfo endpoint to validate the token response = await client.get( "https://www.googleapis.com/oauth2/v1/tokeninfo", params={"access_token": token}, headers={"User-Agent": "FastMCP-Google-OAuth"}, ) if response.status_code != 200: logger.debug( "Google token verification failed: %d", response.status_code, ) return None token_info = response.json() # Check if token is expired expires_in = token_info.get("expires_in") if expires_in and int(expires_in) <= 0: logger.debug("Google token has expired") return None # Extract scopes from token info scope_string = token_info.get("scope", "") token_scopes = [ scope.strip() for scope in scope_string.split(" ") if scope.strip() ] # Check required scopes if self.required_scopes: token_scopes_set = set(token_scopes) required_scopes_set = set(self.required_scopes) if not required_scopes_set.issubset(token_scopes_set): logger.debug( "Google token missing required scopes. Has %d, needs %d", len(token_scopes_set), len(required_scopes_set), ) return None # Get additional user info if we have the right scopes user_data = {} if "openid" in token_scopes or "profile" in token_scopes: try: userinfo_response = await client.get( "https://www.googleapis.com/oauth2/v2/userinfo", headers={ "Authorization": f"Bearer {token}", "User-Agent": "FastMCP-Google-OAuth", }, ) if userinfo_response.status_code == 200: user_data = userinfo_response.json() except Exception as e: logger.debug("Failed to fetch Google user info: %s", e) # Calculate expiration time expires_at = None if expires_in: expires_at = int(time.time() + int(expires_in)) # Create AccessToken with Google user info access_token = AccessToken( token=token, client_id=token_info.get( "audience", "unknown" ), # Use audience as client_id scopes=token_scopes, expires_at=expires_at, claims={ "sub": user_data.get("id") or token_info.get("user_id", "unknown"), "email": user_data.get("email"), "name": user_data.get("name"), "picture": user_data.get("picture"), "given_name": user_data.get("given_name"), "family_name": user_data.get("family_name"), "locale": user_data.get("locale"), "google_user_data": user_data, "google_token_info": token_info, }, ) logger.debug("Google token verified successfully") return access_token except httpx.RequestError as e: logger.debug("Failed to verify Google token: %s", e) return None except Exception as e: logger.debug("Google token verification error: %s", e) return None class GoogleProvider(OAuthProxy): """Complete Google OAuth provider for FastMCP. This provider makes it trivial to add Google OAuth protection to any FastMCP server. Just provide your Google OAuth app credentials and a base URL, and you're ready to go. Features: - Transparent OAuth proxy to Google - Automatic token validation via Google's tokeninfo API - User information extraction from Google APIs - Minimal configuration required Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.google import GoogleProvider auth = GoogleProvider( client_id="123456789.apps.googleusercontent.com", client_secret="GOCSPX-abc123...", base_url="https://my-server.com" ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, client_id: str, client_secret: str | None = None, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, valid_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize Google OAuth provider. Args: client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com") client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123..."). Optional for PKCE public clients (e.g., native apps). When omitted, jwt_signing_key must be provided. base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback") required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include: - "openid" for OpenID Connect (default) - "https://www.googleapis.com/auth/userinfo.email" for email access - "https://www.googleapis.com/auth/userinfo.profile" for profile info Google scope shorthands like "email" and "profile" are automatically normalized to their full URI forms for token verification. valid_scopes: All scopes that clients are allowed to request, advertised through well-known endpoints. Defaults to required_scopes if not provided. Use this when you want clients to be able to request additional scopes beyond the required minimum. Shorthands are normalized to full URI forms. timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to Google. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by Google's own consent). SECURITY WARNING: Only set to False for local development or testing environments. extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure refresh tokens are returned. You can override these defaults or add additional parameters. Example: {"prompt": "select_account"} to let users choose their Google account. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ # Parse scopes if provided as string # Google requires at least one scope - openid is the minimal OIDC scope required_scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) # Normalize valid_scopes if provided parsed_valid_scopes = ( parse_scopes(valid_scopes) if valid_scopes is not None else None ) valid_scopes_final = ( [_normalize_google_scope(s) for s in parsed_valid_scopes] if parsed_valid_scopes is not None else None ) # Create Google token verifier # Normalization of shorthand scopes (e.g. "email" -> full URI) happens # inside GoogleTokenVerifier so required_scopes match what Google returns. token_verifier = GoogleTokenVerifier( required_scopes=required_scopes_final, timeout_seconds=timeout_seconds, http_client=http_client, ) # Set Google-specific defaults for extra authorize params # access_type=offline ensures refresh tokens are returned # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise) google_defaults = { "access_type": "offline", "prompt": "consent", } # User-provided params override defaults if extra_authorize_params: google_defaults.update(extra_authorize_params) extra_authorize_params_final = google_defaults # Initialize OAuth proxy with Google endpoints super().__init__( upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth", upstream_token_endpoint="https://oauth2.googleapis.com/token", upstream_client_id=client_id, upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, extra_authorize_params=extra_authorize_params_final, valid_scopes=valid_scopes_final, ) logger.debug( "Initialized Google OAuth provider for client %s with scopes: %s", client_id, required_scopes_final, ) ================================================ FILE: src/fastmcp/server/auth/providers/in_memory.py ================================================ import secrets import time from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, RefreshToken, TokenError, construct_redirect_uri, ) from mcp.shared.auth import ( OAuthClientInformationFull, OAuthToken, ) from pydantic import AnyHttpUrl from fastmcp.server.auth.auth import ( ClientRegistrationOptions, OAuthProvider, RevocationOptions, ) # Default expiration times (in seconds) DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry class InMemoryOAuthProvider(OAuthProvider): """ An in-memory OAuth provider for testing purposes. It simulates the OAuth 2.1 flow locally without external calls. """ def __init__( self, base_url: AnyHttpUrl | str | None = None, service_documentation_url: AnyHttpUrl | str | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, required_scopes: list[str] | None = None, ): super().__init__( base_url=base_url or "http://fastmcp.example.com", service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, required_scopes=required_scopes, ) self.clients: dict[str, OAuthClientInformationFull] = {} self.auth_codes: dict[str, AuthorizationCode] = {} self.access_tokens: dict[str, AccessToken] = {} self.refresh_tokens: dict[str, RefreshToken] = {} # For revoking associated tokens self._access_to_refresh_map: dict[ str, str ] = {} # access_token_str -> refresh_token_str self._refresh_to_access_map: dict[ str, str ] = {} # refresh_token_str -> access_token_str async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: return self.clients.get(client_id) async def register_client(self, client_info: OAuthClientInformationFull) -> None: # Validate scopes against valid_scopes if configured (matches MCP SDK behavior) if ( client_info.scope is not None and self.client_registration_options is not None and self.client_registration_options.valid_scopes is not None ): requested_scopes = set(client_info.scope.split()) valid_scopes = set(self.client_registration_options.valid_scopes) invalid_scopes = requested_scopes - valid_scopes if invalid_scopes: raise ValueError( f"Requested scopes are not valid: {', '.join(invalid_scopes)}" ) if client_info.client_id is None: raise ValueError("client_id is required for client registration") if client_info.client_id in self.clients: # As per RFC 7591, if client_id is already known, it's an update. # For this simple provider, we'll treat it as re-registration. # A real provider might handle updates or raise errors for conflicts. pass self.clients[client_info.client_id] = client_info async def authorize( self, client: OAuthClientInformationFull, params: AuthorizationParams ) -> str: """ Simulates user authorization and generates an authorization code. Returns a redirect URI with the code and state. """ if client.client_id not in self.clients: raise AuthorizeError( error="unauthorized_client", error_description=f"Client '{client.client_id}' not registered.", ) # Validate redirect_uri (already validated by AuthorizationHandler, but good practice) try: # OAuthClientInformationFull should have a method like validate_redirect_uri # For this test provider, we assume it's valid if it matches one in client_info # The AuthorizationHandler already does robust validation using client.validate_redirect_uri if client.redirect_uris and params.redirect_uri not in client.redirect_uris: # This check might be too simplistic if redirect_uris can be patterns # or if params.redirect_uri is None and client has a default. # However, the AuthorizationHandler handles the primary validation. pass # Let's assume AuthorizationHandler did its job. except Exception as e: # Replace with specific validation error if client.validate_redirect_uri existed raise AuthorizeError( error="invalid_request", error_description="Invalid redirect_uri." ) from e auth_code_value = f"test_auth_code_{secrets.token_hex(16)}" expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS # Ensure scopes are a list scopes_list = params.scopes if params.scopes is not None else [] if client.scope: # Filter params.scopes against client's registered scopes client_allowed_scopes = set(client.scope.split()) scopes_list = [s for s in scopes_list if s in client_allowed_scopes] if client.client_id is None: raise AuthorizeError( error="invalid_client", error_description="Client ID is required" ) auth_code = AuthorizationCode( code=auth_code_value, client_id=client.client_id, redirect_uri=params.redirect_uri, redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, scopes=scopes_list, expires_at=expires_at, code_challenge=params.code_challenge, # code_challenge_method is assumed S256 by the framework ) self.auth_codes[auth_code_value] = auth_code return construct_redirect_uri( str(params.redirect_uri), code=auth_code_value, state=params.state ) async def load_authorization_code( self, client: OAuthClientInformationFull, authorization_code: str ) -> AuthorizationCode | None: auth_code_obj = self.auth_codes.get(authorization_code) if auth_code_obj: if auth_code_obj.client_id != client.client_id: return None # Belongs to a different client if auth_code_obj.expires_at < time.time(): del self.auth_codes[authorization_code] # Expired return None return auth_code_obj return None async def exchange_authorization_code( self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode ) -> OAuthToken: # Authorization code should have been validated (existence, expiry, client_id match) # by the TokenHandler calling load_authorization_code before this. # We might want to re-verify or simply trust it's valid. if authorization_code.code not in self.auth_codes: raise TokenError( "invalid_grant", "Authorization code not found or already used." ) # Consume the auth code del self.auth_codes[authorization_code.code] access_token_value = f"test_access_token_{secrets.token_hex(32)}" refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}" access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) # Refresh token expiry refresh_token_expires_at = None if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None: refresh_token_expires_at = int( time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS ) if client.client_id is None: raise TokenError("invalid_client", "Client ID is required") self.access_tokens[access_token_value] = AccessToken( token=access_token_value, client_id=client.client_id, scopes=authorization_code.scopes, expires_at=access_token_expires_at, ) self.refresh_tokens[refresh_token_value] = RefreshToken( token=refresh_token_value, client_id=client.client_id, scopes=authorization_code.scopes, # Refresh token inherits scopes expires_at=refresh_token_expires_at, ) self._access_to_refresh_map[access_token_value] = refresh_token_value self._refresh_to_access_map[refresh_token_value] = access_token_value return OAuthToken( access_token=access_token_value, token_type="Bearer", expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token=refresh_token_value, scope=" ".join(authorization_code.scopes), ) async def load_refresh_token( self, client: OAuthClientInformationFull, refresh_token: str ) -> RefreshToken | None: token_obj = self.refresh_tokens.get(refresh_token) if token_obj: if token_obj.client_id != client.client_id: return None # Belongs to different client if token_obj.expires_at is not None and token_obj.expires_at < time.time(): self._revoke_internal( refresh_token_str=token_obj.token ) # Clean up expired return None return token_obj return None async def exchange_refresh_token( self, client: OAuthClientInformationFull, refresh_token: RefreshToken, # This is the RefreshToken object, already loaded scopes: list[str], # Requested scopes for the new access token ) -> OAuthToken: # Validate scopes: requested scopes must be a subset of original scopes original_scopes = set(refresh_token.scopes) requested_scopes = set(scopes) if not requested_scopes.issubset(original_scopes): raise TokenError( "invalid_scope", "Requested scopes exceed those authorized by the refresh token.", ) # Invalidate old refresh token and its associated access token (rotation) self._revoke_internal(refresh_token_str=refresh_token.token) # Issue new tokens new_access_token_value = f"test_access_token_{secrets.token_hex(32)}" new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}" access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) # Refresh token expiry refresh_token_expires_at = None if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None: refresh_token_expires_at = int( time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS ) if client.client_id is None: raise TokenError("invalid_client", "Client ID is required") self.access_tokens[new_access_token_value] = AccessToken( token=new_access_token_value, client_id=client.client_id, scopes=scopes, # Use newly requested (and validated) scopes expires_at=access_token_expires_at, ) self.refresh_tokens[new_refresh_token_value] = RefreshToken( token=new_refresh_token_value, client_id=client.client_id, scopes=scopes, # New refresh token also gets these scopes expires_at=refresh_token_expires_at, ) self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value return OAuthToken( access_token=new_access_token_value, token_type="Bearer", expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, refresh_token=new_refresh_token_value, scope=" ".join(scopes), ) async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] token_obj = self.access_tokens.get(token) if token_obj: if token_obj.expires_at is not None and token_obj.expires_at < time.time(): self._revoke_internal( access_token_str=token_obj.token ) # Clean up expired return None return token_obj return None async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] """ Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. Args: token: The token string to validate Returns: AccessToken object if valid, None if invalid or expired """ return await self.load_access_token(token) def _revoke_internal( self, access_token_str: str | None = None, refresh_token_str: str | None = None ): """Internal helper to remove tokens and their associations.""" removed_access_token = None removed_refresh_token = None if access_token_str: if access_token_str in self.access_tokens: del self.access_tokens[access_token_str] removed_access_token = access_token_str # Get associated refresh token associated_refresh = self._access_to_refresh_map.pop(access_token_str, None) if associated_refresh: if associated_refresh in self.refresh_tokens: del self.refresh_tokens[associated_refresh] removed_refresh_token = associated_refresh self._refresh_to_access_map.pop(associated_refresh, None) if refresh_token_str: if refresh_token_str in self.refresh_tokens: del self.refresh_tokens[refresh_token_str] removed_refresh_token = refresh_token_str # Get associated access token associated_access = self._refresh_to_access_map.pop(refresh_token_str, None) if associated_access: if associated_access in self.access_tokens: del self.access_tokens[associated_access] removed_access_token = associated_access self._access_to_refresh_map.pop(associated_access, None) # Clean up any dangling references if one part of the pair was already gone if removed_access_token and removed_access_token in self._access_to_refresh_map: del self._access_to_refresh_map[removed_access_token] if ( removed_refresh_token and removed_refresh_token in self._refresh_to_access_map ): del self._refresh_to_access_map[removed_refresh_token] async def revoke_token( self, token: AccessToken | RefreshToken, ) -> None: """Revokes an access or refresh token and its counterpart.""" if isinstance(token, AccessToken): self._revoke_internal(access_token_str=token.token) elif isinstance(token, RefreshToken): self._revoke_internal(refresh_token_str=token.token) # If token is not found or already revoked, _revoke_internal does nothing, which is correct. ================================================ FILE: src/fastmcp/server/auth/providers/introspection.py ================================================ """OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP. This module provides token verification for opaque tokens using the OAuth 2.0 Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to validate tokens issued by authorization servers that don't use JWT format. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier # Verify opaque tokens via RFC 7662 introspection verifier = IntrospectionTokenVerifier( introspection_url="https://auth.example.com/oauth/introspect", client_id="your-client-id", client_secret="your-client-secret", required_scopes=["read", "write"] ) mcp = FastMCP("My Protected Server", auth=verifier) ``` """ from __future__ import annotations import base64 import contextlib import time from typing import Any, Literal, get_args import httpx from pydantic import AnyHttpUrl, SecretStr from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.token_cache import TokenCache logger = get_logger(__name__) ClientAuthMethod = Literal["client_secret_basic", "client_secret_post"] class IntrospectionTokenVerifier(TokenVerifier): """ OAuth 2.0 Token Introspection verifier (RFC 7662). This verifier validates opaque tokens by calling an OAuth 2.0 token introspection endpoint. Unlike JWT verification which is stateless, token introspection requires a network call to the authorization server for each token validation. The verifier authenticates to the introspection endpoint using either: - HTTP Basic Auth (client_secret_basic, default): credentials in Authorization header - POST body authentication (client_secret_post): credentials in request body Both methods are specified in RFC 6749 (OAuth 2.0) and RFC 7662 (Token Introspection). Use this when: - Your authorization server issues opaque (non-JWT) tokens - You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers - Your tokens require real-time revocation checking - Your authorization server supports RFC 7662 introspection Caching is disabled by default to preserve real-time revocation semantics. Set ``cache_ttl_seconds`` to enable caching and reduce load on the introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes). Example: ```python verifier = IntrospectionTokenVerifier( introspection_url="https://auth.example.com/oauth/introspect", client_id="my-service", client_secret="secret-key", required_scopes=["api:read"] ) ``` """ def __init__( self, *, introspection_url: str, client_id: str, client_secret: str | SecretStr, client_auth_method: ClientAuthMethod = "client_secret_basic", timeout_seconds: int = 10, required_scopes: list[str] | None = None, base_url: AnyHttpUrl | str | None = None, cache_ttl_seconds: int | None = None, max_cache_size: int | None = None, http_client: httpx.AsyncClient | None = None, ): """ Initialize the introspection token verifier. Args: introspection_url: URL of the OAuth 2.0 token introspection endpoint client_id: OAuth client ID for authenticating to the introspection endpoint client_secret: OAuth client secret for authenticating to the introspection endpoint client_auth_method: Client authentication method. "client_secret_basic" (default) uses HTTP Basic Auth header, "client_secret_post" sends credentials in POST body timeout_seconds: HTTP request timeout in seconds (default: 10) required_scopes: Required scopes for all tokens (optional) base_url: Base URL for TokenVerifier protocol cache_ttl_seconds: How long to cache introspection results in seconds. Caching is disabled by default (None) to preserve real-time revocation semantics. Set to a positive integer to enable caching (e.g., 300 for 5 minutes). max_cache_size: Maximum number of tokens to cache when caching is enabled. Default: 10000. http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ # Parse scopes if provided as string parsed_required_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None ) super().__init__(base_url=base_url, required_scopes=parsed_required_scopes) self.introspection_url = introspection_url self.client_id = client_id self.client_secret = ( client_secret.get_secret_value() if isinstance(client_secret, SecretStr) else client_secret ) # Validate client_auth_method to catch typos/invalid values early valid_methods = get_args(ClientAuthMethod) if client_auth_method not in valid_methods: options = " or ".join(f"'{m}'" for m in valid_methods) raise ValueError( f"Invalid client_auth_method: {client_auth_method!r}. " f"Must be {options}." ) self.client_auth_method: ClientAuthMethod = client_auth_method self.timeout_seconds = timeout_seconds self._http_client = http_client self.logger = get_logger(__name__) self._cache = TokenCache( ttl_seconds=cache_ttl_seconds, max_size=max_cache_size, ) def _create_basic_auth_header(self) -> str: """Create HTTP Basic Auth header value from client credentials.""" credentials = f"{self.client_id}:{self.client_secret}" encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") return f"Basic {encoded}" def _extract_scopes(self, introspection_response: dict[str, Any]) -> list[str]: """ Extract scopes from introspection response. RFC 7662 allows scopes to be returned as either: - A space-separated string in the 'scope' field - An array of strings in the 'scope' field (less common but valid) """ scope_value = introspection_response.get("scope") if scope_value is None: return [] # Handle string (space-separated) scopes if isinstance(scope_value, str): return [s.strip() for s in scope_value.split() if s.strip()] # Handle array of scopes if isinstance(scope_value, list): return [str(s) for s in scope_value if s] return [] async def verify_token(self, token: str) -> AccessToken | None: """ Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662). This method makes a POST request to the introspection endpoint with the token, authenticated using the configured client authentication method (client_secret_basic or client_secret_post). Results are cached in-memory to reduce load on the introspection endpoint. Cache TTL and size are configurable via constructor parameters. Args: token: The opaque token string to validate Returns: AccessToken object if valid and active, None if invalid, inactive, or expired """ # Check cache first is_cached, cached_result = self._cache.get(token) if is_cached: self.logger.debug("Token introspection cache hit") return cached_result try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: # Prepare introspection request per RFC 7662 # Build request data with token and token_type_hint data = { "token": token, "token_type_hint": "access_token", } # Build headers headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } # Add client authentication based on method if self.client_auth_method == "client_secret_basic": headers["Authorization"] = self._create_basic_auth_header() elif self.client_auth_method == "client_secret_post": data["client_id"] = self.client_id data["client_secret"] = self.client_secret response = await client.post( self.introspection_url, data=data, headers=headers, ) # Check for HTTP errors - don't cache HTTP errors (may be transient) if response.status_code != 200: self.logger.debug( "Token introspection failed: HTTP %d - %s", response.status_code, response.text[:200] if response.text else "", ) return None introspection_data = response.json() # Check if token is active (required field per RFC 7662) # Don't cache inactive tokens - they may become valid later # (e.g., tokens with future nbf, or propagation delays) if not introspection_data.get("active", False): self.logger.debug("Token introspection returned active=false") return None # Extract client_id (should be present for active tokens) client_id = introspection_data.get( "client_id" ) or introspection_data.get("sub", "unknown") # Extract expiration time exp = introspection_data.get("exp") if exp: # Validate expiration (belt and suspenders - server should set active=false) if exp < time.time(): self.logger.debug( "Token validation failed: expired token for client %s", client_id, ) return None # Extract scopes scopes = self._extract_scopes(introspection_data) # Check required scopes # Don't cache scope failures - permissions may be updated dynamically if self.required_scopes: token_scopes = set(scopes) required_scopes = set(self.required_scopes) if not required_scopes.issubset(token_scopes): self.logger.debug( "Token missing required scopes. Has: %s, Required: %s", token_scopes, required_scopes, ) return None # Create AccessToken with introspection response data result = AccessToken( token=token, client_id=str(client_id), scopes=scopes, expires_at=int(exp) if exp else None, claims=introspection_data, # Store full response for extensibility ) self._cache.set(token, result) return result except httpx.TimeoutException: self.logger.debug( "Token introspection timed out after %d seconds", self.timeout_seconds ) return None except httpx.RequestError as e: self.logger.debug("Token introspection request failed: %s", e) return None except Exception as e: self.logger.debug("Token introspection error: %s", e) return None ================================================ FILE: src/fastmcp/server/auth/providers/jwt.py ================================================ """TokenVerifier implementations for FastMCP.""" from __future__ import annotations import contextlib import json import time from dataclasses import dataclass from typing import Any, cast import httpx from authlib.jose import JsonWebKey, JsonWebToken from authlib.jose.errors import JoseError from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from pydantic import AnyHttpUrl, SecretStr from typing_extensions import TypedDict from fastmcp.server.auth import AccessToken, TokenVerifier from fastmcp.server.auth.ssrf import SSRFError, SSRFFetchError, ssrf_safe_fetch from fastmcp.utilities.auth import decode_jwt_header, parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class JWKData(TypedDict, total=False): """JSON Web Key data structure.""" kty: str # Key type (e.g., "RSA") - required kid: str # Key ID (optional but recommended) use: str # Usage (e.g., "sig") alg: str # Algorithm (e.g., "RS256") n: str # Modulus (for RSA keys) e: str # Exponent (for RSA keys) x5c: list[str] # X.509 certificate chain (for JWKs) x5t: str # X.509 certificate thumbprint (for JWKs) class JWKSData(TypedDict): """JSON Web Key Set data structure.""" keys: list[JWKData] @dataclass(frozen=True, kw_only=True, repr=False) class RSAKeyPair: """RSA key pair for JWT testing.""" private_key: SecretStr public_key: str @classmethod def generate(cls) -> RSAKeyPair: """ Generate an RSA key pair for testing. Returns: RSAKeyPair: Generated key pair """ # Generate private key private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, ) # Serialize private key to PEM format private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ).decode("utf-8") # Serialize public key to PEM format public_pem = ( private_key.public_key() .public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) .decode("utf-8") ) return cls( private_key=SecretStr(private_pem), public_key=public_pem, ) 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, kid: str | None = None, ) -> str: """ Generate a test JWT token 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 kid: Key ID to include in header """ # Create header header = {"alg": "RS256"} if kid: header["kid"] = kid # Create payload payload: dict[str, str | int | list[str]] = { "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(["RS256"]) token_bytes = jwt_lib.encode( header, payload, self.private_key.get_secret_value() ) return token_bytes.decode("utf-8") def _looks_like_pem_public_key(key: str | bytes) -> bool: """Return True when key text appears to be PEM-encoded asymmetric key material.""" if isinstance(key, bytes): key = key.decode("utf-8", errors="replace") key_text = key.strip() pem_markers = ( "-----BEGIN PUBLIC KEY-----", "-----BEGIN RSA PUBLIC KEY-----", "-----BEGIN EC PUBLIC KEY-----", "-----BEGIN CERTIFICATE-----", ) return any(marker in key_text for marker in pem_markers) class JWTVerifier(TokenVerifier): """ JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. 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 (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 """ def __init__( self, *, public_key: str | bytes | None = None, jwks_uri: str | None = None, issuer: str | list[str] | None = None, audience: str | list[str] | None = None, algorithm: str | None = None, required_scopes: list[str] | None = None, base_url: AnyHttpUrl | str | None = None, ssrf_safe: bool = False, http_client: httpx.AsyncClient | None = None, ): """ Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint. Parameters: public_key: PEM-encoded public key for asymmetric algorithms or shared secret for symmetric algorithms. jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. issuer: Expected issuer claim value or list of allowed issuer values. audience: Expected audience claim value or list of allowed audience values. algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. required_scopes: Scopes that must be present in validated tokens. base_url: Base URL passed to the parent TokenVerifier. ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, public IPs, DNS pinning). Enable when the JWKS URI comes from untrusted input (e.g. CIMD documents). Defaults to False so operator-configured JWKS URIs (including localhost) work normally. http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused for JWKS fetches and the caller is responsible for its lifecycle. When None (default), a fresh client is created per fetch. Cannot be used with ssrf_safe=True. Raises: ValueError: If neither or both of `public_key` and `jwks_uri` are provided, if `algorithm` is unsupported, or if `http_client` is provided with `ssrf_safe=True`. """ if not public_key and not jwks_uri: raise ValueError("Either public_key or jwks_uri must be provided") if public_key and jwks_uri: raise ValueError("Provide either public_key or jwks_uri, not both") # Only enforce ssrf_safe/http_client exclusivity when JWKS fetching is used if jwks_uri and ssrf_safe and http_client is not None: raise ValueError( "http_client cannot be used with ssrf_safe=True; " "SSRF-safe mode requires its own hardened transport" ) algorithm = algorithm or "RS256" if algorithm not in { "HS256", "HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", }: raise ValueError(f"Unsupported algorithm: {algorithm}.") if algorithm.startswith("HS"): if jwks_uri: raise ValueError( "Symmetric HS* algorithms cannot be used with jwks_uri; " "configure a shared secret via public_key instead." ) if public_key and _looks_like_pem_public_key(public_key): raise ValueError( "Symmetric HS* algorithms require a shared secret, not a public key." ) # Parse scopes if provided as string parsed_required_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None ) # Initialize parent TokenVerifier super().__init__( base_url=base_url, required_scopes=parsed_required_scopes, ) self.algorithm = algorithm self.issuer = issuer self.audience = audience self.public_key = public_key self.jwks_uri = jwks_uri self.ssrf_safe = ssrf_safe self._http_client = http_client self.jwt = JsonWebToken([self.algorithm]) self.logger = get_logger(__name__) # Simple JWKS cache self._jwks_cache: dict[str, str] = {} self._jwks_cache_time: float = 0 self._cache_ttl = 3600 # 1 hour async def _get_verification_key(self, token: str) -> str | bytes: """Get the verification key for the token.""" if self.public_key: return self.public_key # Extract kid from token header for JWKS lookup try: header = decode_jwt_header(token) kid = header.get("kid") return await self._get_jwks_key(kid) except (ValueError, KeyError, IndexError, json.JSONDecodeError) as e: raise ValueError(f"Failed to extract key ID from token: {e}") from e async def _get_jwks_key(self, kid: str | None) -> str: """Fetch key from JWKS with simple caching and SSRF protection.""" if not self.jwks_uri: raise ValueError("JWKS URI not configured") current_time = time.time() # Check cache first if current_time - self._jwks_cache_time < self._cache_ttl: if kid and kid in self._jwks_cache: return self._jwks_cache[kid] elif not kid and len(self._jwks_cache) == 1: # If no kid but only one key cached, use it return next(iter(self._jwks_cache.values())) # Fetch JWKS — with SSRF protection when enabled (untrusted URIs) try: jwks_data = await self._fetch_jwks() # Cache all keys self._jwks_cache = {} for key_data in jwks_data.get("keys", []): key_kid = key_data.get("kid") jwk = JsonWebKey.import_key(key_data) public_key = jwk.get_public_key() if key_kid: self._jwks_cache[key_kid] = public_key else: # Key without kid - use a default identifier self._jwks_cache["_default"] = public_key self._jwks_cache_time = current_time # Select the appropriate key if kid: if kid not in self._jwks_cache: self.logger.debug( "JWKS key lookup failed: key ID '%s' not found", kid ) raise ValueError(f"Key ID '{kid}' not found in JWKS") return self._jwks_cache[kid] else: # No kid in token - only allow if there's exactly one key if len(self._jwks_cache) == 1: return next(iter(self._jwks_cache.values())) elif len(self._jwks_cache) > 1: raise ValueError( "Multiple keys in JWKS but no key ID (kid) in token" ) else: raise ValueError("No keys found in JWKS") except (SSRFError, SSRFFetchError) as e: self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e) raise ValueError(f"Failed to fetch JWKS: {e}") from e except httpx.HTTPError as e: raise ValueError(f"Failed to fetch JWKS: {e}") from e except json.JSONDecodeError as e: raise ValueError(f"Invalid JWKS JSON: {e}") from e except (JoseError, TypeError, KeyError) as e: self.logger.debug("JWKS key processing failed: %s", e) raise ValueError(f"Failed to process JWKS: {e}") from e async def _fetch_jwks(self) -> dict[str, Any]: """Fetch JWKS data, using SSRF-safe or standard fetch based on config.""" if not self.jwks_uri: raise ValueError("JWKS URI not configured") if self.ssrf_safe: content = await ssrf_safe_fetch( self.jwks_uri, max_size=65536, timeout=10.0, overall_timeout=30.0, ) return json.loads(content) else: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=httpx.Timeout(10.0)) ) as client: response = await client.get(self.jwks_uri) response.raise_for_status() return response.json() def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: """ Extract scopes from JWT claims. Supports both 'scope' and 'scp' claims. Checks the `scope` claim first (standard OAuth2 claim), then the `scp` claim (used by some Identity Providers). """ for claim in ["scope", "scp"]: if claim in claims: if isinstance(claims[claim], str): return claims[claim].split() elif isinstance(claims[claim], list): return claims[claim] return [] async def load_access_token(self, token: str) -> AccessToken | None: """ Validate a JWT bearer token and return an AccessToken when the token is valid. Parameters: token (str): The JWT bearer token string to validate. Returns: AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. """ try: # Get verification key (static or from JWKS) verification_key = await self._get_verification_key(token) # Decode and verify the JWT token claims = self.jwt.decode(token, verification_key) # Extract client ID early for logging client_id = ( claims.get("client_id") or claims.get("azp") or claims.get("sub") or "unknown" ) # Validate expiration exp = claims.get("exp") if exp and exp < time.time(): self.logger.debug( "Token validation failed: expired token for client %s", client_id ) self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate issuer - note we use issuer instead of issuer_url here because # issuer is optional, allowing users to make this check optional if self.issuer: iss = claims.get("iss") # Handle different combinations of issuer types issuer_valid = False if isinstance(self.issuer, list): # self.issuer is a list - check if token issuer matches any expected issuer issuer_valid = iss in self.issuer else: # self.issuer is a string - check for equality issuer_valid = iss == self.issuer if not issuer_valid: self.logger.debug( "Token validation failed: issuer mismatch for client %s", client_id, ) self.logger.info("Bearer token rejected for client %s", client_id) return None # Validate audience if configured if self.audience: aud = claims.get("aud") # Handle different combinations of audience types audience_valid = False if isinstance(self.audience, list): # self.audience is a list - check if any expected audience is present if isinstance(aud, list): # Both are lists - check for intersection audience_valid = any( expected in aud for expected in self.audience ) else: # aud is a string - check if it's in our expected list audience_valid = aud in cast(list, self.audience) else: # self.audience is a string - use original logic if isinstance(aud, list): audience_valid = self.audience in aud else: audience_valid = aud == self.audience if not audience_valid: self.logger.debug( "Token validation failed: audience mismatch for client %s", client_id, ) self.logger.info("Bearer token rejected for client %s", client_id) return None # Extract scopes scopes = self._extract_scopes(claims) # Check required scopes if self.required_scopes: token_scopes = set(scopes) required_scopes = set(self.required_scopes) if not required_scopes.issubset(token_scopes): self.logger.debug( "Token missing required scopes. Has: %s, Required: %s", token_scopes, required_scopes, ) self.logger.info("Bearer token rejected for client %s", client_id) return None return AccessToken( token=token, client_id=str(client_id), scopes=scopes, expires_at=int(exp) if exp else None, claims=claims, ) except JoseError: self.logger.debug("Token validation failed: JWT signature/format invalid") return None except (ValueError, TypeError, KeyError, AttributeError) as e: self.logger.debug("Token validation failed: %s", str(e)) return None async def verify_token(self, token: str) -> AccessToken | None: """ Verify a bearer token and return access info if valid. This method implements the TokenVerifier protocol by delegating to our existing load_access_token method. Args: token: The JWT token string to validate Returns: AccessToken object if valid, None if invalid or expired """ return await self.load_access_token(token) class StaticTokenVerifier(TokenVerifier): """ Simple static token verifier for testing and development. This verifier validates tokens against a predefined dictionary of valid token strings and their associated claims. When a token string matches a key in the dictionary, the verifier returns the corresponding claims as if the token was validated by a real authorization server. Use this when: - You're developing or testing locally without a real OAuth server - You need predictable tokens for automated testing - You want to simulate different users/scopes without complex setup - You're prototyping and need simple API key-style authentication WARNING: Never use this in production - tokens are stored in plain text! """ def __init__( self, tokens: dict[str, dict[str, Any]], required_scopes: list[str] | None = None, ): """ Initialize the static token verifier. Args: tokens: Dict mapping token strings to token metadata Each token should have: client_id, scopes, expires_at (optional) required_scopes: Required scopes for all tokens """ super().__init__(required_scopes=required_scopes) self.tokens = tokens async def verify_token(self, token: str) -> AccessToken | None: """Verify token against static token dictionary.""" token_data = self.tokens.get(token) if not token_data: return None # Check expiration if present expires_at = token_data.get("expires_at") if expires_at is not None and expires_at < time.time(): return None scopes = token_data.get("scopes", []) # Check required scopes if self.required_scopes: token_scopes = set(scopes) required_scopes = set(self.required_scopes) if not required_scopes.issubset(token_scopes): logger.debug( f"Token missing required scopes. Has: {token_scopes}, Required: {required_scopes}" ) return None return AccessToken( token=token, client_id=token_data["client_id"], scopes=scopes, expires_at=expires_at, claims=token_data, ) ================================================ FILE: src/fastmcp/server/auth/providers/oci.py ================================================ """OCI OIDC provider for FastMCP. The pull request for the provider is submitted to fastmcp. This module provides OIDC Implementation to integrate MCP servers with OCI. You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. You can use the signer object to create OCI service object. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.oci import OCIProvider from fastmcp.server.dependencies import get_access_token from fastmcp.utilities.logging import get_logger import os import oci from oci.auth.signers import TokenExchangeSigner logger = get_logger(__name__) # Load configuration from environment config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain # Simple OCI OIDC protection auth = OCIProvider( config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application required_scopes=["openid", "profile", "email"], redirect_path="/auth/callback", base_url="http://localhost:8000", ) # NOTE: For production use, replace this with a thread-safe cache implementation # such as threading.Lock-protected dict or a proper caching library _global_token_cache = {} # In memory cache for OCI session token signer def get_oci_signer() -> TokenExchangeSigner: authntoken = get_access_token() tokenID = authntoken.claims.get("jti") token = authntoken.token # Check if the signer exists for the token ID in memory cache cached_signer = _global_token_cache.get(tokenID) logger.debug(f"Global cached signer: {cached_signer}") if cached_signer: logger.debug(f"Using globally cached signer for token ID: {tokenID}") return cached_signer # If the signer is not yet created for the token then create new OCI signer object logger.debug(f"Creating new signer for token ID: {tokenID}") signer = TokenExchangeSigner( jwt_or_func=token, oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application ) logger.debug(f"Signer {signer} created for token ID: {tokenID}") #Cache the signer object in memory cache _global_token_cache[tokenID] = signer logger.debug(f"Signer cached for token ID: {tokenID}") return signer mcp = FastMCP("My Protected Server", auth=auth) ``` """ from typing import Literal from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from fastmcp.server.auth.oidc_proxy import OIDCProxy from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class OCIProvider(OIDCProxy): """An OCI IAM Domain provider implementation for FastMCP. This provider is a complete OCI integration that's ready to use with just the configuration URL, client ID, client secret, and base URL. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.oci import OCIProvider import os # Load configuration from environment auth = OCIProvider( config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application base_url="http://localhost:8000", required_scopes=["openid", "profile", "email"], redirect_path="/auth/callback", ) mcp = FastMCP("My Protected Server", auth=auth) ``` """ def __init__( self, *, config_url: AnyHttpUrl | str, client_id: str, client_secret: str, base_url: AnyHttpUrl | str, audience: str | None = None, issuer_url: AnyHttpUrl | str | None = None, required_scopes: list[str] | None = None, redirect_path: str | None = None, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, ) -> None: """Initialize OCI OIDC provider. Args: config_url: OCI OIDC Discovery URL client_id: OCI IAM Domain Integrated Application client id client_secret: OCI Integrated Application client secret base_url: Public URL where OIDC endpoints will be accessible (includes any mount path) audience: OCI API audience (optional) issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. required_scopes: Required OCI scopes (defaults to ["openid"]) redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. """ # Parse scopes if provided as string oci_required_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else ["openid"] ) super().__init__( config_url=config_url, client_id=client_id, client_secret=client_secret, audience=audience, base_url=base_url, issuer_url=issuer_url, redirect_path=redirect_path, required_scopes=oci_required_scopes, allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized OCI OAuth provider for client %s with scopes: %s", client_id, oci_required_scopes, ) ================================================ FILE: src/fastmcp/server/auth/providers/propelauth.py ================================================ """PropelAuth authentication provider for FastMCP. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider auth = PropelAuthProvider( auth_url="https://auth.yourdomain.com", introspection_client_id="your-client-id", introspection_client_secret="your-client-secret", base_url="https://your-fastmcp-server.com", required_scopes=["read:user_data"], ) mcp = FastMCP("My App", auth=auth) ``` """ from __future__ import annotations from typing import TypedDict import httpx from pydantic import AnyHttpUrl, SecretStr from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False): timeout_seconds: int cache_ttl_seconds: int | None max_cache_size: int | None http_client: httpx.AsyncClient | None class PropelAuthProvider(RemoteAuthProvider): """PropelAuth resource server provider using OAuth 2.1 token introspection. This provider validates access tokens via PropelAuth's introspection endpoint and forwards authorization server metadata for OAuth discovery. Setup: 1. Enable MCP authentication in the PropelAuth Dashboard 2. Configure scopes on the MCP page 3. Select which redirect URIs to enable by picking which clients you support 4. Generate introspection credentials (Client ID + Client Secret) For detailed setup instructions, see: https://docs.propelauth.com/mcp-authentication/overview Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.propelauth import PropelAuthProvider auth = PropelAuthProvider( auth_url="https://auth.yourdomain.com", introspection_client_id="your-client-id", introspection_client_secret="your-client-secret", base_url="https://your-fastmcp-server.com", required_scopes=["read:user_data"], ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, auth_url: AnyHttpUrl | str, introspection_client_id: str, introspection_client_secret: str | SecretStr, base_url: AnyHttpUrl | str, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, resource: AnyHttpUrl | str | None = None, token_introspection_overrides: ( PropelAuthTokenIntrospectionOverrides | None ) = None, ): """Initialize PropelAuth provider. Args: auth_url: Your PropelAuth Auth URL (from the Backend Integration page) introspection_client_id: Introspection Client ID from the PropelAuth Dashboard introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard base_url: Public URL of this FastMCP server required_scopes: Optional list of scopes that must be present in tokens scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. resource: Optional resource URI (RFC 8707) identifying this MCP server. Use this when multiple MCP servers share the same PropelAuth authorization server (e.g. ``resource="https://api.example.com/mcp"``), so only tokens intended for this MCP server are accepted. token_introspection_overrides: Optional overrides for the underlying IntrospectionTokenVerifier (timeout, caching, http_client) """ normalized_auth_url = str(auth_url).rstrip("/") introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect" authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1") if resource is None: self._resource = None logger.debug( "PropelAuthProvider: no resource configured, audience checking disabled" ) else: self._resource = str(resource) token_verifier = self._create_token_verifier( introspection_url=introspection_url, client_id=introspection_client_id, client_secret=introspection_client_secret, required_scopes=required_scopes, introspection_overrides=token_introspection_overrides, ) self._normalized_auth_url = normalized_auth_url super().__init__( token_verifier=token_verifier, authorization_servers=[authorization_server_url], base_url=base_url, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, ) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get routes for this provider. Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)), and creates an authorization server metadata route that forwards to PropelAuth's route Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward PropelAuth OAuth authorization server metadata""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1" ) response.raise_for_status() metadata = response.json() return JSONResponse(metadata) except Exception as e: return JSONResponse( { "error": "server_error", "error_description": f"Failed to fetch PropelAuth metadata: {e}", }, status_code=500, ) routes.append( Route( "/.well-known/oauth-authorization-server", endpoint=oauth_authorization_server_metadata, methods=["GET"], ) ) return routes async def verify_token(self, token: str) -> AccessToken | None: """Verify token and check the ``aud`` claim against the configured resource.""" result = await super().verify_token(token) if result is None or self._resource is None: return result aud = result.claims.get("aud") if aud != self._resource: logger.debug( "PropelAuthProvider: token audience %r does not match resource %s", aud, self._resource, ) return None return result def _create_token_verifier( self, introspection_url: str, client_id: str, client_secret: str | SecretStr, required_scopes: list[str] | None, introspection_overrides: PropelAuthTokenIntrospectionOverrides | None, ) -> IntrospectionTokenVerifier: # Being defensive here, check for only the fields we are expecting safe_overrides: PropelAuthTokenIntrospectionOverrides = {} if introspection_overrides is not None: if "timeout_seconds" in introspection_overrides: safe_overrides["timeout_seconds"] = introspection_overrides[ "timeout_seconds" ] if "cache_ttl_seconds" in introspection_overrides: safe_overrides["cache_ttl_seconds"] = introspection_overrides[ "cache_ttl_seconds" ] if "max_cache_size" in introspection_overrides: safe_overrides["max_cache_size"] = introspection_overrides[ "max_cache_size" ] if "http_client" in introspection_overrides: safe_overrides["http_client"] = introspection_overrides["http_client"] return IntrospectionTokenVerifier( introspection_url=introspection_url, client_id=client_id, client_secret=client_secret, required_scopes=required_scopes, **safe_overrides, ) ================================================ FILE: src/fastmcp/server/auth/providers/scalekit.py ================================================ """Scalekit authentication provider for FastMCP. This module provides ScalekitProvider - a complete authentication solution that integrates with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server authentication for seamless MCP client authentication. """ from __future__ import annotations import httpx from pydantic import AnyHttpUrl from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class ScalekitProvider(RemoteAuthProvider): """Scalekit resource server provider for OAuth 2.1 authentication. This provider implements Scalekit integration using resource server pattern. FastMCP acts as a protected resource server that validates access tokens issued by Scalekit's authorization server. IMPORTANT SETUP REQUIREMENTS: 1. Create an MCP Server in Scalekit Dashboard: - Go to your [Scalekit Dashboard](https://app.scalekit.com/) - Navigate to MCP Servers section - Register a new MCP Server with appropriate scopes - Ensure the Resource Identifier matches exactly what you configure as MCP URL - Note the Resource ID 2. Environment Configuration: - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - Set SCALEKIT_RESOURCE_ID from your created resource - Set BASE_URL to your FastMCP server's public URL For detailed setup instructions, see: https://docs.scalekit.com/mcp/overview/ Example: ```python from fastmcp.server.auth.providers.scalekit import ScalekitProvider # Create Scalekit resource server provider scalekit_auth = ScalekitProvider( environment_url="https://your-env.scalekit.com", resource_id="sk_resource_...", base_url="https://your-fastmcp-server.com", ) # Use with FastMCP mcp = FastMCP("My App", auth=scalekit_auth) ``` """ def __init__( self, *, environment_url: AnyHttpUrl | str, resource_id: str, base_url: AnyHttpUrl | str | None = None, mcp_url: AnyHttpUrl | str | None = None, client_id: str | None = None, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Scalekit resource server provider. Args: environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") resource_id: Your Scalekit resource ID base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility) mcp_url: Deprecated alias for base_url. Will be removed in a future release. client_id: Deprecated parameter, no longer required. Will be removed in a future release. required_scopes: Optional list of scopes that must be present in tokens scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit """ # Resolve base_url from mcp_url if needed (backwards compatibility) resolved_base_url = base_url or mcp_url if not resolved_base_url: raise ValueError("Either base_url or mcp_url must be provided") if mcp_url is not None: logger.warning( "ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. " "Rename it to 'base_url'." ) if client_id is not None: logger.warning( "ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward " "compatibility and will be removed in a future release." ) self.environment_url = str(environment_url).rstrip("/") self.resource_id = resource_id parsed_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else [] ) self.required_scopes = parsed_scopes base_url_value = str(resolved_base_url) logger.debug( "Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s", self.environment_url, self.resource_id, base_url_value, self.required_scopes, ) # Create default JWT verifier if none provided if token_verifier is None: logger.debug( "Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s", f"{self.environment_url}/keys", self.environment_url, self.required_scopes, ) token_verifier = JWTVerifier( jwks_uri=f"{self.environment_url}/keys", issuer=self.environment_url, algorithm="RS256", audience=self.resource_id, required_scopes=self.required_scopes or None, ) else: logger.debug("Using custom token verifier for ScalekitProvider") # Initialize RemoteAuthProvider with Scalekit as the authorization server super().__init__( token_verifier=token_verifier, authorization_servers=[ AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") ], base_url=base_url_value, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, ) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth routes including Scalekit authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Scalekit's OAuth metadata to clients. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider routes = super().get_routes(mcp_path) logger.debug( "Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s", mcp_path, self.resource_id, ) async def oauth_authorization_server_metadata(request): """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" try: metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" logger.debug( "Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url ) async with httpx.AsyncClient() as client: response = await client.get(metadata_url) response.raise_for_status() metadata = response.json() logger.debug( "Scalekit metadata fetched successfully: metadata_keys=%s", list(metadata.keys()), ) return JSONResponse(metadata) except Exception as e: logger.error(f"Failed to fetch Scalekit metadata: {e}") return JSONResponse( { "error": "server_error", "error_description": f"Failed to fetch Scalekit metadata: {e}", }, status_code=500, ) # Add Scalekit authorization server metadata forwarding routes.append( Route( "/.well-known/oauth-authorization-server", endpoint=oauth_authorization_server_metadata, methods=["GET"], ) ) return routes ================================================ FILE: src/fastmcp/server/auth/providers/supabase.py ================================================ """Supabase authentication provider for FastMCP. This module provides SupabaseProvider - a complete authentication solution that integrates with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) for seamless MCP client authentication. """ from __future__ import annotations from typing import Literal import httpx from pydantic import AnyHttpUrl from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class SupabaseProvider(RemoteAuthProvider): """Supabase metadata provider for DCR (Dynamic Client Registration). This provider implements Supabase Auth integration using metadata forwarding. This approach allows Supabase to handle the OAuth flow directly while FastMCP acts as a resource server, verifying JWTs issued by Supabase Auth. IMPORTANT SETUP REQUIREMENTS: 1. Supabase Project Setup: - Create a Supabase project at https://supabase.com - Note your project URL (e.g., "https://abc123.supabase.co") - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - Asymmetric keys (RS256/ES256) are recommended for production 2. JWT Verification: - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json - JWTs are issued by {project_url}{auth_route} - Default auth_route is "/auth/v1" (can be customized for self-hosted setups) - Tokens are cached for up to 10 minutes by Supabase's edge servers - Algorithm must match your Supabase Auth configuration 3. Authorization: - Supabase uses Row Level Security (RLS) policies for database authorization - OAuth-level scopes are an upcoming feature in Supabase Auth - Both approaches will be supported once scope handling is available For detailed setup instructions, see: https://supabase.com/docs/guides/auth/jwts Example: ```python from fastmcp.server.auth.providers.supabase import SupabaseProvider # Create Supabase metadata provider (JWT verifier created automatically) supabase_auth = SupabaseProvider( project_url="https://abc123.supabase.co", base_url="https://your-fastmcp-server.com", algorithm="ES256", # Match your Supabase Auth configuration ) # Use with FastMCP mcp = FastMCP("My App", auth=supabase_auth) ``` """ def __init__( self, *, project_url: AnyHttpUrl | str, base_url: AnyHttpUrl | str, auth_route: str = "/auth/v1", algorithm: Literal["RS256", "ES256"] = "ES256", required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize Supabase metadata provider. Args: project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") base_url: Public URL of this FastMCP server auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized for self-hosted Supabase Auth setups using custom routes. algorithm: JWT signing algorithm (RS256 or ES256). Must match your Supabase Auth configuration. Defaults to ES256. required_scopes: Optional list of scopes to require for all requests. Note: Supabase currently uses RLS policies for authorization. OAuth-level scopes are an upcoming feature. scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase """ self.project_url = str(project_url).rstrip("/") self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) self.auth_route = auth_route.strip("/") # Parse scopes if provided as string parsed_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None ) # Create default JWT verifier if none provided if token_verifier is None: logger.warning( "SupabaseProvider cannot validate token audience for the specific resource " "because Supabase Auth does not support RFC 8707 resource indicators. " "This may leave the server vulnerable to cross-server token replay." ) token_verifier = JWTVerifier( jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json", issuer=f"{self.project_url}/{self.auth_route}", algorithm=algorithm, audience="authenticated", required_scopes=parsed_scopes, ) # Initialize RemoteAuthProvider with Supabase as the authorization server super().__init__( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")], base_url=self.base_url, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, ) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth routes including Supabase authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards Supabase's OAuth metadata to clients. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server" ) response.raise_for_status() metadata = response.json() return JSONResponse(metadata) except Exception as e: return JSONResponse( { "error": "server_error", "error_description": f"Failed to fetch Supabase metadata: {e}", }, status_code=500, ) # Add Supabase authorization server metadata forwarding routes.append( Route( "/.well-known/oauth-authorization-server", endpoint=oauth_authorization_server_metadata, methods=["GET"], ) ) return routes ================================================ FILE: src/fastmcp/server/auth/providers/workos.py ================================================ """WorkOS authentication providers for FastMCP. This module provides two WorkOS authentication strategies: 1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) 2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit Choose based on your WorkOS setup and authentication requirements. """ from __future__ import annotations import contextlib from typing import Literal import httpx from key_value.aio.protocols import AsyncKeyValue from pydantic import AnyHttpUrl from starlette.responses import JSONResponse from starlette.routing import Route from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) class WorkOSTokenVerifier(TokenVerifier): """Token verifier for WorkOS OAuth tokens. WorkOS AuthKit tokens are opaque, so we verify them by calling the /oauth2/userinfo endpoint to check validity and get user info. """ def __init__( self, *, authkit_domain: str, required_scopes: list[str] | None = None, timeout_seconds: int = 10, http_client: httpx.AsyncClient | None = None, ): """Initialize the WorkOS token verifier. Args: authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") required_scopes: Required OAuth scopes timeout_seconds: HTTP request timeout http_client: Optional httpx.AsyncClient for connection pooling. When provided, the client is reused across calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ super().__init__(required_scopes=required_scopes) self.authkit_domain = authkit_domain.rstrip("/") self.timeout_seconds = timeout_seconds self._http_client = http_client async def verify_token(self, token: str) -> AccessToken | None: """Verify WorkOS OAuth token by calling userinfo endpoint.""" try: async with ( contextlib.nullcontext(self._http_client) if self._http_client is not None else httpx.AsyncClient(timeout=self.timeout_seconds) ) as client: # Use WorkOS AuthKit userinfo endpoint to validate token response = await client.get( f"{self.authkit_domain}/oauth2/userinfo", headers={ "Authorization": f"Bearer {token}", "User-Agent": "FastMCP-WorkOS-OAuth", }, ) if response.status_code != 200: logger.debug( "WorkOS token verification failed: %d - %s", response.status_code, response.text[:200], ) return None user_data = response.json() token_scopes = ( parse_scopes(user_data.get("scope") or user_data.get("scopes")) or [] ) if self.required_scopes and not all( scope in token_scopes for scope in self.required_scopes ): logger.debug( "WorkOS token missing required scopes. required=%s actual=%s", self.required_scopes, token_scopes, ) return None # Create AccessToken with WorkOS user info return AccessToken( token=token, client_id=str(user_data.get("sub", "unknown")), scopes=token_scopes, expires_at=None, # Will be set from token introspection if needed claims={ "sub": user_data.get("sub"), "email": user_data.get("email"), "email_verified": user_data.get("email_verified"), "name": user_data.get("name"), "given_name": user_data.get("given_name"), "family_name": user_data.get("family_name"), }, ) except httpx.RequestError as e: logger.debug("Failed to verify WorkOS token: %s", e) return None except Exception as e: logger.debug("WorkOS token verification error: %s", e) return None class WorkOSProvider(OAuthProxy): """Complete WorkOS OAuth provider for FastMCP. This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. It provides OAuth2 authentication for users through WorkOS Connect applications. Features: - Transparent OAuth proxy to WorkOS AuthKit - Automatic token validation via userinfo endpoint - User information extraction from ID tokens - Support for standard OAuth scopes (openid, profile, email) Setup Requirements: 1. Create a WorkOS Connect application in your dashboard 2. Note your AuthKit domain (e.g., "https://your-app.authkit.app") 3. Configure redirect URI as: http://localhost:8000/auth/callback 4. Note your Client ID and Client Secret Example: ```python from fastmcp import FastMCP from fastmcp.server.auth.providers.workos import WorkOSProvider auth = WorkOSProvider( client_id="client_123", client_secret="sk_test_456", authkit_domain="https://your-app.authkit.app", base_url="http://localhost:8000" ) mcp = FastMCP("My App", auth=auth) ``` """ def __init__( self, *, client_id: str, client_secret: str, authkit_domain: str, base_url: AnyHttpUrl | str, issuer_url: AnyHttpUrl | str | None = None, redirect_path: str | None = None, required_scopes: list[str] | None = None, timeout_seconds: int = 10, allowed_client_redirect_uris: list[str] | None = None, client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool | Literal["external"] = True, consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize WorkOS OAuth provider. Args: client_id: WorkOS client ID client_secret: WorkOS client secret authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL to avoid 404s during discovery when mounting under a path. redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") required_scopes: Required OAuth scopes (no default) timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10) allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. If None (default), all URIs are allowed. If empty list, no URIs are allowed. client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). If None, an encrypted file store will be created in the data directory (derived from `platformdirs`). jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. require_authorization_consent: Whether to require user consent before authorizing clients (default True). When True, users see a consent screen before being redirected to WorkOS. When False, authorization proceeds directly without user confirmation. When "external", the built-in consent screen is skipped but no warning is logged, indicating that consent is handled externally (e.g. by the upstream IdP). SECURITY WARNING: Only set to False for local development or testing environments. http_client: Optional httpx.AsyncClient for connection pooling in token verification. When provided, the client is reused across verify_token calls and the caller is responsible for its lifecycle. When None (default), a fresh client is created per call. """ # Apply defaults and ensure authkit_domain is a full URL authkit_domain_str = authkit_domain if not authkit_domain_str.startswith(("http://", "https://")): authkit_domain_str = f"https://{authkit_domain_str}" authkit_domain_final = authkit_domain_str.rstrip("/") scopes_final = ( parse_scopes(required_scopes) if required_scopes is not None else [] ) # Create WorkOS token verifier token_verifier = WorkOSTokenVerifier( authkit_domain=authkit_domain_final, required_scopes=scopes_final, timeout_seconds=timeout_seconds, http_client=http_client, ) # Initialize OAuth proxy with WorkOS AuthKit endpoints super().__init__( upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize", upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token", upstream_client_id=client_id, upstream_client_secret=client_secret, token_verifier=token_verifier, base_url=base_url, redirect_path=redirect_path, issuer_url=issuer_url or base_url, # Default to base_url if not specified allowed_client_redirect_uris=allowed_client_redirect_uris, client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, consent_csp_policy=consent_csp_policy, ) logger.debug( "Initialized WorkOS OAuth provider for client %s with AuthKit domain %s", client_id, authkit_domain_final, ) class AuthKitProvider(RemoteAuthProvider): """AuthKit metadata provider for DCR (Dynamic Client Registration). This provider implements AuthKit integration using metadata forwarding instead of OAuth proxying. This is the recommended approach for WorkOS DCR as it allows WorkOS to handle the OAuth flow directly while FastMCP acts as a resource server. IMPORTANT SETUP REQUIREMENTS: 1. Enable Dynamic Client Registration in WorkOS Dashboard: - Go to Applications → Configuration - Toggle "Dynamic Client Registration" to enabled 2. Configure your FastMCP server URL as a callback: - Add your server URL to the Redirects tab in WorkOS dashboard - Example: https://your-fastmcp-server.com/oauth2/callback For detailed setup instructions, see: https://workos.com/docs/authkit/mcp/integrating/token-verification Example: ```python from fastmcp.server.auth.providers.workos import AuthKitProvider # Create AuthKit metadata provider (JWT verifier created automatically) workos_auth = AuthKitProvider( authkit_domain="https://your-workos-domain.authkit.app", base_url="https://your-fastmcp-server.com", ) # Use with FastMCP mcp = FastMCP("My App", auth=workos_auth) ``` """ def __init__( self, *, authkit_domain: AnyHttpUrl | str, base_url: AnyHttpUrl | str, client_id: str | None = None, required_scopes: list[str] | None = None, scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, token_verifier: TokenVerifier | None = None, ): """Initialize AuthKit metadata provider. Args: authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") base_url: Public URL of this FastMCP server client_id: Your WorkOS project client ID (e.g., "client_01ABC..."). Used to validate the JWT audience claim. Found in your WorkOS Dashboard under API Keys. This is the project-level client ID, not individual MCP client IDs. required_scopes: Optional list of scopes to require for all requests scopes_supported: Optional list of scopes to advertise in OAuth metadata. If None, uses required_scopes. Use this when the scopes clients should request differ from the scopes enforced on tokens. resource_name: Optional name for the protected resource metadata. resource_documentation: Optional documentation URL for the protected resource. token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit """ self.authkit_domain = str(authkit_domain).rstrip("/") self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) # Parse scopes if provided as string parsed_scopes = ( parse_scopes(required_scopes) if required_scopes is not None else None ) # Create default JWT verifier if none provided if token_verifier is None: logger.warning( "AuthKitProvider cannot validate token audience for the specific resource " "because AuthKit does not support RFC 8707 resource indicators. " "This may leave the server vulnerable to cross-server token replay. " "Consider using WorkOSProvider (OAuth proxy) for audience-bound tokens." ) token_verifier = JWTVerifier( jwks_uri=f"{self.authkit_domain}/oauth2/jwks", issuer=self.authkit_domain, algorithm="RS256", audience=client_id, required_scopes=parsed_scopes, ) # Initialize RemoteAuthProvider with AuthKit as the authorization server super().__init__( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(self.authkit_domain)], base_url=self.base_url, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, ) def get_routes( self, mcp_path: str | None = None, ) -> list[Route]: """Get OAuth routes including AuthKit authorization server metadata forwarding. This returns the standard protected resource routes plus an authorization server metadata endpoint that forwards AuthKit's OAuth metadata to clients. Args: mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") This is used to advertise the resource URL in metadata. """ # Get the standard protected resource routes from RemoteAuthProvider routes = super().get_routes(mcp_path) async def oauth_authorization_server_metadata(request): """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{self.authkit_domain}/.well-known/oauth-authorization-server" ) response.raise_for_status() metadata = response.json() return JSONResponse(metadata) except Exception as e: return JSONResponse( { "error": "server_error", "error_description": f"Failed to fetch AuthKit metadata: {e}", }, status_code=500, ) # Add AuthKit authorization server metadata forwarding routes.append( Route( "/.well-known/oauth-authorization-server", endpoint=oauth_authorization_server_metadata, methods=["GET"], ) ) return routes ================================================ FILE: src/fastmcp/server/auth/redirect_validation.py ================================================ """Utilities for validating client redirect URIs in OAuth flows. This module provides secure redirect URI validation with wildcard support, protecting against userinfo-based bypass attacks like http://localhost@evil.com. """ import fnmatch from urllib.parse import urlparse from pydantic import AnyUrl def _parse_host_port(netloc: str) -> tuple[str | None, str | None]: """Parse host and port from netloc, handling wildcards. Args: netloc: The netloc component (e.g., "localhost:8080" or "localhost:*") Returns: Tuple of (host, port_str) where port_str may be "*" or a number string """ # Handle userinfo (remove it for parsing, but we check separately) if "@" in netloc: netloc = netloc.split("@")[-1] # Handle IPv6 addresses [::1]:port if netloc.startswith("["): bracket_end = netloc.find("]") if bracket_end == -1: return netloc, None host = netloc[1:bracket_end] rest = netloc[bracket_end + 1 :] if rest.startswith(":"): return host, rest[1:] return host, None # Handle regular host:port if ":" in netloc: host, port = netloc.rsplit(":", 1) return host, port return netloc, None def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: """Match host component, supporting *.example.com wildcard patterns. Args: uri_host: The host from the URI being validated pattern_host: The host pattern (may start with *.) Returns: True if the host matches """ if not uri_host or not pattern_host: return uri_host == pattern_host # Normalize to lowercase for comparison uri_host = uri_host.lower() pattern_host = pattern_host.lower() # Handle *.example.com wildcard subdomain patterns if pattern_host.startswith("*."): suffix = pattern_host[1:] # .example.com # Only match actual subdomains (foo.example.com), NOT the base domain return uri_host.endswith(suffix) and uri_host != pattern_host[2:] return uri_host == pattern_host def _match_port( uri_port: str | None, pattern_port: str | None, uri_scheme: str, ) -> bool: """Match port component, supporting * wildcard for any port. Args: uri_port: The port from the URI (None if default, string otherwise) pattern_port: The port from the pattern (None if default, "*" for wildcard) uri_scheme: The URI scheme (http/https) for default port handling Returns: True if the port matches """ # Wildcard matches any port if pattern_port == "*": return True # Normalize None to default ports default_port = "443" if uri_scheme == "https" else "80" uri_effective = uri_port if uri_port else default_port pattern_effective = pattern_port if pattern_port else default_port return uri_effective == pattern_effective def _match_path(uri_path: str, pattern_path: str) -> bool: """Match path component using fnmatch for wildcard support. Args: uri_path: The path from the URI pattern_path: The path pattern (may contain * wildcards) Returns: True if the path matches """ # Normalize empty paths to / uri_path = uri_path or "/" pattern_path = pattern_path or "/" # Empty or root pattern path matches any path # This makes http://localhost:* match http://localhost:3000/callback if pattern_path == "/": return True # Use fnmatch for path wildcards (e.g., /auth/*) return fnmatch.fnmatch(uri_path, pattern_path) def matches_allowed_pattern(uri: str, pattern: str) -> bool: """Securely check if a URI matches an allowed pattern with wildcard support. This function parses both the URI and pattern as URLs, comparing each component separately to prevent bypass attacks like userinfo injection. Patterns support wildcards: - 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/ Security: Rejects URIs with userinfo (user:pass@host) which could bypass naive string matching (e.g., http://localhost@evil.com). Args: uri: The redirect URI to validate pattern: The allowed pattern (may contain wildcards) Returns: True if the URI matches the pattern """ try: uri_parsed = urlparse(uri) pattern_parsed = urlparse(pattern) except ValueError: return False # SECURITY: Reject URIs with userinfo (user:pass@host) # This prevents bypass attacks like http://localhost@evil.com/callback # which would match http://localhost:* with naive fnmatch if uri_parsed.username is not None or uri_parsed.password is not None: return False # Scheme must match exactly if uri_parsed.scheme.lower() != pattern_parsed.scheme.lower(): return False # Parse host and port manually to handle wildcards uri_host, uri_port = _parse_host_port(uri_parsed.netloc) pattern_host, pattern_port = _parse_host_port(pattern_parsed.netloc) # Host must match (with subdomain wildcard support) if not _match_host(uri_host, pattern_host): return False # Port must match (with * wildcard support) if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): return False # Path must match (with fnmatch wildcards) return _match_path(uri_parsed.path, pattern_parsed.path) 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, all URIs are allowed (for DCR compatibility). If empty list, no URIs are allowed. To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS. 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, allow all for DCR compatibility # (clients need to dynamically register with their own redirect URIs) if allowed_patterns is None: 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:*", ] ================================================ FILE: src/fastmcp/server/auth/ssrf.py ================================================ """SSRF-safe HTTP utilities for FastMCP. This module provides SSRF-protected HTTP fetching with: - DNS resolution and IP validation before requests - DNS pinning to prevent rebinding TOCTOU attacks - Support for both CIMD and JWKS fetches """ from __future__ import annotations import asyncio import ipaddress import socket import time from collections.abc import Mapping from dataclasses import dataclass from urllib.parse import urlparse import httpx from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) def format_ip_for_url(ip_str: str) -> str: """Format IP address for use in URL (bracket IPv6 addresses). IPv6 addresses must be bracketed in URLs to distinguish the address from the port separator. For example: https://[2001:db8::1]:443/path Args: ip_str: IP address string Returns: IP string suitable for URL (IPv6 addresses are bracketed) """ try: ip = ipaddress.ip_address(ip_str) if isinstance(ip, ipaddress.IPv6Address): return f"[{ip_str}]" return ip_str except ValueError: return ip_str class SSRFError(Exception): """Raised when an SSRF protection check fails.""" class SSRFFetchError(Exception): """Raised when SSRF-safe fetch fails.""" def is_ip_allowed(ip_str: str) -> bool: """Check if an IP address is allowed (must be globally routable unicast). Uses ip.is_global which catches: - Private (10.x, 172.16-31.x, 192.168.x) - Loopback (127.x, ::1) - Link-local (169.254.x, fe80::) - includes AWS metadata! - Reserved, unspecified - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks Additionally blocks multicast addresses (not caught by is_global). Args: ip_str: IP address string to check Returns: True if the IP is allowed (public unicast internet), False if blocked """ try: ip = ipaddress.ip_address(ip_str) except ValueError: return False if not ip.is_global: return False # Block multicast (not caught by is_global for some ranges) if ip.is_multicast: return False # IPv6-specific checks for embedded IPv4 addresses if isinstance(ip, ipaddress.IPv6Address): if ip.ipv4_mapped: return is_ip_allowed(str(ip.ipv4_mapped)) if ip.sixtofour: return is_ip_allowed(str(ip.sixtofour)) if ip.teredo: server, client = ip.teredo return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) return True async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: """Resolve hostname to IP addresses using DNS. Args: hostname: Hostname to resolve port: Port number (used for getaddrinfo) Returns: List of resolved IP addresses Raises: SSRFError: If resolution fails """ loop = asyncio.get_running_loop() try: infos = await loop.run_in_executor( None, lambda: socket.getaddrinfo( hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM ), ) ips = list({info[4][0] for info in infos}) if not ips: raise SSRFError(f"DNS resolution returned no addresses for {hostname}") return ips except socket.gaierror as e: raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e @dataclass class ValidatedURL: """A URL that has been validated for SSRF with resolved IPs.""" original_url: str hostname: str port: int path: str resolved_ips: list[str] @dataclass class SSRFFetchResponse: """Response payload from an SSRF-safe fetch.""" content: bytes status_code: int headers: dict[str, str] async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: """Validate URL for SSRF and resolve to IPs. Args: url: URL to validate require_path: If True, require non-root path (for CIMD) Returns: ValidatedURL with resolved IPs Raises: SSRFError: If URL is invalid or resolves to blocked IPs """ try: parsed = urlparse(url) except (ValueError, AttributeError) as e: raise SSRFError(f"Invalid URL: {e}") from e if parsed.scheme != "https": raise SSRFError(f"URL must use HTTPS, got: {parsed.scheme}") if not parsed.netloc: raise SSRFError("URL must have a host") if require_path and parsed.path in ("", "/"): raise SSRFError("URL must have a non-root path") hostname = parsed.hostname or parsed.netloc port = parsed.port or 443 # Resolve and validate IPs resolved_ips = await resolve_hostname(hostname, port) blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] if blocked: raise SSRFError( f"URL resolves to blocked IP address(es): {blocked}. " f"Private, loopback, link-local, and reserved IPs are not allowed." ) return ValidatedURL( original_url=url, hostname=hostname, port=port, path=parsed.path + ("?" + parsed.query if parsed.query else ""), resolved_ips=resolved_ips, ) async def ssrf_safe_fetch( url: str, *, require_path: bool = False, max_size: int = 5120, timeout: float = 10.0, overall_timeout: float = 30.0, ) -> bytes: """Fetch URL with comprehensive SSRF protection and DNS pinning. Security measures: 1. HTTPS only 2. DNS resolution with IP validation 3. Connects to validated IP directly (DNS pinning prevents rebinding) 4. Response size limit 5. Redirects disabled 6. Overall timeout Args: url: URL to fetch require_path: If True, require non-root path max_size: Maximum response size in bytes (default 5KB) timeout: Per-operation timeout in seconds overall_timeout: Overall timeout for entire operation Returns: Response body as bytes Raises: SSRFError: If SSRF validation fails SSRFFetchError: If fetch fails """ response = await ssrf_safe_fetch_response( url, require_path=require_path, max_size=max_size, timeout=timeout, overall_timeout=overall_timeout, allowed_status_codes={200}, ) return response.content async def ssrf_safe_fetch_response( url: str, *, require_path: bool = False, max_size: int = 5120, timeout: float = 10.0, overall_timeout: float = 30.0, request_headers: Mapping[str, str] | None = None, allowed_status_codes: set[int] | None = None, ) -> SSRFFetchResponse: """Fetch URL with SSRF protection and return response metadata. This is equivalent to :func:`ssrf_safe_fetch` but returns response headers and status code, and supports conditional request headers. """ start_time = time.monotonic() # Validate URL and resolve DNS validated = await validate_url(url, require_path=require_path) last_error: Exception | None = None expected_statuses = allowed_status_codes or {200} for pinned_ip in validated.resolved_ips: elapsed = time.monotonic() - start_time if elapsed > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") remaining = max(1.0, overall_timeout - elapsed) pinned_url = ( f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" ) logger.debug( "SSRF-safe fetch: %s -> %s (pinned to %s)", url, pinned_url, pinned_ip, ) headers = {"Host": validated.hostname} if request_headers: for key, value in request_headers.items(): # Host must remain pinned to the validated hostname. if key.lower() == "host": continue headers[key] = value try: # Use httpx with streaming to enforce size limit during download async with ( httpx.AsyncClient( timeout=httpx.Timeout( connect=min(timeout, remaining), read=min(timeout, remaining), write=min(timeout, remaining), pool=min(timeout, remaining), ), follow_redirects=False, verify=True, ) as client, client.stream( "GET", pinned_url, headers=headers, extensions={"sni_hostname": validated.hostname}, ) as response, ): if time.monotonic() - start_time > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") if response.status_code not in expected_statuses: raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") # Check Content-Length header first if available content_length = response.headers.get("content-length") if content_length: try: size = int(content_length) if size > max_size: raise SSRFFetchError( f"Response too large: {size} bytes (max {max_size})" ) except ValueError: pass # Stream the response and enforce size limit during download chunks = [] total = 0 async for chunk in response.aiter_bytes(): if time.monotonic() - start_time > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") total += len(chunk) if total > max_size: raise SSRFFetchError( f"Response too large: exceeded {max_size} bytes" ) chunks.append(chunk) return SSRFFetchResponse( content=b"".join(chunks), status_code=response.status_code, headers=dict(response.headers), ) except httpx.TimeoutException as e: last_error = e continue except httpx.RequestError as e: last_error = e continue if last_error is not None: if isinstance(last_error, httpx.TimeoutException): raise SSRFFetchError(f"Timeout fetching {url}") from last_error raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") ================================================ FILE: src/fastmcp/server/context.py ================================================ from __future__ import annotations import logging import weakref from collections.abc import Callable, Generator, Mapping, Sequence from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass from logging import Logger from typing import Any, Literal, overload import mcp.types from mcp import LoggingLevel, ServerSession from mcp.server.lowlevel.server import request_ctx from mcp.shared.context import RequestContext from mcp.types import ( GetPromptResult, ModelPreferences, Root, SamplingMessage, ) from mcp.types import Prompt as SDKPrompt from mcp.types import Resource as SDKResource from pydantic.networks import AnyUrl from starlette.requests import Request from typing_extensions import TypeVar from uncalled_for import SharedContext from fastmcp.resources.base import ResourceResult from fastmcp.server.elicitation import ( AcceptedElicitation, CancelledElicitation, DeclinedElicitation, handle_elicit_accept, parse_elicit_response_type, ) from fastmcp.server.low_level import MiddlewareServerSession from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool from fastmcp.server.sampling.run import ( sample_impl, sample_step_impl, ) from fastmcp.server.server import FastMCP, StateValue from fastmcp.server.transforms.visibility import ( Visibility, ) from fastmcp.server.transforms.visibility import ( disable_components as _disable_components, ) from fastmcp.server.transforms.visibility import ( enable_components as _enable_components, ) from fastmcp.server.transforms.visibility import ( get_session_transforms as _get_session_transforms, ) from fastmcp.server.transforms.visibility import ( get_visibility_rules as _get_visibility_rules, ) from fastmcp.server.transforms.visibility import ( reset_visibility as _reset_visibility, ) from fastmcp.utilities.logging import _clamp_logger, get_logger from fastmcp.utilities.versions import VersionSpec logger: Logger = get_logger(name=__name__) to_client_logger: Logger = logger.getChild(suffix="to_client") # Convert all levels of server -> client messages to debug level # This clamp can be undone at runtime by calling `_unclamp_logger` or calling # `_clamp_logger` with a different max level. _clamp_logger(logger=to_client_logger, max_level="DEBUG") T = TypeVar("T", default=Any) ResultT = TypeVar("ResultT", default=str) # Import ToolChoiceOption from sampling module (after other imports) from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402 _current_context: ContextVar[Context | None] = ContextVar("context", default=None) TransportType = Literal["stdio", "sse", "streamable-http"] _current_transport: ContextVar[TransportType | None] = ContextVar( "transport", default=None ) def set_transport( transport: TransportType, ) -> Token[TransportType | None]: """Set the current transport type. Returns token for reset.""" return _current_transport.set(transport) def reset_transport(token: Token[TransportType | None]) -> None: """Reset transport to previous value.""" _current_transport.reset(token) @dataclass class LogData: """Data object for passing log arguments to client-side handlers. This provides an interface to match the Python standard library logging, for compatibility with structured logging. """ msg: str extra: Mapping[str, Any] | None = None _mcp_level_to_python_level = { "debug": logging.DEBUG, "info": logging.INFO, "notice": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR, "critical": logging.CRITICAL, "alert": logging.CRITICAL, "emergency": logging.CRITICAL, } @contextmanager def set_context(context: Context) -> Generator[Context, None, None]: token = _current_context.set(context) try: yield context finally: _current_context.reset(token) @dataclass class Context: """Context object providing access to MCP capabilities. This provides a cleaner interface to MCP's RequestContext functionality. It gets injected into tool and resource functions that request it via type hints. To use context in a tool function, add a parameter with the Context type annotation: ```python @server.tool async def my_tool(x: int, ctx: Context) -> str: # Log messages to the client await ctx.info(f"Processing {x}") await ctx.debug("Debug info") await ctx.warning("Warning message") await ctx.error("Error message") # Report progress await ctx.report_progress(50, 100, "Processing") # Access resources data = await ctx.read_resource("resource://data") # Get request info request_id = ctx.request_id client_id = ctx.client_id # Manage state across the session (persists across requests) await ctx.set_state("key", "value") value = await ctx.get_state("key") # Store non-serializable values for the current request only await ctx.set_state("client", http_client, serializable=False) return str(x) ``` State Management: Context provides session-scoped state that persists across requests within the same MCP session. State is automatically keyed by session, ensuring isolation between different clients. State set during `on_initialize` middleware will persist to subsequent tool calls when using the same session object (STDIO, SSE, single-server HTTP). For distributed/serverless HTTP deployments where different machines handle the init and tool calls, state is isolated by the mcp-session-id header. The context parameter name can be anything as long as it's annotated with Context. The context is optional - tools that don't need it can omit the parameter. """ # Default TTL for session state: 1 day in seconds _STATE_TTL_SECONDS: int = 86400 def __init__( self, fastmcp: FastMCP, session: ServerSession | None = None, *, task_id: str | None = None, origin_request_id: str | None = None, ): self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp) self._session: ServerSession | None = session # For state ops during init self._tokens: list[Token] = [] # Background task support (SEP-1686) self._task_id: str | None = task_id self._origin_request_id: str | None = origin_request_id # Request-scoped state for non-serializable values (serializable=False) self._request_state: dict[str, Any] = {} @property def is_background_task(self) -> bool: """True when this context is running in a background task (Docket worker). When True, certain operations like elicit() and sample() will use task-aware implementations that can pause the task and wait for client input. Example: ```python @server.tool(task=True) async def my_task(ctx: Context) -> str: # Works transparently in both foreground and background task modes result = await ctx.elicit("Need input", str) return str(result) ``` """ return self._task_id is not None @property def task_id(self) -> str | None: """Get the background task ID if running in a background task. Returns None if not running in a background task context. """ return self._task_id @property def origin_request_id(self) -> str | None: """Get the request ID that originated this execution, if available. In foreground request mode, this is the current request_id. In background task mode, this is the request_id captured when the task was submitted, if one was available. """ if self.request_context is not None: return str(self.request_context.request_id) return self._origin_request_id @property def fastmcp(self) -> FastMCP: """Get the FastMCP instance.""" fastmcp = self._fastmcp() if fastmcp is None: raise RuntimeError("FastMCP instance is no longer available") return fastmcp async def __aenter__(self) -> Context: """Enter the context manager and set this context as the current context.""" # Inherit request-scoped state from parent context so middleware # and tool contexts share the same in-memory state dict. parent = _current_context.get(None) if parent is not None: self._request_state = parent._request_state # Always set this context and save the token token = _current_context.set(self) self._tokens.append(token) # Set current server for dependency injection (use weakref to avoid reference cycles) from fastmcp.server.dependencies import ( _current_docket, _current_server, _current_worker, is_docket_available, ) self._server_token = _current_server.set(weakref.ref(self.fastmcp)) # Set docket/worker from server instance for this request's context. # This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount) # where lifespan ContextVars don't propagate to request handlers. server = self.fastmcp if is_docket_available(): if server._docket is not None: self._docket_token = _current_docket.set(server._docket) if server._worker is not None: self._worker_token = _current_worker.set(server._worker) else: # Without docket, the lifespan won't provide a SharedContext, # so create one scoped to this Context for Shared() dependencies. self._shared_context = SharedContext() await self._shared_context.__aenter__() return self async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: """Exit the context manager and reset the most recent token.""" from fastmcp.server.dependencies import ( _current_docket, _current_server, _current_worker, ) # Mirror __aenter__: clean up docket/worker tokens or SharedContext if hasattr(self, "_worker_token"): _current_worker.reset(self._worker_token) del self._worker_token if hasattr(self, "_docket_token"): _current_docket.reset(self._docket_token) del self._docket_token if hasattr(self, "_shared_context"): await self._shared_context.__aexit__(exc_type, exc_val, exc_tb) del self._shared_context if hasattr(self, "_server_token"): _current_server.reset(self._server_token) del self._server_token # Reset context token if self._tokens: token = self._tokens.pop() _current_context.reset(token) @property def request_context(self) -> RequestContext[ServerSession, Any, Request] | None: """Access to the underlying request context. Returns None when the MCP session has not been established yet. Returns the full RequestContext once the MCP session is available. For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies, which works whether or not the MCP session is available. Example in middleware: ```python async def on_request(self, context, call_next): ctx = context.fastmcp_context if ctx.request_context: # MCP session available - can access session_id, request_id, etc. session_id = ctx.session_id else: # MCP session not available yet - use HTTP helpers from fastmcp.server.dependencies import get_http_request request = get_http_request() return await call_next(context) ``` """ try: return request_ctx.get() except LookupError: return None @property def lifespan_context(self) -> dict[str, Any]: """Access the server's lifespan context. Returns the context dict yielded by the server's lifespan function. Returns an empty dict if no lifespan was configured or if the MCP session is not yet established. In background tasks (Docket workers), where request_context is not available, falls back to reading from the FastMCP server's lifespan result directly. Example: ```python @server.tool def my_tool(ctx: Context) -> str: db = ctx.lifespan_context.get("db") if db: return db.query("SELECT 1") return "No database connection" ``` """ rc = self.request_context if rc is None: # In background tasks, request_context is not available. # Fall back to the server's lifespan result directly (#3095). result = self.fastmcp._lifespan_result if result is not None: return result return {} return rc.lifespan_context async def report_progress( self, progress: float, total: float | None = None, message: str | None = None ) -> None: """Report progress for the current operation. Works in both foreground (MCP progress notifications) and background (Docket task execution) contexts. Args: progress: Current progress value e.g. 24 total: Optional total value e.g. 100 message: Optional status message describing current progress """ progress_token = ( self.request_context.meta.progressToken if self.request_context and self.request_context.meta else None ) # Foreground: Send MCP progress notification if we have a token if progress_token is not None: await self.session.send_progress_notification( progress_token=progress_token, progress=progress, total=total, message=message, related_request_id=self.request_id, ) return # Background: Update Docket execution progress (stored in Redis) # This makes progress visible via tasks/get and notifications/tasks/status from fastmcp.server.dependencies import is_docket_available if not is_docket_available(): return try: from docket.dependencies import current_execution execution = current_execution.get() # Update progress in Redis using Docket's progress API. # Docket only exposes increment() (relative), so we compute # the delta from the last reported value stored on this execution. if total is not None: await execution.progress.set_total(int(total)) current = int(progress) last: int = getattr(execution, "_fastmcp_last_progress", 0) delta = current - last if delta > 0: await execution.progress.increment(delta) execution._fastmcp_last_progress = current # type: ignore[attr-defined] if message is not None: await execution.progress.set_message(message) except LookupError: # Not running in Docket worker context - no progress tracking available pass async def _paginate_list( self, request_factory: Callable[[str | None], Any], call_method: Callable[[Any], Any], extract_items: Callable[[Any], list[Any]], ) -> list[Any]: """Generic pagination helper for list operations. Args: request_factory: Function that creates a request from a cursor call_method: Async method to call with the request extract_items: Function to extract items from the result Returns: List of all items across all pages """ all_items: list[Any] = [] cursor: str | None = None seen_cursors: set[str] = set() while True: request = request_factory(cursor) result = await call_method(request) all_items.extend(extract_items(result)) if not result.nextCursor: break if result.nextCursor in seen_cursors: break seen_cursors.add(result.nextCursor) cursor = result.nextCursor return all_items async def list_resources(self) -> list[SDKResource]: """List all available resources from the server. Returns: List of Resource objects available on the server """ return await self._paginate_list( request_factory=lambda cursor: mcp.types.ListResourcesRequest( params=mcp.types.PaginatedRequestParams(cursor=cursor) if cursor else None ), call_method=self.fastmcp._list_resources_mcp, extract_items=lambda result: result.resources, ) async def list_prompts(self) -> list[SDKPrompt]: """List all available prompts from the server. Returns: List of Prompt objects available on the server """ return await self._paginate_list( request_factory=lambda cursor: mcp.types.ListPromptsRequest( params=mcp.types.PaginatedRequestParams(cursor=cursor) if cursor else None ), call_method=self.fastmcp._list_prompts_mcp, extract_items=lambda result: result.prompts, ) async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: """Get a prompt by name with optional arguments. Args: name: The name of the prompt to get arguments: Optional arguments to pass to the prompt Returns: The prompt result """ result = await self.fastmcp.render_prompt(name, arguments) if isinstance(result, mcp.types.CreateTaskResult): raise RuntimeError( "Unexpected CreateTaskResult: Context calls should not have task metadata" ) return result.to_mcp_prompt_result() async def read_resource(self, uri: str | AnyUrl) -> ResourceResult: """Read a resource by URI. Args: uri: Resource URI to read Returns: ResourceResult with contents """ result = await self.fastmcp.read_resource(str(uri)) if isinstance(result, mcp.types.CreateTaskResult): raise RuntimeError( "Unexpected CreateTaskResult: Context calls should not have task metadata" ) return result async def log( self, message: str, level: LoggingLevel | None = None, logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: """Send a log message to the client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. Args: message: Log message level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", "alert", or "emergency". Default is "info". logger_name: Optional logger name extra: Optional mapping for additional arguments """ data = LogData(msg=message, extra=extra) related_request_id = self.origin_request_id await _log_to_server_and_client( data=data, session=self.session, level=level or "info", logger_name=logger_name, related_request_id=related_request_id, ) @property def transport(self) -> TransportType | None: """Get the current transport type. Returns the transport type used to run this server: "stdio", "sse", or "streamable-http". Returns None if called outside of a server context. """ return _current_transport.get() def client_supports_extension(self, extension_id: str) -> bool: """Check whether the connected client supports a given MCP extension. Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by the client during initialization. Returns ``False`` when no session is available (e.g., outside a request context) or when the client did not advertise the extension. Example:: from fastmcp.server.apps import UI_EXTENSION_ID @mcp.tool async def my_tool(ctx: Context) -> str: if ctx.client_supports_extension(UI_EXTENSION_ID): return "UI-capable client" return "text-only client" """ rc = self.request_context if rc is None: return False session = rc.session if not isinstance(session, MiddlewareServerSession): return False return session.client_supports_extension(extension_id) @property def client_id(self) -> str | None: """Get the client ID if available.""" return ( getattr(self.request_context.meta, "client_id", None) if self.request_context and self.request_context.meta else None ) @property def request_id(self) -> str: """Get the unique ID for this request. Raises RuntimeError if MCP request context is not available. """ if self.request_context is None: raise RuntimeError( "request_id is not available because the MCP session has not been established yet. " "Check `context.request_context` for None before accessing this attribute." ) return str(self.request_context.request_id) @property def session_id(self) -> str: """Get the MCP session ID for ALL transports. Returns the session ID that can be used as a key for session-based data storage (e.g., Redis) to share data between tool calls within the same client session. Returns: The session ID for StreamableHTTP transports, or a generated ID for other transports. Raises: RuntimeError if no session is available. Example: ```python @server.tool def store_data(data: dict, ctx: Context) -> str: session_id = ctx.session_id redis_client.set(f"session:{session_id}:data", json.dumps(data)) return f"Data stored for session {session_id}" ``` """ from uuid import uuid4 # Get session from request context or _session (for on_initialize) request_ctx = self.request_context if request_ctx is not None: session = request_ctx.session elif self._session is not None: session = self._session else: raise RuntimeError( "session_id is not available because no session exists. " "This typically means you're outside a request context." ) # Check for cached session ID session_id = getattr(session, "_fastmcp_state_prefix", None) if session_id is not None: return session_id # For HTTP, try to get from header if request_ctx is not None: request = request_ctx.request if request: session_id = request.headers.get("mcp-session-id") # For STDIO/SSE/in-memory, generate a UUID if session_id is None: session_id = str(uuid4()) # Cache on session for consistency session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] return session_id @property def session(self) -> ServerSession: """Access to the underlying session for advanced usage. In request mode: Returns the session from the active request context. In background task mode: Returns the session stored at Context creation. Raises RuntimeError if no session is available. """ # Background task mode: use the stored session if self.is_background_task and self._session is not None: return self._session # Request mode: use request context if self.request_context is not None: return self.request_context.session # Fallback to stored session (e.g., during on_initialize) if self._session is not None: return self._session raise RuntimeError( "session is not available because the MCP session has not been established yet. " "Check `context.request_context` for None before accessing this attribute." ) # Convenience methods for common log levels async def debug( self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: """Send a `DEBUG`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( level="debug", message=message, logger_name=logger_name, extra=extra, ) async def info( self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: """Send a `INFO`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( level="info", message=message, logger_name=logger_name, extra=extra, ) async def warning( self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: """Send a `WARNING`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( level="warning", message=message, logger_name=logger_name, extra=extra, ) async def error( self, message: str, logger_name: str | None = None, extra: Mapping[str, Any] | None = None, ) -> None: """Send a `ERROR`-level message to the connected MCP Client. Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" await self.log( level="error", message=message, logger_name=logger_name, extra=extra, ) async def list_roots(self) -> list[Root]: """List the roots available to the server, as indicated by the client.""" result = await self.session.list_roots() return result.roots async def send_notification( self, notification: mcp.types.ServerNotificationType ) -> None: """Send a notification to the client immediately. Args: notification: An MCP notification instance (e.g., ToolListChangedNotification()) """ await self.session.send_notification(mcp.types.ServerNotification(notification)) async def close_sse_stream(self) -> None: """Close the current response stream to trigger client reconnection. When using StreamableHTTP transport with an EventStore configured, this method gracefully closes the HTTP connection for the current request. The client will automatically reconnect (after `retry_interval` milliseconds) and resume receiving events from where it left off via the EventStore. This is useful for long-running operations to avoid load balancer timeouts. Instead of holding a connection open for minutes, you can periodically close and let the client reconnect. Example: ```python @mcp.tool async def long_running_task(ctx: Context) -> str: for i in range(100): await ctx.report_progress(i, 100) # Close connection every 30 iterations to avoid LB timeouts if i % 30 == 0 and i > 0: await ctx.close_sse_stream() await do_work() return "Done" ``` Note: This is a no-op (with a debug log) if not using StreamableHTTP transport with an EventStore configured. """ if not self.request_context or not self.request_context.close_sse_stream: logger.debug( "close_sse_stream() called but not applicable " "(requires StreamableHTTP transport with event_store)" ) return await self.request_context.close_sse_stream() async def sample_step( self, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, tool_choice: ToolChoiceOption | str | None = None, execute_tools: bool = True, mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SampleStep: """ Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. Use this for fine-grained control over the sampling loop. Args: messages: The message(s) to send. Can be a string, list of strings, or list of SamplingMessage objects. system_prompt: Optional system prompt for the LLM. temperature: Optional sampling temperature. max_tokens: Maximum tokens to generate. Defaults to 512. model_preferences: Optional model preferences. tools: Optional list of tools the LLM can use. tool_choice: Tool choice mode ("auto", "required", or "none"). execute_tools: If True (default), execute tool calls and append results to history. If False, return immediately with tool_calls available in the step for manual execution. mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. tool_concurrency: Controls parallel execution of tools: - None (default): Sequential execution (one at a time) - 0: Unlimited parallel execution - N > 0: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. Returns: SampleStep containing: - .response: The raw LLM response - .history: Messages including input, assistant response, and tool results - .is_tool_use: True if the LLM requested tool execution - .tool_calls: List of tool calls (if any) - .text: The text content (if any) Example: messages = "Research X" while True: step = await ctx.sample_step(messages, tools=[search]) if not step.is_tool_use: print(step.text) break # Continue with tool results messages = step.history """ return await sample_step_impl( self, messages=messages, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, model_preferences=model_preferences, tools=tools, tool_choice=tool_choice, auto_execute_tools=execute_tools, mask_error_details=mask_error_details, tool_concurrency=tool_concurrency, ) @overload async def sample( self, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT], mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Overload: With result_type, returns SamplingResult[ResultT].""" @overload async def sample( self, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: None = None, mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SamplingResult[str]: """Overload: Without result_type, returns SamplingResult[str].""" async def sample( self, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SamplingResult[ResultT] | SamplingResult[str]: """ Send a sampling request to the client and await the response. This method runs to completion automatically. When tools are provided, it executes a tool loop: if the LLM returns a tool use request, the tools are executed and the results are sent back to the LLM. This continues until the LLM provides a final text response. When result_type is specified, a synthetic `final_response` tool is created. The LLM calls this tool to provide the structured response, which is validated against the result_type and returned as `.result`. For fine-grained control over the sampling loop, use sample_step() instead. Args: messages: The message(s) to send. Can be a string, list of strings, or list of SamplingMessage objects. system_prompt: Optional system prompt for the LLM. temperature: Optional sampling temperature. max_tokens: Maximum tokens to generate. Defaults to 512. model_preferences: Optional model preferences. tools: Optional list of tools the LLM can use. Accepts plain functions or SamplingTools. result_type: Optional type for structured output. When specified, a synthetic `final_response` tool is created and the LLM's response is validated against this type. mask_error_details: If True, mask detailed error messages from tool execution. When None (default), uses the global settings value. Tools can raise ToolError to bypass masking. tool_concurrency: Controls parallel execution of tools: - None (default): Sequential execution (one at a time) - 0: Unlimited parallel execution - N > 0: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. Returns: SamplingResult[T] containing: - .text: The text representation (raw text or JSON for structured) - .result: The typed result (str for text, parsed object for structured) - .history: All messages exchanged during sampling Note: Background task support for sampling is planned for a future release. Currently, sampling in background tasks requires using the low-level session.create_message() API directly. """ # TODO: Add background task support similar to elicit() when is_background_task return await sample_impl( self, messages=messages, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, model_preferences=model_preferences, tools=tools, result_type=result_type, mask_error_details=mask_error_details, tool_concurrency=tool_concurrency, ) @overload async def elicit( self, message: str, response_type: None, ) -> ( AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation ): ... """When response_type is None, the accepted elicitation will contain an empty dict""" @overload async def elicit( self, message: str, response_type: type[T], ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... """When response_type is not None, the accepted elicitation will contain the response data""" @overload async def elicit( self, message: str, response_type: list[str], ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... """When response_type is a list of strings, the accepted elicitation will contain the selected string response""" @overload async def elicit( self, message: str, response_type: dict[str, dict[str, str]], ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... """When response_type is a dict mapping keys to title dicts, the accepted elicitation will contain the selected key""" @overload async def elicit( self, message: str, response_type: list[list[str]], ) -> ( AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ): ... """When response_type is a list containing a list of strings (multi-select), the accepted elicitation will contain a list of selected strings""" @overload async def elicit( self, message: str, response_type: list[dict[str, dict[str, str]]], ) -> ( AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ): ... """When response_type is a list containing a dict mapping keys to title dicts (multi-select with titles), the accepted elicitation will contain a list of selected keys""" async def elicit( self, message: str, response_type: type[T] | list[str] | dict[str, dict[str, str]] | list[list[str]] | list[dict[str, dict[str, str]]] | None = None, ) -> ( AcceptedElicitation[T] | AcceptedElicitation[dict[str, Any]] | AcceptedElicitation[str] | AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation ): """ Send an elicitation request to the client and await the response. Call this method at any time to request additional information from the user through the client. The client must support elicitation, or the request will error. Note that the MCP protocol only supports simple object schemas with primitive types. You can provide a dataclass, TypedDict, or BaseModel to comply. If you provide a primitive type, an object schema with a single "value" field will be generated for the MCP interaction and automatically deconstructed into the primitive type upon response. If the response_type is None, the generated schema will be that of an empty object in order to comply with the MCP protocol requirements. Clients must send an empty object ("{}")in response. Args: message: A human-readable message explaining what information is needed response_type: The type of the response, which should be a primitive type or dataclass or BaseModel. If it is a primitive type, an object schema with a single "value" field will be generated. Note: This method works transparently in both request and background task contexts. In background task mode (SEP-1686), it will set the task status to "input_required" and wait for the client to provide input. """ config = parse_elicit_response_type(response_type) if self.is_background_task: # Background task mode: use task-aware elicitation result = await self._elicit_for_task( message=message, schema=config.schema, ) else: # Standard request mode: use session.elicit directly result = await self.session.elicit( message=message, requestedSchema=config.schema, related_request_id=self.request_id, ) if result.action == "accept": return handle_elicit_accept(config, result.content) elif result.action == "decline": return DeclinedElicitation() elif result.action == "cancel": return CancelledElicitation() else: raise ValueError(f"Unexpected elicitation action: {result.action}") async def _elicit_for_task( self, message: str, schema: dict[str, Any], ) -> mcp.types.ElicitResult: """Send an elicitation request from a background task (SEP-1686). This method handles elicitation when running in a Docket worker context, where there's no active MCP request. It: 1. Sets the task status to "input_required" 2. Sends the elicitation request with task metadata 3. Waits for the client to provide input via tasks/sendInput 4. Returns the result and resumes task execution Args: message: The message to display to the user schema: The JSON schema for the expected response Returns: ElicitResult with the user's response Raises: RuntimeError: If not running in a background task context """ if not self.is_background_task: raise RuntimeError( "_elicit_for_task called but not in a background task context" ) # Import here to avoid circular imports and optional dependency issues from fastmcp.server.tasks.elicitation import elicit_for_task return await elicit_for_task( task_id=self._task_id, # type: ignore[arg-type] session=self._session, message=message, schema=schema, fastmcp=self.fastmcp, ) def _make_state_key(self, key: str) -> str: """Create session-prefixed key for state storage.""" return f"{self.session_id}:{key}" async def set_state( self, key: str, value: Any, *, serializable: bool = True ) -> None: """Set a value in the state store. By default, values are stored in the session-scoped state store and persist across requests within the same MCP session. Values must be JSON-serializable (dicts, lists, strings, numbers, etc.). For non-serializable values (e.g., HTTP clients, database connections), pass ``serializable=False``. These values are stored in a request-scoped dict and only live for the current MCP request (tool call, resource read, or prompt render). They will not be available in subsequent requests. The key is automatically prefixed with the session identifier. """ prefixed_key = self._make_state_key(key) if not serializable: self._request_state[prefixed_key] = value return # Clear any request-scoped shadow so the session value is visible self._request_state.pop(prefixed_key, None) try: await self.fastmcp._state_store.put( key=prefixed_key, value=StateValue(value=value), ttl=self._STATE_TTL_SECONDS, ) except Exception as e: # Catch serialization errors from Pydantic (ValueError) or # the key_value library (SerializationError). Both contain # "serialize" in the message. Other exceptions propagate as-is. if "serialize" in str(e).lower(): raise TypeError( f"Value for state key {key!r} is not serializable. " f"Use set_state({key!r}, value, serializable=False) to store " f"non-serializable values. Note: non-serializable state is " f"request-scoped and will not persist across requests." ) from e raise async def get_state(self, key: str) -> Any: """Get a value from the state store. Checks request-scoped state first (set with ``serializable=False``), then falls back to the session-scoped state store. Returns None if the key is not found. """ prefixed_key = self._make_state_key(key) if prefixed_key in self._request_state: return self._request_state[prefixed_key] result = await self.fastmcp._state_store.get(key=prefixed_key) return result.value if result is not None else None async def delete_state(self, key: str) -> None: """Delete a value from the state store. Removes from both request-scoped and session-scoped stores. """ prefixed_key = self._make_state_key(key) self._request_state.pop(prefixed_key, None) await self.fastmcp._state_store.delete(key=prefixed_key) # ------------------------------------------------------------------------- # Session visibility control # ------------------------------------------------------------------------- async def _get_visibility_rules(self) -> list[dict[str, Any]]: """Load visibility rule dicts from session state.""" return await _get_visibility_rules(self) async def _get_session_transforms(self) -> list[Visibility]: """Get session-specific Visibility transforms from state store.""" return await _get_session_transforms(self) async def enable_components( self, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Enable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ await _enable_components( self, names=names, keys=keys, version=version, tags=tags, components=components, match_all=match_all, ) async def disable_components( self, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Disable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ await _disable_components( self, names=names, keys=keys, version=version, tags=tags, components=components, match_all=match_all, ) async def reset_visibility(self) -> None: """Clear all session visibility rules. Use this to reset session visibility back to global defaults. Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. """ await _reset_visibility(self) _MCP_LEVEL_SEVERITY: dict[LoggingLevel, int] = { "debug": 0, "info": 1, "notice": 2, "warning": 3, "error": 4, "critical": 5, "alert": 6, "emergency": 7, } async def _log_to_server_and_client( data: LogData, session: ServerSession, level: LoggingLevel, logger_name: str | None = None, related_request_id: str | None = None, ) -> None: """Log a message to the server and client.""" from fastmcp.server.low_level import MiddlewareServerSession if isinstance(session, MiddlewareServerSession): min_level = session._minimum_logging_level or session.fastmcp.client_log_level if min_level is not None: if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]: return msg_prefix = f"Sending {level.upper()} to client" if logger_name: msg_prefix += f" ({logger_name})" to_client_logger.log( level=_mcp_level_to_python_level[level], msg=f"{msg_prefix}: {data.msg}", extra=data.extra, ) await session.send_log_message( level=level, data=data, logger=logger_name, related_request_id=related_request_id, ) ================================================ FILE: src/fastmcp/server/dependencies.py ================================================ """Dependency injection for FastMCP. DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, CurrentWorker) and background task execution require fastmcp[tasks]. """ from __future__ import annotations import contextlib import inspect import logging import weakref from collections.abc import AsyncGenerator, Callable from contextlib import AsyncExitStack, asynccontextmanager from contextvars import ContextVar, Token from dataclasses import dataclass from datetime import datetime, timezone from functools import lru_cache from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable from mcp.server.auth.middleware.auth_context import ( get_access_token as _sdk_get_access_token, ) from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser from mcp.server.auth.provider import ( AccessToken as _SDKAccessToken, ) from mcp.server.lowlevel.server import request_ctx from starlette.requests import Request from uncalled_for import Dependency, get_dependency_parameters from uncalled_for.resolution import _Depends from fastmcp.exceptions import FastMCPError from fastmcp.server.auth import AccessToken from fastmcp.server.http import _current_http_request from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type _logger = logging.getLogger(__name__) if TYPE_CHECKING: from docket import Docket from docket.worker import Worker from mcp.server.session import ServerSession from fastmcp.server.context import Context from fastmcp.server.server import FastMCP __all__ = [ "AccessToken", "CurrentAccessToken", "CurrentContext", "CurrentDocket", "CurrentFastMCP", "CurrentHeaders", "CurrentRequest", "CurrentWorker", "Progress", "TaskContextInfo", "TokenClaim", "get_access_token", "get_context", "get_http_headers", "get_http_request", "get_server", "get_task_context", "get_task_session", "is_docket_available", "register_task_session", "require_docket", "resolve_dependencies", "transform_context_annotations", "without_injected_parameters", ] # --- TaskContextInfo and get_task_context --- @dataclass(frozen=True, slots=True) class TaskContextInfo: """Information about the current background task context. Returned by ``get_task_context()`` when running inside a Docket worker. Contains identifiers needed to communicate with the MCP session. """ task_id: str """The MCP task ID (server-generated UUID).""" session_id: str """The session ID that submitted this task.""" def get_task_context() -> TaskContextInfo | None: """Get the current task context if running inside a background task worker. This function extracts task information from the Docket execution context. Returns None if not running in a task context (e.g., foreground execution). Returns: TaskContextInfo with task_id and session_id, or None if not in a task. """ if not is_docket_available(): return None from docket.dependencies import current_execution try: execution = current_execution.get() # Parse the task key: {session_id}:{task_id}:{task_type}:{component} from fastmcp.server.tasks.keys import parse_task_key key_parts = parse_task_key(execution.key) return TaskContextInfo( task_id=key_parts["client_task_id"], session_id=key_parts["session_id"], ) except LookupError: # Not in worker context return None except (ValueError, KeyError): # Invalid task key format return None # --- Session registry for background task Context --- _task_sessions: dict[str, weakref.ref[ServerSession]] = {} def register_task_session(session_id: str, session: ServerSession) -> None: """Register a session for Context access in background tasks. Called automatically when a task is submitted to Docket. The session is stored as a weakref so it doesn't prevent garbage collection when the client disconnects. Args: session_id: The session identifier session: The ServerSession instance """ _task_sessions[session_id] = weakref.ref(session) def get_task_session(session_id: str) -> ServerSession | None: """Get a registered session by ID if still alive. Args: session_id: The session identifier Returns: The ServerSession if found and alive, None otherwise """ ref = _task_sessions.get(session_id) if ref is None: return None session = ref() if session is None: # Session was garbage collected, clean up entry _task_sessions.pop(session_id, None) return session # --- ContextVars --- _current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( "server", default=None ) _current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) _current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) _task_access_token: ContextVar[AccessToken | None] = ContextVar( "task_access_token", default=None ) # --- Docket availability check --- _DOCKET_AVAILABLE: bool | None = None def is_docket_available() -> bool: """Check if pydocket is installed.""" global _DOCKET_AVAILABLE if _DOCKET_AVAILABLE is None: try: import docket # noqa: F401 _DOCKET_AVAILABLE = True except ImportError: _DOCKET_AVAILABLE = False return _DOCKET_AVAILABLE def require_docket(feature: str) -> None: """Raise ImportError with install instructions if docket not available. Args: feature: Description of what requires docket (e.g., "`task=True`", "CurrentDocket()"). Will be included in the error message. """ if not is_docket_available(): raise ImportError( f"FastMCP background tasks require the `tasks` extra. " f"Install with: pip install 'fastmcp[tasks]'. " f"(Triggered by {feature})" ) # Import Progress separately — it's docket-specific, not part of uncalled-for try: from docket.dependencies import Progress as DocketProgress except ImportError: DocketProgress = None # type: ignore[assignment] # --- Context utilities --- def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: """Transform ctx: Context into ctx: Context = CurrentContext(). Transforms ALL params typed as Context to use Docket's DI system, unless they already have a Dependency-based default (like CurrentContext()). This unifies the legacy type annotation DI with Docket's Depends() system, allowing both patterns to work through a single resolution path. Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults after those without). KEYWORD_ONLY parameters keep their position since Python allows them to have defaults in any order. Args: fn: Function to transform Returns: Function with modified signature (same function object, updated __signature__) """ from fastmcp.server.context import Context # Get the function's signature try: sig = inspect.signature(fn) except (ValueError, TypeError): return fn # Get type hints for accurate type checking try: type_hints = get_type_hints(fn, include_extras=True) except Exception: type_hints = getattr(fn, "__annotations__", {}) # First pass: identify which params need transformation params_to_transform: set[str] = set() optional_context_params: set[str] = set() for name, param in sig.parameters.items(): annotation = type_hints.get(name, param.annotation) if is_class_member_of_type(annotation, Context): if not isinstance(param.default, Dependency): params_to_transform.add(name) if param.default is None: optional_context_params.add(name) if not params_to_transform: return fn # Second pass: build new param list preserving parameter kind structure # Python signature structure: [POSITIONAL_ONLY] / [POSITIONAL_OR_KEYWORD] *args [KEYWORD_ONLY] **kwargs # Within POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD: params without defaults must come first # KEYWORD_ONLY params can have defaults in any order P = inspect.Parameter # Group params by section, preserving order within each positional_only_no_default: list[P] = [] positional_only_with_default: list[P] = [] positional_or_keyword_no_default: list[P] = [] positional_or_keyword_with_default: list[P] = [] var_positional: list[P] = [] # *args (at most one) keyword_only: list[P] = [] # After * or *args, order preserved var_keyword: list[P] = [] # **kwargs (at most one) for name, param in sig.parameters.items(): # Transform Context params by adding CurrentContext default if name in params_to_transform: # We use CurrentContext() instead of Depends(get_context) because # get_context() returns the Context which is an AsyncContextManager, # and the DI system would try to enter it again (it's already entered) if name in optional_context_params: param = param.replace(default=OptionalCurrentContext()) else: param = param.replace(default=CurrentContext()) # Sort into buckets based on parameter kind if param.kind == P.POSITIONAL_ONLY: if param.default is P.empty: positional_only_no_default.append(param) else: positional_only_with_default.append(param) elif param.kind == P.POSITIONAL_OR_KEYWORD: if param.default is P.empty: positional_or_keyword_no_default.append(param) else: positional_or_keyword_with_default.append(param) elif param.kind == P.VAR_POSITIONAL: var_positional.append(param) elif param.kind == P.KEYWORD_ONLY: keyword_only.append(param) elif param.kind == P.VAR_KEYWORD: var_keyword.append(param) # Reconstruct parameter list maintaining Python's required structure new_params: list[P] = ( positional_only_no_default + positional_only_with_default + positional_or_keyword_no_default + positional_or_keyword_with_default + var_positional + keyword_only + var_keyword ) # Update function's signature in place # Handle methods by setting signature on the underlying function # For bound methods, we need to preserve the 'self' parameter because # inspect.signature(bound_method) automatically removes the first param if inspect.ismethod(fn): # Get the original __func__ signature which includes 'self' func_sig = inspect.signature(fn.__func__) # Insert 'self' at the beginning of our new params self_param = next(iter(func_sig.parameters.values())) # Should be 'self' new_sig = func_sig.replace(parameters=[self_param, *new_params]) fn.__func__.__signature__ = new_sig # type: ignore[union-attr] else: new_sig = sig.replace(parameters=new_params) fn.__signature__ = new_sig # type: ignore[attr-defined] # Clear caches that may have cached the old signature # This ensures get_dependency_parameters and without_injected_parameters # see the transformed signature _clear_signature_caches(fn) return fn def _clear_signature_caches(fn: Callable[..., Any]) -> None: """Clear signature-related caches for a function. Called after modifying a function's signature to ensure downstream code sees the updated signature. """ from uncalled_for.introspection import _parameter_cache, _signature_cache _signature_cache.pop(fn, None) _parameter_cache.pop(fn, None) if inspect.ismethod(fn): _signature_cache.pop(fn.__func__, None) _parameter_cache.pop(fn.__func__, None) def get_context() -> Context: """Get the current FastMCP Context instance directly.""" from fastmcp.server.context import _current_context context = _current_context.get() if context is None: raise RuntimeError("No active context found.") return context def get_server() -> FastMCP: """Get the current FastMCP server instance directly. Returns: The active FastMCP server Raises: RuntimeError: If no server in context """ server_ref = _current_server.get() if server_ref is None: raise RuntimeError("No FastMCP server instance in context") server = server_ref() if server is None: raise RuntimeError("FastMCP server instance is no longer available") return server def get_http_request() -> Request: """Get the current HTTP request. Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. """ # Try MCP SDK's request_ctx first (set during normal MCP request handling) request = None with contextlib.suppress(LookupError): request = request_ctx.get().request # Fallback to FastMCP's HTTP context variable # This is needed during `on_initialize` middleware where request_ctx isn't set yet if request is None: request = _current_http_request.get() if request is None: raise RuntimeError("No active HTTP request found.") return request def get_http_headers( include_all: bool = False, include: set[str] | None = None, ) -> dict[str, str]: """Extract headers from the current HTTP request if available. Never raises an exception, even if there is no active HTTP request (in which case an empty dict is returned). By default, strips problematic headers like `content-length` and `authorization` that cause issues if forwarded to downstream services. If `include_all` is True, all headers are returned. The `include` parameter allows specific headers to be included even if they would normally be excluded. This is useful for proxy transports that need to forward authorization headers to upstream MCP servers. """ if include_all: exclude_headers: set[str] = set() else: exclude_headers = { "host", "content-length", "content-type", "connection", "transfer-encoding", "upgrade", "te", "keep-alive", "expect", "accept", "authorization", # Proxy-related headers "proxy-authenticate", "proxy-authorization", "proxy-connection", # MCP-related headers "mcp-session-id", } if include: exclude_headers -= {h.lower() for h in include} # (just in case) if not all(h.lower() == h for h in exclude_headers): raise ValueError("Excluded headers must be lowercase") headers: dict[str, str] = {} try: request = get_http_request() for name, value in request.headers.items(): lower_name = name.lower() if lower_name not in exclude_headers: headers[lower_name] = str(value) return headers except RuntimeError: return {} def get_access_token() -> AccessToken | None: """Get the FastMCP access token from the current context. This function first tries to get the token from the current HTTP request's scope, which is more reliable for long-lived connections where the SDK's auth_context_var may become stale after token refresh. Falls back to the SDK's context var if no request is available. In background tasks (Docket workers), falls back to the token snapshot stored in Redis at task submission time. Returns: The access token if an authenticated user is available, None otherwise. """ access_token: _SDKAccessToken | None = None # First, try to get from current HTTP request's scope (issue #1863) # This is more reliable than auth_context_var for Streamable HTTP sessions # where tokens may be refreshed between MCP messages try: request = get_http_request() user = request.scope.get("user") if isinstance(user, AuthenticatedUser): access_token = user.access_token except RuntimeError: # No HTTP request available, fall back to context var pass # Fall back to SDK's context var if we didn't get a token from the request if access_token is None: access_token = _sdk_get_access_token() # Fall back to background task snapshot (#3095) # In Docket workers, neither HTTP request nor SDK context var are available. # The token was snapshotted in Redis at submit_to_docket() time and restored # into this ContextVar by _CurrentContext.__aenter__(). if access_token is None: task_token = _task_access_token.get() if task_token is not None: # Check expiration: if expires_at is set and past, treat as expired if task_token.expires_at is not None: if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()): return None return task_token if access_token is None or isinstance(access_token, AccessToken): return access_token # If the object is not a FastMCP AccessToken, convert it to one if the # fields are compatible (e.g. `claims` is not present in the SDK's AccessToken). # This is a workaround for the case where the SDK or auth provider returns a different type # If it fails, it will raise a TypeError try: access_token_as_dict = access_token.model_dump() return AccessToken( token=access_token_as_dict["token"], client_id=access_token_as_dict["client_id"], scopes=access_token_as_dict["scopes"], # Optional fields expires_at=access_token_as_dict.get("expires_at"), resource=access_token_as_dict.get("resource"), claims=access_token_as_dict.get("claims") or {}, ) except Exception as e: raise TypeError( f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. " "Ensure the SDK is using the correct AccessToken type." ) from e # --- Schema generation helper --- @lru_cache(maxsize=5000) def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: """Create a wrapper function without injected parameters. Returns a wrapper that excludes Context and Docket dependency parameters, making it safe to use with Pydantic TypeAdapter for schema generation and validation. The wrapper internally handles all dependency resolution and Context injection when called. Handles: - Legacy Context injection (always works) - Depends() injection (always works - uses docket or vendored DI engine) Args: fn: Original function with Context and/or dependencies Returns: Async wrapper function without injected parameters """ from fastmcp.server.context import Context # Identify parameters to exclude context_kwarg = find_kwarg_by_type(fn, Context) dependency_params = get_dependency_parameters(fn) exclude = set() if context_kwarg: exclude.add(context_kwarg) if dependency_params: exclude.update(dependency_params.keys()) if not exclude: return fn # Build new signature with only user parameters sig = inspect.signature(fn) user_params = [ param for name, param in sig.parameters.items() if name not in exclude ] new_sig = inspect.Signature(user_params) # Create async wrapper that handles dependency resolution fn_is_async = is_coroutine_function(fn) async def wrapper(**user_kwargs: Any) -> Any: async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs: if fn_is_async: return await fn(**resolved_kwargs) else: # Run sync functions in threadpool to avoid blocking the event loop result = await call_sync_fn_in_threadpool(fn, **resolved_kwargs) # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) if inspect.isawaitable(result): result = await result return result # Resolve string annotations (from `from __future__ import annotations`) using # the original function's module context. The wrapper's __globals__ points to # this module (dependencies.py) and is read-only, so some Pydantic versions # can't resolve names like Annotated or Literal from string annotations. try: resolved_hints = get_type_hints(fn, include_extras=True) except Exception: resolved_hints = getattr(fn, "__annotations__", {}) wrapper.__signature__ = new_sig # type: ignore[attr-defined] wrapper.__annotations__ = { k: v for k, v in resolved_hints.items() if k not in exclude and k != "return" } wrapper.__name__ = getattr(fn, "__name__", "wrapper") wrapper.__doc__ = getattr(fn, "__doc__", None) wrapper.__module__ = fn.__module__ wrapper.__qualname__ = getattr(fn, "__qualname__", wrapper.__qualname__) return wrapper # --- Dependency resolution --- @asynccontextmanager async def _resolve_fastmcp_dependencies( fn: Callable[..., Any], arguments: dict[str, Any] ) -> AsyncGenerator[dict[str, Any], None]: """Resolve Docket dependencies for a FastMCP function. Sets up the minimal context needed for Docket's Depends() to work: - A cache for resolved dependencies - An AsyncExitStack for managing context manager lifetimes The Docket instance (for CurrentDocket dependency) is managed separately by the server's lifespan and made available via ContextVar. Note: This does NOT set up Docket's Execution context. If user code needs Docket-specific dependencies like TaskArgument(), TaskKey(), etc., those will fail with clear errors about missing context. Args: fn: The function to resolve dependencies for arguments: The arguments passed to the function Yields: Dictionary of resolved dependencies merged with provided arguments """ dependency_params = get_dependency_parameters(fn) if not dependency_params: yield arguments return # Initialize dependency cache and exit stack cache_token = _Depends.cache.set({}) try: async with AsyncExitStack() as stack: stack_token = _Depends.stack.set(stack) try: resolved: dict[str, Any] = {} for parameter, dependency in dependency_params.items(): # If argument was explicitly provided, use that instead if parameter in arguments: resolved[parameter] = arguments[parameter] continue # Resolve the dependency try: resolved[parameter] = await stack.enter_async_context( dependency ) except FastMCPError: # Let FastMCPError subclasses (ToolError, ResourceError, etc.) # propagate unchanged so they can be handled appropriately raise except Exception as error: fn_name = getattr(fn, "__name__", repr(fn)) raise RuntimeError( f"Failed to resolve dependency '{parameter}' for {fn_name}" ) from error # Merge resolved dependencies with provided arguments final_arguments = {**arguments, **resolved} yield final_arguments finally: _Depends.stack.reset(stack_token) finally: _Depends.cache.reset(cache_token) @asynccontextmanager async def resolve_dependencies( fn: Callable[..., Any], arguments: dict[str, Any] ) -> AsyncGenerator[dict[str, Any], None]: """Resolve dependencies for a FastMCP function. This function: 1. Filters out any dependency parameter names from user arguments (security) 2. Resolves Depends() parameters via the DI system The filtering prevents external callers from overriding injected parameters by providing values for dependency parameter names. This is a security feature. Note: Context injection is handled via transform_context_annotations() which converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration time, so all injection goes through the unified DI system. Args: fn: The function to resolve dependencies for arguments: User arguments (may contain keys that match dependency names, which will be filtered out) Yields: Dictionary of filtered user args + resolved dependencies Example: ```python async with resolve_dependencies(my_tool, {"name": "Alice"}) as kwargs: result = my_tool(**kwargs) if inspect.isawaitable(result): result = await result ``` """ # Filter out dependency parameters from user arguments to prevent override # This is a security measure - external callers should never be able to # provide values for injected parameters dependency_params = get_dependency_parameters(fn) user_args = {k: v for k, v in arguments.items() if k not in dependency_params} async with _resolve_fastmcp_dependencies(fn, user_args) as resolved_kwargs: yield resolved_kwargs # --- Dependency classes --- # These must inherit from docket.dependencies.Dependency when docket is available # so that get_dependency_parameters can detect them. async def _restore_task_access_token( session_id: str, task_id: str ) -> Token[AccessToken | None] | None: """Restore the access token snapshot from Redis into a ContextVar. Called when setting up context in a Docket worker. The token was stored at submit_to_docket() time. The token is restored regardless of expiration; get_access_token() checks expiry when reading from the ContextVar. Returns: The ContextVar token for resetting, or None if nothing was restored. """ docket = _current_docket.get() if docket is None: return None token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token") try: async with docket.redis() as redis: token_data = await redis.get(token_key) if token_data is not None: restored = AccessToken.model_validate_json(token_data) return _task_access_token.set(restored) except Exception: _logger.warning( "Failed to restore access token for task %s:%s", session_id, task_id, exc_info=True, ) return None async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None: """Restore the origin request ID snapshot for a background task. Returns None if no request ID was captured at submission time. """ docket = _current_docket.get() if docket is None: return None request_id_key = docket.key( f"fastmcp:task:{session_id}:{task_id}:origin_request_id" ) try: async with docket.redis() as redis: request_id_data = await redis.get(request_id_key) if request_id_data is None: return None if isinstance(request_id_data, bytes): return request_id_data.decode() return str(request_id_data) except Exception: _logger.warning( "Failed to restore origin request ID for task %s:%s", session_id, task_id, exc_info=True, ) return None class _CurrentContext(Dependency["Context"]): """Async context manager for Context dependency. In foreground (request) mode: returns the active context from _current_context. In background (Docket worker) mode: creates a task-aware Context with task_id and restores the access token snapshot from Redis. """ _context: Context | None = None _access_token_cv_token: Token[AccessToken | None] | None = None async def __aenter__(self) -> Context: from fastmcp.server.context import Context, _current_context # Try foreground context first (normal MCP request) context = _current_context.get() if context is not None: return context # Check if we're in a Docket worker context task_info = get_task_context() if task_info is not None: # Get session from registry (registered when task was submitted) session = get_task_session(task_info.session_id) # Get server from ContextVar server = get_server() origin_request_id = await _restore_task_origin_request_id( task_info.session_id, task_info.task_id ) # Create task-aware Context self._context = Context( fastmcp=server, session=session, task_id=task_info.task_id, origin_request_id=origin_request_id, ) # Enter the context to set up ContextVars await self._context.__aenter__() # Restore access token snapshot from Redis (#3095) self._access_token_cv_token = await _restore_task_access_token( task_info.session_id, task_info.task_id ) return self._context # Neither foreground nor background context available raise RuntimeError( "No active context found. This can happen if:\n" " - Called outside an MCP request handler\n" " - Called in a background task before session was registered\n" "Check `context.request_context` for None before accessing." ) async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: # Clean up access token ContextVar if self._access_token_cv_token is not None: _task_access_token.reset(self._access_token_cv_token) self._access_token_cv_token = None # Clean up if we created a context for background task if self._context is not None: await self._context.__aexit__(exc_type, exc_value, traceback) self._context = None class _OptionalCurrentContext(Dependency["Context | None"]): """Context dependency that degrades to None when no context is active. This is implemented as a wrapper (composition), not a subclass of `_CurrentContext`, to avoid overriding `__aenter__` with an incompatible return type. """ _inner: _CurrentContext | None = None async def __aenter__(self) -> Context | None: inner = _CurrentContext() try: context = await inner.__aenter__() except RuntimeError as exc: if "No active context found" in str(exc): return None raise self._inner = inner return context async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: if self._inner is None: return await self._inner.__aexit__(exc_type, exc_value, traceback) self._inner = None def CurrentContext() -> Context: """Get the current FastMCP Context instance. This dependency provides access to the active FastMCP Context for the current MCP operation (tool/resource/prompt call). Returns: A dependency that resolves to the active Context instance Raises: RuntimeError: If no active context found (during resolution) Example: ```python from fastmcp.dependencies import CurrentContext @mcp.tool() async def log_progress(ctx: Context = CurrentContext()) -> str: ctx.report_progress(50, 100, "Halfway done") return "Working" ``` """ return cast("Context", _CurrentContext()) def OptionalCurrentContext() -> Context | None: """Get the current FastMCP Context, or None when no context is active.""" return cast("Context | None", _OptionalCurrentContext()) class _CurrentDocket(Dependency["Docket"]): """Async context manager for Docket dependency.""" async def __aenter__(self) -> Docket: require_docket("CurrentDocket()") docket = _current_docket.get() if docket is None: raise RuntimeError( "No Docket instance found. Docket is only initialized when there are " "task-enabled components (task=True). Add task=True to a component " "to enable Docket infrastructure." ) return docket async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def CurrentDocket() -> Docket: """Get the current Docket instance managed by FastMCP. This dependency provides access to the Docket instance that FastMCP automatically creates for background task scheduling. Returns: A dependency that resolves to the active Docket instance Raises: RuntimeError: If not within a FastMCP server context ImportError: If fastmcp[tasks] not installed Example: ```python from fastmcp.dependencies import CurrentDocket @mcp.tool() async def schedule_task(docket: Docket = CurrentDocket()) -> str: await docket.add(some_function)(arg1, arg2) return "Scheduled" ``` """ require_docket("CurrentDocket()") return cast("Docket", _CurrentDocket()) class _CurrentWorker(Dependency["Worker"]): """Async context manager for Worker dependency.""" async def __aenter__(self) -> Worker: require_docket("CurrentWorker()") worker = _current_worker.get() if worker is None: raise RuntimeError( "No Worker instance found. Worker is only initialized when there are " "task-enabled components (task=True). Add task=True to a component " "to enable Docket infrastructure." ) return worker async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def CurrentWorker() -> Worker: """Get the current Docket Worker instance managed by FastMCP. This dependency provides access to the Worker instance that FastMCP automatically creates for background task processing. Returns: A dependency that resolves to the active Worker instance Raises: RuntimeError: If not within a FastMCP server context ImportError: If fastmcp[tasks] not installed Example: ```python from fastmcp.dependencies import CurrentWorker @mcp.tool() async def check_worker_status(worker: Worker = CurrentWorker()) -> str: return f"Worker: {worker.name}" ``` """ require_docket("CurrentWorker()") return cast("Worker", _CurrentWorker()) class _CurrentFastMCP(Dependency["FastMCP"]): """Async context manager for FastMCP server dependency.""" async def __aenter__(self) -> FastMCP: server_ref = _current_server.get() if server_ref is None: raise RuntimeError("No FastMCP server instance in context") server = server_ref() if server is None: raise RuntimeError("FastMCP server instance is no longer available") return server async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def CurrentFastMCP() -> FastMCP: """Get the current FastMCP server instance. This dependency provides access to the active FastMCP server. Returns: A dependency that resolves to the active FastMCP server Raises: RuntimeError: If no server in context (during resolution) Example: ```python from fastmcp.dependencies import CurrentFastMCP @mcp.tool() async def introspect(server: FastMCP = CurrentFastMCP()) -> str: return f"Server: {server.name}" ``` """ from fastmcp.server.server import FastMCP return cast(FastMCP, _CurrentFastMCP()) class _CurrentRequest(Dependency[Request]): """Async context manager for HTTP Request dependency.""" async def __aenter__(self) -> Request: return get_http_request() async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def CurrentRequest() -> Request: """Get the current HTTP request. This dependency provides access to the Starlette Request object for the current HTTP request. Only available when running over HTTP transports (SSE or Streamable HTTP). Returns: A dependency that resolves to the active Starlette Request Raises: RuntimeError: If no HTTP request in context (e.g., STDIO transport) Example: ```python from fastmcp.server.dependencies import CurrentRequest from starlette.requests import Request @mcp.tool() async def get_client_ip(request: Request = CurrentRequest()) -> str: return request.client.host if request.client else "Unknown" ``` """ return cast(Request, _CurrentRequest()) class _CurrentHeaders(Dependency[dict[str, str]]): """Async context manager for HTTP Headers dependency.""" async def __aenter__(self) -> dict[str, str]: return get_http_headers(include={"authorization"}) async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def CurrentHeaders() -> dict[str, str]: """Get the current HTTP request headers. This dependency provides access to the HTTP headers for the current request, including the authorization header. Returns an empty dictionary when no HTTP request is available, making it safe to use in code that might run over any transport. Returns: A dependency that resolves to a dictionary of header name -> value Example: ```python from fastmcp.server.dependencies import CurrentHeaders @mcp.tool() async def get_auth_type(headers: dict = CurrentHeaders()) -> str: auth = headers.get("authorization", "") return "Bearer" if auth.startswith("Bearer ") else "None" ``` """ return cast(dict[str, str], _CurrentHeaders()) # --- Progress dependency --- @runtime_checkable class ProgressLike(Protocol): """Protocol for progress tracking interface. Defines the common interface between InMemoryProgress (server context) and Docket's Progress (worker context). """ @property def current(self) -> int | None: """Current progress value.""" ... @property def total(self) -> int: """Total/target progress value.""" ... @property def message(self) -> str | None: """Current progress message.""" ... async def set_total(self, total: int) -> None: """Set the total/target value for progress tracking.""" ... async def increment(self, amount: int = 1) -> None: """Atomically increment the current progress value.""" ... async def set_message(self, message: str | None) -> None: """Update the progress status message.""" ... class InMemoryProgress: """In-memory progress tracker for immediate tool execution. Provides the same interface as Docket's Progress but stores state in memory instead of Redis. Useful for testing and immediate execution where progress doesn't need to be observable across processes. """ def __init__(self) -> None: self._current: int | None = None self._total: int = 1 self._message: str | None = None async def __aenter__(self) -> InMemoryProgress: return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass @property def current(self) -> int | None: return self._current @property def total(self) -> int: return self._total @property def message(self) -> str | None: return self._message async def set_total(self, total: int) -> None: """Set the total/target value for progress tracking.""" if total < 1: raise ValueError("Total must be at least 1") self._total = total async def increment(self, amount: int = 1) -> None: """Atomically increment the current progress value.""" if amount < 1: raise ValueError("Amount must be at least 1") if self._current is None: self._current = amount else: self._current += amount async def set_message(self, message: str | None) -> None: """Update the progress status message.""" self._message = message class Progress(Dependency["Progress"]): """FastMCP Progress dependency that works in both server and worker contexts. Handles three execution modes: - In Docket worker: Uses the execution's progress (observable via Redis) - In FastMCP server with Docket: Falls back to in-memory progress - In FastMCP server without Docket: Uses in-memory progress This allows tools to use Progress() regardless of whether they're called immediately or as background tasks, and regardless of whether pydocket is installed. """ _impl: ProgressLike | None = None async def __aenter__(self) -> Progress: server_ref = _current_server.get() if server_ref is None or server_ref() is None: raise RuntimeError("Progress dependency requires a FastMCP server context.") if is_docket_available(): from docket.dependencies import Progress as DocketProgress try: docket_progress = DocketProgress() self._impl = await docket_progress.__aenter__() return self except LookupError: pass self._impl = InMemoryProgress() return self async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: self._impl = None @property def current(self) -> int | None: """Current progress value.""" assert self._impl is not None, "Progress must be used as a dependency" return self._impl.current @property def total(self) -> int: """Total/target progress value.""" assert self._impl is not None, "Progress must be used as a dependency" return self._impl.total @property def message(self) -> str | None: """Current progress message.""" assert self._impl is not None, "Progress must be used as a dependency" return self._impl.message async def set_total(self, total: int) -> None: """Set the total/target value for progress tracking.""" assert self._impl is not None, "Progress must be used as a dependency" await self._impl.set_total(total) async def increment(self, amount: int = 1) -> None: """Atomically increment the current progress value.""" assert self._impl is not None, "Progress must be used as a dependency" await self._impl.increment(amount) async def set_message(self, message: str | None) -> None: """Update the progress status message.""" assert self._impl is not None, "Progress must be used as a dependency" await self._impl.set_message(message) # --- Access Token dependency --- class _CurrentAccessToken(Dependency[AccessToken]): """Async context manager for AccessToken dependency.""" _access_token_cv_token: Token[AccessToken | None] | None = None async def __aenter__(self) -> AccessToken: token = get_access_token() # If no token found and we're in a Docket worker, try restoring from # Redis. This handles the case where ctx: Context is not in the # function signature, so _CurrentContext never ran the restoration. if token is None: task_info = get_task_context() if task_info is not None: self._access_token_cv_token = await _restore_task_access_token( task_info.session_id, task_info.task_id ) token = get_access_token() if token is None: raise RuntimeError( "No access token found. Ensure authentication is configured " "and the request is authenticated." ) return token async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: if self._access_token_cv_token is not None: _task_access_token.reset(self._access_token_cv_token) self._access_token_cv_token = None def CurrentAccessToken() -> AccessToken: """Get the current access token for the authenticated user. This dependency provides access to the AccessToken for the current authenticated request. Raises an error if no authentication is present. Returns: A dependency that resolves to the active AccessToken Raises: RuntimeError: If no authenticated user (use get_access_token() for optional) Example: ```python from fastmcp.server.dependencies import CurrentAccessToken from fastmcp.server.auth import AccessToken @mcp.tool() async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: return token.claims.get("sub", "unknown") ``` """ return cast(AccessToken, _CurrentAccessToken()) # --- Token Claim dependency --- class _TokenClaim(Dependency[str]): """Dependency that extracts a specific claim from the access token.""" def __init__(self, claim_name: str): self.claim_name = claim_name async def __aenter__(self) -> str: token = get_access_token() if token is None: raise RuntimeError( f"No access token available. Cannot extract claim '{self.claim_name}'." ) value = token.claims.get(self.claim_name) if value is None: raise RuntimeError( f"Claim '{self.claim_name}' not found in access token. " f"Available claims: {list(token.claims.keys())}" ) return str(value) async def __aexit__( self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: pass def TokenClaim(name: str) -> str: """Get a specific claim from the access token. This dependency extracts a single claim value from the current access token. It's useful for getting user identifiers, roles, or other token claims without needing the full token object. Args: name: The name of the claim to extract (e.g., "oid", "sub", "email") Returns: A dependency that resolves to the claim value as a string Raises: RuntimeError: If no access token is available or claim is missing Example: ```python from fastmcp.server.dependencies import TokenClaim @mcp.tool() async def add_expense( user_id: str = TokenClaim("oid"), # Azure object ID amount: float, ): # user_id is automatically injected from the token await db.insert({"user_id": user_id, "amount": amount}) ``` """ return cast(str, _TokenClaim(name)) ================================================ FILE: src/fastmcp/server/elicitation.py ================================================ from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Any, Generic, Literal, cast, get_origin from mcp.server.elicitation import ( CancelledElicitation, DeclinedElicitation, ) from pydantic import BaseModel from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema from typing_extensions import TypeVar from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter __all__ = [ "AcceptedElicitation", "CancelledElicitation", "DeclinedElicitation", "ElicitConfig", "ScalarElicitationType", "get_elicitation_schema", "handle_elicit_accept", "parse_elicit_response_type", ] logger = get_logger(__name__) T = TypeVar("T", default=Any) class ElicitationJsonSchema(GenerateJsonSchema): """Custom JSON schema generator for MCP elicitation that always inlines enums. MCP elicitation requires inline enum schemas without $ref/$defs references. This generator ensures enums are always generated inline for compatibility. Optionally adds enumNames for better UI display when available. """ def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override] """Override to prevent ref generation for enums and handle list schemas.""" # For enum schemas, bypass the ref mechanism entirely if schema["type"] == "enum": # Directly call our custom enum_schema without going through handler # This prevents the ref/defs mechanism from being invoked return self.enum_schema(schema) # For list schemas, check if items are enums if schema["type"] == "list": return self.list_schema(schema) # For all other types, use the default implementation return super().generate_inner(schema) def list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue: """Generate schema for list types, detecting enum items for multi-select.""" items_schema = schema.get("items_schema") # Check if items are enum/Literal if items_schema and items_schema.get("type") == "enum": # Generate array with enum items items = self.enum_schema(items_schema) # type: ignore[arg-type] # If items have oneOf pattern, convert to anyOf for multi-select per SEP-1330 if "oneOf" in items: items = {"anyOf": items["oneOf"]} return { "type": "array", "items": items, # Will be {"enum": [...]} or {"anyOf": [...]} } # Check if items are Literal (which Pydantic represents differently) if items_schema: # Try to detect Literal patterns items_result = super().generate_inner(items_schema) # If it's a const pattern or enum-like, allow it if ( "const" in items_result or "enum" in items_result or "oneOf" in items_result ): # Convert oneOf to anyOf for multi-select if "oneOf" in items_result: items_result = {"anyOf": items_result["oneOf"]} return { "type": "array", "items": items_result, } # Default behavior for non-enum arrays return super().list_schema(schema) def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue: """Generate inline enum schema. Always generates enum pattern: `{"enum": [value, ...]}` Titled enums are handled separately via dict-based syntax in ctx.elicit(). """ # Get the base schema from parent - always use simple enum pattern return super().enum_schema(schema) # we can't use the low-level AcceptedElicitation because it only works with BaseModels class AcceptedElicitation(BaseModel, Generic[T]): """Result when user accepts the elicitation.""" action: Literal["accept"] = "accept" data: T @dataclass class ScalarElicitationType(Generic[T]): value: T @dataclass class ElicitConfig: """Configuration for an elicitation request. Attributes: schema: The JSON schema to send to the client response_type: The type to validate responses with (None for raw schemas) is_raw: True if schema was built directly (extract "value" from response) """ schema: dict[str, Any] response_type: type | None is_raw: bool def parse_elicit_response_type(response_type: Any) -> ElicitConfig: """Parse response_type into schema and handling configuration. Supports multiple syntaxes: - None: Empty object schema, expect empty response - dict: `{"low": {"title": "..."}}` -> single-select titled enum - list patterns: - `[["a", "b"]]` -> multi-select untitled - `[{"low": {...}}]` -> multi-select titled - `["a", "b"]` -> single-select untitled - `list[X]` type annotation: multi-select with type - Scalar types (bool, int, float, str, Literal, Enum): single value - Other types (dataclass, BaseModel): use directly """ if response_type is None: return ElicitConfig( schema={"type": "object", "properties": {}}, response_type=None, is_raw=False, ) if isinstance(response_type, dict): return _parse_dict_syntax(response_type) if isinstance(response_type, list): return _parse_list_syntax(response_type) if get_origin(response_type) is list: return _parse_generic_list(response_type) if _is_scalar_type(response_type): return _parse_scalar_type(response_type) # Other types (dataclass, BaseModel, etc.) - use directly return ElicitConfig( schema=get_elicitation_schema(response_type), response_type=response_type, is_raw=False, ) def _is_scalar_type(response_type: Any) -> bool: """Check if response_type is a scalar type that needs wrapping.""" return ( response_type in {bool, int, float, str} or get_origin(response_type) is Literal or (isinstance(response_type, type) and issubclass(response_type, Enum)) ) def _parse_dict_syntax(d: dict[str, Any]) -> ElicitConfig: """Parse dict syntax: {"low": {"title": "..."}} -> single-select titled.""" if not d: raise ValueError("Dict response_type cannot be empty.") enum_schema = _dict_to_enum_schema(d, multi_select=False) return ElicitConfig( schema={ "type": "object", "properties": {"value": enum_schema}, "required": ["value"], }, response_type=None, is_raw=True, ) def _parse_list_syntax(lst: list[Any]) -> ElicitConfig: """Parse list patterns: [[...]], [{...}], or [...].""" # [["a", "b", "c"]] -> multi-select untitled if ( len(lst) == 1 and isinstance(lst[0], list) and lst[0] and all(isinstance(item, str) for item in lst[0]) ): return ElicitConfig( schema={ "type": "object", "properties": {"value": {"type": "array", "items": {"enum": lst[0]}}}, "required": ["value"], }, response_type=None, is_raw=True, ) # [{"low": {"title": "..."}}] -> multi-select titled if len(lst) == 1 and isinstance(lst[0], dict) and lst[0]: enum_schema = _dict_to_enum_schema(lst[0], multi_select=True) return ElicitConfig( schema={ "type": "object", "properties": {"value": {"type": "array", "items": enum_schema}}, "required": ["value"], }, response_type=None, is_raw=True, ) # ["a", "b", "c"] -> single-select untitled if lst and all(isinstance(item, str) for item in lst): # Construct Literal type from tuple - use cast since we can't construct Literal dynamically # but we know the values are all strings choice_literal: type[Any] = cast(type[Any], Literal[tuple(lst)]) # type: ignore[valid-type] wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type] return ElicitConfig( schema=get_elicitation_schema(wrapped), response_type=wrapped, is_raw=False, ) raise ValueError(f"Invalid list response_type format. Received: {lst}") def _parse_generic_list(response_type: Any) -> ElicitConfig: """Parse list[X] type annotation -> multi-select.""" wrapped = ScalarElicitationType[response_type] return ElicitConfig( schema=get_elicitation_schema(wrapped), response_type=wrapped, is_raw=False, ) def _parse_scalar_type(response_type: Any) -> ElicitConfig: """Parse scalar types (bool, int, float, str, Literal, Enum).""" wrapped = ScalarElicitationType[response_type] return ElicitConfig( schema=get_elicitation_schema(wrapped), response_type=wrapped, is_raw=False, ) def handle_elicit_accept( config: ElicitConfig, content: Any ) -> AcceptedElicitation[Any]: """Handle an accepted elicitation response. Args: config: The elicitation configuration from parse_elicit_response_type content: The response content from the client Returns: AcceptedElicitation with the extracted/validated data """ # For raw schemas (dict/nested-list syntax), extract value directly if config.is_raw: if not isinstance(content, dict) or "value" not in content: raise ValueError("Elicitation response missing required 'value' field.") return AcceptedElicitation[Any](data=content["value"]) # For typed schemas, validate with Pydantic if config.response_type is not None: type_adapter = get_cached_typeadapter(config.response_type) validated_data = type_adapter.validate_python(content) if isinstance(validated_data, ScalarElicitationType): return AcceptedElicitation[Any](data=validated_data.value) return AcceptedElicitation[Any](data=validated_data) # For None response_type, expect empty response if content: raise ValueError( f"Elicitation expected an empty response, but received: {content}" ) return AcceptedElicitation[dict[str, Any]](data={}) def _dict_to_enum_schema( enum_dict: dict[str, dict[str, str]], multi_select: bool = False ) -> dict[str, Any]: """Convert dict enum to SEP-1330 compliant schema pattern. Args: enum_dict: {"low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}} multi_select: If True, use anyOf pattern; if False, use oneOf pattern Returns: {"type": "string", "oneOf": [...]} for single-select {"anyOf": [...]} for multi-select (used as array items) """ pattern_key = "anyOf" if multi_select else "oneOf" pattern = [] for value, metadata in enum_dict.items(): title = metadata.get("title", value) pattern.append({"const": value, "title": title}) result: dict[str, Any] = {pattern_key: pattern} if not multi_select: result["type"] = "string" return result def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]: """Get the schema for an elicitation response. Args: response_type: The type of the response """ # Use custom schema generator that inlines enums for MCP compatibility schema = get_cached_typeadapter(response_type).json_schema( schema_generator=ElicitationJsonSchema ) schema = compress_schema(schema) # Validate the schema to ensure it follows MCP elicitation requirements validate_elicitation_json_schema(schema) return schema def validate_elicitation_json_schema(schema: dict[str, Any]) -> None: """Validate that a JSON schema follows MCP elicitation requirements. This ensures the schema is compatible with MCP elicitation requirements: - Must be an object schema - Must only contain primitive field types (string, number, integer, boolean) - Must be flat (no nested objects or arrays of objects) - Allows const fields (for Literal types) and enum fields (for Enum types) - Only primitive types and their nullable variants are allowed Args: schema: The JSON schema to validate Raises: TypeError: If the schema doesn't meet MCP elicitation requirements """ ALLOWED_TYPES = {"string", "number", "integer", "boolean"} # Check that the schema is an object if schema.get("type") != "object": raise TypeError( f"Elicitation schema must be an object schema, got type '{schema.get('type')}'. " "Elicitation schemas are limited to flat objects with primitive properties only." ) properties = schema.get("properties", {}) for prop_name, prop_schema in properties.items(): prop_type = prop_schema.get("type") # Handle nullable types if isinstance(prop_type, list): if "null" in prop_type: prop_type = [t for t in prop_type if t != "null"] if len(prop_type) == 1: prop_type = prop_type[0] elif prop_schema.get("nullable", False): continue # Nullable with no other type is fine # Handle const fields (Literal types) if "const" in prop_schema: continue # const fields are allowed regardless of type # Handle enum fields (Enum types) if "enum" in prop_schema: continue # enum fields are allowed regardless of type # Handle references to definitions (like Enum types) if "$ref" in prop_schema: # Get the referenced definition ref_path = prop_schema["$ref"] if ref_path.startswith("#/$defs/"): def_name = ref_path[8:] # Remove "#/$defs/" prefix ref_def = schema.get("$defs", {}).get(def_name, {}) # If the referenced definition has an enum, it's allowed if "enum" in ref_def: continue # If the referenced definition has a type that's allowed, it's allowed ref_type = ref_def.get("type") if ref_type in ALLOWED_TYPES: continue # If we can't determine what the ref points to, reject it for safety raise TypeError( f"Elicitation schema field '{prop_name}' contains a reference '{ref_path}' " "that could not be validated. Only references to enum types or primitive types are allowed." ) # Handle union types (oneOf/anyOf) if "oneOf" in prop_schema or "anyOf" in prop_schema: union_schemas = prop_schema.get("oneOf", []) + prop_schema.get("anyOf", []) for union_schema in union_schemas: # Allow const and enum in unions if "const" in union_schema or "enum" in union_schema: continue union_type = union_schema.get("type") if union_type not in ALLOWED_TYPES: raise TypeError( f"Elicitation schema field '{prop_name}' has union type '{union_type}' which is not " f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." ) continue # Check for arrays before checking primitive types if prop_type == "array": items_schema = prop_schema.get("items", {}) if items_schema.get("type") == "object": raise TypeError( f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. " "Elicitation schemas must be flat objects with primitive properties only." ) # Allow arrays with enum patterns (for multi-select) if "enum" in items_schema: continue # Allowed: {"type": "array", "items": {"enum": [...]}} # Allow arrays with oneOf/anyOf const patterns (SEP-1330) if "oneOf" in items_schema or "anyOf" in items_schema: union_schemas = items_schema.get("oneOf", []) + items_schema.get( "anyOf", [] ) if union_schemas and all("const" in s for s in union_schemas): continue # Allowed: {"type": "array", "items": {"anyOf": [{"const": ...}, ...]}} # Reject other array types (e.g., arrays of primitives without enum pattern) raise TypeError( f"Elicitation schema field '{prop_name}' is an array, but arrays are only allowed " "when items are enums (for multi-select). Only enum arrays are supported in elicitation schemas." ) # Check for nested objects (not allowed) if prop_type == "object": raise TypeError( f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. " "Elicitation schemas must be flat objects with primitive properties only." ) # Check if it's a primitive type if prop_type not in ALLOWED_TYPES: raise TypeError( f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not " f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." ) ================================================ FILE: src/fastmcp/server/event_store.py ================================================ """EventStore implementation backed by AsyncKeyValue. This module provides an EventStore implementation that enables SSE polling/resumability for Streamable HTTP transports. Events are stored using the key_value package's AsyncKeyValue protocol, allowing users to configure any compatible backend (in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware. """ from __future__ import annotations from uuid import uuid4 from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore from mcp.server.streamable_http import EventCallback, EventId, EventMessage, StreamId from mcp.server.streamable_http import EventStore as SDKEventStore from mcp.types import JSONRPCMessage from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel logger = get_logger(__name__) class EventEntry(FastMCPBaseModel): """Stored event entry.""" event_id: str stream_id: str message: dict | None # JSONRPCMessage serialized to dict class StreamEventList(FastMCPBaseModel): """List of event IDs for a stream.""" event_ids: list[str] class EventStore(SDKEventStore): """EventStore implementation backed by AsyncKeyValue. Enables SSE polling/resumability by storing events that can be replayed when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware and OAuthProxy. Example: ```python from fastmcp import FastMCP from fastmcp.server.event_store import EventStore # Default in-memory storage event_store = EventStore() # Or with a custom backend from key_value.aio.stores.redis import RedisStore redis_backend = RedisStore(url="redis://localhost") event_store = EventStore(storage=redis_backend) mcp = FastMCP("MyServer") app = mcp.http_app(event_store=event_store, retry_interval=2000) ``` Args: storage: AsyncKeyValue backend. Defaults to MemoryStore. max_events_per_stream: Maximum events to retain per stream. Default 100. ttl: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration. """ def __init__( self, storage: AsyncKeyValue | None = None, max_events_per_stream: int = 100, ttl: int | None = 3600, ): self._storage: AsyncKeyValue = storage or MemoryStore() self._max_events_per_stream = max_events_per_stream self._ttl = ttl # PydanticAdapter for type-safe storage (following OAuth proxy pattern) self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry]( key_value=self._storage, pydantic_model=EventEntry, default_collection="fastmcp_events", ) self._stream_store: PydanticAdapter[StreamEventList] = PydanticAdapter[ StreamEventList ]( key_value=self._storage, pydantic_model=StreamEventList, default_collection="fastmcp_streams", ) async def store_event( self, stream_id: StreamId, message: JSONRPCMessage | None ) -> EventId: """Store an event and return its ID. Args: stream_id: ID of the stream the event belongs to message: The JSON-RPC message to store, or None for priming events Returns: The generated event ID for the stored event """ event_id = str(uuid4()) # Store the event entry entry = EventEntry( event_id=event_id, stream_id=stream_id, message=message.model_dump(mode="json") if message else None, ) await self._event_store.put(key=event_id, value=entry, ttl=self._ttl) # Update stream's event list stream_data = await self._stream_store.get(key=stream_id) event_ids = stream_data.event_ids if stream_data else [] event_ids.append(event_id) # Trim to max events (delete old events) if len(event_ids) > self._max_events_per_stream: for old_id in event_ids[: -self._max_events_per_stream]: await self._event_store.delete(key=old_id) event_ids = event_ids[-self._max_events_per_stream :] await self._stream_store.put( key=stream_id, value=StreamEventList(event_ids=event_ids), ttl=self._ttl, ) return event_id async def replay_events_after( self, last_event_id: EventId, send_callback: EventCallback, ) -> StreamId | None: """Replay events that occurred after the specified event ID. Args: last_event_id: The ID of the last event the client received send_callback: A callback function to send events to the client Returns: The stream ID of the replayed events, or None if the event ID was not found """ # Look up the event to find its stream entry = await self._event_store.get(key=last_event_id) if not entry: logger.warning(f"Event ID {last_event_id} not found in store") return None stream_id = entry.stream_id stream_data = await self._stream_store.get(key=stream_id) if not stream_data: logger.warning(f"Stream {stream_id} not found in store") return None event_ids = stream_data.event_ids # Find events after last_event_id try: start_idx = event_ids.index(last_event_id) + 1 except ValueError: logger.warning(f"Event ID {last_event_id} not found in stream {stream_id}") return None # Replay events after the last one for event_id in event_ids[start_idx:]: event = await self._event_store.get(key=event_id) if event and event.message: msg = JSONRPCMessage.model_validate(event.message) await send_callback(EventMessage(msg, event.event_id)) return stream_id ================================================ FILE: src/fastmcp/server/http.py ================================================ from __future__ import annotations from collections.abc import AsyncGenerator, Callable, Generator from contextlib import asynccontextmanager, contextmanager from contextvars import ContextVar from typing import TYPE_CHECKING from mcp.server.auth.routes import build_resource_metadata_url from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport from mcp.server.streamable_http import ( EventStore, ) from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Mount, Route from starlette.types import Lifespan, Receive, Scope, Send from fastmcp.server.auth import AuthProvider from fastmcp.server.auth.middleware import RequireAuthMiddleware from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.server.server import FastMCP logger = get_logger(__name__) class StreamableHTTPASGIApp: """ASGI application wrapper for Streamable HTTP server transport.""" def __init__(self, session_manager): self.session_manager = session_manager async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: try: await self.session_manager.handle_request(scope, receive, send) except RuntimeError as e: if str(e) == "Task group is not initialized. Make sure to use run().": logger.error( f"Original RuntimeError from mcp library: {e}", exc_info=True ) new_error_message = ( "FastMCP's StreamableHTTPSessionManager task group was not initialized. " "This commonly occurs when the FastMCP application's lifespan is not " "passed to the parent ASGI application (e.g., FastAPI or Starlette). " "Please ensure you are setting `lifespan=mcp_app.lifespan` in your " "parent app's constructor, where `mcp_app` is the application instance " "returned by `fastmcp_instance.http_app()`. \\n" "For more details, see the FastMCP ASGI integration documentation: " "https://gofastmcp.com/deployment/asgi" ) # Raise a new RuntimeError that includes the original error's message # for full context, but leads with the more helpful guidance. raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e else: # Re-raise other RuntimeErrors if they don't match the specific message raise _current_http_request: ContextVar[Request | None] = ContextVar( "http_request", default=None, ) class StarletteWithLifespan(Starlette): @property def lifespan(self) -> Lifespan[Starlette]: return self.router.lifespan_context @contextmanager def set_http_request(request: Request) -> Generator[Request, None, None]: token = _current_http_request.set(request) try: yield request finally: _current_http_request.reset(token) class RequestContextMiddleware: """ Middleware that stores each request in a ContextVar and sets transport type. """ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] == "http": from fastmcp.server.context import reset_transport, set_transport # Get transport type from app state (set during app creation) transport_type = getattr(scope["app"].state, "transport_type", None) transport_token = set_transport(transport_type) if transport_type else None try: with set_http_request(Request(scope)): await self.app(scope, receive, send) finally: if transport_token is not None: reset_transport(transport_token) else: await self.app(scope, receive, send) def create_base_app( routes: list[BaseRoute], middleware: list[Middleware], debug: bool = False, lifespan: Callable | None = None, ) -> StarletteWithLifespan: """Create a base Starlette app with common middleware and routes. Args: routes: List of routes to include in the app middleware: List of middleware to include in the app debug: Whether to enable debug mode lifespan: Optional lifespan manager for the app Returns: A Starlette application """ # Always add RequestContextMiddleware as the outermost middleware # TODO(ty): remove type ignore when ty supports Starlette Middleware typing middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type] return StarletteWithLifespan( routes=routes, middleware=middleware, debug=debug, lifespan=lifespan, ) def create_sse_app( server: FastMCP[LifespanResultT], message_path: str, sse_path: str, auth: AuthProvider | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, ) -> StarletteWithLifespan: """Return an instance of the SSE server app. Args: server: The FastMCP server instance message_path: Path for SSE messages sse_path: Path for SSE connections auth: Optional authentication provider (AuthProvider) debug: Whether to enable debug mode routes: Optional list of custom routes middleware: Optional list of middleware Returns: A Starlette application with RequestContextMiddleware """ server_routes: list[BaseRoute] = [] server_middleware: list[Middleware] = [] # Set up SSE transport sse = SseServerTransport(message_path) # Create handler for SSE connections async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response: async with sse.connect_sse(scope, receive, send) as streams: await server._mcp_server.run( streams[0], streams[1], server._mcp_server.create_initialization_options(), ) return Response() # Set up auth if enabled if auth: # Get auth middleware from the provider auth_middleware = auth.get_middleware() # Get auth provider's own routes (OAuth endpoints, metadata, etc) auth_routes = auth.get_routes(mcp_path=sse_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) # Build RFC 9728-compliant metadata URL resource_url = auth._get_resource_url(sse_path) resource_metadata_url = ( build_resource_metadata_url(resource_url) if resource_url else None ) # Create protected SSE endpoint route server_routes.append( Route( sse_path, endpoint=RequireAuthMiddleware( handle_sse, auth.required_scopes, resource_metadata_url, ), methods=["GET"], ) ) # Wrap the SSE message endpoint with RequireAuthMiddleware server_routes.append( Mount( message_path, app=RequireAuthMiddleware( sse.handle_post_message, auth.required_scopes, resource_metadata_url, ), ) ) else: # No auth required async def sse_endpoint(request: Request) -> Response: return await handle_sse(request.scope, request.receive, request._send) server_routes.append( Route( sse_path, endpoint=sse_endpoint, methods=["GET"], ) ) server_routes.append( Mount( message_path, app=sse.handle_post_message, ) ) # Add custom routes with lowest precedence if routes: server_routes.extend(routes) server_routes.extend(server._get_additional_http_routes()) # Add middleware if middleware: server_middleware.extend(middleware) @asynccontextmanager async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: async with server._lifespan_manager(): yield # Create and return the app app = create_base_app( routes=server_routes, middleware=server_middleware, debug=debug, lifespan=lifespan, ) # Store the FastMCP server instance on the Starlette app state app.state.fastmcp_server = server app.state.path = sse_path app.state.transport_type = "sse" return app def create_streamable_http_app( server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: EventStore | None = None, retry_interval: int | None = None, auth: AuthProvider | None = None, json_response: bool = False, stateless_http: bool = False, debug: bool = False, routes: list[BaseRoute] | None = None, middleware: list[Middleware] | None = None, ) -> StarletteWithLifespan: """Return an instance of the StreamableHTTP server app. Args: server: The FastMCP server instance streamable_http_path: Path for StreamableHTTP connections event_store: Optional event store for SSE polling/resumability retry_interval: Optional retry interval in milliseconds for SSE polling. Controls how quickly clients should reconnect after server-initiated disconnections. Requires event_store to be set. Defaults to SDK default. auth: Optional authentication provider (AuthProvider) json_response: Whether to use JSON response format stateless_http: Whether to use stateless mode (new transport per request) debug: Whether to enable debug mode routes: Optional list of custom routes middleware: Optional list of middleware Returns: A Starlette application with StreamableHTTP support """ server_routes: list[BaseRoute] = [] server_middleware: list[Middleware] = [] # Create session manager using the provided event store session_manager = StreamableHTTPSessionManager( app=server._mcp_server, event_store=event_store, retry_interval=retry_interval, json_response=json_response, stateless=stateless_http, ) # Create the ASGI app wrapper streamable_http_app = StreamableHTTPASGIApp(session_manager) # Add StreamableHTTP routes with or without auth if auth: # Get auth middleware from the provider auth_middleware = auth.get_middleware() # Get auth provider's own routes (OAuth endpoints, metadata, etc) auth_routes = auth.get_routes(mcp_path=streamable_http_path) server_routes.extend(auth_routes) server_middleware.extend(auth_middleware) # Build RFC 9728-compliant metadata URL resource_url = auth._get_resource_url(streamable_http_path) resource_metadata_url = ( build_resource_metadata_url(resource_url) if resource_url else None ) # Create protected HTTP endpoint route # Stateless servers have no session tracking, so GET SSE streams # (for server-initiated notifications) serve no purpose. http_methods = ( ["POST", "DELETE"] if stateless_http else ["GET", "POST", "DELETE"] ) server_routes.append( Route( streamable_http_path, endpoint=RequireAuthMiddleware( streamable_http_app, auth.required_scopes, resource_metadata_url, ), methods=http_methods, ) ) else: # No auth required http_methods = ["POST", "DELETE"] if stateless_http else None server_routes.append( Route( streamable_http_path, endpoint=streamable_http_app, methods=http_methods, ) ) # Add custom routes with lowest precedence if routes: server_routes.extend(routes) server_routes.extend(server._get_additional_http_routes()) # Add middleware if middleware: server_middleware.extend(middleware) # Create a lifespan manager to start and stop the session manager @asynccontextmanager async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: async with server._lifespan_manager(), session_manager.run(): yield # Create and return the app with lifespan app = create_base_app( routes=server_routes, middleware=server_middleware, debug=debug, lifespan=lifespan, ) # Store the FastMCP server instance on the Starlette app state app.state.fastmcp_server = server app.state.path = streamable_http_path app.state.transport_type = "streamable-http" return app ================================================ FILE: src/fastmcp/server/lifespan.py ================================================ """Composable lifespans for FastMCP servers. This module provides a `@lifespan` decorator for creating composable server lifespans that can be combined using the `|` operator. Example: ```python from fastmcp import FastMCP from fastmcp.server.lifespan import lifespan @lifespan async def db_lifespan(server): conn = await connect_db() yield {"db": conn} await conn.close() @lifespan async def cache_lifespan(server): cache = await connect_cache() yield {"cache": cache} await cache.close() mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) ``` To compose with existing `@asynccontextmanager` lifespans, wrap them explicitly: ```python from contextlib import asynccontextmanager from fastmcp.server.lifespan import lifespan, ContextManagerLifespan @asynccontextmanager async def legacy_lifespan(server): yield {"legacy": True} @lifespan async def new_lifespan(server): yield {"new": True} # Wrap the legacy lifespan explicitly combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan ``` """ from __future__ import annotations from collections.abc import AsyncIterator, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from fastmcp.server.server import FastMCP LifespanFn = Callable[["FastMCP[Any]"], AsyncIterator[dict[str, Any] | None]] LifespanContextManagerFn = Callable[ ["FastMCP[Any]"], AbstractAsyncContextManager[dict[str, Any] | None] ] class Lifespan: """Composable lifespan wrapper. Wraps an async generator function and enables composition via the `|` operator. The wrapped function should yield a dict that becomes part of the lifespan context. """ def __init__(self, fn: LifespanFn) -> None: """Initialize a Lifespan wrapper. Args: fn: An async generator function that takes a FastMCP server and yields a dict for the lifespan context. """ self._fn = fn @asynccontextmanager async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: """Execute the lifespan as an async context manager. Args: server: The FastMCP server instance. Yields: The lifespan context dict. """ async with asynccontextmanager(self._fn)(server) as result: yield result if result is not None else {} def __or__(self, other: Lifespan) -> ComposedLifespan: """Compose with another lifespan using the | operator. Args: other: Another Lifespan instance. Returns: A ComposedLifespan that runs both lifespans. Raises: TypeError: If other is not a Lifespan instance. """ if not isinstance(other, Lifespan): raise TypeError( f"Cannot compose Lifespan with {type(other).__name__}. " f"Use @lifespan decorator or wrap with ContextManagerLifespan()." ) return ComposedLifespan(self, other) class ContextManagerLifespan(Lifespan): """Lifespan wrapper for already-wrapped context manager functions. Use this for functions already decorated with @asynccontextmanager. """ _fn: LifespanContextManagerFn # Override type for this subclass def __init__(self, fn: LifespanContextManagerFn) -> None: """Initialize with a context manager factory function.""" self._fn = fn @asynccontextmanager async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: """Execute the lifespan as an async context manager. Args: server: The FastMCP server instance. Yields: The lifespan context dict. """ # self._fn is already a context manager factory, just call it async with self._fn(server) as result: yield result if result is not None else {} class ComposedLifespan(Lifespan): """Two lifespans composed together. Enters the left lifespan first, then the right. Exits in reverse order. Results are shallow-merged into a single dict. """ def __init__(self, left: Lifespan, right: Lifespan) -> None: """Initialize a composed lifespan. Args: left: The first lifespan to enter. right: The second lifespan to enter. """ # Don't call super().__init__ since we override __call__ self._left = left self._right = right @asynccontextmanager async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: """Execute both lifespans, merging their results. Args: server: The FastMCP server instance. Yields: The merged lifespan context dict from both lifespans. """ async with ( self._left(server) as left_result, self._right(server) as right_result, ): yield {**left_result, **right_result} def lifespan(fn: LifespanFn) -> Lifespan: """Decorator to create a composable lifespan. Use this decorator on an async generator function to make it composable with other lifespans using the `|` operator. Example: ```python @lifespan async def my_lifespan(server): # Setup resource = await create_resource() yield {"resource": resource} # Teardown await resource.close() mcp = FastMCP("server", lifespan=my_lifespan | other_lifespan) ``` Args: fn: An async generator function that takes a FastMCP server and yields a dict for the lifespan context. Returns: A composable Lifespan wrapper. """ return Lifespan(fn) ================================================ FILE: src/fastmcp/server/low_level.py ================================================ from __future__ import annotations import weakref from collections.abc import Awaitable, Callable from contextlib import AsyncExitStack from typing import TYPE_CHECKING, Any import anyio import mcp.types from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import LoggingLevel, McpError from mcp.server.lowlevel.server import ( LifespanResultT, NotificationOptions, RequestT, ) from mcp.server.lowlevel.server import ( Server as _Server, ) from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.server.stdio import stdio_server as stdio_server from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from pydantic import AnyUrl from fastmcp.server.apps import UI_EXTENSION_ID from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.server.server import FastMCP logger = get_logger(__name__) class MiddlewareServerSession(ServerSession): """ServerSession that routes initialization requests through FastMCP middleware.""" def __init__(self, fastmcp: FastMCP, *args, **kwargs): super().__init__(*args, **kwargs) self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) # Task group for subscription tasks (set during session run) self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] # Minimum logging level requested by the client via logging/setLevel self._minimum_logging_level: LoggingLevel | None = None @property def fastmcp(self) -> FastMCP: """Get the FastMCP instance.""" fastmcp = self._fastmcp_ref() if fastmcp is None: raise RuntimeError("FastMCP instance is no longer available") return fastmcp def client_supports_extension(self, extension_id: str) -> bool: """Check if the connected client supports a given MCP extension. Inspects the ``extensions`` extra field on ``ClientCapabilities`` sent by the client during initialization. """ client_params = self._client_params if client_params is None: return False caps = client_params.capabilities if caps is None: return False # ClientCapabilities uses extra="allow" — extensions is an extra field extras = caps.model_extra or {} extensions: dict[str, Any] | None = extras.get("extensions") if not extensions: return False return extension_id in extensions async def _received_request( self, responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult], ): """ Override the _received_request method to route special requests through FastMCP middleware. Handles initialization requests and SEP-1686 task methods. """ import fastmcp.server.context from fastmcp.server.middleware.middleware import MiddlewareContext if isinstance(responder.request.root, mcp.types.InitializeRequest): # The MCP SDK's ServerSession._received_request() handles the # initialize request internally by calling responder.respond() # to send the InitializeResult directly to the write stream, then # returning None. This bypasses the middleware return path entirely, # so middleware would only see the request, never the response. # # To expose the response to middleware (e.g., for logging server # capabilities), we wrap responder.respond() to capture the # InitializeResult before it's sent, then return it from # call_original_handler so it flows back through the middleware chain. captured_response: mcp.types.ServerResult | None = None original_respond = responder.respond async def capturing_respond( response: mcp.types.ServerResult, ) -> None: nonlocal captured_response captured_response = response return await original_respond(response) responder.respond = capturing_respond # type: ignore[method-assign] async def call_original_handler( ctx: MiddlewareContext, ) -> mcp.types.InitializeResult | None: await super(MiddlewareServerSession, self)._received_request(responder) if captured_response is not None and isinstance( captured_response.root, mcp.types.InitializeResult ): return captured_response.root return None async with fastmcp.server.context.Context( fastmcp=self.fastmcp, session=self ) as fastmcp_ctx: # Create the middleware context. mw_context = MiddlewareContext( message=responder.request.root, source="client", type="request", method="initialize", fastmcp_context=fastmcp_ctx, ) try: return await self.fastmcp._run_middleware( mw_context, call_original_handler ) except McpError as e: # McpError can be thrown from middleware in `on_initialize` # send the error to responder. if not responder._completed: with responder: await responder.respond(e.error) else: # Don't re-raise: prevents responding to initialize request twice logger.warning( "Received McpError but responder is already completed. " "Cannot send error response as response was already sent.", exc_info=e, ) return None # Fall through to default handling (task methods now handled via registered handlers) return await super()._received_request(responder) class LowLevelServer(_Server[LifespanResultT, RequestT]): def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) # Store a weak reference to FastMCP to avoid circular references self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) # FastMCP servers support notifications for all components self.notification_options = NotificationOptions( prompts_changed=True, resources_changed=True, tools_changed=True, ) @property def fastmcp(self) -> FastMCP: """Get the FastMCP instance.""" fastmcp = self._fastmcp_ref() if fastmcp is None: raise RuntimeError("FastMCP instance is no longer available") return fastmcp def create_initialization_options( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, **kwargs: Any, ) -> InitializationOptions: # ensure we use the FastMCP notification options if notification_options is None: notification_options = self.notification_options return super().create_initialization_options( notification_options=notification_options, experimental_capabilities=experimental_capabilities, **kwargs, ) def get_capabilities( self, notification_options: NotificationOptions, experimental_capabilities: dict[str, dict[str, Any]], ) -> mcp.types.ServerCapabilities: """Override to set capabilities.tasks as a first-class field per SEP-1686. This ensures task capabilities appear in capabilities.tasks instead of capabilities.experimental.tasks, which is required by the MCP spec and enables proper task detection by clients like VS Code Copilot 1.107+. """ from fastmcp.server.tasks.capabilities import get_task_capabilities # Get base capabilities from SDK (pass empty dict for experimental) # since we'll set tasks as a first-class field instead capabilities = super().get_capabilities( notification_options, experimental_capabilities or {}, ) # Set tasks as a first-class field (not experimental) per SEP-1686 capabilities.tasks = get_task_capabilities() # Advertise MCP Apps extension support (io.modelcontextprotocol/ui) # Uses the same extra-field pattern as tasks above — ServerCapabilities # has extra="allow" so this survives serialization. # Merge with any existing extensions to avoid clobbering other features. existing_extensions: dict[str, Any] = ( getattr(capabilities, "extensions", None) or {} ) capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}} return capabilities async def run( self, read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], write_stream: MemoryObjectSendStream[SessionMessage], initialization_options: InitializationOptions, raise_exceptions: bool = False, stateless: bool = False, ): """ Overrides the run method to use the MiddlewareServerSession. """ async with AsyncExitStack() as stack: lifespan_context = await stack.enter_async_context(self.lifespan(self)) session = await stack.enter_async_context( MiddlewareServerSession( self.fastmcp, read_stream, write_stream, initialization_options, stateless=stateless, ) ) async with anyio.create_task_group() as tg: # Store task group on session for subscription tasks (SEP-1686) session._subscription_task_group = tg async for message in session.incoming_messages: tg.start_soon( self._handle_message, message, session, lifespan_context, raise_exceptions, ) def read_resource( self, ) -> Callable[ [ Callable[ [AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ] ], Callable[ [AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ], ]: """ Decorator for registering a read_resource handler with CreateTaskResult support. The MCP SDK's read_resource decorator does not support returning CreateTaskResult for background task execution. This decorator wraps the result in ServerResult. This decorator can be removed once the MCP SDK adds native CreateTaskResult support for resources. """ def decorator( func: Callable[ [AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ], ) -> Callable[ [AnyUrl], Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], ]: async def handler( req: mcp.types.ReadResourceRequest, ) -> mcp.types.ServerResult: result = await func(req.params.uri) return mcp.types.ServerResult(result) self.request_handlers[mcp.types.ReadResourceRequest] = handler return func return decorator def get_prompt( self, ) -> Callable[ [ Callable[ [str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], ] ], Callable[ [str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], ], ]: """ Decorator for registering a get_prompt handler with CreateTaskResult support. The MCP SDK's get_prompt decorator does not support returning CreateTaskResult for background task execution. This decorator wraps the result in ServerResult. This decorator can be removed once the MCP SDK adds native CreateTaskResult support for prompts. """ def decorator( func: Callable[ [str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], ], ) -> Callable[ [str, dict[str, Any] | None], Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], ]: async def handler( req: mcp.types.GetPromptRequest, ) -> mcp.types.ServerResult: result = await func(req.params.name, req.params.arguments) return mcp.types.ServerResult(result) self.request_handlers[mcp.types.GetPromptRequest] = handler return func return decorator ================================================ FILE: src/fastmcp/server/middleware/__init__.py ================================================ from .authorization import AuthMiddleware from .middleware import ( CallNext, Middleware, MiddlewareContext, ) from .ping import PingMiddleware __all__ = [ "AuthMiddleware", "CallNext", "Middleware", "MiddlewareContext", "PingMiddleware", ] ================================================ FILE: src/fastmcp/server/middleware/authorization.py ================================================ """Authorization middleware for FastMCP. This module provides middleware-based authorization using callable auth checks. AuthMiddleware applies auth checks globally to all components on the server. Example: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes, restrict_tag from fastmcp.server.middleware import AuthMiddleware # Require specific scope for all components mcp = FastMCP(middleware=[ AuthMiddleware(auth=require_scopes("api")) ]) # Tag-based: components tagged "admin" require "admin" scope mcp = FastMCP(middleware=[ AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) ]) ``` """ from __future__ import annotations import logging from collections.abc import Sequence import mcp.types as mt from fastmcp.exceptions import AuthorizationError from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth.authorization import ( AuthCheck, AuthContext, run_auth_checks, ) from fastmcp.server.dependencies import get_access_token from fastmcp.server.middleware.middleware import ( CallNext, Middleware, MiddlewareContext, ) from fastmcp.tools.base import Tool, ToolResult logger = logging.getLogger(__name__) class AuthMiddleware(Middleware): """Global authorization middleware using callable checks. This middleware applies auth checks to all components (tools, resources, prompts) on the server. It uses the same callable API as component-level auth checks. The middleware: - Filters tools/resources/prompts from list responses based on auth checks - Checks auth before tool execution, resource read, and prompt render - Skips all auth checks for STDIO transport (no OAuth concept) Args: auth: A single auth check function or list of check functions. All checks must pass for authorization to succeed (AND logic). Example: ```python from fastmcp import FastMCP from fastmcp.server.auth import require_scopes # Require specific scope for all components mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) # Multiple scopes (AND logic) mcp = FastMCP(middleware=[ AuthMiddleware(auth=require_scopes("read", "api")) ]) ``` """ def __init__(self, auth: AuthCheck | list[AuthCheck]) -> None: self.auth = auth async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], ) -> Sequence[Tool]: """Filter tools/list response based on auth checks.""" tools = await call_next(context) # STDIO has no auth concept, skip filtering # Late import to avoid circular import with context.py from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return tools token = get_access_token() authorized_tools: list[Tool] = [] for tool in tools: ctx = AuthContext(token=token, component=tool) try: if await run_auth_checks(self.auth, ctx): authorized_tools.append(tool) except AuthorizationError: continue return authorized_tools async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult], ) -> ToolResult: """Check auth before tool execution.""" # STDIO has no auth concept, skip enforcement # Late import to avoid circular import with context.py from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return await call_next(context) # Get the tool being called tool_name = context.message.name fastmcp = context.fastmcp_context if fastmcp is None: # Fail closed: deny access when context is missing logger.warning( f"AuthMiddleware: fastmcp_context is None for tool '{tool_name}'. " "Denying access for security." ) raise AuthorizationError( f"Authorization failed for tool '{tool_name}': missing context" ) # Get tool (component auth is checked in get_tool, raises if unauthorized) tool = await fastmcp.fastmcp.get_tool(tool_name) if tool is None: raise AuthorizationError( f"Authorization failed for tool '{tool_name}': tool not found" ) # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=tool) if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for tool '{tool_name}': insufficient permissions" ) return await call_next(context) async def on_list_resources( self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], ) -> Sequence[Resource]: """Filter resources/list response based on auth checks.""" resources = await call_next(context) # STDIO has no auth concept, skip filtering from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return resources token = get_access_token() authorized_resources: list[Resource] = [] for resource in resources: ctx = AuthContext(token=token, component=resource) try: if await run_auth_checks(self.auth, ctx): authorized_resources.append(resource) except AuthorizationError: continue return authorized_resources async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], ) -> ResourceResult: """Check auth before resource read.""" # STDIO has no auth concept, skip enforcement from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return await call_next(context) # Get the resource being read uri = context.message.uri fastmcp = context.fastmcp_context if fastmcp is None: logger.warning( f"AuthMiddleware: fastmcp_context is None for resource '{uri}'. " "Denying access for security." ) raise AuthorizationError( f"Authorization failed for resource '{uri}': missing context" ) # Get resource/template (component auth is checked in get_*, raises if unauthorized) component = await fastmcp.fastmcp.get_resource(str(uri)) if component is None: component = await fastmcp.fastmcp.get_resource_template(str(uri)) if component is None: raise AuthorizationError( f"Authorization failed for resource '{uri}': resource not found" ) # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=component) if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for resource '{uri}': insufficient permissions" ) return await call_next(context) async def on_list_resource_templates( self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[ mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] ], ) -> Sequence[ResourceTemplate]: """Filter resource templates/list response based on auth checks.""" templates = await call_next(context) # STDIO has no auth concept, skip filtering from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return templates token = get_access_token() authorized_templates: list[ResourceTemplate] = [] for template in templates: ctx = AuthContext(token=token, component=template) try: if await run_auth_checks(self.auth, ctx): authorized_templates.append(template) except AuthorizationError: continue return authorized_templates async def on_list_prompts( self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]], ) -> Sequence[Prompt]: """Filter prompts/list response based on auth checks.""" prompts = await call_next(context) # STDIO has no auth concept, skip filtering from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return prompts token = get_access_token() authorized_prompts: list[Prompt] = [] for prompt in prompts: ctx = AuthContext(token=token, component=prompt) try: if await run_auth_checks(self.auth, ctx): authorized_prompts.append(prompt) except AuthorizationError: continue return authorized_prompts async def on_get_prompt( self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult], ) -> PromptResult: """Check auth before prompt render.""" # STDIO has no auth concept, skip enforcement from fastmcp.server.context import _current_transport if _current_transport.get() == "stdio": return await call_next(context) # Get the prompt being rendered prompt_name = context.message.name fastmcp = context.fastmcp_context if fastmcp is None: logger.warning( f"AuthMiddleware: fastmcp_context is None for prompt '{prompt_name}'. " "Denying access for security." ) raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': missing context" ) # Get prompt (component auth is checked in get_prompt, raises if unauthorized) prompt = await fastmcp.fastmcp.get_prompt(prompt_name) if prompt is None: raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': prompt not found" ) # Global auth check token = get_access_token() ctx = AuthContext(token=token, component=prompt) if not await run_auth_checks(self.auth, ctx): raise AuthorizationError( f"Authorization failed for prompt '{prompt_name}': insufficient permissions" ) return await call_next(context) ================================================ FILE: src/fastmcp/server/middleware/caching.py ================================================ """A middleware for response caching.""" import hashlib from collections.abc import Sequence from logging import Logger from typing import Any, TypedDict import mcp.types import pydantic_core from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols.key_value import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore from key_value.aio.wrappers.limit_size import LimitSizeWrapper from key_value.aio.wrappers.statistics import StatisticsWrapper from key_value.aio.wrappers.statistics.wrapper import ( KVStoreCollectionStatistics, ) from pydantic import Field from typing_extensions import NotRequired, Self, override from fastmcp.prompts.base import Message, Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceContent, ResourceResult from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel logger: Logger = get_logger(name=__name__) # Constants ONE_HOUR_IN_SECONDS = 3600 FIVE_MINUTES_IN_SECONDS = 300 ONE_MB_IN_BYTES = 1024 * 1024 GLOBAL_KEY = "__global__" class CachableResourceContent(FastMCPBaseModel): """A wrapper for ResourceContent that can be cached.""" content: str | bytes mime_type: str | None = None meta: dict[str, Any] | None = None class CachableResourceResult(FastMCPBaseModel): """A wrapper for ResourceResult that can be cached.""" contents: list[CachableResourceContent] meta: dict[str, Any] | None = None def get_size(self) -> int: return len(self.model_dump_json()) @classmethod def wrap(cls, value: ResourceResult) -> Self: return cls( contents=[ CachableResourceContent( content=item.content, mime_type=item.mime_type, meta=item.meta ) for item in value.contents ], meta=value.meta, ) def unwrap(self) -> ResourceResult: return ResourceResult( contents=[ ResourceContent( content=item.content, mime_type=item.mime_type, meta=item.meta ) for item in self.contents ], meta=self.meta, ) class CachableToolResult(FastMCPBaseModel): content: list[mcp.types.ContentBlock] structured_content: dict[str, Any] | None meta: dict[str, Any] | None @classmethod def wrap(cls, value: ToolResult) -> Self: return cls( content=value.content, structured_content=value.structured_content, meta=value.meta, ) def unwrap(self) -> ToolResult: return ToolResult( content=self.content, structured_content=self.structured_content, meta=self.meta, ) class CachableMessage(FastMCPBaseModel): """A wrapper for Message that can be cached.""" role: str content: ( mcp.types.TextContent | mcp.types.ImageContent | mcp.types.AudioContent | mcp.types.EmbeddedResource ) class CachablePromptResult(FastMCPBaseModel): """A wrapper for PromptResult that can be cached.""" messages: list[CachableMessage] description: str | None = None meta: dict[str, Any] | None = None def get_size(self) -> int: return len(self.model_dump_json()) @classmethod def wrap(cls, value: PromptResult) -> Self: return cls( messages=[ CachableMessage(role=m.role, content=m.content) for m in value.messages ], description=value.description, meta=value.meta, ) def unwrap(self) -> PromptResult: return PromptResult( messages=[ Message(content=m.content, role=m.role) # type: ignore[arg-type] for m in self.messages ], description=self.description, meta=self.meta, ) class SharedMethodSettings(TypedDict): """Shared config for a cache method.""" ttl: NotRequired[int] enabled: NotRequired[bool] class ListToolsSettings(SharedMethodSettings): """Configuration options for Tool-related caching.""" class ListResourcesSettings(SharedMethodSettings): """Configuration options for Resource-related caching.""" class ListPromptsSettings(SharedMethodSettings): """Configuration options for Prompt-related caching.""" class CallToolSettings(SharedMethodSettings): """Configuration options for Tool-related caching.""" included_tools: NotRequired[list[str]] excluded_tools: NotRequired[list[str]] class ReadResourceSettings(SharedMethodSettings): """Configuration options for Resource-related caching.""" class GetPromptSettings(SharedMethodSettings): """Configuration options for Prompt-related caching.""" class ResponseCachingStatistics(FastMCPBaseModel): list_tools: KVStoreCollectionStatistics | None = Field(default=None) list_resources: KVStoreCollectionStatistics | None = Field(default=None) list_prompts: KVStoreCollectionStatistics | None = Field(default=None) read_resource: KVStoreCollectionStatistics | None = Field(default=None) get_prompt: KVStoreCollectionStatistics | None = Field(default=None) call_tool: KVStoreCollectionStatistics | None = Field(default=None) class ResponseCachingMiddleware(Middleware): """The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching but cache implementations may offer additional features like LRU eviction, size limits, and more. When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects this means that response caching may not be compatible with other middleware that expects original subclasses. Notes: - Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests. - Cache keys are derived from method name and arguments. """ def __init__( self, cache_storage: AsyncKeyValue | None = None, list_tools_settings: ListToolsSettings | None = None, list_resources_settings: ListResourcesSettings | None = None, list_prompts_settings: ListPromptsSettings | None = None, read_resource_settings: ReadResourceSettings | None = None, get_prompt_settings: GetPromptSettings | None = None, call_tool_settings: CallToolSettings | None = None, max_item_size: int = ONE_MB_IN_BYTES, ): """Initialize the response caching middleware. Args: cache_storage: The cache backend to use. If None, an in-memory cache is used. list_tools_settings: The settings for the list tools method. If None, the default settings are used (5 minute TTL). list_resources_settings: The settings for the list resources method. If None, the default settings are used (5 minute TTL). list_prompts_settings: The settings for the list prompts method. If None, the default settings are used (5 minute TTL). read_resource_settings: The settings for the read resource method. If None, the default settings are used (1 hour TTL). get_prompt_settings: The settings for the get prompt method. If None, the default settings are used (1 hour TTL). call_tool_settings: The settings for the call tool method. If None, the default settings are used (1 hour TTL). max_item_size: The maximum size of items eligible for caching. Defaults to 1MB. """ self._backend: AsyncKeyValue = cache_storage or MemoryStore() # When the size limit is exceeded, the put will silently fail self._size_limiter: LimitSizeWrapper = LimitSizeWrapper( key_value=self._backend, max_size=max_item_size, raise_on_too_large=False ) self._stats: StatisticsWrapper = StatisticsWrapper(key_value=self._size_limiter) self._list_tools_settings: ListToolsSettings = ( list_tools_settings or ListToolsSettings() ) self._list_resources_settings: ListResourcesSettings = ( list_resources_settings or ListResourcesSettings() ) self._list_prompts_settings: ListPromptsSettings = ( list_prompts_settings or ListPromptsSettings() ) self._read_resource_settings: ReadResourceSettings = ( read_resource_settings or ReadResourceSettings() ) self._get_prompt_settings: GetPromptSettings = ( get_prompt_settings or GetPromptSettings() ) self._call_tool_settings: CallToolSettings = ( call_tool_settings or CallToolSettings() ) self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter( key_value=self._stats, pydantic_model=list[Tool], default_collection="tools/list", ) self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter( key_value=self._stats, pydantic_model=list[Resource], default_collection="resources/list", ) self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter( key_value=self._stats, pydantic_model=list[Prompt], default_collection="prompts/list", ) self._read_resource_cache: PydanticAdapter[CachableResourceResult] = ( PydanticAdapter( key_value=self._stats, pydantic_model=CachableResourceResult, default_collection="resources/read", ) ) self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( key_value=self._stats, pydantic_model=CachablePromptResult, default_collection="prompts/get", ) self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( key_value=self._stats, pydantic_model=CachableToolResult, default_collection="tools/call", ) @override async def on_list_tools( self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]], ) -> Sequence[Tool]: """List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._list_tools_settings.get("enabled") is False: return await call_next(context) if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY): return cached_value tools: Sequence[Tool] = await call_next(context=context) # Turn any subclass of Tool into a Tool cachable_tools: list[Tool] = [ Tool( name=tool.name, title=tool.title, description=tool.description, parameters=tool.parameters, output_schema=tool.output_schema, annotations=tool.annotations, meta=tool.meta, tags=tool.tags, ) for tool in tools ] await self._list_tools_cache.put( key=GLOBAL_KEY, value=cachable_tools, ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_tools @override async def on_list_resources( self, context: MiddlewareContext[mcp.types.ListResourcesRequest], call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]], ) -> Sequence[Resource]: """List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._list_resources_settings.get("enabled") is False: return await call_next(context) if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY): return cached_value resources: Sequence[Resource] = await call_next(context=context) # Turn any subclass of Resource into a Resource cachable_resources: list[Resource] = [ Resource( name=resource.name, title=resource.title, description=resource.description, tags=resource.tags, meta=resource.meta, mime_type=resource.mime_type, annotations=resource.annotations, uri=resource.uri, ) for resource in resources ] await self._list_resources_cache.put( key=GLOBAL_KEY, value=cachable_resources, ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_resources @override async def on_list_prompts( self, context: MiddlewareContext[mcp.types.ListPromptsRequest], call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]], ) -> Sequence[Prompt]: """List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._list_prompts_settings.get("enabled") is False: return await call_next(context) if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY): return cached_value prompts: Sequence[Prompt] = await call_next(context=context) # Turn any subclass of Prompt into a Prompt cachable_prompts: list[Prompt] = [ Prompt( name=prompt.name, title=prompt.title, description=prompt.description, tags=prompt.tags, meta=prompt.meta, arguments=prompt.arguments, ) for prompt in prompts ] await self._list_prompts_cache.put( key=GLOBAL_KEY, value=cachable_prompts, ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), ) return cachable_prompts @override async def on_call_tool( self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], ) -> ToolResult: """Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" tool_name = context.message.name if self._call_tool_settings.get( "enabled" ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): return await call_next(context=context) cache_key: str = _make_call_tool_cache_key(msg=context.message) if cached_value := await self._call_tool_cache.get(key=cache_key): return cached_value.unwrap() tool_result: ToolResult = await call_next(context=context) cachable_tool_result: CachableToolResult = CachableToolResult.wrap( value=tool_result ) await self._call_tool_cache.put( key=cache_key, value=cachable_tool_result, ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) return cachable_tool_result.unwrap() @override async def on_read_resource( self, context: MiddlewareContext[mcp.types.ReadResourceRequestParams], call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult], ) -> ResourceResult: """Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._read_resource_settings.get("enabled") is False: return await call_next(context=context) cache_key: str = _make_read_resource_cache_key(msg=context.message) cached_value: CachableResourceResult | None if cached_value := await self._read_resource_cache.get(key=cache_key): return cached_value.unwrap() value: ResourceResult = await call_next(context=context) cached_value = CachableResourceResult.wrap(value) await self._read_resource_cache.put( key=cache_key, value=cached_value, ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) return cached_value.unwrap() @override async def on_get_prompt( self, context: MiddlewareContext[mcp.types.GetPromptRequestParams], call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult], ) -> PromptResult: """Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise, otherwise call the next middleware and store the result in the cache if caching is enabled.""" if self._get_prompt_settings.get("enabled") is False: return await call_next(context=context) cache_key: str = _make_get_prompt_cache_key(msg=context.message) if cached_value := await self._get_prompt_cache.get(key=cache_key): return cached_value.unwrap() value: PromptResult = await call_next(context=context) await self._get_prompt_cache.put( key=cache_key, value=CachablePromptResult.wrap(value), ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS), ) return value def _matches_tool_cache_settings(self, tool_name: str) -> bool: """Check if the tool matches the cache settings for tool calls.""" if included_tools := self._call_tool_settings.get("included_tools"): if tool_name not in included_tools: return False if excluded_tools := self._call_tool_settings.get("excluded_tools"): if tool_name in excluded_tools: return False return True def statistics(self) -> ResponseCachingStatistics: """Get the statistics for the cache.""" return ResponseCachingStatistics( list_tools=self._stats.statistics.collections.get("tools/list"), list_resources=self._stats.statistics.collections.get("resources/list"), list_prompts=self._stats.statistics.collections.get("prompts/list"), read_resource=self._stats.statistics.collections.get("resources/read"), get_prompt=self._stats.statistics.collections.get("prompts/get"), call_tool=self._stats.statistics.collections.get("tools/call"), ) def _get_arguments_str(arguments: dict[str, Any] | None) -> str: """Get a string representation of the arguments.""" if arguments is None: return "null" try: return pydantic_core.to_json(value=arguments, fallback=str).decode() except TypeError: return repr(arguments) def _hash_cache_key(value: str) -> str: """Build a fixed-length SHA-256 cache key from request-derived input.""" return hashlib.sha256(value.encode()).hexdigest() def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: """Make a cache key for a tool call using a stable hash of name and arguments.""" return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: """Make a cache key for a resource read using a stable hash of URI.""" return _hash_cache_key(str(msg.uri)) def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: """Make a cache key for a prompt get using a stable hash of name and arguments.""" return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") ================================================ FILE: src/fastmcp/server/middleware/dereference.py ================================================ """Middleware that dereferences $ref in JSON schemas before sending to clients.""" from collections.abc import Sequence from typing import Any import mcp.types as mt from typing_extensions import override from fastmcp.resources.template import ResourceTemplate from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.base import Tool from fastmcp.utilities.json_schema import dereference_refs class DereferenceRefsMiddleware(Middleware): """Dereferences $ref in component schemas before sending to clients. Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref properly. This middleware inlines all $ref definitions so schemas are self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. """ @override async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], ) -> Sequence[Tool]: tools = await call_next(context) return [_dereference_tool(tool) for tool in tools] @override async def on_list_resource_templates( self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[ mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] ], ) -> Sequence[ResourceTemplate]: templates = await call_next(context) return [_dereference_resource_template(t) for t in templates] def _dereference_tool(tool: Tool) -> Tool: """Return a copy of the tool with dereferenced schemas.""" updates: dict[str, object] = {} if "$defs" in tool.parameters or _has_ref(tool.parameters): updates["parameters"] = dereference_refs(tool.parameters) if tool.output_schema is not None and ( "$defs" in tool.output_schema or _has_ref(tool.output_schema) ): updates["output_schema"] = dereference_refs(tool.output_schema) if updates: return tool.model_copy(update=updates) return tool def _dereference_resource_template(template: ResourceTemplate) -> ResourceTemplate: """Return a copy of the template with dereferenced schemas.""" if "$defs" in template.parameters or _has_ref(template.parameters): return template.model_copy( update={"parameters": dereference_refs(template.parameters)} ) return template def _has_ref(schema: dict[str, Any]) -> bool: """Check if a schema contains any $ref.""" if "$ref" in schema: return True for value in schema.values(): if isinstance(value, dict) and _has_ref(value): return True if isinstance(value, list): for item in value: if isinstance(item, dict) and _has_ref(item): return True return False ================================================ FILE: src/fastmcp/server/middleware/error_handling.py ================================================ """Error handling middleware for consistent error responses and tracking.""" import asyncio import logging import traceback from collections.abc import Callable from typing import Any import anyio from mcp import McpError from mcp.types import ErrorData from fastmcp.exceptions import NotFoundError from .middleware import CallNext, Middleware, MiddlewareContext class ErrorHandlingMiddleware(Middleware): """Middleware that provides consistent error handling and logging. Catches exceptions, logs them appropriately, and converts them to proper MCP error responses. Also tracks error patterns for monitoring. Example: ```python from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware import logging # Configure logging to see error details logging.basicConfig(level=logging.ERROR) mcp = FastMCP("MyServer") mcp.add_middleware(ErrorHandlingMiddleware()) ``` """ def __init__( self, logger: logging.Logger | None = None, include_traceback: bool = False, error_callback: Callable[[Exception, MiddlewareContext], None] | None = None, transform_errors: bool = True, ): """Initialize error handling middleware. Args: logger: Logger instance for error logging. If None, uses 'fastmcp.errors' include_traceback: Whether to include full traceback in error logs error_callback: Optional callback function called for each error transform_errors: Whether to transform non-MCP errors to McpError """ self.logger = logger or logging.getLogger("fastmcp.errors") self.include_traceback = include_traceback self.error_callback = error_callback self.transform_errors = transform_errors self.error_counts = {} def _log_error(self, error: Exception, context: MiddlewareContext) -> None: """Log error with appropriate detail level.""" error_type = type(error).__name__ method = context.method or "unknown" # Track error counts error_key = f"{error_type}:{method}" self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 base_message = f"Error in {method}: {error_type}: {error!s}" if self.include_traceback: self.logger.error(f"{base_message}\n{traceback.format_exc()}") else: self.logger.error(base_message) # Call custom error callback if provided if self.error_callback: try: self.error_callback(error, context) except Exception as callback_error: self.logger.error(f"Error in error callback: {callback_error}") def _transform_error( self, error: Exception, context: MiddlewareContext ) -> Exception: """Transform non-MCP errors to proper MCP errors.""" if isinstance(error, McpError): return error if not self.transform_errors: return error # Map common exceptions to appropriate MCP error codes error_type = type(error.__cause__) if error.__cause__ else type(error) if error_type in (ValueError, TypeError): return McpError( ErrorData(code=-32602, message=f"Invalid params: {error!s}") ) elif error_type in (FileNotFoundError, KeyError, NotFoundError): # MCP spec defines -32002 specifically for resource not found method = context.method or "" if method.startswith("resources/"): return McpError( ErrorData(code=-32002, message=f"Resource not found: {error!s}") ) return McpError(ErrorData(code=-32001, message=f"Not found: {error!s}")) elif error_type is PermissionError: return McpError( ErrorData(code=-32000, message=f"Permission denied: {error!s}") ) # asyncio.TimeoutError is a subclass of TimeoutError in Python 3.10, alias in 3.11+ elif error_type in (TimeoutError, asyncio.TimeoutError): return McpError( ErrorData(code=-32000, message=f"Request timeout: {error!s}") ) else: return McpError( ErrorData(code=-32603, message=f"Internal error: {error!s}") ) async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Handle errors for all messages.""" try: return await call_next(context) except Exception as error: self._log_error(error, context) # Transform and re-raise transformed_error = self._transform_error(error, context) raise transformed_error from error def get_error_stats(self) -> dict[str, int]: """Get error statistics for monitoring.""" return self.error_counts.copy() class RetryMiddleware(Middleware): """Middleware that implements automatic retry logic for failed requests. Retries requests that fail with transient errors, using exponential backoff to avoid overwhelming the server or external dependencies. Example: ```python from fastmcp.server.middleware.error_handling import RetryMiddleware # Retry up to 3 times with exponential backoff retry_middleware = RetryMiddleware( max_retries=3, retry_exceptions=(ConnectionError, TimeoutError) ) mcp = FastMCP("MyServer") mcp.add_middleware(retry_middleware) ``` """ def __init__( self, max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 60.0, backoff_multiplier: float = 2.0, retry_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError), logger: logging.Logger | None = None, ): """Initialize retry middleware. Args: max_retries: Maximum number of retry attempts base_delay: Initial delay between retries in seconds max_delay: Maximum delay between retries in seconds backoff_multiplier: Multiplier for exponential backoff retry_exceptions: Tuple of exception types that should trigger retries logger: Logger for retry attempts """ self.max_retries = max_retries self.base_delay = base_delay self.max_delay = max_delay self.backoff_multiplier = backoff_multiplier self.retry_exceptions = retry_exceptions self.logger = logger or logging.getLogger("fastmcp.retry") def _should_retry(self, error: Exception) -> bool: """Determine if an error should trigger a retry.""" return isinstance(error, self.retry_exceptions) def _calculate_delay(self, attempt: int) -> float: """Calculate delay for the given attempt number.""" delay = self.base_delay * (self.backoff_multiplier**attempt) return min(delay, self.max_delay) async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Implement retry logic for requests.""" last_error = None for attempt in range(self.max_retries + 1): try: return await call_next(context) except Exception as error: last_error = error # Don't retry on the last attempt or if it's not a retryable error if attempt == self.max_retries or not self._should_retry(error): break delay = self._calculate_delay(attempt) self.logger.warning( f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): " f"{type(error).__name__}: {error!s}. Retrying in {delay:.1f}s..." ) await anyio.sleep(delay) # Re-raise the last error if all retries failed if last_error: raise last_error ================================================ FILE: src/fastmcp/server/middleware/logging.py ================================================ """Comprehensive logging middleware for FastMCP servers.""" import json import logging import time from collections.abc import Callable from logging import Logger from typing import Any import pydantic_core from .middleware import CallNext, Middleware, MiddlewareContext def default_serializer(data: Any) -> str: """The default serializer for Payloads in the logging middleware.""" return pydantic_core.to_json(data, fallback=str).decode() class BaseLoggingMiddleware(Middleware): """Base class for logging middleware.""" logger: Logger log_level: int include_payloads: bool include_payload_length: bool estimate_payload_tokens: bool max_payload_length: int | None methods: list[str] | None structured_logging: bool payload_serializer: Callable[[Any], str] | None def _serialize_payload(self, context: MiddlewareContext[Any]) -> str: payload: str if not self.payload_serializer: payload = default_serializer(context.message) else: try: payload = self.payload_serializer(context.message) except Exception as e: self.logger.warning( f"Failed to serialize payload due to {e}: {context.type} {context.method} {context.source}." ) payload = default_serializer(context.message) return payload def _format_message(self, message: dict[str, str | int | float]) -> str: """Format a message for logging.""" if self.structured_logging: return json.dumps(message) else: return " ".join([f"{k}={v}" for k, v in message.items()]) def _create_before_message( self, context: MiddlewareContext[Any] ) -> dict[str, str | int | float]: message: dict[str, str | int | float] = { "event": context.type + "_start", "method": context.method or "unknown", "source": context.source, } if ( self.include_payloads or self.include_payload_length or self.estimate_payload_tokens ): payload = self._serialize_payload(context) if self.include_payload_length or self.estimate_payload_tokens: payload_length = len(payload) payload_tokens = payload_length // 4 if self.estimate_payload_tokens: message["payload_tokens"] = payload_tokens if self.include_payload_length: message["payload_length"] = payload_length if self.max_payload_length and len(payload) > self.max_payload_length: payload = payload[: self.max_payload_length] + "..." if self.include_payloads: message["payload"] = payload message["payload_type"] = type(context.message).__name__ return message def _create_error_message( self, context: MiddlewareContext[Any], start_time: float, error: Exception, ) -> dict[str, str | int | float]: duration_ms: float = _get_duration_ms(start_time) message = { "event": context.type + "_error", "method": context.method or "unknown", "source": context.source, "duration_ms": duration_ms, "error": str(object=error), } return message def _create_after_message( self, context: MiddlewareContext[Any], start_time: float, ) -> dict[str, str | int | float]: duration_ms: float = _get_duration_ms(start_time) message = { "event": context.type + "_success", "method": context.method or "unknown", "source": context.source, "duration_ms": duration_ms, } return message def _log_message( self, message: dict[str, str | int | float], log_level: int | None = None ): self.logger.log(log_level or self.log_level, self._format_message(message)) async def on_message( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] ) -> Any: """Log messages for configured methods.""" if self.methods and context.method not in self.methods: return await call_next(context) self._log_message(self._create_before_message(context)) start_time = time.perf_counter() try: result = await call_next(context) self._log_message(self._create_after_message(context, start_time)) return result except Exception as e: self._log_message( self._create_error_message(context, start_time, e), logging.ERROR ) raise class LoggingMiddleware(BaseLoggingMiddleware): """Middleware that provides comprehensive request and response logging. Logs all MCP messages with configurable detail levels. Useful for debugging, monitoring, and understanding server usage patterns. Example: ```python from fastmcp.server.middleware.logging import LoggingMiddleware import logging # Configure logging logging.basicConfig(level=logging.INFO) mcp = FastMCP("MyServer") mcp.add_middleware(LoggingMiddleware()) ``` """ def __init__( self, *, logger: logging.Logger | None = None, log_level: int = logging.INFO, include_payloads: bool = False, include_payload_length: bool = False, estimate_payload_tokens: bool = False, max_payload_length: int = 1000, methods: list[str] | None = None, payload_serializer: Callable[[Any], str] | None = None, ): """Initialize logging middleware. Args: logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests' log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs include_payload_length: Whether to include response size in logs estimate_payload_tokens: Whether to estimate response tokens max_payload_length: Maximum length of payload to log (prevents huge logs) methods: List of methods to log. If None, logs all methods. payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging") self.log_level = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length self.estimate_payload_tokens: bool = estimate_payload_tokens self.max_payload_length: int = max_payload_length self.methods: list[str] | None = methods self.payload_serializer: Callable[[Any], str] | None = payload_serializer self.structured_logging: bool = False class StructuredLoggingMiddleware(BaseLoggingMiddleware): """Middleware that provides structured JSON logging for better log analysis. Outputs structured logs that are easier to parse and analyze with log aggregation tools like ELK stack, Splunk, or cloud logging services. Example: ```python from fastmcp.server.middleware.logging import StructuredLoggingMiddleware import logging mcp = FastMCP("MyServer") mcp.add_middleware(StructuredLoggingMiddleware()) ``` """ def __init__( self, *, logger: logging.Logger | None = None, log_level: int = logging.INFO, include_payloads: bool = False, include_payload_length: bool = False, estimate_payload_tokens: bool = False, methods: list[str] | None = None, payload_serializer: Callable[[Any], str] | None = None, ): """Initialize structured logging middleware. Args: logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured' log_level: Log level for messages (default: INFO) include_payloads: Whether to include message payloads in logs include_payload_length: Whether to include payload size in logs estimate_payload_tokens: Whether to estimate token count using length // 4 methods: List of methods to log. If None, logs all methods. payload_serializer: Callable that converts objects to a JSON string for the payload. If not provided, uses FastMCP's default tool serializer. """ self.logger: Logger = logger or logging.getLogger( "fastmcp.middleware.structured_logging" ) self.log_level: int = log_level self.include_payloads: bool = include_payloads self.include_payload_length: bool = include_payload_length self.estimate_payload_tokens: bool = estimate_payload_tokens self.methods: list[str] | None = methods self.payload_serializer: Callable[[Any], str] | None = payload_serializer self.max_payload_length: int | None = None self.structured_logging: bool = True def _get_duration_ms(start_time: float, /) -> float: return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2) ================================================ FILE: src/fastmcp/server/middleware/middleware.py ================================================ from __future__ import annotations import logging from collections.abc import Awaitable, Sequence from dataclasses import dataclass, field, replace from datetime import datetime, timezone from functools import partial from typing import ( TYPE_CHECKING, Any, Generic, Literal, Protocol, runtime_checkable, ) import mcp.types as mt from typing_extensions import TypeVar from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool, ToolResult if TYPE_CHECKING: from fastmcp.server.context import Context __all__ = [ "CallNext", "Middleware", "MiddlewareContext", ] logger = logging.getLogger(__name__) T = TypeVar("T", default=Any) R = TypeVar("R", covariant=True, default=Any) @runtime_checkable class CallNext(Protocol[T, R]): def __call__(self, context: MiddlewareContext[T]) -> Awaitable[R]: ... @dataclass(kw_only=True, frozen=True) class MiddlewareContext(Generic[T]): """ Unified context for all middleware operations. """ message: T fastmcp_context: Context | None = None # Common metadata source: Literal["client", "server"] = "client" type: Literal["request", "notification"] = "request" method: str | None = None timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def copy(self, **kwargs: Any) -> MiddlewareContext[T]: return replace(self, **kwargs) def make_middleware_wrapper( middleware: Middleware, call_next: CallNext[T, R] ) -> CallNext[T, R]: """Create a wrapper that applies a single middleware to a context. The closure bakes in the middleware and call_next function, so it can be passed to other functions that expect a call_next function.""" async def wrapper(context: MiddlewareContext[T]) -> R: return await middleware(context, call_next) return wrapper class Middleware: """Base class for FastMCP middleware with dispatching hooks.""" async def __call__( self, context: MiddlewareContext[T], call_next: CallNext[T, Any], ) -> Any: """Main entry point that orchestrates the pipeline.""" handler_chain = await self._dispatch_handler( context, call_next=call_next, ) return await handler_chain(context) async def _dispatch_handler( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] ) -> CallNext[Any, Any]: """Builds a chain of handlers for a given message.""" handler = call_next match context.method: case "initialize": handler = partial(self.on_initialize, call_next=handler) case "tools/call": handler = partial(self.on_call_tool, call_next=handler) case "resources/read": handler = partial(self.on_read_resource, call_next=handler) case "prompts/get": handler = partial(self.on_get_prompt, call_next=handler) case "tools/list": handler = partial(self.on_list_tools, call_next=handler) case "resources/list": handler = partial(self.on_list_resources, call_next=handler) case "resources/templates/list": handler = partial(self.on_list_resource_templates, call_next=handler) case "prompts/list": handler = partial(self.on_list_prompts, call_next=handler) match context.type: case "request": handler = partial(self.on_request, call_next=handler) case "notification": handler = partial(self.on_notification, call_next=handler) handler = partial(self.on_message, call_next=handler) return handler async def on_message( self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any], ) -> Any: return await call_next(context) async def on_request( self, context: MiddlewareContext[mt.Request[Any, Any]], call_next: CallNext[mt.Request[Any, Any], Any], ) -> Any: return await call_next(context) async def on_notification( self, context: MiddlewareContext[mt.Notification[Any, Any]], call_next: CallNext[mt.Notification[Any, Any], Any], ) -> Any: return await call_next(context) async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], ) -> mt.InitializeResult | None: return await call_next(context) async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult], ) -> ToolResult: return await call_next(context) async def on_read_resource( self, context: MiddlewareContext[mt.ReadResourceRequestParams], call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], ) -> ResourceResult: return await call_next(context) async def on_get_prompt( self, context: MiddlewareContext[mt.GetPromptRequestParams], call_next: CallNext[mt.GetPromptRequestParams, PromptResult], ) -> PromptResult: return await call_next(context) async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], ) -> Sequence[Tool]: return await call_next(context) async def on_list_resources( self, context: MiddlewareContext[mt.ListResourcesRequest], call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], ) -> Sequence[Resource]: return await call_next(context) async def on_list_resource_templates( self, context: MiddlewareContext[mt.ListResourceTemplatesRequest], call_next: CallNext[ mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] ], ) -> Sequence[ResourceTemplate]: return await call_next(context) async def on_list_prompts( self, context: MiddlewareContext[mt.ListPromptsRequest], call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]], ) -> Sequence[Prompt]: return await call_next(context) ================================================ FILE: src/fastmcp/server/middleware/ping.py ================================================ """Ping middleware for keeping client connections alive.""" from typing import Any import anyio from .middleware import CallNext, Middleware, MiddlewareContext class PingMiddleware(Middleware): """Middleware that sends periodic pings to keep client connections alive. Starts a background ping task on first message from each session. The task sends server-to-client pings at the configured interval until the session ends. Example: ```python from fastmcp import FastMCP from fastmcp.server.middleware import PingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(PingMiddleware(interval_ms=5000)) ``` """ def __init__(self, interval_ms: int = 30000): """Initialize ping middleware. Args: interval_ms: Interval between pings in milliseconds (default: 30000) Raises: ValueError: If interval_ms is not positive """ if interval_ms <= 0: raise ValueError("interval_ms must be positive") self.interval_ms = interval_ms self._active_sessions: set[int] = set() self._lock = anyio.Lock() async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Start ping task on first message from a session.""" if ( context.fastmcp_context is None or context.fastmcp_context.request_context is None ): return await call_next(context) session = context.fastmcp_context.session session_id = id(session) async with self._lock: if session_id not in self._active_sessions: # _subscription_task_group is added by MiddlewareServerSession tg = session._subscription_task_group # type: ignore[attr-defined] if tg is not None: self._active_sessions.add(session_id) tg.start_soon(self._ping_loop, session, session_id) return await call_next(context) async def _ping_loop(self, session: Any, session_id: int) -> None: """Send periodic pings until session ends.""" try: while True: await anyio.sleep(self.interval_ms / 1000) await session.send_ping() finally: self._active_sessions.discard(session_id) ================================================ FILE: src/fastmcp/server/middleware/rate_limiting.py ================================================ """Rate limiting middleware for protecting FastMCP servers from abuse.""" import time from collections import defaultdict, deque from collections.abc import Callable from typing import Any import anyio from mcp import McpError from mcp.types import ErrorData from .middleware import CallNext, Middleware, MiddlewareContext class RateLimitError(McpError): """Error raised when rate limit is exceeded.""" def __init__(self, message: str = "Rate limit exceeded"): super().__init__(ErrorData(code=-32000, message=message)) class TokenBucketRateLimiter: """Token bucket implementation for rate limiting.""" def __init__(self, capacity: int, refill_rate: float): """Initialize token bucket. Args: capacity: Maximum number of tokens in the bucket refill_rate: Tokens added per second """ self.capacity = capacity self.refill_rate = refill_rate self.tokens = capacity self.last_refill = time.time() self._lock = anyio.Lock() async def consume(self, tokens: int = 1) -> bool: """Try to consume tokens from the bucket. Args: tokens: Number of tokens to consume Returns: True if tokens were available and consumed, False otherwise """ async with self._lock: now = time.time() elapsed = now - self.last_refill # Add tokens based on elapsed time self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate) self.last_refill = now if self.tokens >= tokens: self.tokens -= tokens return True return False class SlidingWindowRateLimiter: """Sliding window rate limiter implementation.""" def __init__(self, max_requests: int, window_seconds: int): """Initialize sliding window rate limiter. Args: max_requests: Maximum requests allowed in the time window window_seconds: Time window in seconds """ self.max_requests = max_requests self.window_seconds = window_seconds self.requests = deque() self._lock = anyio.Lock() async def is_allowed(self) -> bool: """Check if a request is allowed.""" async with self._lock: now = time.time() cutoff = now - self.window_seconds # Remove old requests outside the window while self.requests and self.requests[0] < cutoff: self.requests.popleft() if len(self.requests) < self.max_requests: self.requests.append(now) return True return False class RateLimitingMiddleware(Middleware): """Middleware that implements rate limiting to prevent server abuse. Uses a token bucket algorithm by default, allowing for burst traffic while maintaining a sustainable long-term rate. Example: ```python from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware # Allow 10 requests per second with bursts up to 20 rate_limiter = RateLimitingMiddleware( max_requests_per_second=10, burst_capacity=20 ) mcp = FastMCP("MyServer") mcp.add_middleware(rate_limiter) ``` """ def __init__( self, max_requests_per_second: float = 10.0, burst_capacity: int | None = None, get_client_id: Callable[[MiddlewareContext], str] | None = None, global_limit: bool = False, ): """Initialize rate limiting middleware. Args: max_requests_per_second: Sustained requests per second allowed burst_capacity: Maximum burst capacity. If None, defaults to 2x max_requests_per_second get_client_id: Function to extract client ID from context. If None, uses global limiting global_limit: If True, apply limit globally; if False, per-client """ self.max_requests_per_second = max_requests_per_second self.burst_capacity = burst_capacity or int(max_requests_per_second * 2) self.get_client_id = get_client_id self.global_limit = global_limit # Storage for rate limiters per client self.limiters: dict[str, TokenBucketRateLimiter] = defaultdict( lambda: TokenBucketRateLimiter( self.burst_capacity, self.max_requests_per_second ) ) # Global rate limiter if self.global_limit: self.global_limiter = TokenBucketRateLimiter( self.burst_capacity, self.max_requests_per_second ) def _get_client_identifier(self, context: MiddlewareContext) -> str: """Get client identifier for rate limiting.""" if self.get_client_id: return self.get_client_id(context) return "global" async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Apply rate limiting to requests.""" if self.global_limit: # Global rate limiting allowed = await self.global_limiter.consume() if not allowed: raise RateLimitError("Global rate limit exceeded") else: # Per-client rate limiting client_id = self._get_client_identifier(context) limiter = self.limiters[client_id] allowed = await limiter.consume() if not allowed: raise RateLimitError(f"Rate limit exceeded for client: {client_id}") return await call_next(context) class SlidingWindowRateLimitingMiddleware(Middleware): """Middleware that implements sliding window rate limiting. Uses a sliding window approach which provides more precise rate limiting but uses more memory to track individual request timestamps. Example: ```python from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware # Allow 100 requests per minute rate_limiter = SlidingWindowRateLimitingMiddleware( max_requests=100, window_minutes=1 ) mcp = FastMCP("MyServer") mcp.add_middleware(rate_limiter) ``` """ def __init__( self, max_requests: int, window_minutes: int = 1, get_client_id: Callable[[MiddlewareContext], str] | None = None, ): """Initialize sliding window rate limiting middleware. Args: max_requests: Maximum requests allowed in the time window window_minutes: Time window in minutes get_client_id: Function to extract client ID from context """ self.max_requests = max_requests self.window_seconds = window_minutes * 60 self.get_client_id = get_client_id # Storage for rate limiters per client self.limiters: dict[str, SlidingWindowRateLimiter] = defaultdict( lambda: SlidingWindowRateLimiter(self.max_requests, self.window_seconds) ) def _get_client_identifier(self, context: MiddlewareContext) -> str: """Get client identifier for rate limiting.""" if self.get_client_id: return self.get_client_id(context) return "global" async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Apply sliding window rate limiting to requests.""" client_id = self._get_client_identifier(context) limiter = self.limiters[client_id] allowed = await limiter.is_allowed() if not allowed: raise RateLimitError( f"Rate limit exceeded: {self.max_requests} requests per " f"{self.window_seconds // 60} minutes for client: {client_id}" ) return await call_next(context) ================================================ FILE: src/fastmcp/server/middleware/response_limiting.py ================================================ """Response limiting middleware for controlling tool response sizes.""" from __future__ import annotations import logging import mcp.types as mt import pydantic_core from mcp.types import TextContent from fastmcp.tools.base import ToolResult from .middleware import CallNext, Middleware, MiddlewareContext __all__ = ["ResponseLimitingMiddleware"] logger = logging.getLogger(__name__) class ResponseLimitingMiddleware(Middleware): """Middleware that limits the response size of tool calls. Intercepts tool call responses and enforces size limits. If a response exceeds the limit, it extracts text content, truncates it, and returns a single TextContent block. Example: ```python from fastmcp import FastMCP from fastmcp.server.middleware.response_limiting import ( ResponseLimitingMiddleware, ) mcp = FastMCP("MyServer") # Limit all tool responses to 500KB mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) # Limit only specific tools mcp.add_middleware( ResponseLimitingMiddleware( max_size=100_000, tools=["search", "fetch_data"], ) ) ``` """ def __init__( self, *, max_size: int = 1_000_000, truncation_suffix: str = "\n\n[Response truncated due to size limit]", tools: list[str] | None = None, ) -> None: """Initialize response limiting middleware. Args: max_size: Maximum response size in bytes. Defaults to 1MB (1,000,000). truncation_suffix: Suffix to append when truncating responses. Defaults to "\\n\\n[Response truncated due to size limit]". tools: List of tool names to apply limiting to. If None, applies to all. """ if max_size <= 0: raise ValueError(f"max_size must be positive, got {max_size}") self.max_size = max_size self.truncation_suffix = truncation_suffix self.tools = set(tools) if tools is not None else None def _truncate_to_result(self, text: str) -> ToolResult: """Truncate text to fit within max_size and wrap in ToolResult.""" suffix_bytes = len(self.truncation_suffix.encode("utf-8")) # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]} overhead = 50 target_size = self.max_size - suffix_bytes - overhead if target_size <= 0: # Edge case: max_size too small for even the suffix truncated = self.truncation_suffix else: # Truncate to target size, preserving UTF-8 boundaries encoded = text.encode("utf-8") if len(encoded) <= target_size: truncated = text + self.truncation_suffix else: truncated = ( encoded[:target_size].decode("utf-8", errors="ignore") + self.truncation_suffix ) return ToolResult(content=[TextContent(type="text", text=truncated)]) async def on_call_tool( self, context: MiddlewareContext[mt.CallToolRequestParams], call_next: CallNext[mt.CallToolRequestParams, ToolResult], ) -> ToolResult: """Intercept tool calls and limit response size.""" result = await call_next(context) # Check if we should limit this tool if self.tools is not None and context.message.name not in self.tools: return result # Measure serialized size serialized = pydantic_core.to_json(result, fallback=str) if len(serialized) <= self.max_size: return result # Over limit: extract text, truncate, return single TextContent logger.warning( "Tool %r response exceeds size limit: %d bytes > %d bytes, truncating", context.message.name, len(serialized), self.max_size, ) texts = [b.text for b in result.content if isinstance(b, TextContent)] text = ( "\n\n".join(texts) if texts else serialized.decode("utf-8", errors="replace") ) return self._truncate_to_result(text) ================================================ FILE: src/fastmcp/server/middleware/timing.py ================================================ """Timing middleware for measuring and logging request performance.""" import logging import time from typing import Any from .middleware import CallNext, Middleware, MiddlewareContext class TimingMiddleware(Middleware): """Middleware that logs the execution time of requests. Only measures and logs timing for request messages (not notifications). Provides insights into performance characteristics of your MCP server. Example: ```python from fastmcp.server.middleware.timing import TimingMiddleware mcp = FastMCP("MyServer") mcp.add_middleware(TimingMiddleware()) # Now all requests will be timed and logged ``` """ def __init__( self, logger: logging.Logger | None = None, log_level: int = logging.INFO ): """Initialize timing middleware. Args: logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing' log_level: Log level for timing messages (default: INFO) """ self.logger = logger or logging.getLogger("fastmcp.timing") self.log_level = log_level async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: """Time request execution and log the results.""" method = context.method or "unknown" start_time = time.perf_counter() try: result = await call_next(context) duration_ms = (time.perf_counter() - start_time) * 1000 self.logger.log( self.log_level, f"Request {method} completed in {duration_ms:.2f}ms" ) return result except Exception as e: duration_ms = (time.perf_counter() - start_time) * 1000 self.logger.log( self.log_level, f"Request {method} failed after {duration_ms:.2f}ms: {e}", ) raise class DetailedTimingMiddleware(Middleware): """Enhanced timing middleware with per-operation breakdowns. Provides detailed timing information for different types of MCP operations, allowing you to identify performance bottlenecks in specific operations. Example: ```python from fastmcp.server.middleware.timing import DetailedTimingMiddleware import logging # Configure logging to see the output logging.basicConfig(level=logging.INFO) mcp = FastMCP("MyServer") mcp.add_middleware(DetailedTimingMiddleware()) ``` """ def __init__( self, logger: logging.Logger | None = None, log_level: int = logging.INFO ): """Initialize detailed timing middleware. Args: logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing.detailed' log_level: Log level for timing messages (default: INFO) """ self.logger = logger or logging.getLogger("fastmcp.timing.detailed") self.log_level = log_level async def _time_operation( self, context: MiddlewareContext, call_next: CallNext, operation_name: str ) -> Any: """Helper method to time any operation.""" start_time = time.perf_counter() try: result = await call_next(context) duration_ms = (time.perf_counter() - start_time) * 1000 self.logger.log( self.log_level, f"{operation_name} completed in {duration_ms:.2f}ms" ) return result except Exception as e: duration_ms = (time.perf_counter() - start_time) * 1000 self.logger.log( self.log_level, f"{operation_name} failed after {duration_ms:.2f}ms: {e}", ) raise async def on_call_tool( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time tool execution.""" tool_name = getattr(context.message, "name", "unknown") return await self._time_operation(context, call_next, f"Tool '{tool_name}'") async def on_read_resource( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time resource reading.""" resource_uri = getattr(context.message, "uri", "unknown") return await self._time_operation( context, call_next, f"Resource '{resource_uri}'" ) async def on_get_prompt( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time prompt retrieval.""" prompt_name = getattr(context.message, "name", "unknown") return await self._time_operation(context, call_next, f"Prompt '{prompt_name}'") async def on_list_tools( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time tool listing.""" return await self._time_operation(context, call_next, "List tools") async def on_list_resources( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time resource listing.""" return await self._time_operation(context, call_next, "List resources") async def on_list_resource_templates( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time resource template listing.""" return await self._time_operation(context, call_next, "List resource templates") async def on_list_prompts( self, context: MiddlewareContext, call_next: CallNext ) -> Any: """Time prompt listing.""" return await self._time_operation(context, call_next, "List prompts") ================================================ FILE: src/fastmcp/server/middleware/tool_injection.py ================================================ """A middleware for injecting tools into the MCP server context.""" import warnings from collections.abc import Sequence from logging import Logger from typing import Annotated, Any import mcp.types from mcp.types import Prompt from pydantic import AnyUrl from typing_extensions import override import fastmcp from fastmcp.resources.base import ResourceResult from fastmcp.server.context import Context from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger logger: Logger = get_logger(name=__name__) class ToolInjectionMiddleware(Middleware): """A middleware for injecting tools into the context.""" def __init__(self, tools: Sequence[Tool]): """Initialize the tool injection middleware.""" self._tools_to_inject: Sequence[Tool] = tools self._tools_to_inject_by_name: dict[str, Tool] = { tool.name: tool for tool in tools } @override async def on_list_tools( self, context: MiddlewareContext[mcp.types.ListToolsRequest], call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]], ) -> Sequence[Tool]: """Inject tools into the response.""" return [*self._tools_to_inject, *await call_next(context)] @override async def on_call_tool( self, context: MiddlewareContext[mcp.types.CallToolRequestParams], call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], ) -> ToolResult: """Intercept tool calls to injected tools.""" if context.message.name in self._tools_to_inject_by_name: tool = self._tools_to_inject_by_name[context.message.name] return await tool.run(arguments=context.message.arguments or {}) return await call_next(context) async def list_prompts(context: Context) -> list[Prompt]: """List prompts available on the server.""" return await context.list_prompts() list_prompts_tool = Tool.from_function( fn=list_prompts, ) async def get_prompt( context: Context, name: Annotated[str, "The name of the prompt to render."], arguments: Annotated[ dict[str, Any] | None, "The arguments to pass to the prompt." ] = None, ) -> mcp.types.GetPromptResult: """Render a prompt available on the server.""" return await context.get_prompt(name=name, arguments=arguments) get_prompt_tool = Tool.from_function( fn=get_prompt, ) class PromptToolMiddleware(ToolInjectionMiddleware): """A middleware for injecting prompts as tools into the context. .. deprecated:: Use ``fastmcp.server.transforms.PromptsAsTools`` instead. """ def __init__(self) -> None: if fastmcp.settings.deprecation_warnings: warnings.warn( "PromptToolMiddleware is deprecated. Use the PromptsAsTools transform instead: " "from fastmcp.server.transforms import PromptsAsTools", DeprecationWarning, stacklevel=2, ) tools: list[Tool] = [list_prompts_tool, get_prompt_tool] super().__init__(tools=tools) async def list_resources(context: Context) -> list[mcp.types.Resource]: """List resources available on the server.""" return await context.list_resources() list_resources_tool = Tool.from_function( fn=list_resources, ) async def read_resource( context: Context, uri: Annotated[AnyUrl | str, "The URI of the resource to read."], ) -> ResourceResult: """Read a resource available on the server.""" return await context.read_resource(uri=uri) read_resource_tool = Tool.from_function( fn=read_resource, ) class ResourceToolMiddleware(ToolInjectionMiddleware): """A middleware for injecting resources as tools into the context. .. deprecated:: Use ``fastmcp.server.transforms.ResourcesAsTools`` instead. """ def __init__(self) -> None: if fastmcp.settings.deprecation_warnings: warnings.warn( "ResourceToolMiddleware is deprecated. Use the ResourcesAsTools transform instead: " "from fastmcp.server.transforms import ResourcesAsTools", DeprecationWarning, stacklevel=2, ) tools: list[Tool] = [list_resources_tool, read_resource_tool] super().__init__(tools=tools) ================================================ FILE: src/fastmcp/server/mixins/__init__.py ================================================ """Server mixins for FastMCP.""" from fastmcp.server.mixins.lifespan import LifespanMixin from fastmcp.server.mixins.mcp_operations import MCPOperationsMixin from fastmcp.server.mixins.transport import TransportMixin __all__ = ["LifespanMixin", "MCPOperationsMixin", "TransportMixin"] ================================================ FILE: src/fastmcp/server/mixins/lifespan.py ================================================ """Lifespan and Docket task infrastructure for FastMCP Server.""" from __future__ import annotations import asyncio import weakref from collections.abc import AsyncIterator from contextlib import AsyncExitStack, asynccontextmanager, suppress from typing import TYPE_CHECKING, Any import anyio from uncalled_for import SharedContext import fastmcp from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from docket import Docket from fastmcp.server.server import FastMCP logger = get_logger(__name__) class LifespanMixin: """Mixin providing lifespan and Docket task infrastructure for FastMCP.""" @property def docket(self: FastMCP) -> Docket | None: """Get the Docket instance if Docket support is enabled. Returns None if Docket is not enabled or server hasn't been started yet. """ return self._docket @asynccontextmanager async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]: """Manage Docket instance and Worker for background task execution. Docket infrastructure is only initialized if: 1. pydocket is installed (fastmcp[tasks] extra) 2. There are task-enabled components (task_config.mode != 'forbidden') This means users with pydocket installed but no task-enabled components won't spin up Docket/Worker infrastructure. """ from fastmcp.server.dependencies import _current_server, is_docket_available # Set FastMCP server in ContextVar so CurrentFastMCP can access it # (use weakref to avoid reference cycles) server_token = _current_server.set(weakref.ref(self)) try: # If docket is not available, skip task infrastructure but still # set up SharedContext so Shared() dependencies work. if not is_docket_available(): async with SharedContext(): yield return # Collect task-enabled components at startup with all transforms applied. # Components must be available now to be registered with Docket workers; # dynamically added components after startup won't be registered. try: task_components = list(await self.get_tasks()) except Exception as e: logger.warning(f"Failed to get tasks: {e}") if fastmcp.settings.mounted_components_raise_on_load_error: raise task_components = [] # If no task-enabled components, skip Docket infrastructure but still # set up SharedContext so Shared() dependencies work. if not task_components: async with SharedContext(): yield return # Docket is available AND there are task-enabled components from docket import Docket, Worker from fastmcp import settings from fastmcp.server.dependencies import ( _current_docket, _current_worker, ) # Create Docket instance using configured name and URL async with Docket( name=settings.docket.name, url=settings.docket.url, ) as docket: # Store on server instance for cross-task access (FastMCPTransport) self._docket = docket # Register task-enabled components with Docket for component in task_components: component.register_with_docket(docket) # Set Docket in ContextVar so CurrentDocket can access it docket_token = _current_docket.set(docket) try: # Build worker kwargs from settings worker_kwargs: dict[str, Any] = { "concurrency": settings.docket.concurrency, "redelivery_timeout": settings.docket.redelivery_timeout, "reconnection_delay": settings.docket.reconnection_delay, "minimum_check_interval": settings.docket.minimum_check_interval, } if settings.docket.worker_name: worker_kwargs["name"] = settings.docket.worker_name # Create and start Worker async with Worker(docket, **worker_kwargs) as worker: # Store on server instance for cross-context access self._worker = worker # Set Worker in ContextVar so CurrentWorker can access it worker_token = _current_worker.set(worker) try: worker_task = asyncio.create_task(worker.run_forever()) try: yield finally: worker_task.cancel() with suppress(asyncio.CancelledError): await worker_task finally: _current_worker.reset(worker_token) self._worker = None finally: # Reset ContextVar _current_docket.reset(docket_token) # Clear instance attribute self._docket = None finally: # Reset server ContextVar _current_server.reset(server_token) @asynccontextmanager async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]: async with self._lifespan_lock: if self._lifespan_result_set: self._lifespan_ref_count += 1 should_enter_lifespan = False else: self._lifespan_ref_count = 1 should_enter_lifespan = True if not should_enter_lifespan: try: yield finally: async with self._lifespan_lock: self._lifespan_ref_count -= 1 if self._lifespan_ref_count == 0: self._lifespan_result_set = False self._lifespan_result = None return # Use an explicit AsyncExitStack so we can shield teardown from # cancellation. Without this, Ctrl-C causes CancelledError to # propagate into lifespan finally blocks, preventing any async # cleanup (e.g. closing DB connections, flushing buffers). stack = AsyncExitStack() try: user_lifespan_result = await stack.enter_async_context(self._lifespan(self)) await stack.enter_async_context(self._docket_lifespan()) self._lifespan_result = user_lifespan_result self._lifespan_result_set = True # Start lifespans for all providers for provider in self.providers: await stack.enter_async_context(provider.lifespan()) self._started.set() try: yield finally: self._started.clear() finally: try: with anyio.CancelScope(shield=True): await stack.aclose() finally: async with self._lifespan_lock: self._lifespan_ref_count -= 1 if self._lifespan_ref_count == 0: self._lifespan_result_set = False self._lifespan_result = None def _setup_task_protocol_handlers(self: FastMCP) -> None: """Register SEP-1686 task protocol handlers with SDK. Only registers handlers if docket is installed. Without docket, task protocol requests will return "method not found" errors. """ from fastmcp.server.dependencies import is_docket_available if not is_docket_available(): return from mcp.types import ( CancelTaskRequest, GetTaskPayloadRequest, GetTaskRequest, ListTasksRequest, ServerResult, ) from fastmcp.server.tasks.requests import ( tasks_cancel_handler, tasks_get_handler, tasks_list_handler, tasks_result_handler, ) # Manually register handlers (SDK decorators fail with locally-defined functions) # SDK expects handlers that receive Request objects and return ServerResult async def handle_get_task(req: GetTaskRequest) -> ServerResult: params = req.params.model_dump(by_alias=True, exclude_none=True) result = await tasks_get_handler(self, params) return ServerResult(result) async def handle_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: params = req.params.model_dump(by_alias=True, exclude_none=True) result = await tasks_result_handler(self, params) return ServerResult(result) async def handle_list_tasks(req: ListTasksRequest) -> ServerResult: params = ( req.params.model_dump(by_alias=True, exclude_none=True) if req.params else {} ) result = await tasks_list_handler(self, params) return ServerResult(result) async def handle_cancel_task(req: CancelTaskRequest) -> ServerResult: params = req.params.model_dump(by_alias=True, exclude_none=True) result = await tasks_cancel_handler(self, params) return ServerResult(result) # Register directly with SDK (same as what decorators do internally) self._mcp_server.request_handlers[GetTaskRequest] = handle_get_task self._mcp_server.request_handlers[GetTaskPayloadRequest] = ( handle_get_task_result ) self._mcp_server.request_handlers[ListTasksRequest] = handle_list_tasks self._mcp_server.request_handlers[CancelTaskRequest] = handle_cancel_task ================================================ FILE: src/fastmcp/server/mixins/mcp_operations.py ================================================ """MCP protocol handler setup and wire-format handlers for FastMCP Server.""" from __future__ import annotations from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, TypeVar, cast import mcp.types from mcp.shared.exceptions import McpError from mcp.types import ContentBlock from pydantic import AnyUrl from fastmcp.exceptions import DisabledError, NotFoundError from fastmcp.server.tasks.config import TaskMeta from fastmcp.utilities.logging import get_logger from fastmcp.utilities.pagination import paginate_sequence from fastmcp.utilities.versions import VersionSpec, dedupe_with_versions if TYPE_CHECKING: from fastmcp.server.server import FastMCP logger = get_logger(__name__) PaginateT = TypeVar("PaginateT") def _apply_pagination( items: Sequence[PaginateT], cursor: str | None, page_size: int | None, ) -> tuple[list[PaginateT], str | None]: """Apply pagination to items, raising McpError for invalid cursors. If page_size is None, returns all items without pagination. """ if page_size is None: return list(items), None try: return paginate_sequence(items, cursor, page_size) except ValueError as e: raise McpError(mcp.types.ErrorData(code=-32602, message=str(e))) from e class MCPOperationsMixin: """Mixin providing MCP protocol handler setup and wire-format handlers. Note: Methods registered with SDK decorators (e.g., _list_tools_mcp, _call_tool_mcp) cannot use `self: FastMCP` type hints because the SDK's `get_type_hints()` fails to resolve FastMCP at runtime (it's only available under TYPE_CHECKING). When type hints fail to resolve, the SDK falls back to calling handlers with no arguments. These methods use untyped `self` to avoid this issue. """ def _setup_handlers(self: FastMCP) -> None: """Set up core MCP protocol handlers. List handlers use SDK decorators that pass the request object to our handler (needed for pagination cursor). The SDK also populates caches like _tool_cache. Exception: list_resource_templates SDK decorator doesn't pass the request, so we register that handler directly. The call_tool decorator is from the SDK (supports CreateTaskResult + validate_input). The read_resource and get_prompt decorators are from LowLevelServer to add CreateTaskResult support until the SDK provides it natively. """ self._mcp_server.list_tools()(self._list_tools_mcp) self._mcp_server.list_resources()(self._list_resources_mcp) self._mcp_server.list_prompts()(self._list_prompts_mcp) # list_resource_templates SDK decorator doesn't pass the request to handlers, # so we register directly to get cursor access for pagination self._mcp_server.request_handlers[mcp.types.ListResourceTemplatesRequest] = ( self._wrap_list_handler(self._list_resource_templates_mcp) ) self._mcp_server.call_tool(validate_input=self.strict_input_validation)( self._call_tool_mcp ) self._mcp_server.read_resource()(self._read_resource_mcp) self._mcp_server.get_prompt()(self._get_prompt_mcp) self._mcp_server.set_logging_level()(self._set_logging_level_mcp) # Register SEP-1686 task protocol handlers self._setup_task_protocol_handlers() def _wrap_list_handler( self: FastMCP, handler: Callable[..., Awaitable[Any]] ) -> Callable[..., Awaitable[mcp.types.ServerResult]]: """Wrap a list handler to pass the request and return ServerResult.""" async def wrapper(request: Any) -> mcp.types.ServerResult: result = await handler(request) return mcp.types.ServerResult(result) return wrapper async def _list_tools_mcp( self, request: mcp.types.ListToolsRequest ) -> mcp.types.ListToolsResult: """ List all available tools, in the format expected by the low-level MCP server. Supports pagination when list_page_size is configured. """ # Cast self to FastMCP for type checking (see class docstring for why # we can't use `self: FastMCP` annotation on SDK-registered handlers) server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_tools") tools = dedupe_with_versions(list(await server.list_tools()), lambda t: t.name) sdk_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools] # SDK may pass None for internal cache refresh despite type hint cursor = ( request.params.cursor if request is not None and request.params else None ) page, next_cursor = _apply_pagination(sdk_tools, cursor, server._list_page_size) return mcp.types.ListToolsResult(tools=page, nextCursor=next_cursor) async def _list_resources_mcp( self, request: mcp.types.ListResourcesRequest ) -> mcp.types.ListResourcesResult: """ List all available resources, in the format expected by the low-level MCP server. Supports pagination when list_page_size is configured. """ server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_resources") resources = dedupe_with_versions( list(await server.list_resources()), lambda r: str(r.uri) ) sdk_resources = [ resource.to_mcp_resource(uri=str(resource.uri)) for resource in resources ] cursor = request.params.cursor if request.params else None page, next_cursor = _apply_pagination( sdk_resources, cursor, server._list_page_size ) return mcp.types.ListResourcesResult(resources=page, nextCursor=next_cursor) async def _list_resource_templates_mcp( self, request: mcp.types.ListResourceTemplatesRequest ) -> mcp.types.ListResourceTemplatesResult: """ List all available resource templates, in the format expected by the low-level MCP server. Supports pagination when list_page_size is configured. """ server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_resource_templates") templates = dedupe_with_versions( list(await server.list_resource_templates()), lambda t: t.uri_template ) sdk_templates = [ template.to_mcp_template(uriTemplate=template.uri_template) for template in templates ] cursor = request.params.cursor if request.params else None page, next_cursor = _apply_pagination( sdk_templates, cursor, server._list_page_size ) return mcp.types.ListResourceTemplatesResult( resourceTemplates=page, nextCursor=next_cursor ) async def _list_prompts_mcp( self, request: mcp.types.ListPromptsRequest ) -> mcp.types.ListPromptsResult: """ List all available prompts, in the format expected by the low-level MCP server. Supports pagination when list_page_size is configured. """ server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_prompts") prompts = dedupe_with_versions( list(await server.list_prompts()), lambda p: p.name ) sdk_prompts = [prompt.to_mcp_prompt(name=prompt.name) for prompt in prompts] cursor = request.params.cursor if request.params else None page, next_cursor = _apply_pagination( sdk_prompts, cursor, server._list_page_size ) return mcp.types.ListPromptsResult(prompts=page, nextCursor=next_cursor) async def _call_tool_mcp( self, key: str, arguments: dict[str, Any] ) -> ( list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | mcp.types.CallToolResult | mcp.types.CreateTaskResult ): """ Handle MCP 'callTool' requests. Extracts task metadata from MCP request context and passes it explicitly to call_tool(). The tool's _run() method handles the backgrounding decision, ensuring middleware runs before Docket. Args: key: The name of the tool to call arguments: Arguments to pass to the tool Returns: Tool result or CreateTaskResult for background execution """ server = cast("FastMCP", self) logger.debug( f"[{server.name}] Handler called: call_tool %s with %s", key, arguments ) try: # Extract version and task metadata from request context. # fn_key is set by call_tool() after finding the tool. version_str: str | None = None task_meta: TaskMeta | None = None try: ctx = server._mcp_server.request_context # Extract version from request-level _meta.fastmcp.version if ctx.meta: meta_dict = ctx.meta.model_dump(exclude_none=True) version_str = meta_dict.get("fastmcp", {}).get("version") # Extract SEP-1686 task metadata if ctx.experimental.is_task: mcp_task_meta = ctx.experimental.task_metadata task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) except (AttributeError, LookupError): pass version = VersionSpec(eq=version_str) if version_str else None result = await server.call_tool( key, arguments, version=version, task_meta=task_meta ) if isinstance(result, mcp.types.CreateTaskResult): return result return result.to_mcp_result() except DisabledError as e: raise NotFoundError(f"Unknown tool: {key!r}") from e except NotFoundError as e: raise NotFoundError(f"Unknown tool: {key!r}") from e async def _read_resource_mcp( self, uri: AnyUrl | str ) -> mcp.types.ReadResourceResult | mcp.types.CreateTaskResult: """Handle MCP 'readResource' requests. Extracts task metadata from MCP request context and passes it explicitly to read_resource(). The resource's _read() method handles the backgrounding decision, ensuring middleware runs before Docket. Args: uri: The resource URI Returns: ReadResourceResult or CreateTaskResult for background execution """ server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: read_resource %s", uri) try: # Extract version and task metadata from request context. version_str: str | None = None task_meta: TaskMeta | None = None try: ctx = server._mcp_server.request_context # Extract version from _meta.fastmcp.version if provided if ctx.meta: meta_dict = ctx.meta.model_dump(exclude_none=True) fastmcp_meta = meta_dict.get("fastmcp") or {} version_str = fastmcp_meta.get("version") # Extract SEP-1686 task metadata if ctx.experimental.is_task: mcp_task_meta = ctx.experimental.task_metadata task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) except (AttributeError, LookupError): pass version = VersionSpec(eq=version_str) if version_str else None result = await server.read_resource( str(uri), version=version, task_meta=task_meta ) if isinstance(result, mcp.types.CreateTaskResult): return result return result.to_mcp_result(uri) except DisabledError as e: raise McpError( mcp.types.ErrorData( code=-32002, message=f"Resource not found: {str(uri)!r}" ) ) from e except NotFoundError as e: raise McpError( mcp.types.ErrorData(code=-32002, message=f"Resource not found: {e}") ) from e async def _get_prompt_mcp( self, name: str, arguments: dict[str, Any] | None ) -> mcp.types.GetPromptResult | mcp.types.CreateTaskResult: """Handle MCP 'getPrompt' requests. Extracts task metadata from MCP request context and passes it explicitly to render_prompt(). The prompt's _render() method handles the backgrounding decision, ensuring middleware runs before Docket. Args: name: The prompt name arguments: Prompt arguments Returns: GetPromptResult or CreateTaskResult for background execution """ server = cast("FastMCP", self) logger.debug( f"[{server.name}] Handler called: get_prompt %s with %s", name, arguments ) try: # Extract version and task metadata from request context. # fn_key is set by render_prompt() after finding the prompt. version_str: str | None = None task_meta: TaskMeta | None = None try: ctx = server._mcp_server.request_context # Extract version from request-level _meta.fastmcp.version if ctx.meta: meta_dict = ctx.meta.model_dump(exclude_none=True) version_str = meta_dict.get("fastmcp", {}).get("version") # Extract SEP-1686 task metadata if ctx.experimental.is_task: mcp_task_meta = ctx.experimental.task_metadata task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) except (AttributeError, LookupError): pass version = VersionSpec(eq=version_str) if version_str else None result = await server.render_prompt( name, arguments, version=version, task_meta=task_meta ) if isinstance(result, mcp.types.CreateTaskResult): return result return result.to_mcp_prompt_result() except DisabledError as e: raise NotFoundError(f"Unknown prompt: {name!r}") from e except NotFoundError: raise async def _set_logging_level_mcp(self, level: mcp.types.LoggingLevel) -> None: """Handle MCP 'logging/setLevel' requests. Stores the requested minimum log level on the session so that subsequent log messages below this level are suppressed. """ from fastmcp.server.low_level import MiddlewareServerSession server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: set_logging_level %s", level) try: ctx = server._mcp_server.request_context session = ctx.session if isinstance(session, MiddlewareServerSession): session._minimum_logging_level = level except LookupError: pass ================================================ FILE: src/fastmcp/server/mixins/transport.py ================================================ """Transport-related methods for FastMCP Server.""" from __future__ import annotations from collections.abc import Awaitable, Callable from functools import partial from typing import TYPE_CHECKING, Any, Literal import anyio import uvicorn from mcp.server.lowlevel.server import NotificationOptions from mcp.server.stdio import stdio_server from starlette.middleware import Middleware as ASGIMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import BaseRoute, Route import fastmcp from fastmcp.server.event_store import EventStore from fastmcp.server.http import ( StarletteWithLifespan, create_sse_app, create_streamable_http_app, ) from fastmcp.server.providers.base import Provider from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.wrapped_provider import _WrappedProvider from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.logging import get_logger, temporary_log_level if TYPE_CHECKING: from fastmcp.server.server import FastMCP, Transport logger = get_logger(__name__) class TransportMixin: """Mixin providing transport-related methods for FastMCP. Includes HTTP/stdio/SSE transport handling and custom HTTP routes. """ async def run_async( self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any, ) -> None: """Run the FastMCP server asynchronously. Args: transport: Transport protocol to use ("stdio", "http", "sse", or "streamable-http") show_banner: Whether to display the server banner. If None, uses the FASTMCP_SHOW_SERVER_BANNER setting (default: True). """ if show_banner is None: show_banner = fastmcp.settings.show_server_banner if transport is None: transport = fastmcp.settings.transport if transport not in {"stdio", "http", "sse", "streamable-http"}: raise ValueError(f"Unknown transport: {transport}") if transport == "stdio": await self.run_stdio_async( show_banner=show_banner, **transport_kwargs, ) elif transport in {"http", "sse", "streamable-http"}: await self.run_http_async( transport=transport, show_banner=show_banner, **transport_kwargs, ) else: raise ValueError(f"Unknown transport: {transport}") def run( self: FastMCP, transport: Transport | None = None, show_banner: bool | None = None, **transport_kwargs: Any, ) -> None: """Run the FastMCP server. Note this is a synchronous function. Args: transport: Transport protocol to use ("http", "stdio", "sse", or "streamable-http") show_banner: Whether to display the server banner. If None, uses the FASTMCP_SHOW_SERVER_BANNER setting (default: True). """ anyio.run( partial( self.run_async, transport, show_banner=show_banner, **transport_kwargs, ) ) def custom_route( self: FastMCP, path: str, methods: list[str], name: str | None = None, include_in_schema: bool = True, ) -> Callable[ [Callable[[Request], Awaitable[Response]]], Callable[[Request], Awaitable[Response]], ]: """ Decorator to register a custom HTTP route on the FastMCP server. Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, which can be useful for OAuth callbacks, health checks, or admin APIs. The handler function must be an async function that accepts a Starlette Request and returns a Response. Args: path: URL path for the route (e.g., "/auth/callback") methods: List of HTTP methods to support (e.g., ["GET", "POST"]) name: Optional name for the route (to reference this route with Starlette's reverse URL lookup feature) include_in_schema: Whether to include in OpenAPI schema, defaults to True Example: Register a custom HTTP route for a health check endpoint: ```python @server.custom_route("/health", methods=["GET"]) async def health_check(request: Request) -> Response: return JSONResponse({"status": "ok"}) ``` """ def decorator( fn: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: self._additional_http_routes.append( Route( path, endpoint=fn, methods=methods, name=name, include_in_schema=include_in_schema, ) ) return fn return decorator def _get_additional_http_routes(self: FastMCP) -> list[BaseRoute]: """Get all additional HTTP routes including from mounted servers. Collects custom HTTP routes registered via ``@server.custom_route()`` from this server **and** from any FastMCP servers reachable through mounted providers (recursively). This ensures that routes defined on a child server are forwarded to the parent's HTTP app when using ``server.mount(child)``. Note: When path collisions occur between a parent and a mounted child, the parent's routes take precedence because they appear first in the returned list. Returns: List of Starlette Route objects """ routes: list[BaseRoute] = list(self._additional_http_routes) def _unwrap_provider(provider: Provider) -> Provider: """Unwrap _WrappedProvider layers to find the inner provider.""" while isinstance(provider, _WrappedProvider): provider = provider._inner return provider for provider in self.providers: inner = _unwrap_provider(provider) if isinstance(inner, FastMCPProvider): # Recurse into the mounted server to collect its routes # (and any routes from servers mounted on *it*). routes.extend(inner.server._get_additional_http_routes()) return routes async def run_stdio_async( self: FastMCP, show_banner: bool = True, log_level: str | None = None, stateless: bool = False, ) -> None: """Run the server using stdio transport. Args: show_banner: Whether to display the server banner log_level: Log level for the server stateless: Whether to run in stateless mode (no session initialization) """ from fastmcp.server.context import reset_transport, set_transport # Display server banner if show_banner: log_server_banner(server=self) token = set_transport("stdio") try: with temporary_log_level(log_level): async with self._lifespan_manager(): async with stdio_server() as (read_stream, write_stream): mode = " (stateless)" if stateless else "" logger.info( f"Starting MCP server {self.name!r} with transport 'stdio'{mode}" ) await self._mcp_server.run( read_stream, write_stream, self._mcp_server.create_initialization_options( notification_options=NotificationOptions( tools_changed=True ), ), stateless=stateless, ) finally: reset_transport(token) async def run_http_async( self: FastMCP, 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, json_response: bool | None = None, stateless_http: bool | None = None, stateless: bool | None = None, ) -> None: """Run the server using HTTP transport. Args: transport: Transport protocol to use - "http" (default), "streamable-http", or "sse" host: Host address to bind to (defaults to settings.host) port: Port to bind to (defaults to settings.port) log_level: Log level for the server (defaults to settings.log_level) path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) uvicorn_config: Additional configuration for the Uvicorn server middleware: A list of middleware to apply to the app json_response: Whether to use JSON response format (defaults to settings.json_response) stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http) stateless: Alias for stateless_http for CLI consistency """ # Allow stateless as alias for stateless_http if stateless is not None and stateless_http is None: stateless_http = stateless # Resolve from settings/env var if not explicitly set if stateless_http is None: stateless_http = fastmcp.settings.stateless_http # SSE doesn't support stateless mode if stateless_http and transport == "sse": raise ValueError("SSE transport does not support stateless mode") host = host or fastmcp.settings.host port = port or fastmcp.settings.port default_log_level_to_use = (log_level or fastmcp.settings.log_level).lower() app = self.http_app( path=path, transport=transport, middleware=middleware, json_response=json_response, stateless_http=stateless_http, ) # Display server banner if show_banner: log_server_banner(server=self) uvicorn_config_from_user = uvicorn_config or {} config_kwargs: dict[str, Any] = { "timeout_graceful_shutdown": 2, "lifespan": "on", "ws": "websockets-sansio", } config_kwargs.update(uvicorn_config_from_user) if "log_config" not in config_kwargs and "log_level" not in config_kwargs: config_kwargs["log_level"] = default_log_level_to_use with temporary_log_level(log_level): async with self._lifespan_manager(): config = uvicorn.Config(app, host=host, port=port, **config_kwargs) server = uvicorn.Server(config) path = getattr(app.state, "path", "").lstrip("/") mode = " (stateless)" if stateless_http else "" logger.info( f"Starting MCP server {self.name!r} with transport {transport!r}{mode} on http://{host}:{port}/{path}" ) await server.serve() def http_app( self: FastMCP, 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", event_store: EventStore | None = None, retry_interval: int | None = None, ) -> StarletteWithLifespan: """Create a Starlette app using the specified HTTP transport. Args: path: The path for the HTTP endpoint middleware: A list of middleware to apply to the app json_response: Whether to use JSON response format stateless_http: Whether to use stateless mode (new transport per request) transport: Transport protocol to use - "http", "streamable-http", or "sse" event_store: Optional event store for SSE polling/resumability. When set, enables clients to reconnect and resume receiving events after server-initiated disconnections. Only used with streamable-http transport. retry_interval: Optional retry interval in milliseconds for SSE polling. Controls how quickly clients should reconnect after server-initiated disconnections. Requires event_store to be set. Only used with streamable-http transport. Returns: A Starlette application configured with the specified transport """ if transport in ("streamable-http", "http"): return create_streamable_http_app( server=self, streamable_http_path=path or fastmcp.settings.streamable_http_path, event_store=event_store, retry_interval=retry_interval, auth=self.auth, json_response=( json_response if json_response is not None else fastmcp.settings.json_response ), stateless_http=( stateless_http if stateless_http is not None else fastmcp.settings.stateless_http ), debug=fastmcp.settings.debug, middleware=middleware, ) elif transport == "sse": return create_sse_app( server=self, message_path=fastmcp.settings.message_path, sse_path=path or fastmcp.settings.sse_path, auth=self.auth, debug=fastmcp.settings.debug, middleware=middleware, ) else: raise ValueError(f"Unknown transport: {transport}") ================================================ FILE: src/fastmcp/server/openapi/__init__.py ================================================ """OpenAPI server implementation for FastMCP. .. deprecated:: This module is deprecated. Import from fastmcp.server.providers.openapi instead. The recommended approach is to use OpenAPIProvider with FastMCP: from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("My API Server") mcp.add_provider(provider) FastMCPOpenAPI is still available but deprecated. """ import warnings warnings.warn( "fastmcp.server.openapi is deprecated. " "Import from fastmcp.server.providers.openapi instead.", DeprecationWarning, stacklevel=2, ) # Re-export from new canonical location from fastmcp.server.providers.openapi import ( # noqa: E402 ComponentFn as ComponentFn, MCPType as MCPType, OpenAPIProvider as OpenAPIProvider, OpenAPIResource as OpenAPIResource, OpenAPIResourceTemplate as OpenAPIResourceTemplate, OpenAPITool as OpenAPITool, RouteMap as RouteMap, RouteMapFn as RouteMapFn, ) # Keep FastMCPOpenAPI for backwards compat (it has its own deprecation warning) from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402 __all__ = [ "ComponentFn", "FastMCPOpenAPI", "MCPType", "OpenAPIProvider", "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", "RouteMap", "RouteMapFn", ] ================================================ FILE: src/fastmcp/server/openapi/components.py ================================================ """OpenAPI component implementations - backwards compatibility stub. This module is deprecated. Import from fastmcp.server.providers.openapi instead. """ from __future__ import annotations import warnings warnings.warn( "fastmcp.server.openapi.components is deprecated. " "Import from fastmcp.server.providers.openapi instead.", DeprecationWarning, stacklevel=2, ) from fastmcp.server.providers.openapi import ( # noqa: E402 OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, ) # Export public symbols __all__ = [ "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", ] ================================================ FILE: src/fastmcp/server/openapi/routing.py ================================================ """Route mapping logic for OpenAPI operations. .. deprecated:: This module is deprecated. Import from fastmcp.server.providers.openapi instead. """ # ruff: noqa: E402 import warnings # Backwards compatibility - export everything that was previously public __all__ = [ "DEFAULT_ROUTE_MAPPINGS", "ComponentFn", "MCPType", "RouteMap", "RouteMapFn", "_determine_route_type", ] warnings.warn( "fastmcp.server.openapi.routing is deprecated. " "Import from fastmcp.server.providers.openapi instead.", DeprecationWarning, stacklevel=2, ) # Re-export from new canonical location from fastmcp.server.providers.openapi.routing import ( DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, ) from fastmcp.server.providers.openapi.routing import ( ComponentFn as ComponentFn, ) from fastmcp.server.providers.openapi.routing import ( MCPType as MCPType, ) from fastmcp.server.providers.openapi.routing import ( RouteMap as RouteMap, ) from fastmcp.server.providers.openapi.routing import ( RouteMapFn as RouteMapFn, ) from fastmcp.server.providers.openapi.routing import ( _determine_route_type as _determine_route_type, ) ================================================ FILE: src/fastmcp/server/openapi/server.py ================================================ """FastMCPOpenAPI - backwards compatibility wrapper. This class is deprecated. Use FastMCP with OpenAPIProvider instead: from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("My API Server", providers=[provider]) """ from __future__ import annotations import warnings from typing import Any import httpx from fastmcp.server.providers.openapi import ( ComponentFn, OpenAPIProvider, RouteMap, RouteMapFn, ) from fastmcp.server.server import FastMCP class FastMCPOpenAPI(FastMCP): """FastMCP server implementation that creates components from an OpenAPI schema. .. deprecated:: Use FastMCP with OpenAPIProvider instead. This class will be removed in a future version. Example (deprecated): ```python from fastmcp.server.openapi import FastMCPOpenAPI import httpx server = FastMCPOpenAPI( openapi_spec=spec, client=httpx.AsyncClient(), ) ``` New approach: ```python from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("API Server", providers=[provider]) ``` """ def __init__( self, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, **settings: Any, ): """Initialize a FastMCP server from an OpenAPI schema. .. deprecated:: Use FastMCP with OpenAPIProvider instead. Args: openapi_spec: OpenAPI schema as a dictionary client: Optional httpx AsyncClient for making HTTP requests. If not provided, a default client is created from the spec. name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components **settings: Additional settings for FastMCP """ warnings.warn( "FastMCPOpenAPI is deprecated. Use FastMCP with OpenAPIProvider instead:\n" " provider = OpenAPIProvider(openapi_spec=spec, client=client)\n" " mcp = FastMCP('name', providers=[provider])", DeprecationWarning, stacklevel=2, ) super().__init__(name=name or "OpenAPI FastMCP", **settings) # Store references for backwards compatibility self._client = client self._mcp_component_fn = mcp_component_fn # Create provider with the client provider = OpenAPIProvider( openapi_spec=openapi_spec, client=client, route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, ) self.add_provider(provider) # Expose internal attributes for backwards compatibility self._spec = provider._spec self._director = provider._director # Export public symbols __all__ = [ "FastMCPOpenAPI", ] ================================================ FILE: src/fastmcp/server/providers/__init__.py ================================================ """Providers for dynamic MCP components. This module provides the `Provider` abstraction for providing tools, resources, and prompts dynamically at runtime. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): def __init__(self, db_url: str): self.db = Database(db_url) async def _list_tools(self) -> list[Tool]: rows = await self.db.fetch("SELECT * FROM tools") return [self._make_tool(row) for row in rows] async def _get_tool(self, name: str) -> Tool | None: row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) return self._make_tool(row) if row else None mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) ``` """ from typing import TYPE_CHECKING from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.providers.base import Provider from fastmcp.server.providers.fastmcp_provider import FastMCPProvider from fastmcp.server.providers.filesystem import FileSystemProvider from fastmcp.server.providers.local_provider import LocalProvider from fastmcp.server.providers.skills import ( ClaudeSkillsProvider, SkillProvider, SkillsDirectoryProvider, SkillsProvider, ) if TYPE_CHECKING: from fastmcp.server.providers.openapi import OpenAPIProvider as OpenAPIProvider from fastmcp.server.providers.proxy import ProxyProvider as ProxyProvider __all__ = [ "AggregateProvider", "ClaudeSkillsProvider", "FastMCPProvider", "FileSystemProvider", "LocalProvider", "OpenAPIProvider", "Provider", "ProxyProvider", "SkillProvider", "SkillsDirectoryProvider", "SkillsProvider", # Backwards compatibility alias for SkillsDirectoryProvider ] def __getattr__(name: str): """Lazy import for providers to avoid circular imports.""" if name == "ProxyProvider": from fastmcp.server.providers.proxy import ProxyProvider return ProxyProvider if name == "OpenAPIProvider": from fastmcp.server.providers.openapi import OpenAPIProvider return OpenAPIProvider raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ================================================ FILE: src/fastmcp/server/providers/aggregate.py ================================================ """AggregateProvider for combining multiple providers into one. This module provides `AggregateProvider`, a utility class that presents multiple providers as a single unified provider. Useful when you want to combine custom providers without creating a full FastMCP server. Example: ```python from fastmcp.server.providers import AggregateProvider # Combine multiple providers into one combined = AggregateProvider() combined.add_provider(provider1) combined.add_provider(provider2, namespace="api") # Tools become "api_foo" # Use like any other provider tools = await combined.list_tools() ``` """ from __future__ import annotations import logging from collections.abc import AsyncIterator, Sequence from contextlib import AsyncExitStack, asynccontextmanager from typing import TYPE_CHECKING, TypeVar from fastmcp.exceptions import NotFoundError from fastmcp.server.providers.base import Provider from fastmcp.server.transforms import Namespace from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool logger = logging.getLogger(__name__) T = TypeVar("T") class AggregateProvider(Provider): """Utility provider that combines multiple providers into one. Components are aggregated from all providers. For get_* operations, providers are queried in parallel and the highest version is returned. When adding providers with a namespace, wrap_transform() is used to apply the Namespace transform. This means namespace transformation is handled by the wrapped provider, not by AggregateProvider. Errors from individual providers are logged and skipped (graceful degradation). Example: ```python combined = AggregateProvider() combined.add_provider(db_provider) combined.add_provider(api_provider, namespace="api") # db_provider's tools keep original names # api_provider's tools become "api_foo", "api_bar", etc. ``` """ def __init__(self, providers: Sequence[Provider] | None = None) -> None: """Initialize with an optional sequence of providers. Args: providers: Optional initial providers (without namespacing). For namespaced providers, use add_provider() instead. """ super().__init__() self.providers: list[Provider] = list(providers or []) def add_provider(self, provider: Provider, *, namespace: str = "") -> None: """Add a provider with optional namespace. If the provider is a FastMCP server, it's automatically wrapped in FastMCPProvider to ensure middleware is invoked correctly. Args: provider: The provider to add. namespace: Optional namespace prefix. When set: - Tools become "namespace_toolname" - Resources become "protocol://namespace/path" - Prompts become "namespace_promptname" """ # Import here to avoid circular imports from fastmcp.server.server import FastMCP # Auto-wrap FastMCP servers to ensure middleware is invoked if isinstance(provider, FastMCP): from fastmcp.server.providers.fastmcp_provider import FastMCPProvider provider = FastMCPProvider(provider) # Apply namespace via wrap_transform if specified if namespace: provider = provider.wrap_transform(Namespace(namespace)) self.providers.append(provider) def _collect_list_results( self, results: list[Sequence[T] | BaseException], operation: str ) -> list[T]: """Collect successful list results, logging any exceptions.""" collected: list[T] = [] for i, result in enumerate(results): if isinstance(result, BaseException): logger.debug( f"Error during {operation} from provider " f"{self.providers[i]}: {result}" ) continue collected.extend(result) return collected def _get_highest_version_result( self, results: list[FastMCPComponent | None | BaseException], operation: str, ) -> FastMCPComponent | None: """Get the highest version from successful non-None results. Used for versioned components where we want the highest version across all providers rather than the first match. """ valid: list[FastMCPComponent] = [] for i, result in enumerate(results): if isinstance(result, BaseException): if not isinstance(result, NotFoundError): logger.debug( f"Error during {operation} from provider " f"{self.providers[i]}: {result}" ) continue if result is not None: valid.append(result) if not valid: return None return max(valid, key=version_sort_key) def __repr__(self) -> str: return f"AggregateProvider(providers={self.providers!r})" # ------------------------------------------------------------------------- # Tools # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """List all tools from all providers.""" results = await gather( *[p.list_tools() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_tools") async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get tool by name from providers.""" results = await gather( *[p.get_tool(name, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def _list_resources(self) -> Sequence[Resource]: """List all resources from all providers.""" results = await gather( *[p.list_resources() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_resources") async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get resource by URI from providers.""" results = await gather( *[p.get_resource(uri, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """List all resource templates from all providers.""" results = await gather( *[p.list_resource_templates() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_resource_templates") async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get resource template by URI from providers.""" results = await gather( *[p.get_resource_template(uri, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result( list(results), f"get_resource_template({uri!r})" ) # type: ignore[return-value] # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def _list_prompts(self) -> Sequence[Prompt]: """List all prompts from all providers.""" results = await gather( *[p.list_prompts() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "list_prompts") async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get prompt by name from providers.""" results = await gather( *[p.get_prompt(name, version) for p in self.providers], return_exceptions=True, ) return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] # ------------------------------------------------------------------------- # Tasks # ------------------------------------------------------------------------- async def get_tasks(self) -> Sequence[FastMCPComponent]: """Get all task-eligible components from all providers.""" results = await gather( *[p.get_tasks() for p in self.providers], return_exceptions=True, ) return self._collect_list_results(results, "get_tasks") # ------------------------------------------------------------------------- # Lifecycle # ------------------------------------------------------------------------- @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: """Combine lifespans of all providers.""" async with AsyncExitStack() as stack: for p in self.providers: await stack.enter_async_context(p.lifespan()) yield ================================================ FILE: src/fastmcp/server/providers/base.py ================================================ """Base Provider class for dynamic MCP components. This module provides the `Provider` abstraction for providing tools, resources, and prompts dynamically at runtime. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers import Provider from fastmcp.tools import Tool class DatabaseProvider(Provider): def __init__(self, db_url: str): super().__init__() self.db = Database(db_url) async def _list_tools(self) -> list[Tool]: rows = await self.db.fetch("SELECT * FROM tools") return [self._make_tool(row) for row in rows] async def _get_tool(self, name: str) -> Tool | None: row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) return self._make_tool(row) if row else None mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) ``` """ from __future__ import annotations from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from functools import partial from typing import TYPE_CHECKING, Literal, cast from typing_extensions import Self from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms.visibility import Visibility from fastmcp.tools.base import Tool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: from fastmcp.server.transforms import Transform class Provider: """Base class for dynamic component providers. Subclass and override whichever methods you need. Default implementations return empty lists / None, so you only need to implement what your provider supports. Provider semantics: - Return `None` from `get_*` methods to indicate "I don't have it" (search continues) - Static components (registered via decorators) always take precedence over providers - Providers are queried in registration order; first non-None wins - Components execute themselves via run()/read()/render() - providers just source them Error handling: - `list_*` methods: Errors are logged and the provider returns empty (graceful degradation). This allows other providers to still contribute their components. """ def __init__(self) -> None: self._transforms: list[Transform] = [] def __repr__(self) -> str: return f"{self.__class__.__name__}()" @property def transforms(self) -> list[Transform]: """All transforms applied to components from this provider.""" return list(self._transforms) def add_transform(self, transform: Transform) -> None: """Add a transform to this provider. Transforms modify components (tools, resources, prompts) as they flow through the provider. They're applied in order - first added is innermost. Args: transform: The transform to add. Example: ```python from fastmcp.server.transforms import Namespace provider = MyProvider() provider.add_transform(Namespace("api")) # Tools become "api_toolname" ``` """ self._transforms.append(transform) def wrap_transform(self, transform: Transform) -> Provider: """Return a new provider with this transform applied (immutable). Unlike add_transform() which mutates this provider, wrap_transform() returns a new provider that wraps this one. The original provider is unchanged. This is useful when you want to apply transforms without side effects, such as adding the same provider to multiple aggregators with different namespaces. Args: transform: The transform to apply. Returns: A new provider that wraps this one with the transform applied. Example: ```python from fastmcp.server.transforms import Namespace provider = MyProvider() namespaced = provider.wrap_transform(Namespace("api")) # provider is unchanged # namespaced returns tools as "api_toolname" ``` """ # Import here to avoid circular imports from fastmcp.server.providers.wrapped_provider import _WrappedProvider return _WrappedProvider(self, transform) # ------------------------------------------------------------------------- # Internal transform chain building # ------------------------------------------------------------------------- async def list_tools(self) -> Sequence[Tool]: """List tools with all transforms applied. Applies transforms sequentially: base → transforms (in order). Each transform receives the result from the previous transform. Components may be marked as disabled but are NOT filtered here - filtering happens at the server level to allow session transforms to override. Returns: Transformed sequence of tools (including disabled ones). """ tools = await self._list_tools() for transform in self.transforms: tools = await transform.list_tools(tools) return tools async def get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get tool by transformed name with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete, allowing session-level transforms to override provider-level disables. Args: name: The transformed tool name to look up. version: Optional version filter. If None, returns highest version. Returns: The tool if found (may be marked disabled), None if not found. """ async def base(n: str, version: VersionSpec | None = None) -> Tool | None: return await self._get_tool(n, version) chain = base for transform in self.transforms: chain = partial(transform.get_tool, call_next=chain) return await chain(name, version=version) async def list_resources(self) -> Sequence[Resource]: """List resources with all transforms applied. Components may be marked as disabled but are NOT filtered here. """ resources = await self._list_resources() for transform in self.transforms: resources = await transform.list_resources(resources) return resources async def get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get resource by transformed URI with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. Args: uri: The transformed resource URI to look up. version: Optional version filter. If None, returns highest version. Returns: The resource if found (may be marked disabled), None if not found. """ async def base(u: str, version: VersionSpec | None = None) -> Resource | None: return await self._get_resource(u, version) chain = base for transform in self.transforms: chain = partial(transform.get_resource, call_next=chain) return await chain(uri, version=version) async def list_resource_templates(self) -> Sequence[ResourceTemplate]: """List resource templates with all transforms applied. Components may be marked as disabled but are NOT filtered here. """ templates = await self._list_resource_templates() for transform in self.transforms: templates = await transform.list_resource_templates(templates) return templates async def get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get resource template by transformed URI with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. Args: uri: The transformed template URI to look up. version: Optional version filter. If None, returns highest version. Returns: The template if found (may be marked disabled), None if not found. """ async def base( u: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: return await self._get_resource_template(u, version) chain = base for transform in self.transforms: chain = partial(transform.get_resource_template, call_next=chain) return await chain(uri, version=version) async def list_prompts(self) -> Sequence[Prompt]: """List prompts with all transforms applied. Components may be marked as disabled but are NOT filtered here. """ prompts = await self._list_prompts() for transform in self.transforms: prompts = await transform.list_prompts(prompts) return prompts async def get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get prompt by transformed name with all transforms applied. Note: This method does NOT filter disabled components. The Server (FastMCP) performs enabled filtering after all transforms complete. Args: name: The transformed prompt name to look up. version: Optional version filter. If None, returns highest version. Returns: The prompt if found (may be marked disabled), None if not found. """ async def base(n: str, version: VersionSpec | None = None) -> Prompt | None: return await self._get_prompt(n, version) chain = base for transform in self.transforms: chain = partial(transform.get_prompt, call_next=chain) return await chain(name, version=version) # ------------------------------------------------------------------------- # Private list/get methods (override these to provide components) # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """Return all available tools. Override to provide tools dynamically. Returns ALL versions of all tools. The server handles deduplication to show one tool per name. """ return [] async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a specific tool by name. Default implementation filters _list_tools() and picks the highest version that matches the spec. Args: name: The tool name. version: Optional version filter. If None, returns highest version. If specified, returns highest version matching the spec. Returns: The Tool if found, or None to continue searching other providers. """ tools = await self._list_tools() matching = [t for t in tools if t.name == name] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resources(self) -> Sequence[Resource]: """Return all available resources. Override to provide resources dynamically. Returns ALL versions of all resources. The server handles deduplication to show one resource per URI. """ return [] async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a specific resource by URI. Default implementation filters _list_resources() and returns highest version matching the spec. Args: uri: The resource URI. version: Optional version filter. If None, returns highest version. Returns: The Resource if found, or None to continue searching other providers. """ resources = await self._list_resources() matching = [r for r in resources if str(r.uri) == uri] if version: matching = [r for r in matching if version.matches(r.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all available resource templates. Override to provide resource templates dynamically. Returns ALL versions. The server handles deduplication. """ return [] async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template that matches the given URI. Default implementation lists all templates, finds those whose pattern matches the URI, and returns the highest version matching the spec. Args: uri: The URI to match against templates. version: Optional version filter. If None, returns highest version. Returns: The ResourceTemplate if a matching one is found, or None to continue searching. """ templates = await self._list_resource_templates() matching = [t for t in templates if t.matches(uri) is not None] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_prompts(self) -> Sequence[Prompt]: """Return all available prompts. Override to provide prompts dynamically. Returns ALL versions of all prompts. The server handles deduplication to show one prompt per name. """ return [] async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a specific prompt by name. Default implementation filters _list_prompts() and picks the highest version matching the spec. Args: name: The prompt name. version: Optional version filter. If None, returns highest version. Returns: The Prompt if found, or None to continue searching other providers. """ prompts = await self._list_prompts() matching = [p for p in prompts if p.name == name] if version: matching = [p for p in matching if version.matches(p.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ------------------------------------------------------------------------- # Task registration # ------------------------------------------------------------------------- async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return components that should be registered as background tasks. Override to customize which components are task-eligible. Default calls list_* methods, applies provider transforms, and filters for components with task_config.mode != 'forbidden'. Used by the server during startup to register functions with Docket. """ # Fetch all component types in parallel results = await gather( self._list_tools(), self._list_resources(), self._list_resource_templates(), self._list_prompts(), ) tools = cast(Sequence[Tool], results[0]) resources = cast(Sequence[Resource], results[1]) templates = cast(Sequence[ResourceTemplate], results[2]) prompts = cast(Sequence[Prompt], results[3]) # Apply provider's own transforms sequentially # For tasks, we need the fully-transformed names for transform in self.transforms: tools = await transform.list_tools(tools) resources = await transform.list_resources(resources) templates = await transform.list_resource_templates(templates) prompts = await transform.list_prompts(prompts) return [ c for c in [ *tools, *resources, *templates, *prompts, ] if c.task_config.supports_tasks() ] # ------------------------------------------------------------------------- # Lifecycle methods # ------------------------------------------------------------------------- @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: """User-overridable lifespan for custom setup and teardown. Override this method to perform provider-specific initialization like opening database connections, setting up external resources, or other state management needed for the provider's lifetime. The lifespan scope matches the server's lifespan - code before yield runs at startup, code after yield runs at shutdown. Example: ```python @asynccontextmanager async def lifespan(self): # Setup self.db = await connect_database() try: yield finally: # Teardown await self.db.close() ``` """ yield # ------------------------------------------------------------------------- # Enable/Disable # ------------------------------------------------------------------------- def enable( self, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, only: bool = False, ) -> Self: """Enable components matching all specified criteria. Adds a visibility transform that marks matching components as enabled. Later transforms override earlier ones, so enable after disable makes the component enabled. With only=True, switches to allowlist mode - first disables everything, then enables matching components. Args: names: Component names or URIs to enable. keys: Component keys to enable (e.g., {"tool:my_tool@v1"}). version: Component version spec to enable (e.g., VersionSpec(eq="v1") or VersionSpec(gte="v2")). Unversioned components will not match. tags: Enable components with these tags. components: Component types to include (e.g., {"tool", "prompt"}). only: If True, ONLY enable matching components (allowlist mode). Returns: Self for method chaining. """ if only: # Allowlist: disable everything, then enable matching # The enable transform runs later on return path, so it overrides self._transforms.append(Visibility(False, match_all=True)) self._transforms.append( Visibility( True, names=names, keys=keys, version=version, components=set(components) if components else None, tags=set(tags) if tags else None, ) ) return self def disable( self, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, ) -> Self: """Disable components matching all specified criteria. Adds a visibility transform that marks matching components as disabled. Components can be re-enabled by calling enable() with matching criteria (the later transform wins). Args: names: Component names or URIs to disable. keys: Component keys to disable (e.g., {"tool:my_tool@v1"}). version: Component version spec to disable (e.g., VersionSpec(eq="v1") or VersionSpec(gte="v2")). Unversioned components will not match. tags: Disable components with these tags. components: Component types to include (e.g., {"tool", "prompt"}). Returns: Self for method chaining. """ self._transforms.append( Visibility( False, names=names, keys=keys, version=version, components=set(components) if components else None, tags=set(tags) if tags else None, ) ) return self ================================================ FILE: src/fastmcp/server/providers/fastmcp_provider.py ================================================ """FastMCPProvider for wrapping FastMCP servers as providers. This module provides the `FastMCPProvider` class that wraps a FastMCP server and exposes its components through the Provider interface. It also provides FastMCPProvider* component classes that delegate execution to the wrapped server's middleware, ensuring middleware runs when components are executed. """ from __future__ import annotations import re from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, overload from urllib.parse import quote import mcp.types from mcp.types import AnyUrl from fastmcp.prompts.base import Prompt, PromptResult from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.telemetry import delegate_span from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from fastmcp.server.server import FastMCP def _expand_uri_template(template: str, params: dict[str, Any]) -> str: """Expand a URI template with parameters. Handles both {name} path placeholders and RFC 6570 {?param1,param2} query parameter syntax. """ result = template # Replace {name} path placeholders for key, value in params.items(): result = re.sub(rf"\{{{key}\}}", str(value), result) # Expand {?param1,param2,...} query parameter blocks def _expand_query_block(match: re.Match[str]) -> str: names = [n.strip() for n in match.group(1).split(",")] parts = [] for name in names: if name in params: parts.append(f"{quote(name)}={quote(str(params[name]))}") if parts: return "?" + "&".join(parts) return "" result = re.sub(r"\{\?([^}]+)\}", _expand_query_block, result) return result # ----------------------------------------------------------------------------- # FastMCPProvider component classes # ----------------------------------------------------------------------------- class FastMCPProviderTool(Tool): """Tool that delegates execution to a wrapped server's middleware. When `run()` is called, this tool invokes the wrapped server's `_call_tool_middleware()` method, ensuring the server's middleware chain is executed. """ _server: Any = None # FastMCP, but Any to avoid circular import _original_name: str | None = None def __init__( self, server: Any, original_name: str, **kwargs: Any, ): super().__init__(**kwargs) self._server = server self._original_name = original_name @classmethod def wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool: """Wrap a Tool to delegate execution to the server's middleware.""" return cls( server=server, original_name=tool.name, name=tool.name, version=tool.version, description=tool.description, parameters=tool.parameters, output_schema=tool.output_schema, tags=tool.tags, annotations=tool.annotations, task_config=tool.task_config, meta=tool.get_meta(), title=tool.title, icons=tool.icons, ) @overload async def _run( self, arguments: dict[str, Any], task_meta: None = None, ) -> ToolResult: ... @overload async def _run( self, arguments: dict[str, Any], task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def _run( self, arguments: dict[str, Any], task_meta: TaskMeta | None = None, ) -> ToolResult | mcp.types.CreateTaskResult: """Delegate to child server's call_tool() with task_meta. Passes task_meta through to the child server so it can handle backgrounding appropriately. fn_key is already set by the parent server before calling this method. """ # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None with delegate_span( self._original_name or "", "FastMCPProvider", self._original_name or "" ): return await self._server.call_tool( self._original_name, arguments, version=version, task_meta=task_meta ) async def run(self, arguments: dict[str, Any]) -> ToolResult: """Delegate to child server's call_tool() without task_meta. This is called when the tool is used within a TransformedTool forwarding function or other contexts where task_meta is not available. """ # Pass exact version so child executes the correct version version = VersionSpec(eq=self.version) if self.version else None result = await self._server.call_tool( self._original_name, arguments, version=version ) # Result from call_tool should always be ToolResult when no task_meta if isinstance(result, mcp.types.CreateTaskResult): raise RuntimeError( "Unexpected CreateTaskResult from call_tool without task_meta" ) return result def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "FastMCPProvider", "fastmcp.delegate.original_name": self._original_name, } class FastMCPProviderResource(Resource): """Resource that delegates reading to a wrapped server's read_resource(). When `read()` is called, this resource invokes the wrapped server's `read_resource()` method, ensuring the server's middleware chain is executed. """ _server: Any = None # FastMCP, but Any to avoid circular import _original_uri: str | None = None def __init__( self, server: Any, original_uri: str, **kwargs: Any, ): super().__init__(**kwargs) self._server = server self._original_uri = original_uri @classmethod def wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource: """Wrap a Resource to delegate reading to the server's middleware.""" return cls( server=server, original_uri=str(resource.uri), uri=resource.uri, version=resource.version, name=resource.name, description=resource.description, mime_type=resource.mime_type, tags=resource.tags, annotations=resource.annotations, task_config=resource.task_config, meta=resource.get_meta(), title=resource.title, icons=resource.icons, ) @overload async def _read(self, task_meta: None = None) -> ResourceResult: ... @overload async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ... async def _read( self, task_meta: TaskMeta | None = None ) -> ResourceResult | mcp.types.CreateTaskResult: """Delegate to child server's read_resource() with task_meta. Passes task_meta through to the child server so it can handle backgrounding appropriately. fn_key is already set by the parent server before calling this method. """ # Pass exact version so child reads the correct version version = VersionSpec(eq=self.version) if self.version else None with delegate_span( self._original_uri or "", "FastMCPProvider", self._original_uri or "" ): return await self._server.read_resource( self._original_uri, version=version, task_meta=task_meta ) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "FastMCPProvider", "fastmcp.delegate.original_uri": self._original_uri, } class FastMCPProviderPrompt(Prompt): """Prompt that delegates rendering to a wrapped server's render_prompt(). When `render()` is called, this prompt invokes the wrapped server's `render_prompt()` method, ensuring the server's middleware chain is executed. """ _server: Any = None # FastMCP, but Any to avoid circular import _original_name: str | None = None def __init__( self, server: Any, original_name: str, **kwargs: Any, ): super().__init__(**kwargs) self._server = server self._original_name = original_name @classmethod def wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt: """Wrap a Prompt to delegate rendering to the server's middleware.""" return cls( server=server, original_name=prompt.name, name=prompt.name, version=prompt.version, description=prompt.description, arguments=prompt.arguments, tags=prompt.tags, task_config=prompt.task_config, meta=prompt.get_meta(), title=prompt.title, icons=prompt.icons, ) @overload async def _render( self, arguments: dict[str, Any] | None = None, task_meta: None = None, ) -> PromptResult: ... @overload async def _render( self, arguments: dict[str, Any] | None, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def _render( self, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: """Delegate to child server's render_prompt() with task_meta. Passes task_meta through to the child server so it can handle backgrounding appropriately. fn_key is already set by the parent server before calling this method. """ # Pass exact version so child renders the correct version version = VersionSpec(eq=self.version) if self.version else None with delegate_span( self._original_name or "", "FastMCPProvider", self._original_name or "" ): return await self._server.render_prompt( self._original_name, arguments, version=version, task_meta=task_meta ) async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult: """Delegate to child server's render_prompt() without task_meta. This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. """ # Pass exact version so child renders the correct version version = VersionSpec(eq=self.version) if self.version else None result = await self._server.render_prompt( self._original_name, arguments, version=version ) # Result from render_prompt should always be PromptResult when no task_meta if isinstance(result, mcp.types.CreateTaskResult): raise RuntimeError( "Unexpected CreateTaskResult from render_prompt without task_meta" ) return result def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "FastMCPProvider", "fastmcp.delegate.original_name": self._original_name, } class FastMCPProviderResourceTemplate(ResourceTemplate): """Resource template that creates FastMCPProviderResources. When `create_resource()` is called, this template creates a FastMCPProviderResource that will invoke the wrapped server's middleware when read. """ _server: Any = None # FastMCP, but Any to avoid circular import _original_uri_template: str | None = None def __init__( self, server: Any, original_uri_template: str, **kwargs: Any, ): super().__init__(**kwargs) self._server = server self._original_uri_template = original_uri_template @classmethod def wrap( cls, server: Any, template: ResourceTemplate ) -> FastMCPProviderResourceTemplate: """Wrap a ResourceTemplate to create FastMCPProviderResources.""" return cls( server=server, original_uri_template=template.uri_template, uri_template=template.uri_template, version=template.version, name=template.name, description=template.description, mime_type=template.mime_type, parameters=template.parameters, tags=template.tags, annotations=template.annotations, task_config=template.task_config, meta=template.get_meta(), title=template.title, icons=template.icons, ) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a FastMCPProviderResource for the given URI. The `uri` is the external/transformed URI (e.g., with namespace prefix). We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. """ # Expand the original template with params to get internal URI original_uri = _expand_uri_template(self._original_uri_template or "", params) return FastMCPProviderResource( server=self._server, original_uri=original_uri, uri=AnyUrl(uri), name=self.name, description=self.description, mime_type=self.mime_type, ) @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: None = None ) -> ResourceResult: ... @overload async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta ) -> mcp.types.CreateTaskResult: ... async def _read( self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None ) -> ResourceResult | mcp.types.CreateTaskResult: """Delegate to child server's read_resource() with task_meta. Passes task_meta through to the child server so it can handle backgrounding appropriately. fn_key is already set by the parent server before calling this method. """ # Expand the original template with params to get internal URI original_uri = _expand_uri_template(self._original_uri_template or "", params) # Pass exact version so child reads the correct version version = VersionSpec(eq=self.version) if self.version else None with delegate_span( original_uri, "FastMCPProvider", self._original_uri_template or "" ): return await self._server.read_resource( original_uri, version=version, task_meta=task_meta ) async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read the resource content for background task execution. Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. """ # Expand the original template with arguments to get internal URI original_uri = _expand_uri_template( self._original_uri_template or "", arguments ) # Pass exact version so child reads the correct version version = VersionSpec(eq=self.version) if self.version else None # Read from the wrapped server result = await self._server.read_resource(original_uri, version=version) if isinstance(result, mcp.types.CreateTaskResult): raise RuntimeError("Unexpected CreateTaskResult during Docket execution") return result def register_with_docket(self, docket: Docket) -> None: """No-op: the child's actual template is registered via get_tasks().""" async def add_to_docket( self, docket: Docket, params: dict[str, Any], *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this template for background execution via docket. The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(**params) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "FastMCPProvider", "fastmcp.delegate.original_uri_template": self._original_uri_template, } # ----------------------------------------------------------------------------- # FastMCPProvider # ----------------------------------------------------------------------------- class FastMCPProvider(Provider): """Provider that wraps a FastMCP server. This provider enables mounting one FastMCP server onto another, exposing the mounted server's tools, resources, and prompts through the parent server. Components returned by this provider are wrapped in FastMCPProvider* classes that delegate execution to the wrapped server's middleware chain. This ensures middleware runs when components are executed. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers import FastMCPProvider main = FastMCP("Main") sub = FastMCP("Sub") @sub.tool def greet(name: str) -> str: return f"Hello, {name}!" # Mount directly - tools accessible by original names main.add_provider(FastMCPProvider(sub)) # Or with namespace from fastmcp.server.transforms import Namespace provider = FastMCPProvider(sub) provider.add_transform(Namespace("sub")) main.add_provider(provider) ``` Note: Normally you would use `FastMCP.mount()` which handles proxy conversion and creates the provider with namespace automatically. """ def __init__(self, server: FastMCP[Any]): """Initialize a FastMCPProvider. Args: server: The FastMCP server to wrap. """ super().__init__() self.server = server # ------------------------------------------------------------------------- # Tool methods # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """List all tools from the mounted server as FastMCPProviderTools. Runs the mounted server's middleware so filtering/transformation applies. Wraps each tool as a FastMCPProviderTool that delegates execution to the nested server's middleware. """ raw_tools = await self.server.list_tools() return [FastMCPProviderTool.wrap(self.server, t) for t in raw_tools] async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name as a FastMCPProviderTool. Passes the full VersionSpec to the nested server, which handles both exact version matching and range filtering. Uses get_tool to ensure the nested server's transforms are applied. """ raw_tool = await self.server.get_tool(name, version) if raw_tool is None: return None return FastMCPProviderTool.wrap(self.server, raw_tool) # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- async def _list_resources(self) -> Sequence[Resource]: """List all resources from the mounted server as FastMCPProviderResources. Runs the mounted server's middleware so filtering/transformation applies. Wraps each resource as a FastMCPProviderResource that delegates reading to the nested server's middleware. """ raw_resources = await self.server.list_resources() return [FastMCPProviderResource.wrap(self.server, r) for r in raw_resources] async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a concrete resource by URI as a FastMCPProviderResource. Passes the full VersionSpec to the nested server, which handles both exact version matching and range filtering. Uses get_resource to ensure the nested server's transforms are applied. """ raw_resource = await self.server.get_resource(uri, version) if raw_resource is None: return None return FastMCPProviderResource.wrap(self.server, raw_resource) # ------------------------------------------------------------------------- # Resource template methods # ------------------------------------------------------------------------- async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """List all resource templates from the mounted server. Runs the mounted server's middleware so filtering/transformation applies. Returns FastMCPProviderResourceTemplate instances that create FastMCPProviderResources when materialized. """ raw_templates = await self.server.list_resource_templates() return [ FastMCPProviderResourceTemplate.wrap(self.server, t) for t in raw_templates ] async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template that matches the given URI. Passes the full VersionSpec to the nested server, which handles both exact version matching and range filtering. Uses get_resource_template to ensure the nested server's transforms are applied. """ raw_template = await self.server.get_resource_template(uri, version) if raw_template is None: return None return FastMCPProviderResourceTemplate.wrap(self.server, raw_template) # ------------------------------------------------------------------------- # Prompt methods # ------------------------------------------------------------------------- async def _list_prompts(self) -> Sequence[Prompt]: """List all prompts from the mounted server as FastMCPProviderPrompts. Runs the mounted server's middleware so filtering/transformation applies. Returns FastMCPProviderPrompt instances that delegate rendering to the wrapped server's middleware. """ raw_prompts = await self.server.list_prompts() return [FastMCPProviderPrompt.wrap(self.server, p) for p in raw_prompts] async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name as a FastMCPProviderPrompt. Passes the full VersionSpec to the nested server, which handles both exact version matching and range filtering. Uses get_prompt to ensure the nested server's transforms are applied. """ raw_prompt = await self.server.get_prompt(name, version) if raw_prompt is None: return None return FastMCPProviderPrompt.wrap(self.server, raw_prompt) # ------------------------------------------------------------------------- # Task registration # ------------------------------------------------------------------------- async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return task-eligible components from the mounted server. Returns the child's ACTUAL components (not wrapped) so their actual functions get registered with Docket. Gets components with child server's transforms applied, then applies this provider's transforms for correct registration keys. """ # Get tasks with child server's transforms already applied components = list(await self.server.get_tasks()) # Separate by type for this provider's transform application tools = [c for c in components if isinstance(c, Tool)] resources = [c for c in components if isinstance(c, Resource)] templates = [c for c in components if isinstance(c, ResourceTemplate)] prompts = [c for c in components if isinstance(c, Prompt)] # Apply this provider's transforms sequentially for transform in self.transforms: tools = await transform.list_tools(tools) resources = await transform.list_resources(resources) templates = await transform.list_resource_templates(templates) prompts = await transform.list_prompts(prompts) # Filter to only task-eligible components (same as base Provider) return [ c for c in [ *tools, *resources, *templates, *prompts, ] if c.task_config.supports_tasks() ] # ------------------------------------------------------------------------- # Lifecycle methods # ------------------------------------------------------------------------- @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: """Start the mounted server's user lifespan. This starts only the wrapped server's user-defined lifespan, NOT its full _lifespan_manager() (which includes Docket). The parent server's Docket handles all background tasks. """ async with self.server._lifespan(self.server): yield ================================================ FILE: src/fastmcp/server/providers/filesystem.py ================================================ """FileSystemProvider for filesystem-based component discovery. FileSystemProvider scans a directory for Python files, imports them, and registers any Tool, Resource, ResourceTemplate, or Prompt objects found. Components are created using the standalone decorators from fastmcp.tools, fastmcp.resources, and fastmcp.prompts: Example: ```python # In mcp/tools.py from fastmcp.tools import tool @tool def greet(name: str) -> str: return f"Hello, {name}!" # In main.py from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) ``` """ from __future__ import annotations import asyncio from collections.abc import Sequence from pathlib import Path from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.filesystem_discovery import discover_and_import from fastmcp.server.providers.local_provider import LocalProvider from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec logger = get_logger(__name__) class FileSystemProvider(LocalProvider): """Provider that discovers components from the filesystem. Scans a directory for Python files and registers any Tool, Resource, ResourceTemplate, or Prompt objects found. Components are created using the standalone decorators: - @tool from fastmcp.tools - @resource from fastmcp.resources - @prompt from fastmcp.prompts Args: root: Root directory to scan. Defaults to current directory. reload: If True, re-scan files on every request (dev mode). Defaults to False (scan once at init, cache results). Example: ```python # In mcp/tools.py from fastmcp.tools import tool @tool def greet(name: str) -> str: return f"Hello, {name}!" # In main.py from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers import FileSystemProvider # Path relative to this file mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) # Dev mode - re-scan on every request mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp", reload=True)]) ``` """ def __init__( self, root: str | Path = ".", reload: bool = False, ) -> None: super().__init__(on_duplicate="replace") self._root = Path(root).resolve() self._reload = reload self._loaded = False # Track files we've warned about: path -> mtime when warned # Re-warn if file changes (mtime differs) self._warned_files: dict[Path, float] = {} # Lock for serializing reload operations (created lazily) self._reload_lock: asyncio.Lock | None = None # Always load once at init to catch errors early self._load_components() def _load_components(self) -> None: """Discover and register all components from the filesystem.""" # Clear existing components if reloading if self._loaded: self._components.clear() result = discover_and_import(self._root) # Log warnings for failed files (only once per file version) for file_path, error in result.failed_files.items(): try: current_mtime = file_path.stat().st_mtime except OSError: current_mtime = 0.0 # Warn if we haven't warned about this file, or if it changed last_warned_mtime = self._warned_files.get(file_path) if last_warned_mtime is None or last_warned_mtime != current_mtime: logger.warning(f"Failed to import {file_path}: {error}") self._warned_files[file_path] = current_mtime # Clear warnings for files that now import successfully successful_files = {fp for fp, _ in result.components} for fp in successful_files: self._warned_files.pop(fp, None) for file_path, component in result.components: try: self._register_component(component) except Exception: logger.exception( "Failed to register %s from %s", getattr(component, "name", repr(component)), file_path, ) self._loaded = True logger.debug( f"FileSystemProvider loaded {len(self._components)} components from {self._root}" ) def _register_component(self, component: FastMCPComponent) -> None: """Register a single component based on its type.""" if isinstance(component, Tool): self.add_tool(component) elif isinstance(component, ResourceTemplate): self.add_template(component) elif isinstance(component, Resource): self.add_resource(component) elif isinstance(component, Prompt): self.add_prompt(component) else: logger.debug("Ignoring unknown component type: %r", type(component)) async def _ensure_loaded(self) -> None: """Ensure components are loaded, reloading if in reload mode. Uses a lock to serialize concurrent reload operations and runs filesystem I/O off the event loop using asyncio.to_thread. """ if not self._reload and self._loaded: return # Create lock lazily (can't create in __init__ without event loop) if self._reload_lock is None: self._reload_lock = asyncio.Lock() async with self._reload_lock: # Double-check after acquiring lock if self._reload or not self._loaded: await asyncio.to_thread(self._load_components) # Override provider methods to support reload mode async def _list_tools(self) -> Sequence[Tool]: """Return all tools, reloading if in reload mode.""" await self._ensure_loaded() return await super()._list_tools() async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, reloading if in reload mode.""" await self._ensure_loaded() return await super()._get_tool(name, version) async def _list_resources(self) -> Sequence[Resource]: """Return all resources, reloading if in reload mode.""" await self._ensure_loaded() return await super()._list_resources() async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI, reloading if in reload mode.""" await self._ensure_loaded() return await super()._get_resource(uri, version) async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all resource templates, reloading if in reload mode.""" await self._ensure_loaded() return await super()._list_resource_templates() async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template, reloading if in reload mode.""" await self._ensure_loaded() return await super()._get_resource_template(uri, version) async def _list_prompts(self) -> Sequence[Prompt]: """Return all prompts, reloading if in reload mode.""" await self._ensure_loaded() return await super()._list_prompts() async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name, reloading if in reload mode.""" await self._ensure_loaded() return await super()._get_prompt(name, version) def __repr__(self) -> str: return f"FileSystemProvider(root={self._root!r}, reload={self._reload})" ================================================ FILE: src/fastmcp/server/providers/filesystem_discovery.py ================================================ """File discovery and module import utilities for filesystem-based routing. This module provides functions to: 1. Discover Python files in a directory tree 2. Import modules (as packages if __init__.py exists, else directly) 3. Extract decorated components (Tool, Resource, Prompt objects) from imported modules """ from __future__ import annotations import importlib.util import sys from dataclasses import dataclass, field from pathlib import Path from types import ModuleType from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @dataclass class DiscoveryResult: """Result of filesystem discovery.""" # Components are real objects (Tool, Resource, ResourceTemplate, Prompt) components: list[tuple[Path, FastMCPComponent]] = field(default_factory=list) failed_files: dict[Path, str] = field(default_factory=dict) # path -> error message def discover_files(root: Path) -> list[Path]: """Recursively discover all Python files under a directory. Excludes __init__.py files (they're for package structure, not components). Args: root: Root directory to scan. Returns: List of .py file paths, sorted for deterministic order. """ if not root.exists(): return [] if not root.is_dir(): # If root is a file, just return it (if it's a .py file) if root.suffix == ".py" and root.name != "__init__.py": return [root] return [] files: list[Path] = [] for path in root.rglob("*.py"): # Skip __init__.py files if path.name == "__init__.py": continue # Skip __pycache__ directories if "__pycache__" in path.parts: continue files.append(path) # Sort for deterministic discovery order return sorted(files) def _is_package_dir(directory: Path) -> bool: """Check if a directory is a Python package (has __init__.py).""" return (directory / "__init__.py").exists() def _find_package_root(file_path: Path) -> Path | None: """Find the root of the package containing this file. Walks up the directory tree until we find a directory without __init__.py. Returns: The package root directory, or None if not in a package. """ current = file_path.parent package_root = None while current != current.parent: # Stop at filesystem root if _is_package_dir(current): package_root = current current = current.parent else: break return package_root def _compute_module_name(file_path: Path, package_root: Path) -> str: """Compute the dotted module name for a file within a package. Args: file_path: Path to the Python file. package_root: Root directory of the package. Returns: Dotted module name (e.g., "mcp.tools.greet"). """ relative = file_path.relative_to(package_root.parent) parts = list(relative.parts) # Remove .py extension from last part parts[-1] = parts[-1].removesuffix(".py") return ".".join(parts) def import_module_from_file(file_path: Path) -> ModuleType: """Import a Python file as a module. If the file is part of a package (directory has __init__.py), imports it as a proper package member (relative imports work). Otherwise, imports directly using spec_from_file_location. Args: file_path: Path to the Python file. Returns: The imported module. Raises: ImportError: If the module cannot be imported. """ file_path = file_path.resolve() # Check if this file is part of a package package_root = _find_package_root(file_path) if package_root is not None: # Import as part of a package module_name = _compute_module_name(file_path, package_root) # Ensure package root's parent is in sys.path package_parent = str(package_root.parent) if package_parent not in sys.path: sys.path.insert(0, package_parent) # Import using standard import machinery # If already imported, reload to pick up changes (for reload mode) try: if module_name in sys.modules: return importlib.reload(sys.modules[module_name]) return importlib.import_module(module_name) except ImportError as e: raise ImportError( f"Failed to import {module_name} from {file_path}: {e}" ) from e else: # Import directly using spec_from_file_location module_name = file_path.stem # Ensure parent directory is in sys.path for imports parent_dir = str(file_path.parent) if parent_dir not in sys.path: sys.path.insert(0, parent_dir) spec = importlib.util.spec_from_file_location(module_name, file_path) if spec is None or spec.loader is None: raise ImportError(f"Cannot load spec for {file_path}") module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module try: spec.loader.exec_module(module) except Exception as e: # Clean up sys.modules on failure sys.modules.pop(module_name, None) raise ImportError(f"Failed to execute module {file_path}: {e}") from e return module def extract_components(module: ModuleType) -> list[FastMCPComponent]: """Extract all MCP components from a module. Scans all module attributes for instances of Tool, Resource, ResourceTemplate, or Prompt objects created by standalone decorators, or functions decorated with @tool/@resource/@prompt that have __fastmcp__ metadata. Args: module: The imported module to scan. Returns: List of component objects (Tool, Resource, ResourceTemplate, Prompt). """ # Import here to avoid circular imports import inspect from fastmcp.decorators import get_fastmcp_meta from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import PromptMeta from fastmcp.resources.base import Resource from fastmcp.resources.function_resource import ResourceMeta from fastmcp.resources.template import ResourceTemplate from fastmcp.server.dependencies import without_injected_parameters from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import ToolMeta component_types = (Tool, Resource, ResourceTemplate, Prompt) components: list[FastMCPComponent] = [] for name in dir(module): # Skip private/magic attributes if name.startswith("_"): continue try: obj = getattr(module, name) except AttributeError: continue # Check if this object is a component type if isinstance(obj, component_types): components.append(obj) continue # Check for functions with __fastmcp__ metadata meta = get_fastmcp_meta(obj) if meta is not None: if isinstance(meta, ToolMeta): resolved_task = meta.task if meta.task is not None else False tool = Tool.from_function( obj, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, tags=meta.tags, output_schema=meta.output_schema, annotations=meta.annotations, meta=meta.meta, task=resolved_task, exclude_args=meta.exclude_args, serializer=meta.serializer, auth=meta.auth, ) components.append(tool) elif isinstance(meta, ResourceMeta): resolved_task = meta.task if meta.task is not None else False has_uri_params = "{" in meta.uri and "}" in meta.uri wrapper_fn = without_injected_parameters(obj) has_func_params = bool(inspect.signature(wrapper_fn).parameters) if has_uri_params or has_func_params: resource = ResourceTemplate.from_function( fn=obj, uri_template=meta.uri, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, mime_type=meta.mime_type, tags=meta.tags, annotations=meta.annotations, meta=meta.meta, task=resolved_task, auth=meta.auth, ) else: resource = Resource.from_function( fn=obj, uri=meta.uri, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, mime_type=meta.mime_type, tags=meta.tags, annotations=meta.annotations, meta=meta.meta, task=resolved_task, auth=meta.auth, ) components.append(resource) elif isinstance(meta, PromptMeta): resolved_task = meta.task if meta.task is not None else False prompt = Prompt.from_function( obj, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, tags=meta.tags, meta=meta.meta, task=resolved_task, auth=meta.auth, ) components.append(prompt) return components def discover_and_import(root: Path) -> DiscoveryResult: """Discover files, import modules, and extract components. This is the main entry point for filesystem-based discovery. Args: root: Root directory to scan. Returns: DiscoveryResult with components and any failed files. Note: Files that fail to import are tracked in failed_files, not logged. The caller is responsible for logging/handling failures. Files with no components are silently skipped. """ result = DiscoveryResult() for file_path in discover_files(root): try: module = import_module_from_file(file_path) except ImportError as e: result.failed_files[file_path] = str(e) continue except Exception as e: result.failed_files[file_path] = str(e) continue components = extract_components(module) for component in components: result.components.append((file_path, component)) return result ================================================ FILE: src/fastmcp/server/providers/local_provider/__init__.py ================================================ """LocalProvider for locally-defined MCP components. This module provides the `LocalProvider` class that manages tools, resources, templates, and prompts registered via decorators or direct methods. """ from fastmcp.server.providers.local_provider.local_provider import ( LocalProvider, ) __all__ = ["LocalProvider"] ================================================ FILE: src/fastmcp/server/providers/local_provider/decorators/__init__.py ================================================ """Decorator mixins for LocalProvider. This module provides mixin classes that add decorator functionality to LocalProvider for tools, resources, templates, and prompts. """ from .prompts import PromptDecoratorMixin from .resources import ResourceDecoratorMixin from .tools import ToolDecoratorMixin __all__ = [ "PromptDecoratorMixin", "ResourceDecoratorMixin", "ToolDecoratorMixin", ] ================================================ FILE: src/fastmcp/server/providers/local_provider/decorators/prompts.py ================================================ """Prompt decorator mixin for LocalProvider. This module provides the PromptDecoratorMixin class that adds prompt registration functionality to LocalProvider. """ from __future__ import annotations import inspect from collections.abc import Callable from functools import partial from typing import TYPE_CHECKING, Any, TypeVar, overload import mcp.types from mcp.types import AnyFunction import fastmcp from fastmcp.prompts.base import Prompt from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider F = TypeVar("F", bound=Callable[..., Any]) class PromptDecoratorMixin: """Mixin class providing prompt decorator functionality for LocalProvider. This mixin contains all methods related to: - Prompt registration via add_prompt() - Prompt decorator (@provider.prompt) """ def add_prompt(self: LocalProvider, prompt: Prompt | Callable[..., Any]) -> Prompt: """Add a prompt to this provider's storage. Accepts either a Prompt object or a decorated function with __fastmcp__ metadata. """ enabled = True if not isinstance(prompt, Prompt): from fastmcp.decorators import get_fastmcp_meta from fastmcp.prompts.function_prompt import PromptMeta meta = get_fastmcp_meta(prompt) if meta is not None and isinstance(meta, PromptMeta): resolved_task = meta.task if meta.task is not None else False enabled = meta.enabled prompt = Prompt.from_function( prompt, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, tags=meta.tags, meta=meta.meta, task=resolved_task, auth=meta.auth, ) else: raise TypeError( f"Expected Prompt or @prompt-decorated function, got {type(prompt).__name__}. " "Use @prompt decorator or pass a Prompt instance." ) self._add_component(prompt) if not enabled: self.disable(keys={prompt.key}) return prompt @overload def prompt( self: LocalProvider, name_or_fn: F, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @overload def prompt( self: LocalProvider, name_or_fn: str | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... def prompt( self: LocalProvider, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, enabled: bool = True, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] ): """Decorator to register a prompt. This decorator supports multiple calling patterns: - @provider.prompt (without parentheses) - @provider.prompt() (with empty parentheses) - @provider.prompt("custom_name") (with name as first argument) - @provider.prompt(name="custom_name") (with name as keyword argument) - provider.prompt(function, name="custom_name") (direct function call) Args: name_or_fn: Either a function (when used as @prompt), a string name, or None name: Optional name for the prompt (keyword-only, alternative to name_or_fn) title: Optional title for the prompt description: Optional description of what the prompt does icons: Optional icons for the prompt tags: Optional set of tags for categorizing the prompt enabled: Whether the prompt is enabled (default True). If False, adds to blocklist. meta: Optional meta information about the prompt task: Optional task configuration for background execution auth: Optional authorization checks for the prompt Returns: The registered FunctionPrompt or a decorator function. Example: ```python provider = LocalProvider() @provider.prompt def analyze(topic: str) -> list: return [{"role": "user", "content": f"Analyze: {topic}"}] @provider.prompt("custom_name") def my_prompt(data: str) -> list: return [{"role": "user", "content": data}] ``` """ if isinstance(name_or_fn, classmethod): raise TypeError( "To decorate a classmethod, use @classmethod above @prompt. " "See https://gofastmcp.com/servers/prompts#using-with-methods" ) def decorate_and_register( fn: AnyFunction, prompt_name: str | None ) -> FunctionPrompt | AnyFunction: # Check for unbound method try: params = list(inspect.signature(fn).parameters.keys()) except (ValueError, TypeError): params = [] if params and params[0] in ("self", "cls"): fn_name = getattr(fn, "__name__", "function") raise TypeError( f"The function '{fn_name}' has '{params[0]}' as its first parameter. " f"Use the standalone @prompt decorator and register the bound method:\n\n" f" from fastmcp.prompts import prompt\n\n" f" class MyClass:\n" f" @prompt\n" f" def {fn_name}(...):\n" f" ...\n\n" f" obj = MyClass()\n" f" mcp.add_prompt(obj.{fn_name})\n\n" f"See https://gofastmcp.com/servers/prompts#using-with-methods" ) resolved_task: bool | TaskConfig = task if task is not None else False if fastmcp.settings.decorator_mode == "object": prompt_obj = Prompt.from_function( fn, name=prompt_name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=resolved_task, auth=auth, ) self._add_component(prompt_obj) if not enabled: self.disable(keys={prompt_obj.key}) return prompt_obj else: from fastmcp.prompts.function_prompt import PromptMeta metadata = PromptMeta( name=prompt_name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=task, auth=auth, enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] self.add_prompt(fn) return fn if inspect.isroutine(name_or_fn): return decorate_and_register(name_or_fn, name) elif isinstance(name_or_fn, str): if name is not None: raise TypeError( f"Cannot specify both a name as first argument and as keyword argument. " f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both." ) prompt_name = name_or_fn elif name_or_fn is None: prompt_name = name else: raise TypeError(f"Invalid first argument: {type(name_or_fn)}") return partial( self.prompt, name=prompt_name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, enabled=enabled, task=task, auth=auth, ) ================================================ FILE: src/fastmcp/server/providers/local_provider/decorators/resources.py ================================================ """Resource decorator mixin for LocalProvider. This module provides the ResourceDecoratorMixin class that adds resource and template registration functionality to LocalProvider. """ from __future__ import annotations import inspect from collections.abc import Callable from typing import TYPE_CHECKING, Any, TypeVar import mcp.types from mcp.types import Annotations, AnyFunction import fastmcp from fastmcp.resources.base import Resource from fastmcp.resources.function_resource import resource as standalone_resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider F = TypeVar("F", bound=Callable[..., Any]) class ResourceDecoratorMixin: """Mixin class providing resource decorator functionality for LocalProvider. This mixin contains all methods related to: - Resource registration via add_resource() - Resource template registration via add_template() - Resource decorator (@provider.resource) """ def add_resource( self: LocalProvider, resource: Resource | ResourceTemplate | Callable[..., Any] ) -> Resource | ResourceTemplate: """Add a resource to this provider's storage. Accepts either a Resource/ResourceTemplate object or a decorated function with __fastmcp__ metadata. """ enabled = True if not isinstance(resource, (Resource, ResourceTemplate)): from fastmcp.decorators import get_fastmcp_meta from fastmcp.resources.function_resource import ResourceMeta from fastmcp.server.dependencies import without_injected_parameters meta = get_fastmcp_meta(resource) if meta is not None and isinstance(meta, ResourceMeta): resolved_task = meta.task if meta.task is not None else False enabled = meta.enabled has_uri_params = "{" in meta.uri and "}" in meta.uri wrapper_fn = without_injected_parameters(resource) has_func_params = bool(inspect.signature(wrapper_fn).parameters) if has_uri_params or has_func_params: resource = ResourceTemplate.from_function( fn=resource, uri_template=meta.uri, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, mime_type=meta.mime_type, tags=meta.tags, annotations=meta.annotations, meta=meta.meta, task=resolved_task, auth=meta.auth, ) else: resource = Resource.from_function( fn=resource, uri=meta.uri, name=meta.name, version=meta.version, title=meta.title, description=meta.description, icons=meta.icons, mime_type=meta.mime_type, tags=meta.tags, annotations=meta.annotations, meta=meta.meta, task=resolved_task, auth=meta.auth, ) else: raise TypeError( f"Expected Resource, ResourceTemplate, or @resource-decorated function, got {type(resource).__name__}. " "Use @resource('uri') decorator or pass a Resource/ResourceTemplate instance." ) self._add_component(resource) if not enabled: self.disable(keys={resource.key}) return resource def add_template( self: LocalProvider, template: ResourceTemplate ) -> ResourceTemplate: """Add a resource template to this provider's storage.""" return self._add_component(template) def resource( self: LocalProvider, uri: str, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, enabled: bool = True, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: """Decorator to register a function as a resource. If the URI contains parameters (e.g. "resource://{param}") or the function has parameters, it will be registered as a template resource. Args: uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") name: Optional name for the resource title: Optional title for the resource description: Optional description of the resource icons: Optional icons for the resource mime_type: Optional MIME type for the resource tags: Optional set of tags for categorizing the resource enabled: Whether the resource is enabled (default True). If False, adds to blocklist. annotations: Optional annotations about the resource's behavior meta: Optional meta information about the resource task: Optional task configuration for background execution auth: Optional authorization checks for the resource Returns: A decorator function. Example: ```python provider = LocalProvider() @provider.resource("data://config") def get_config() -> str: return '{"setting": "value"}' @provider.resource("data://{city}/weather") def get_weather(city: str) -> str: return f"Weather for {city}" ``` """ if isinstance(annotations, dict): annotations = Annotations(**annotations) if inspect.isroutine(uri): raise TypeError( "The @resource decorator was used incorrectly. " "It requires a URI as the first argument. " "Use @resource('uri') instead of @resource" ) resolved_task: bool | TaskConfig = task if task is not None else False def decorator(fn: AnyFunction) -> Any: # Check for unbound method try: params = list(inspect.signature(fn).parameters.keys()) except (ValueError, TypeError): params = [] if params and params[0] in ("self", "cls"): fn_name = getattr(fn, "__name__", "function") raise TypeError( f"The function '{fn_name}' has '{params[0]}' as its first parameter. " f"Use the standalone @resource decorator and register the bound method:\n\n" f" from fastmcp.resources import resource\n\n" f" class MyClass:\n" f" @resource('{uri}')\n" f" def {fn_name}(...):\n" f" ...\n\n" f" obj = MyClass()\n" f" mcp.add_resource(obj.{fn_name})\n\n" f"See https://gofastmcp.com/servers/resources#using-with-methods" ) if fastmcp.settings.decorator_mode == "object": create_resource = standalone_resource( uri, name=name, version=version, title=title, description=description, icons=icons, mime_type=mime_type, tags=tags, annotations=annotations, meta=meta, task=resolved_task, auth=auth, ) obj = create_resource(fn) # In legacy mode, standalone_resource always returns a component assert isinstance(obj, (Resource, ResourceTemplate)) if isinstance(obj, ResourceTemplate): self.add_template(obj) if not enabled: self.disable(keys={obj.key}) else: self.add_resource(obj) if not enabled: self.disable(keys={obj.key}) return obj else: from fastmcp.resources.function_resource import ResourceMeta metadata = ResourceMeta( uri=uri, name=name, version=version, title=title, description=description, icons=icons, tags=tags, mime_type=mime_type, annotations=annotations, meta=meta, task=task, auth=auth, enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] self.add_resource(fn) return fn return decorator ================================================ FILE: src/fastmcp/server/providers/local_provider/decorators/tools.py ================================================ """Tool decorator mixin for LocalProvider. This module provides the ToolDecoratorMixin class that adds tool registration functionality to LocalProvider. """ from __future__ import annotations import inspect import types import warnings from collections.abc import Callable from functools import partial from typing import ( TYPE_CHECKING, Annotated, Any, Literal, TypeVar, Union, get_args, get_origin, overload, ) import mcp.types from mcp.types import AnyFunction, ToolAnnotations import fastmcp from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import Tool from fastmcp.tools.function_tool import FunctionTool from fastmcp.utilities.types import NotSet, NotSetT try: from prefab_ui.app import PrefabApp as _PrefabApp from prefab_ui.components.base import Component as _PrefabComponent _HAS_PREFAB = True except ImportError: _HAS_PREFAB = False if TYPE_CHECKING: from fastmcp.server.providers.local_provider import LocalProvider from fastmcp.tools.base import ToolResultSerializerType F = TypeVar("F", bound=Callable[..., Any]) DuplicateBehavior = Literal["error", "warn", "replace", "ignore"] PREFAB_RENDERER_URI = "ui://prefab/renderer.html" def _is_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: return any(_is_prefab_type(a) for a in get_args(tp)) return False def _has_prefab_return_type(tool: Tool) -> bool: """Check if a FunctionTool's return type annotation is a prefab type.""" if not _HAS_PREFAB or not isinstance(tool, FunctionTool): return False rt = tool.return_type if rt is None or rt is inspect.Parameter.empty: return False return _is_prefab_type(rt) def _ensure_prefab_renderer(provider: LocalProvider) -> None: """Lazily register the shared prefab renderer as a ui:// resource.""" from prefab_ui.renderer import get_renderer_csp, get_renderer_html from fastmcp.resources.types import TextResource from fastmcp.server.apps import ( UI_MIME_TYPE, AppConfig, ResourceCSP, app_config_to_meta_dict, ) renderer_key = f"resource:{PREFAB_RENDERER_URI}@" if renderer_key in provider._components: return csp = get_renderer_csp() resource_app = AppConfig( csp=ResourceCSP( resource_domains=csp.get("resource_domains"), connect_domains=csp.get("connect_domains"), ) ) resource = TextResource( uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime name="Prefab Renderer", text=get_renderer_html(), mime_type=UI_MIME_TYPE, meta={"ui": app_config_to_meta_dict(resource_app)}, ) provider._add_component(resource) def _expand_prefab_ui_meta(tool: Tool) -> None: """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool.""" from prefab_ui.renderer import get_renderer_csp from fastmcp.server.apps import AppConfig, ResourceCSP, app_config_to_meta_dict csp = get_renderer_csp() app_config = AppConfig( resource_uri=PREFAB_RENDERER_URI, csp=ResourceCSP( resource_domains=csp.get("resource_domains"), connect_domains=csp.get("connect_domains"), ), ) meta = dict(tool.meta) if tool.meta else {} meta["ui"] = app_config_to_meta_dict(app_config) tool.meta = meta def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: """Auto-wire prefab UI metadata and renderer resource if needed.""" if not _HAS_PREFAB: return meta = tool.meta or {} ui = meta.get("ui") if ui is True: # Explicit app=True: expand to full AppConfig and register renderer _ensure_prefab_renderer(provider) _expand_prefab_ui_meta(tool) elif ui is None and _has_prefab_return_type(tool): # Inference: return type is a prefab type, auto-wire _ensure_prefab_renderer(provider) _expand_prefab_ui_meta(tool) # If ui is a dict, it's already manually configured — leave it alone class ToolDecoratorMixin: """Mixin class providing tool decorator functionality for LocalProvider. This mixin contains all methods related to: - Tool registration via add_tool() - Tool decorator (@provider.tool) """ def add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool: """Add a tool to this provider's storage. Accepts either a Tool object or a decorated function with __fastmcp__ metadata. """ enabled = True if not isinstance(tool, Tool): from fastmcp.decorators import get_fastmcp_meta from fastmcp.tools.function_tool import ToolMeta fmeta = get_fastmcp_meta(tool) if fmeta is not None and isinstance(fmeta, ToolMeta): resolved_task = fmeta.task if fmeta.task is not None else False enabled = fmeta.enabled # Merge ToolMeta.app into the meta dict tool_meta = fmeta.meta if fmeta.app is not None: from fastmcp.server.apps import app_config_to_meta_dict tool_meta = dict(tool_meta) if tool_meta else {} if fmeta.app is True: tool_meta["ui"] = True else: tool_meta["ui"] = app_config_to_meta_dict(fmeta.app) tool = Tool.from_function( tool, name=fmeta.name, version=fmeta.version, title=fmeta.title, description=fmeta.description, icons=fmeta.icons, tags=fmeta.tags, output_schema=fmeta.output_schema, annotations=fmeta.annotations, meta=tool_meta, task=resolved_task, exclude_args=fmeta.exclude_args, serializer=fmeta.serializer, timeout=fmeta.timeout, auth=fmeta.auth, ) else: tool = Tool.from_function(tool) self._add_component(tool) if not enabled: self.disable(keys={tool.key}) _maybe_apply_prefab_ui(self, tool) return tool @overload def tool( self: LocalProvider, name_or_fn: F, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool = True, task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @overload def tool( self: LocalProvider, name_or_fn: str | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool = True, task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... # NOTE: This method mirrors fastmcp.tools.tool() but adds registration, # the `enabled` param, and supports deprecated params (serializer, exclude_args). # When deprecated params are removed, this should delegate to the standalone # decorator to reduce duplication. def tool( self: LocalProvider, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, enabled: bool = True, task: bool | TaskConfig | None = None, serializer: ToolResultSerializerType | None = None, # Deprecated timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] ): """Decorator to register a tool. This decorator supports multiple calling patterns: - @provider.tool (without parentheses) - @provider.tool() (with empty parentheses) - @provider.tool("custom_name") (with name as first argument) - @provider.tool(name="custom_name") (with name as keyword argument) - provider.tool(function, name="custom_name") (direct function call) Args: name_or_fn: Either a function (when used as @tool), a string name, or None name: Optional name for the tool (keyword-only, alternative to name_or_fn) title: Optional title for the tool description: Optional description of what the tool does icons: Optional icons for the tool tags: Optional set of tags for categorizing the tool output_schema: Optional JSON schema for the tool's output annotations: Optional annotations about the tool's behavior exclude_args: Optional list of argument names to exclude from the tool schema meta: Optional meta information about the tool enabled: Whether the tool is enabled (default True). If False, adds to blocklist. task: Optional task configuration for background execution serializer: Deprecated. Return ToolResult from your tools for full control over serialization. Returns: The registered FunctionTool or a decorator function. Example: ```python provider = LocalProvider() @provider.tool def greet(name: str) -> str: return f"Hello, {name}!" @provider.tool("custom_name") def my_tool(x: int) -> str: return str(x) ``` """ if serializer is not None and fastmcp.settings.deprecation_warnings: warnings.warn( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", DeprecationWarning, stacklevel=2, ) if isinstance(annotations, dict): annotations = ToolAnnotations(**annotations) if isinstance(name_or_fn, classmethod): raise TypeError( "To decorate a classmethod, use @classmethod above @tool. " "See https://gofastmcp.com/servers/tools#using-with-methods" ) def decorate_and_register( fn: AnyFunction, tool_name: str | None ) -> FunctionTool | AnyFunction: # Check for unbound method try: params = list(inspect.signature(fn).parameters.keys()) except (ValueError, TypeError): params = [] if params and params[0] in ("self", "cls"): fn_name = getattr(fn, "__name__", "function") raise TypeError( f"The function '{fn_name}' has '{params[0]}' as its first parameter. " f"Use the standalone @tool decorator and register the bound method:\n\n" f" from fastmcp.tools import tool\n\n" f" class MyClass:\n" f" @tool\n" f" def {fn_name}(...):\n" f" ...\n\n" f" obj = MyClass()\n" f" mcp.add_tool(obj.{fn_name})\n\n" f"See https://gofastmcp.com/servers/tools#using-with-methods" ) resolved_task: bool | TaskConfig = task if task is not None else False if fastmcp.settings.decorator_mode == "object": tool_obj = Tool.from_function( fn, name=tool_name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, meta=meta, serializer=serializer, task=resolved_task, timeout=timeout, auth=auth, ) self._add_component(tool_obj) if not enabled: self.disable(keys={tool_obj.key}) _maybe_apply_prefab_ui(self, tool_obj) return tool_obj else: from fastmcp.tools.function_tool import ToolMeta metadata = ToolMeta( name=tool_name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, meta=meta, task=task, exclude_args=exclude_args, serializer=serializer, timeout=timeout, auth=auth, enabled=enabled, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata # type: ignore[attr-defined] tool_obj = self.add_tool(fn) return fn if inspect.isroutine(name_or_fn): return decorate_and_register(name_or_fn, name) elif isinstance(name_or_fn, str): # Case 3: @tool("custom_name") - name passed as first argument if name is not None: raise TypeError( "Cannot specify both a name as first argument and as keyword argument. " f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both." ) tool_name = name_or_fn elif name_or_fn is None: # Case 4: @tool() or @tool(name="something") - use keyword name tool_name = name else: raise TypeError( f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}" ) # Return partial for cases where we need to wait for the function return partial( self.tool, name=tool_name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, meta=meta, enabled=enabled, task=task, serializer=serializer, timeout=timeout, auth=auth, ) ================================================ FILE: src/fastmcp/server/providers/local_provider/local_provider.py ================================================ """LocalProvider for locally-defined MCP components. This module provides the `LocalProvider` class that manages tools, resources, templates, and prompts registered via decorators or direct methods. LocalProvider can be used standalone and attached to multiple servers: ```python from fastmcp.server.providers import LocalProvider # Create a reusable provider with tools provider = LocalProvider() @provider.tool def greet(name: str) -> str: return f"Hello, {name}!" # Attach to any server from fastmcp import FastMCP server1 = FastMCP("Server1", providers=[provider]) server2 = FastMCP("Server2", providers=[provider]) ``` """ from __future__ import annotations from collections.abc import Sequence from typing import Literal, TypeVar from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.providers.local_provider.decorators import ( PromptDecoratorMixin, ResourceDecoratorMixin, ToolDecoratorMixin, ) from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec, version_sort_key logger = get_logger(__name__) DuplicateBehavior = Literal["error", "warn", "replace", "ignore"] _C = TypeVar("_C", bound=FastMCPComponent) class LocalProvider( Provider, ToolDecoratorMixin, ResourceDecoratorMixin, PromptDecoratorMixin, ): """Provider for locally-defined components. Supports decorator-based registration (`@provider.tool`, `@provider.resource`, `@provider.prompt`) and direct object registration methods. When used standalone, LocalProvider uses default settings. When attached to a FastMCP server via the server's decorators, server-level settings like `_tool_serializer` and `_support_tasks_by_default` are injected. Example: ```python from fastmcp.server.providers import LocalProvider # Standalone usage provider = LocalProvider() @provider.tool def greet(name: str) -> str: return f"Hello, {name}!" @provider.resource("data://config") def get_config() -> str: return '{"setting": "value"}' @provider.prompt def analyze(topic: str) -> list: return [{"role": "user", "content": f"Analyze: {topic}"}] # Attach to server(s) from fastmcp import FastMCP server = FastMCP("MyServer", providers=[provider]) ``` """ def __init__( self, on_duplicate: DuplicateBehavior = "error", ) -> None: """Initialize a LocalProvider with empty storage. Args: on_duplicate: Behavior when adding a component that already exists: - "error": Raise ValueError - "warn": Log warning and replace - "replace": Silently replace - "ignore": Keep existing, return it """ super().__init__() self._on_duplicate = on_duplicate # Unified component storage - keyed by prefixed key (e.g., "tool:name", "resource:uri") self._components: dict[str, FastMCPComponent] = {} # ========================================================================= # Storage methods # ========================================================================= def _get_component_identity(self, component: FastMCPComponent) -> tuple[type, str]: """Get the identity (type, name/uri) for a component. Returns: A tuple of (component_type, logical_name) where logical_name is the name for tools/prompts or URI for resources/templates. """ if isinstance(component, Tool): return (Tool, component.name) elif isinstance(component, ResourceTemplate): return (ResourceTemplate, component.uri_template) elif isinstance(component, Resource): return (Resource, str(component.uri)) elif isinstance(component, Prompt): return (Prompt, component.name) else: # Fall back to key without version suffix key = component.key base_key = key.rsplit("@", 1)[0] if "@" in key else key return (type(component), base_key) def _check_version_mixing(self, component: _C) -> None: """Check that versioned and unversioned components aren't mixed. LocalProvider enforces a simple rule: for any given name/URI, all registered components must either be versioned or unversioned, not both. This prevents confusing situations where unversioned components can't be filtered out by version filters. Args: component: The component being added. Raises: ValueError: If adding would mix versioned and unversioned components. """ comp_type, logical_name = self._get_component_identity(component) is_versioned = component.version is not None # Check all existing components of the same type and logical name for existing in self._components.values(): if not isinstance(existing, comp_type): continue _, existing_name = self._get_component_identity(existing) if existing_name != logical_name: continue existing_versioned = existing.version is not None if is_versioned != existing_versioned: type_name = comp_type.__name__.lower() if is_versioned: raise ValueError( f"Cannot add versioned {type_name} {logical_name!r} " f"(version={component.version!r}): an unversioned " f"{type_name} with this name already exists. " f"Either version all components or none." ) else: raise ValueError( f"Cannot add unversioned {type_name} {logical_name!r}: " f"versioned {type_name}s with this name already exist " f"(e.g., version={existing.version!r}). " f"Either version all components or none." ) def _add_component(self, component: _C) -> _C: """Add a component to unified storage. Args: component: The component to add. Returns: The component that was added (or existing if on_duplicate="ignore"). """ existing = self._components.get(component.key) if existing: if self._on_duplicate == "error": raise ValueError(f"Component already exists: {component.key}") elif self._on_duplicate == "warn": logger.warning(f"Component already exists: {component.key}") elif self._on_duplicate == "ignore": return existing # type: ignore[return-value] # "replace" and "warn" fall through to add # Check for versioned/unversioned mixing before adding self._check_version_mixing(component) self._components[component.key] = component return component def _remove_component(self, key: str) -> None: """Remove a component from unified storage. Args: key: The prefixed key of the component. Raises: KeyError: If the component is not found. """ component = self._components.get(key) if component is None: raise KeyError(f"Component {key!r} not found") del self._components[key] def _get_component(self, key: str) -> FastMCPComponent | None: """Get a component by its prefixed key. Args: key: The prefixed key (e.g., "tool:name", "resource:uri"). Returns: The component, or None if not found. """ return self._components.get(key) def remove_tool(self, name: str, version: str | None = None) -> None: """Remove tool(s) from this provider's storage. Args: name: The tool name. version: If None, removes ALL versions. If specified, removes only that version. Raises: KeyError: If no matching tool is found. """ if version is None: # Remove all versions keys_to_remove = [ k for k, c in self._components.items() if isinstance(c, Tool) and c.name == name ] if not keys_to_remove: raise KeyError(f"Tool {name!r} not found") for key in keys_to_remove: self._remove_component(key) else: # Remove specific version - key format is "tool:name@version" key = f"{Tool.make_key(name)}@{version}" if key not in self._components: raise KeyError(f"Tool {name!r} version {version!r} not found") self._remove_component(key) def remove_resource(self, uri: str, version: str | None = None) -> None: """Remove resource(s) from this provider's storage. Args: uri: The resource URI. version: If None, removes ALL versions. If specified, removes only that version. Raises: KeyError: If no matching resource is found. """ if version is None: # Remove all versions keys_to_remove = [ k for k, c in self._components.items() if isinstance(c, Resource) and str(c.uri) == uri ] if not keys_to_remove: raise KeyError(f"Resource {uri!r} not found") for key in keys_to_remove: self._remove_component(key) else: # Remove specific version key = f"{Resource.make_key(uri)}@{version}" if key not in self._components: raise KeyError(f"Resource {uri!r} version {version!r} not found") self._remove_component(key) def remove_template(self, uri_template: str, version: str | None = None) -> None: """Remove resource template(s) from this provider's storage. Args: uri_template: The template URI pattern. version: If None, removes ALL versions. If specified, removes only that version. Raises: KeyError: If no matching template is found. """ if version is None: # Remove all versions keys_to_remove = [ k for k, c in self._components.items() if isinstance(c, ResourceTemplate) and c.uri_template == uri_template ] if not keys_to_remove: raise KeyError(f"Template {uri_template!r} not found") for key in keys_to_remove: self._remove_component(key) else: # Remove specific version key = f"{ResourceTemplate.make_key(uri_template)}@{version}" if key not in self._components: raise KeyError( f"Template {uri_template!r} version {version!r} not found" ) self._remove_component(key) def remove_prompt(self, name: str, version: str | None = None) -> None: """Remove prompt(s) from this provider's storage. Args: name: The prompt name. version: If None, removes ALL versions. If specified, removes only that version. Raises: KeyError: If no matching prompt is found. """ if version is None: # Remove all versions keys_to_remove = [ k for k, c in self._components.items() if isinstance(c, Prompt) and c.name == name ] if not keys_to_remove: raise KeyError(f"Prompt {name!r} not found") for key in keys_to_remove: self._remove_component(key) else: # Remove specific version key = f"{Prompt.make_key(name)}@{version}" if key not in self._components: raise KeyError(f"Prompt {name!r} version {version!r} not found") self._remove_component(key) # ========================================================================= # Provider interface implementation # ========================================================================= async def _list_tools(self) -> Sequence[Tool]: """Return all tools.""" return [v for v in self._components.values() if isinstance(v, Tool)] async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name. Args: name: The tool name. version: Optional version filter. If None, returns highest version. """ matching = [ v for v in self._components.values() if isinstance(v, Tool) and v.name == name ] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resources(self) -> Sequence[Resource]: """Return all resources.""" return [v for v in self._components.values() if isinstance(v, Resource)] async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI. Args: uri: The resource URI. version: Optional version filter. If None, returns highest version. """ matching = [ v for v in self._components.values() if isinstance(v, Resource) and str(v.uri) == uri ] if version: matching = [r for r in matching if version.matches(r.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all resource templates.""" return [v for v in self._components.values() if isinstance(v, ResourceTemplate)] async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template that matches the given URI. Args: uri: The URI to match against templates. version: Optional version filter. If None, returns highest version. """ # Find all templates that match the URI matching = [ component for component in self._components.values() if isinstance(component, ResourceTemplate) and component.matches(uri) is not None ] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_prompts(self) -> Sequence[Prompt]: """Return all prompts.""" return [v for v in self._components.values() if isinstance(v, Prompt)] async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name. Args: name: The prompt name. version: Optional version filter. If None, returns highest version. """ matching = [ v for v in self._components.values() if isinstance(v, Prompt) and v.name == name ] if version: matching = [p for p in matching if version.matches(p.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ========================================================================= # Task registration # ========================================================================= async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return components eligible for background task execution. Returns components that have task_config.mode != 'forbidden'. This includes both FunctionTool/Resource/Prompt instances created via decorators and custom Tool/Resource/Prompt subclasses. """ return [c for c in self._components.values() if c.task_config.supports_tasks()] # ========================================================================= # Decorator methods # ========================================================================= # Note: Decorator methods (tool, resource, prompt, add_tool, add_resource, # add_template, add_prompt) are provided by mixin classes: # - ToolDecoratorMixin # - ResourceDecoratorMixin # - PromptDecoratorMixin ================================================ FILE: src/fastmcp/server/providers/openapi/README.md ================================================ # OpenAPI Server Implementation (New) This directory contains the next-generation FastMCP server implementation for OpenAPI integration, designed to replace the legacy implementation in `/server/openapi.py`. ## Architecture Overview The new implementation uses a **stateless request building approach** with `openapi-core` and `RequestDirector`, providing zero-latency startup and robust OpenAPI support optimized for serverless environments. ### Core Components 1. **`server.py`** - `FastMCPOpenAPI` main server class with RequestDirector integration 2. **`components.py`** - Simplified component implementations using RequestDirector 3. **`routing.py`** - Route mapping and component selection logic ### Key Architecture Principles #### 1. Stateless Performance - **Zero Startup Latency**: No code generation or heavy initialization - **RequestDirector**: Stateless HTTP request building using openapi-core - **Pre-calculated Schemas**: All complex processing done during parsing #### 2. Unified Implementation - **Single Code Path**: All components use RequestDirector consistently - **No Fallbacks**: Simplified architecture without hybrid complexity - **Performance First**: Optimized for cold starts and serverless deployments #### 3. OpenAPI Compliance - **openapi-core Integration**: Leverages proven library for parameter serialization - **Full Feature Support**: Complete OpenAPI 3.0/3.1 support including deepObject - **Error Handling**: Comprehensive HTTP error mapping to MCP errors ## Component Classes ### RequestDirector-Based Components #### `OpenAPITool` - Executes operations using RequestDirector for HTTP request building - Automatic parameter validation and OpenAPI-compliant serialization - Built-in error handling and structured response processing - **Advantages**: Zero latency, robust, comprehensive OpenAPI support #### `OpenAPIResource` / `OpenAPIResourceTemplate` - Provides resource access using RequestDirector - Consistent parameter handling across all resource types - Support for complex parameter patterns and collision resolution - **Advantages**: High performance, simplified architecture, reliable error handling ## Server Implementation ### `FastMCPOpenAPI` Class The main server class orchestrates the stateless request building approach: ```python class FastMCPOpenAPI(FastMCP): def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs): # 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas self._routes = parse_openapi_to_http_routes(openapi_spec) # 2. Initialize RequestDirector with openapi-core Spec self._spec = Spec.from_dict(openapi_spec) self._director = RequestDirector(self._spec) # 3. Create components using RequestDirector self._create_components() ``` ### Component Creation Logic ```python def _create_tool(self, route: HTTPRoute) -> Tool: # All tools use RequestDirector for consistent, high-performance request building return OpenAPITool( client=self._client, route=route, director=self._director, name=tool_name, description=description, parameters=flat_param_schema ) ``` ## Data Flow ### Stateless Request Building ``` OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HTTP Request → Structured Response ``` 1. **Spec Parsing**: OpenAPI spec parsed to `HTTPRoute` models with pre-calculated schemas 2. **RequestDirector Setup**: openapi-core Spec initialized for request building 3. **Component Creation**: Create components with RequestDirector reference 4. **Request Building**: RequestDirector builds HTTP request from flat parameters 5. **Request Execution**: Execute request with httpx client 6. **Response Processing**: Return structured MCP response ## Key Features ### 1. Enhanced Parameter Handling #### Parameter Collision Resolution - **Automatic Suffixing**: Colliding parameters get location-based suffixes - **Example**: `id` in path and body becomes `id__path` and `id` - **Transparent**: LLMs see suffixed parameters, implementation routes correctly #### DeepObject Style Support - **Native Support**: Generated client handles all deepObject variations - **Explode Handling**: Proper support for explode=true/false - **Complex Objects**: Nested object serialization works correctly ### 2. Robust Error Handling #### HTTP Error Mapping - **Status Code Mapping**: HTTP errors mapped to appropriate MCP errors - **Structured Responses**: Error details preserved in tool results - **Timeout Handling**: Network timeouts handled gracefully #### Request Building Error Handling - **Parameter Validation**: Invalid parameters caught during request building - **Schema Validation**: openapi-core validates all OpenAPI constraints - **Graceful Degradation**: Missing optional parameters handled smoothly ### 3. Performance Optimizations #### Efficient Client Reuse - **Connection Pooling**: HTTP connections reused across requests - **Client Caching**: Generated clients cached for performance - **Async Support**: Full async/await throughout #### Request Optimization - **Pre-calculated Schemas**: All complex processing done during initialization - **Parameter Mapping**: Collision resolution handled upfront - **Zero Latency**: No runtime code generation or complex schema processing ## Configuration ### Server Options ```python server = FastMCPOpenAPI( openapi_spec=spec, # Required: OpenAPI specification client=httpx_client, # Required: HTTP client instance name="API Server", # Optional: Server name route_map=custom_routes, # Optional: Custom route mappings enable_caching=True, # Optional: Enable response caching ) ``` ### Route Mapping Customization ```python from fastmcp.server.openapi_new.routing import RouteMap custom_routes = RouteMap({ "GET:/users": "tool", # Force specific operations to be tools "GET:/status": "resource", # Force specific operations to be resources }) ``` ## Testing Strategy ### Test Structure Tests are organized by functionality: - `test_server.py` - Server integration and RequestDirector behavior - `test_parameter_collisions.py` - Parameter collision handling - `test_deepobject_style.py` - DeepObject parameter style support - `test_openapi_features.py` - General OpenAPI feature compliance ### Testing Philosophy 1. **Real Integration**: Test with real OpenAPI specs and HTTP clients 2. **Minimal Mocking**: Only mock external API endpoints 3. **Behavioral Focus**: Test behavior, not implementation details 4. **Performance Focus**: Test that initialization is fast and stateless ### Example Test Pattern ```python async def test_stateless_request_building(): """Test that server works with stateless RequestDirector approach.""" # Test server initialization is fast start_time = time.time() server = FastMCPOpenAPI(spec=valid_spec, client=client) init_time = time.time() - start_time assert init_time < 0.01 # Should be very fast # Verify RequestDirector functionality assert hasattr(server, '_director') assert hasattr(server, '_spec') ``` ## Migration Benefits ### From Legacy Implementation 1. **Eliminated Startup Latency**: Zero code generation overhead (100-200ms improvement) 2. **Better OpenAPI Compliance**: openapi-core handles all OpenAPI features correctly 3. **Serverless Friendly**: Perfect for cold-start environments 4. **Simplified Architecture**: Single RequestDirector approach eliminates complexity 5. **Enhanced Reliability**: No dynamic code generation failures ### Backward Compatibility - **Same Interface**: Public API unchanged from legacy implementation - **Performance Improvement**: Significantly faster initialization - **No Breaking Changes**: Existing code works without modification ## Monitoring and Debugging ### Logging ```python # Enable debug logging to see implementation choices import logging logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG) ``` ### Key Log Messages - **RequestDirector Initialization**: Success/failure of RequestDirector setup - **Schema Pre-calculation**: Pre-calculated schema and parameter map status - **Request Building**: Parameter mapping and URL construction details - **Performance Metrics**: Request timing and error rates ### Debugging Common Issues 1. **RequestDirector Initialization Fails** - Check OpenAPI spec validity with `openapi-core` - Verify spec format is correct JSON/YAML - Ensure all required OpenAPI fields are present 2. **Parameter Issues** - Enable debug logging for parameter processing - Check for parameter collision warnings - Verify OpenAPI spec parameter definitions 3. **Performance Issues** - Monitor RequestDirector request building timing - Check HTTP client configuration - Review response processing timing ## Future Enhancements ### Planned Features 1. **Advanced Caching**: Intelligent response caching with TTL 2. **Streaming Support**: Handle streaming API responses 3. **Batch Operations**: Optimize multiple operation calls 4. **Enhanced Monitoring**: Detailed metrics and health checks 5. **Configuration Management**: Dynamic configuration updates ### Performance Improvements 1. **Enhanced Schema Caching**: More aggressive schema pre-calculation 2. **Parallel Processing**: Concurrent operation execution 3. **Memory Optimization**: Further reduce memory footprint 4. **Request Optimization**: Smart request batching and deduplication ## Related Documentation - `/utilities/openapi_new/README.md` - Utility implementation details - `/server/openapi/README.md` - Legacy implementation reference - `/tests/server/openapi_new/` - Comprehensive test suite - Project documentation on OpenAPI integration patterns ================================================ FILE: src/fastmcp/server/providers/openapi/__init__.py ================================================ """OpenAPI provider for FastMCP. This module provides OpenAPI integration for FastMCP through the Provider pattern. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("API Server", providers=[provider]) ``` """ from fastmcp.server.providers.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, ) from fastmcp.server.providers.openapi.provider import OpenAPIProvider from fastmcp.server.providers.openapi.routing import ( ComponentFn, MCPType, RouteMap, RouteMapFn, ) __all__ = [ "ComponentFn", "MCPType", "OpenAPIProvider", "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", "RouteMap", "RouteMapFn", ] ================================================ FILE: src/fastmcp/server/providers/openapi/components.py ================================================ """OpenAPI component classes: Tool, Resource, and ResourceTemplate.""" from __future__ import annotations import json import re import warnings from collections.abc import Callable from typing import TYPE_CHECKING, Any import httpx from mcp.types import ToolAnnotations from pydantic.networks import AnyUrl import fastmcp from fastmcp.resources import ( Resource, ResourceContent, ResourceResult, ResourceTemplate, ) from fastmcp.server.dependencies import get_http_headers from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import HTTPRoute from fastmcp.utilities.openapi.director import RequestDirector if TYPE_CHECKING: from fastmcp.server import Context _SAFE_HEADERS = frozenset( { "accept", "accept-encoding", "accept-language", "cache-control", "connection", "content-length", "content-type", "host", "user-agent", } ) def _redact_headers(headers: httpx.Headers) -> dict[str, str]: return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()} __all__ = [ "OpenAPIResource", "OpenAPIResourceTemplate", "OpenAPITool", "_extract_mime_type_from_route", ] logger = get_logger(__name__) # Default MIME type when no response content type can be inferred _DEFAULT_MIME_TYPE = "application/json" def _extract_mime_type_from_route(route: HTTPRoute) -> str: """Extract the primary MIME type from an HTTPRoute's response definitions. Looks for the first successful response (2xx) and returns its content type. Prefers JSON-compatible types when multiple are available. Falls back to "application/json" when no response content type is declared. """ if not route.responses: return _DEFAULT_MIME_TYPE # Priority order for success status codes success_codes = ["200", "201", "202", "204"] response_info = None for status_code in success_codes: if status_code in route.responses: response_info = route.responses[status_code] break # If no explicit success codes, try any 2xx response if response_info is None: for status_code, resp_info in route.responses.items(): if status_code.startswith("2"): response_info = resp_info break if response_info is None or not response_info.content_schema: return _DEFAULT_MIME_TYPE # If there's only one content type, use it directly content_types = list(response_info.content_schema.keys()) if len(content_types) == 1: return content_types[0] # When multiple types exist, prefer JSON-compatible types json_compatible_types = [ "application/json", "application/vnd.api+json", "application/hal+json", "application/ld+json", "text/json", ] for ct in json_compatible_types: if ct in response_info.content_schema: return ct # Fall back to the first available content type return content_types[0] def _slugify(text: str) -> str: """Convert text to a URL-friendly slug format. Only contains lowercase letters, uppercase letters, numbers, and underscores. """ if not text: return "" # Replace spaces and common separators with underscores slug = re.sub(r"[\s\-\.]+", "_", text) # Remove non-alphanumeric characters except underscores slug = re.sub(r"[^a-zA-Z0-9_]", "", slug) # Remove multiple consecutive underscores slug = re.sub(r"_+", "_", slug) # Remove leading/trailing underscores slug = slug.strip("_") return slug class OpenAPITool(Tool): """Tool implementation for OpenAPI endpoints.""" task_config: TaskConfig = TaskConfig(mode="forbidden") def __init__( self, client: httpx.AsyncClient, route: HTTPRoute, director: RequestDirector, name: str, description: str, parameters: dict[str, Any], output_schema: dict[str, Any] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, serializer: Callable[[Any], str] | None = None, # Deprecated ): if serializer is not None and fastmcp.settings.deprecation_warnings: warnings.warn( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", DeprecationWarning, stacklevel=2, ) super().__init__( name=name, description=description, parameters=parameters, output_schema=output_schema, tags=tags or set(), annotations=annotations, serializer=serializer, ) self._client = client self._route = route self._director = director def __repr__(self) -> str: return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" async def run(self, arguments: dict[str, Any]) -> ToolResult: """Execute the HTTP request using RequestDirector.""" # Build the request — errors here are programming/schema issues, # not HTTP failures, so we catch them separately. try: base_url = str(self._client.base_url) or "http://localhost" request = self._director.build(self._route, arguments, base_url) if self._client.headers: for key, value in self._client.headers.items(): if key not in request.headers: request.headers[key] = value mcp_headers = get_http_headers() if mcp_headers: for key, value in mcp_headers.items(): if key not in request.headers: request.headers[key] = value except Exception as e: raise ValueError( f"Error building request for {self._route.method.upper()} " f"{self._route.path}: {type(e).__name__}: {e}" ) from e # Send the request and process the response. try: logger.debug( f"run - sending request; headers: {_redact_headers(request.headers)}" ) response = await self._client.send(request) response.raise_for_status() # Try to parse as JSON first try: result = response.json() # Handle structured content based on output schema if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): structured_output = {"result": result} else: structured_output = result elif not isinstance(result, dict): structured_output = {"result": result} else: structured_output = result # Structured content must be a dict for the MCP protocol. # Wrap non-dict values that slipped through (e.g. a backend # returning an array when the schema declared an object). if not isinstance(structured_output, dict): structured_output = {"result": structured_output} return ToolResult(structured_content=structured_output) except json.JSONDecodeError: return ToolResult(content=response.text) except httpx.HTTPStatusError as e: error_message = ( f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" ) try: error_data = e.response.json() error_message += f" - {error_data}" except (json.JSONDecodeError, ValueError): if e.response.text: error_message += f" - {e.response.text}" raise ValueError(error_message) from e except httpx.TimeoutException as e: raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e except httpx.RequestError as e: raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResource(Resource): """Resource implementation for OpenAPI endpoints.""" task_config: TaskConfig = TaskConfig(mode="forbidden") def __init__( self, client: httpx.AsyncClient, route: HTTPRoute, director: RequestDirector, uri: str, name: str, description: str, mime_type: str = "application/json", tags: set[str] | None = None, ): super().__init__( uri=AnyUrl(uri), name=name, description=description, mime_type=mime_type, tags=tags or set(), ) self._client = client self._route = route self._director = director def __repr__(self) -> str: return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" async def read(self) -> ResourceResult: """Fetch the resource data by making an HTTP request.""" try: path = self._route.path resource_uri = str(self.uri) # If this is a templated resource, extract path parameters from the URI if "{" in path and "}" in path: parts = resource_uri.split("/") if len(parts) > 1: path_params = {} param_matches = re.findall(r"\{([^}]+)\}", path) if param_matches: param_matches.sort(reverse=True) expected_param_count = len(parts) - 1 for i, param_name in enumerate(param_matches): if i < expected_param_count: param_value = parts[-1 - i] path_params[param_name] = param_value for param_name, param_value in path_params.items(): path = path.replace(f"{{{param_name}}}", str(param_value)) # Build headers with correct precedence headers: dict[str, str] = {} if self._client.headers: headers.update(self._client.headers) mcp_headers = get_http_headers() if mcp_headers: headers.update(mcp_headers) response = await self._client.request( method=self._route.method, url=path, headers=headers, ) response.raise_for_status() content_type = response.headers.get("content-type", "").lower() if "application/json" in content_type: result = response.json() return ResourceResult( contents=[ ResourceContent( content=json.dumps(result), mime_type="application/json" ) ] ) elif any(ct in content_type for ct in ["text/", "application/xml"]): return ResourceResult( contents=[ ResourceContent(content=response.text, mime_type=self.mime_type) ] ) else: return ResourceResult( contents=[ ResourceContent( content=response.content, mime_type=self.mime_type ) ] ) except httpx.HTTPStatusError as e: error_message = ( f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" ) try: error_data = e.response.json() error_message += f" - {error_data}" except (json.JSONDecodeError, ValueError): if e.response.text: error_message += f" - {e.response.text}" raise ValueError(error_message) from e except httpx.TimeoutException as e: raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e except httpx.RequestError as e: raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e class OpenAPIResourceTemplate(ResourceTemplate): """Resource template implementation for OpenAPI endpoints.""" task_config: TaskConfig = TaskConfig(mode="forbidden") def __init__( self, client: httpx.AsyncClient, route: HTTPRoute, director: RequestDirector, uri_template: str, name: str, description: str, parameters: dict[str, Any], tags: set[str] | None = None, mime_type: str = _DEFAULT_MIME_TYPE, ): super().__init__( uri_template=uri_template, name=name, description=description, parameters=parameters, tags=tags or set(), mime_type=mime_type, ) self._client = client self._route = route self._director = director def __repr__(self) -> str: return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})" async def create_resource( self, uri: str, params: dict[str, Any], context: Context | None = None, ) -> Resource: """Create a resource with the given parameters.""" uri_parts = [f"{key}={value}" for key, value in params.items()] return OpenAPIResource( client=self._client, route=self._route, director=self._director, uri=uri, name=f"{self.name}-{'-'.join(uri_parts)}", description=self.description or f"Resource for {self._route.path}", mime_type=self.mime_type, tags=set(self._route.tags or []), ) ================================================ FILE: src/fastmcp/server/providers/openapi/provider.py ================================================ """OpenAPIProvider for creating MCP components from OpenAPI specifications.""" from __future__ import annotations from collections import Counter from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from typing import Any, Literal, cast import httpx from jsonschema_path import SchemaPath from fastmcp.prompts import Prompt from fastmcp.resources import Resource, ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.providers.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, _extract_mime_type_from_route, _slugify, ) from fastmcp.server.providers.openapi.routing import ( DEFAULT_ROUTE_MAPPINGS, ComponentFn, MCPType, RouteMap, RouteMapFn, _determine_route_type, ) from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( HTTPRoute, extract_output_schema_from_responses, parse_openapi_to_http_routes, ) from fastmcp.utilities.openapi.director import RequestDirector from fastmcp.utilities.versions import VersionSpec, version_sort_key __all__ = [ "OpenAPIProvider", ] logger = get_logger(__name__) DEFAULT_TIMEOUT: float = 30.0 class OpenAPIProvider(Provider): """Provider that creates MCP components from an OpenAPI specification. Components are created eagerly during initialization by parsing the OpenAPI spec. Each component makes HTTP calls to the described API endpoints. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers.openapi import OpenAPIProvider import httpx client = httpx.AsyncClient(base_url="https://api.example.com") provider = OpenAPIProvider(openapi_spec=spec, client=client) mcp = FastMCP("API Server") mcp.add_provider(provider) ``` """ def __init__( self, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, *, route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, mcp_component_fn: ComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, ): """Initialize provider by parsing OpenAPI spec and creating components. Args: openapi_spec: OpenAPI schema as a dictionary client: Optional httpx AsyncClient for making HTTP requests. If not provided, a default client is created using the first server URL from the OpenAPI spec with a 30-second timeout. To customize timeout or other settings, pass your own client. route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components validate_output: If True (default), tools use the output schema extracted from the OpenAPI spec for response validation. If False, a permissive schema is used instead, allowing any response structure while still returning structured JSON. """ super().__init__() self._owns_client = client is None if client is None: client = self._create_default_client(openapi_spec) self._client = client self._mcp_component_fn = mcp_component_fn self._validate_output = validate_output # Keep track of names to detect collisions self._used_names: dict[str, Counter[str]] = { "tool": Counter(), "resource": Counter(), "resource_template": Counter(), "prompt": Counter(), } # Pre-created component storage self._tools: dict[str, OpenAPITool] = {} self._resources: dict[str, OpenAPIResource] = {} self._templates: dict[str, OpenAPIResourceTemplate] = {} # Create openapi-core Spec and RequestDirector try: self._spec = SchemaPath.from_dict(cast(Any, openapi_spec)) self._director = RequestDirector(self._spec) except Exception as e: logger.exception("Failed to initialize RequestDirector") raise ValueError(f"Invalid OpenAPI specification: {e}") from e http_routes = parse_openapi_to_http_routes(openapi_spec) # Process routes route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS for route in http_routes: route_map = _determine_route_type(route, route_maps) route_type = route_map.mcp_type if route_map_fn is not None: try: result = route_map_fn(route, route_type) if result is not None: route_type = result logger.debug( f"Route {route.method} {route.path} mapping customized: " f"type={route_type.name}" ) except Exception as e: logger.warning( f"Error in route_map_fn for {route.method} {route.path}: {e}. " f"Using default values." ) component_name = self._generate_default_name(route, mcp_names) route_tags = set(route.tags) | route_map.mcp_tags | (tags or set()) if route_type == MCPType.TOOL: self._create_openapi_tool(route, component_name, tags=route_tags) elif route_type == MCPType.RESOURCE: self._create_openapi_resource(route, component_name, tags=route_tags) elif route_type == MCPType.RESOURCE_TEMPLATE: self._create_openapi_template(route, component_name, tags=route_tags) elif route_type == MCPType.EXCLUDE: logger.debug(f"Excluding route: {route.method} {route.path}") logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes") @classmethod def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient: """Create a default httpx client from the OpenAPI spec's server URL.""" servers = openapi_spec.get("servers", []) if not servers or not servers[0].get("url"): raise ValueError( "No server URL found in OpenAPI spec. Either add a 'servers' " "entry to the spec or provide an httpx.AsyncClient explicitly." ) base_url = servers[0]["url"] return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT) @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: """Manage the lifecycle of the auto-created httpx client.""" if self._owns_client: async with self._client: yield else: yield def _generate_default_name( self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None ) -> str: """Generate a default name from the route.""" mcp_names_map = mcp_names_map or {} if route.operation_id: if route.operation_id in mcp_names_map: name = mcp_names_map[route.operation_id] else: name = route.operation_id.split("__")[0] else: name = route.summary or f"{route.method}_{route.path}" name = _slugify(name) if len(name) > 56: name = name[:56] return name def _get_unique_name( self, name: str, component_type: Literal["tool", "resource", "resource_template", "prompt"], ) -> str: """Ensure the name is unique by appending numbers if needed.""" self._used_names[component_type][name] += 1 if self._used_names[component_type][name] == 1: return name new_name = f"{name}_{self._used_names[component_type][name]}" logger.debug( f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'." ) return new_name def _create_openapi_tool( self, route: HTTPRoute, name: str, tags: set[str], ) -> None: """Create and register an OpenAPITool.""" combined_schema = route.flat_param_schema output_schema = extract_output_schema_from_responses( route.responses, route.response_schemas, route.openapi_version, ) if not self._validate_output and output_schema is not None: # Use a permissive schema that accepts any object, preserving # the wrap-result flag so non-object responses still get wrapped permissive: dict[str, Any] = { "type": "object", "additionalProperties": True, } if output_schema.get("x-fastmcp-wrap-result"): permissive["x-fastmcp-wrap-result"] = True output_schema = permissive tool_name = self._get_unique_name(name, "tool") base_description = ( route.description or route.summary or f"Executes {route.method} {route.path}" ) tool = OpenAPITool( client=self._client, route=route, director=self._director, name=tool_name, description=base_description, parameters=combined_schema, output_schema=output_schema, tags=set(route.tags or []) | tags, ) if self._mcp_component_fn is not None: try: self._mcp_component_fn(route, tool) logger.debug(f"Tool {tool_name} customized by component_fn") except Exception as e: logger.warning(f"Error in component_fn for tool {tool_name}: {e}") self._tools[tool.name] = tool def _create_openapi_resource( self, route: HTTPRoute, name: str, tags: set[str], ) -> None: """Create and register an OpenAPIResource.""" resource_name = self._get_unique_name(name, "resource") resource_uri = f"resource://{resource_name}" base_description = ( route.description or route.summary or f"Represents {route.path}" ) resource = OpenAPIResource( client=self._client, route=route, director=self._director, uri=resource_uri, name=resource_name, description=base_description, mime_type=_extract_mime_type_from_route(route), tags=set(route.tags or []) | tags, ) if self._mcp_component_fn is not None: try: self._mcp_component_fn(route, resource) logger.debug(f"Resource {resource_uri} customized by component_fn") except Exception as e: logger.warning( f"Error in component_fn for resource {resource_uri}: {e}" ) self._resources[str(resource.uri)] = resource def _create_openapi_template( self, route: HTTPRoute, name: str, tags: set[str], ) -> None: """Create and register an OpenAPIResourceTemplate.""" template_name = self._get_unique_name(name, "resource_template") path_params = sorted(p.name for p in route.parameters if p.location == "path") uri_template_str = f"resource://{template_name}" if path_params: uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params) base_description = ( route.description or route.summary or f"Template for {route.path}" ) template_params_schema = { "type": "object", "properties": { p.name: { **(p.schema_.copy() if isinstance(p.schema_, dict) else {}), **( {"description": p.description} if p.description and not ( isinstance(p.schema_, dict) and "description" in p.schema_ ) else {} ), } for p in route.parameters if p.location == "path" }, "required": [ p.name for p in route.parameters if p.location == "path" and p.required ], } template = OpenAPIResourceTemplate( client=self._client, route=route, director=self._director, uri_template=uri_template_str, name=template_name, description=base_description, parameters=template_params_schema, tags=set(route.tags or []) | tags, mime_type=_extract_mime_type_from_route(route), ) if self._mcp_component_fn is not None: try: self._mcp_component_fn(route, template) logger.debug(f"Template {uri_template_str} customized by component_fn") except Exception as e: logger.warning( f"Error in component_fn for template {uri_template_str}: {e}" ) self._templates[template.uri_template] = template # ------------------------------------------------------------------------- # Provider interface # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """Return all tools created from the OpenAPI spec.""" return list(self._tools.values()) async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name.""" tool = self._tools.get(name) if tool is None: return None if version is not None and not version.matches(tool.version): return None return tool async def _list_resources(self) -> Sequence[Resource]: """Return all resources created from the OpenAPI spec.""" return list(self._resources.values()) async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI.""" resource = self._resources.get(uri) if resource is None: return None if version is not None and not version.matches(resource.version): return None return resource async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Return all resource templates created from the OpenAPI spec.""" return list(self._templates.values()) async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template that matches the given URI.""" matching = [t for t in self._templates.values() if t.matches(uri) is not None] if not matching: return None if version is not None: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] async def _list_prompts(self) -> Sequence[Prompt]: """Return empty list - OpenAPI doesn't create prompts.""" return [] async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return empty list - OpenAPI components don't support tasks.""" return [] ================================================ FILE: src/fastmcp/server/providers/openapi/routing.py ================================================ """Route mapping logic for OpenAPI operations.""" from __future__ import annotations import enum import re from collections.abc import Callable from dataclasses import dataclass, field from re import Pattern from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from fastmcp.server.providers.openapi.components import ( OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, ) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import HttpMethod, HTTPRoute __all__ = [ "ComponentFn", "MCPType", "RouteMap", "RouteMapFn", ] logger = get_logger(__name__) # Type definitions for the mapping functions RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] ComponentFn = Callable[ [ HTTPRoute, "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate", ], None, ] class MCPType(enum.Enum): """Type of FastMCP component to create from a route. Enum values: TOOL: Convert the route to a callable Tool RESOURCE: Convert the route to a Resource (typically GET endpoints) RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) EXCLUDE: Exclude the route from being converted to any MCP component """ TOOL = "TOOL" RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" EXCLUDE = "EXCLUDE" @dataclass(kw_only=True) class RouteMap: """Mapping configuration for HTTP routes to FastMCP component types.""" methods: list[HttpMethod] | Literal["*"] = field(default="*") pattern: Pattern[str] | str = field(default=r".*") tags: set[str] = field( default_factory=set, metadata={"description": "A set of tags to match. All tags must match."}, ) mcp_type: MCPType = field( metadata={"description": "The type of FastMCP component to create."}, ) mcp_tags: set[str] = field( default_factory=set, metadata={ "description": "A set of tags to apply to the generated FastMCP component." }, ) # Default route mapping: all routes become tools. DEFAULT_ROUTE_MAPPINGS = [ RouteMap(mcp_type=MCPType.TOOL), ] def _determine_route_type( route: HTTPRoute, mappings: list[RouteMap], ) -> RouteMap: """Determine the FastMCP component type based on the route and mappings.""" for route_map in mappings: if route_map.methods == "*" or route.method in route_map.methods: if isinstance(route_map.pattern, Pattern): pattern_matches = route_map.pattern.search(route.path) else: pattern_matches = re.search(route_map.pattern, route.path) if pattern_matches: if route_map.tags: route_tags_set = set(route.tags or []) if not route_map.tags.issubset(route_tags_set): continue logger.debug( f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}" ) return route_map return RouteMap(mcp_type=MCPType.TOOL) ================================================ FILE: src/fastmcp/server/providers/proxy.py ================================================ """ProxyProvider for proxying to remote MCP servers. This module provides the `ProxyProvider` class that proxies components from a remote MCP server via a client factory. It also provides proxy component classes that forward execution to remote servers. """ from __future__ import annotations import base64 import inspect import time from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, cast from urllib.parse import quote import mcp.types from mcp import ServerSession from mcp.client.session import ClientSession from mcp.server.lowlevel.server import request_ctx from mcp.shared.context import LifespanContextT, RequestContext from mcp.shared.exceptions import McpError from mcp.types import ( METHOD_NOT_FOUND, BlobResourceContents, ElicitRequestFormParams, TextResourceContents, ) from pydantic.networks import AnyUrl from fastmcp.client.client import Client, FastMCP1Server from fastmcp.client.elicitation import ElicitResult from fastmcp.client.logging import LogMessage from fastmcp.client.roots import RootsList from fastmcp.client.telemetry import client_span from fastmcp.client.transports import ClientTransportT from fastmcp.exceptions import ResourceError, ToolError from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Message, Prompt, PromptResult from fastmcp.prompts.base import PromptArgument from fastmcp.resources import Resource, ResourceTemplate from fastmcp.resources.base import ResourceContent, ResourceResult from fastmcp.server.context import Context from fastmcp.server.dependencies import get_context from fastmcp.server.providers.base import Provider from fastmcp.server.server import FastMCP from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec, version_sort_key if TYPE_CHECKING: from pathlib import Path from fastmcp.client.transports import ClientTransport logger = get_logger(__name__) # Type alias for client factory functions ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]] # ----------------------------------------------------------------------------- # Proxy Component Classes # ----------------------------------------------------------------------------- class ProxyTool(Tool): """A Tool that represents and executes a tool on a remote server.""" task_config: TaskConfig = TaskConfig(mode="forbidden") _backend_name: str | None = None def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): super().__init__(**kwargs) self._client_factory = client_factory async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" client = self._client_factory() if inspect.isawaitable(client): client = cast(Client, await client) return client def model_copy(self, **kwargs: Any) -> ProxyTool: """Override to preserve _backend_name when name changes.""" update = kwargs.get("update", {}) if "name" in update and self._backend_name is None: # First time name is being changed, preserve original for backend calls update = {**update, "_backend_name": self.name} kwargs["update"] = update return super().model_copy(**kwargs) @classmethod def from_mcp_tool( cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool ) -> ProxyTool: """Factory method to create a ProxyTool from a raw MCP tool schema.""" return cls( client_factory=client_factory, name=mcp_tool.name, title=mcp_tool.title, description=mcp_tool.description, parameters=mcp_tool.inputSchema, annotations=mcp_tool.annotations, output_schema=mcp_tool.outputSchema, icons=mcp_tool.icons, meta=mcp_tool.meta, tags=get_fastmcp_metadata(mcp_tool.meta).get("tags", []), ) async def run( self, arguments: dict[str, Any], context: Context | None = None, ) -> ToolResult: """Executes the tool by making a call through the client.""" backend_name = self._backend_name or self.name with client_span( f"tools/call {backend_name}", "tools/call", backend_name ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() async with client: ctx = context or get_context() # StatefulProxyClient reuses sessions across requests, so # its receive-loop task has stale ContextVars from the first # request. Stash the current RequestContext in the shared # ref so handlers can restore it before forwarding. if isinstance(client, StatefulProxyClient): client._proxy_rc_ref[0] = ( ctx.request_context, ctx._fastmcp, # weakref to FastMCP, not the Context ) # Build meta dict from request context meta: dict[str, Any] | None = None if hasattr(ctx, "request_context"): req_ctx = ctx.request_context # Start with existing meta if present if hasattr(req_ctx, "meta") and req_ctx.meta: meta = dict(req_ctx.meta) # Add task metadata if this is a task request if ( hasattr(req_ctx, "experimental") and hasattr(req_ctx.experimental, "is_task") and req_ctx.experimental.is_task ): task_metadata = req_ctx.experimental.task_metadata if task_metadata: meta = meta or {} meta["modelcontextprotocol.io/task"] = ( task_metadata.model_dump(exclude_none=True) ) result = await client.call_tool_mcp( name=backend_name, arguments=arguments, meta=meta ) if result.isError: raise ToolError(cast(mcp.types.TextContent, result.content[0]).text) # Preserve backend's meta (includes task metadata for background tasks) return ToolResult( content=result.content, structured_content=result.structuredContent, meta=result.meta, ) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "ProxyProvider", "fastmcp.proxy.backend_name": self._backend_name, } class ProxyResource(Resource): """A Resource that represents and reads a resource from a remote server.""" task_config: TaskConfig = TaskConfig(mode="forbidden") _cached_content: ResourceResult | None = None _backend_uri: str | None = None def __init__( self, client_factory: ClientFactoryT, *, _cached_content: ResourceResult | None = None, **kwargs, ): super().__init__(**kwargs) self._client_factory = client_factory self._cached_content = _cached_content async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" client = self._client_factory() if inspect.isawaitable(client): client = cast(Client, await client) return client def model_copy(self, **kwargs: Any) -> ProxyResource: """Override to preserve _backend_uri when uri changes.""" update = kwargs.get("update", {}) if "uri" in update and self._backend_uri is None: # First time uri is being changed, preserve original for backend calls update = {**update, "_backend_uri": str(self.uri)} kwargs["update"] = update return super().model_copy(**kwargs) @classmethod def from_mcp_resource( cls, client_factory: ClientFactoryT, mcp_resource: mcp.types.Resource, ) -> ProxyResource: """Factory method to create a ProxyResource from a raw MCP resource schema.""" return cls( client_factory=client_factory, uri=mcp_resource.uri, name=mcp_resource.name, title=mcp_resource.title, description=mcp_resource.description, mime_type=mcp_resource.mimeType or "text/plain", icons=mcp_resource.icons, meta=mcp_resource.meta, tags=get_fastmcp_metadata(mcp_resource.meta).get("tags", []), task_config=TaskConfig(mode="forbidden"), ) async def read(self) -> ResourceResult: """Read the resource content from the remote server.""" if self._cached_content is not None: return self._cached_content backend_uri = self._backend_uri or str(self.uri) with client_span( f"resources/read {backend_uri}", "resources/read", backend_uri, resource_uri=backend_uri, ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() async with client: result = await client.read_resource(backend_uri) if not result: raise ResourceError( f"Remote server returned empty content for {backend_uri}" ) # Process all items in the result list, not just the first one contents: list[ResourceContent] = [] for item in result: if isinstance(item, TextResourceContents): contents.append( ResourceContent( content=item.text, mime_type=item.mimeType, meta=item.meta, ) ) elif isinstance(item, BlobResourceContents): contents.append( ResourceContent( content=base64.b64decode(item.blob), mime_type=item.mimeType, meta=item.meta, ) ) else: raise ResourceError(f"Unsupported content type: {type(item)}") return ResourceResult(contents=contents) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "ProxyProvider", "fastmcp.proxy.backend_uri": self._backend_uri, } class ProxyTemplate(ResourceTemplate): """A ResourceTemplate that represents and creates resources from a remote server template.""" task_config: TaskConfig = TaskConfig(mode="forbidden") _backend_uri_template: str | None = None def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): super().__init__(**kwargs) self._client_factory = client_factory async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" client = self._client_factory() if inspect.isawaitable(client): client = cast(Client, await client) return client def model_copy(self, **kwargs: Any) -> ProxyTemplate: """Override to preserve _backend_uri_template when uri_template changes.""" update = kwargs.get("update", {}) if "uri_template" in update and self._backend_uri_template is None: # First time uri_template is being changed, preserve original for backend update = {**update, "_backend_uri_template": self.uri_template} kwargs["update"] = update return super().model_copy(**kwargs) @classmethod def from_mcp_template( # type: ignore[override] cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate ) -> ProxyTemplate: """Factory method to create a ProxyTemplate from a raw MCP template schema.""" return cls( client_factory=client_factory, uri_template=mcp_template.uriTemplate, name=mcp_template.name, title=mcp_template.title, description=mcp_template.description, mime_type=mcp_template.mimeType or "text/plain", icons=mcp_template.icons, parameters={}, # Remote templates don't have local parameters meta=mcp_template.meta, tags=get_fastmcp_metadata(mcp_template.meta).get("tags", []), task_config=TaskConfig(mode="forbidden"), ) async def create_resource( self, uri: str, params: dict[str, Any], context: Context | None = None, ) -> ProxyResource: """Create a resource from the template by calling the remote server.""" # don't use the provided uri, because it may not be the same as the # uri_template on the remote server. # quote params to ensure they are valid for the uri_template backend_template = self._backend_uri_template or self.uri_template parameterized_uri = backend_template.format( **{k: quote(v, safe="") for k, v in params.items()} ) client = await self._get_client() async with client: result = await client.read_resource(parameterized_uri) if not result: raise ResourceError( f"Remote server returned empty content for {parameterized_uri}" ) # Process all items in the result list, not just the first one contents: list[ResourceContent] = [] for item in result: if isinstance(item, TextResourceContents): contents.append( ResourceContent( content=item.text, mime_type=item.mimeType, meta=item.meta, ) ) elif isinstance(item, BlobResourceContents): contents.append( ResourceContent( content=base64.b64decode(item.blob), mime_type=item.mimeType, meta=item.meta, ) ) else: raise ResourceError(f"Unsupported content type: {type(item)}") cached_content = ResourceResult(contents=contents) return ProxyResource( client_factory=self._client_factory, uri=parameterized_uri, name=self.name, title=self.title, description=self.description, mime_type=result[ 0 ].mimeType, # Use first item's mimeType for backward compatibility icons=self.icons, meta=self.meta, tags=get_fastmcp_metadata(self.meta).get("tags", []), _cached_content=cached_content, ) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "ProxyProvider", "fastmcp.proxy.backend_uri_template": self._backend_uri_template, } class ProxyPrompt(Prompt): """A Prompt that represents and renders a prompt from a remote server.""" task_config: TaskConfig = TaskConfig(mode="forbidden") _backend_name: str | None = None def __init__(self, client_factory: ClientFactoryT, **kwargs): super().__init__(**kwargs) self._client_factory = client_factory async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" client = self._client_factory() if inspect.isawaitable(client): client = cast(Client, await client) return client def model_copy(self, **kwargs: Any) -> ProxyPrompt: """Override to preserve _backend_name when name changes.""" update = kwargs.get("update", {}) if "name" in update and self._backend_name is None: # First time name is being changed, preserve original for backend calls update = {**update, "_backend_name": self.name} kwargs["update"] = update return super().model_copy(**kwargs) @classmethod def from_mcp_prompt( cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt ) -> ProxyPrompt: """Factory method to create a ProxyPrompt from a raw MCP prompt schema.""" arguments = [ PromptArgument( name=arg.name, description=arg.description, required=arg.required or False, ) for arg in mcp_prompt.arguments or [] ] return cls( client_factory=client_factory, name=mcp_prompt.name, title=mcp_prompt.title, description=mcp_prompt.description, arguments=arguments, icons=mcp_prompt.icons, meta=mcp_prompt.meta, tags=get_fastmcp_metadata(mcp_prompt.meta).get("tags", []), task_config=TaskConfig(mode="forbidden"), ) async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override] """Render the prompt by making a call through the client.""" backend_name = self._backend_name or self.name with client_span( f"prompts/get {backend_name}", "prompts/get", backend_name ) as span: span.set_attribute("fastmcp.provider.type", "ProxyProvider") client = await self._get_client() async with client: result = await client.get_prompt(backend_name, arguments) # Convert GetPromptResult to PromptResult, preserving meta from result # (not the static prompt meta which includes fastmcp tags) # Convert PromptMessages to Messages messages = [ Message(content=m.content, role=m.role) for m in result.messages ] return PromptResult( messages=messages, description=result.description, meta=result.meta, ) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.provider.type": "ProxyProvider", "fastmcp.proxy.backend_name": self._backend_name, } # ----------------------------------------------------------------------------- # ProxyProvider # ----------------------------------------------------------------------------- class _CacheEntry: """A cached sequence of components with a monotonic timestamp.""" __slots__ = ("items", "timestamp") def __init__(self, items: Sequence[Any], timestamp: float): self.items = items self.timestamp = timestamp def is_fresh(self, ttl: float) -> bool: return (time.monotonic() - self.timestamp) < ttl _DEFAULT_CACHE_TTL: float = 300.0 class ProxyProvider(Provider): """Provider that proxies to a remote MCP server via a client factory. This provider fetches components from a remote server and returns Proxy* component instances that forward execution to the remote server. All components returned by this provider have task_config.mode="forbidden" because tasks cannot be executed through a proxy. Component lists (tools, resources, templates, prompts) are cached so that individual lookups (e.g. during ``call_tool``) can resolve from the cache instead of opening a new backend connection. The cache stores the backend's raw component metadata and is shared across all sessions; per-session visibility and auth filtering are applied after cache lookup by the server layer. The cache is refreshed whenever a ``list_*`` call is made, and entries expire after ``cache_ttl`` seconds (default 300). Set ``cache_ttl=0`` to disable caching. Disabling is recommended for backends whose component lists change dynamically. Example: ```python from fastmcp import FastMCP from fastmcp.server.providers.proxy import ProxyProvider, ProxyClient # Create a proxy provider for a remote server proxy = ProxyProvider(lambda: ProxyClient("http://localhost:8000/mcp")) mcp = FastMCP("Proxy Server") mcp.add_provider(proxy) # Can also add with namespace mcp.add_provider(proxy.with_namespace("remote")) ``` """ def __init__( self, client_factory: ClientFactoryT, cache_ttl: float | None = None, ): """Initialize a ProxyProvider. Args: client_factory: A callable that returns a Client instance when called. This gives you full control over session creation and reuse. Can be either a synchronous or asynchronous function. cache_ttl: How long (in seconds) to cache component lists for individual lookups. Defaults to 300. Set to 0 to disable caching. """ super().__init__() self.client_factory = client_factory self._cache_ttl = cache_ttl if cache_ttl is not None else _DEFAULT_CACHE_TTL self._tools_cache: _CacheEntry[Tool] | None = None self._resources_cache: _CacheEntry[Resource] | None = None self._templates_cache: _CacheEntry[ResourceTemplate] | None = None self._prompts_cache: _CacheEntry[Prompt] | None = None async def _get_client(self) -> Client: """Gets a client instance by calling the sync or async factory.""" client = self.client_factory() if inspect.isawaitable(client): client = cast(Client, await client) return client # ------------------------------------------------------------------------- # Tool methods # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """List all tools from the remote server.""" try: client = await self._get_client() async with client: mcp_tools = await client.list_tools() tools = [ ProxyTool.from_mcp_tool(self.client_factory, t) for t in mcp_tools ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: tools = [] else: raise self._tools_cache = _CacheEntry(tools, time.monotonic()) return tools async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: cache = self._tools_cache if cache is None or not cache.is_fresh(self._cache_ttl): await self._list_tools() cache = self._tools_cache assert cache is not None matching = [t for t in cache.items if t.name == name] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ------------------------------------------------------------------------- # Resource methods # ------------------------------------------------------------------------- async def _list_resources(self) -> Sequence[Resource]: """List all resources from the remote server.""" try: client = await self._get_client() async with client: mcp_resources = await client.list_resources() resources = [ ProxyResource.from_mcp_resource(self.client_factory, r) for r in mcp_resources ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: resources = [] else: raise self._resources_cache = _CacheEntry(resources, time.monotonic()) return resources async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: cache = self._resources_cache if cache is None or not cache.is_fresh(self._cache_ttl): await self._list_resources() cache = self._resources_cache assert cache is not None matching = [r for r in cache.items if str(r.uri) == uri] if version: matching = [r for r in matching if version.matches(r.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ------------------------------------------------------------------------- # Resource template methods # ------------------------------------------------------------------------- async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """List all resource templates from the remote server.""" try: client = await self._get_client() async with client: mcp_templates = await client.list_resource_templates() templates = [ ProxyTemplate.from_mcp_template(self.client_factory, t) for t in mcp_templates ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: templates = [] else: raise self._templates_cache = _CacheEntry(templates, time.monotonic()) return templates async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: cache = self._templates_cache if cache is None or not cache.is_fresh(self._cache_ttl): await self._list_resource_templates() cache = self._templates_cache assert cache is not None matching = [t for t in cache.items if t.matches(uri) is not None] if version: matching = [t for t in matching if version.matches(t.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ------------------------------------------------------------------------- # Prompt methods # ------------------------------------------------------------------------- async def _list_prompts(self) -> Sequence[Prompt]: """List all prompts from the remote server.""" try: client = await self._get_client() async with client: mcp_prompts = await client.list_prompts() prompts = [ ProxyPrompt.from_mcp_prompt(self.client_factory, p) for p in mcp_prompts ] except McpError as e: if e.error.code == METHOD_NOT_FOUND: prompts = [] else: raise self._prompts_cache = _CacheEntry(prompts, time.monotonic()) return prompts async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: cache = self._prompts_cache if cache is None or not cache.is_fresh(self._cache_ttl): await self._list_prompts() cache = self._prompts_cache assert cache is not None matching = [p for p in cache.items if p.name == name] if version: matching = [p for p in matching if version.matches(p.version)] if not matching: return None return max(matching, key=version_sort_key) # type: ignore[type-var] # ------------------------------------------------------------------------- # Task methods # ------------------------------------------------------------------------- async def get_tasks(self) -> Sequence[FastMCPComponent]: """Return empty list since proxy components don't support tasks. Override the base implementation to avoid calling list_tools() during server lifespan initialization, which would open the client before any context is set. All Proxy* components have task_config.mode="forbidden". """ return [] # lifespan() uses default implementation (empty context manager) # because client cleanup is handled per-request # ----------------------------------------------------------------------------- # Factory Functions # ----------------------------------------------------------------------------- def _create_client_factory( target: ( Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str ), ) -> ClientFactoryT: """Create a client factory from the given target. Internal helper that handles the session strategy based on the target type: - Connected Client: reuses existing session (with warning about context mixing) - Disconnected Client: creates fresh sessions per request - Other targets: creates ProxyClient and fresh sessions per request """ if isinstance(target, Client): client = target if client.is_connected() and type(client) is ProxyClient: logger.info( "Proxy detected connected ProxyClient - creating fresh sessions for each " "request to avoid request context leakage." ) def fresh_client_factory() -> Client: return client.new() return fresh_client_factory if client.is_connected(): logger.info( "Proxy detected connected client - reusing existing session for all requests. " "This may cause context mixing in concurrent scenarios." ) def reuse_client_factory() -> Client: return client return reuse_client_factory def fresh_client_factory() -> Client: return client.new() return fresh_client_factory else: # target is not a Client, so it's compatible with ProxyClient.__init__ base_client = ProxyClient(cast(Any, target)) def proxy_client_factory() -> Client: return base_client.new() return proxy_client_factory # ----------------------------------------------------------------------------- # FastMCPProxy - Convenience Wrapper # ----------------------------------------------------------------------------- class FastMCPProxy(FastMCP): """A FastMCP server that acts as a proxy to a remote MCP-compliant server. This is a convenience wrapper that creates a FastMCP server with a ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)). Example: ```python from fastmcp.server import create_proxy from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient # Create a proxy server using create_proxy (recommended) proxy = create_proxy("http://localhost:8000/mcp") # Or use FastMCPProxy directly with explicit client factory proxy = FastMCPProxy(client_factory=lambda: ProxyClient("http://localhost:8000/mcp")) ``` """ def __init__( self, *, client_factory: ClientFactoryT, **kwargs, ): """Initialize the proxy server. FastMCPProxy requires explicit session management via client_factory. Use create_proxy() for convenience with automatic session strategy. Args: client_factory: A callable that returns a Client instance when called. This gives you full control over session creation and reuse. Can be either a synchronous or asynchronous function. **kwargs: Additional settings for the FastMCP server. """ super().__init__(**kwargs) self.client_factory = client_factory provider: Provider = ProxyProvider(client_factory) self.add_provider(provider) # ----------------------------------------------------------------------------- # ProxyClient and Related # ----------------------------------------------------------------------------- async def default_proxy_roots_handler( context: RequestContext[ClientSession, LifespanContextT], ) -> RootsList: """Forward list roots request from remote server to proxy's connected clients.""" ctx = get_context() return await ctx.list_roots() async def default_proxy_sampling_handler( messages: list[mcp.types.SamplingMessage], params: mcp.types.CreateMessageRequestParams, context: RequestContext[ClientSession, LifespanContextT], ) -> mcp.types.CreateMessageResult: """Forward sampling request from remote server to proxy's connected clients.""" ctx = get_context() result = await ctx.sample( list(messages), system_prompt=params.systemPrompt, temperature=params.temperature, max_tokens=params.maxTokens, model_preferences=params.modelPreferences, ) content = mcp.types.TextContent(type="text", text=result.text or "") return mcp.types.CreateMessageResult( role="assistant", model="fastmcp-client", # TODO(ty): remove when ty supports isinstance exclusion narrowing content=content, ) async def default_proxy_elicitation_handler( message: str, response_type: type, params: mcp.types.ElicitRequestParams, context: RequestContext[ClientSession, LifespanContextT], ) -> ElicitResult: """Forward elicitation request from remote server to proxy's connected clients.""" ctx = get_context() # requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams requested_schema = ( params.requestedSchema if isinstance(params, ElicitRequestFormParams) else {"type": "object", "properties": {}} ) result = await ctx.session.elicit( message=message, requestedSchema=requested_schema, related_request_id=ctx.request_id, ) return ElicitResult(action=result.action, content=result.content) async def default_proxy_log_handler(message: LogMessage) -> None: """Forward log notification from remote server to proxy's connected clients.""" ctx = get_context() msg = message.data.get("msg") extra = message.data.get("extra") await ctx.log(msg, level=message.level, logger_name=message.logger, extra=extra) async def default_proxy_progress_handler( progress: float, total: float | None, message: str | None, ) -> None: """Forward progress notification from remote server to proxy's connected clients.""" ctx = get_context() await ctx.report_progress(progress, total, message) def _restore_request_context( rc_ref: list[Any], ) -> None: """Set the ``request_ctx`` and ``_current_context`` ContextVars from stashed values. Called at the start of proxy handler invocations in ``StatefulProxyClient`` to fix stale ContextVars in the receive-loop task. Only overrides when the ContextVar is genuinely stale (same session, different request_id) to avoid corrupting the concurrent case where multiple sessions share the same ref via ``copy.copy``. We stash a ``(RequestContext, weakref[FastMCP])`` tuple — never a ``Context`` instance — because ``Context`` properties are themselves ContextVar-dependent and would resolve stale values in the receive loop. Instead we construct a fresh ``Context`` here after restoring ``request_ctx``, so its property accesses read the correct values. """ from fastmcp.server.context import Context, _current_context stashed = rc_ref[0] if stashed is None: return rc, fastmcp_ref = stashed try: current_rc = request_ctx.get() except LookupError: request_ctx.set(rc) fastmcp = fastmcp_ref() if fastmcp is not None: _current_context.set(Context(fastmcp)) return if current_rc.session is rc.session and current_rc.request_id != rc.request_id: request_ctx.set(rc) fastmcp = fastmcp_ref() if fastmcp is not None: _current_context.set(Context(fastmcp)) def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable: """Wrap a proxy handler to restore request_ctx before delegating. The wrapper is a plain ``async def`` so it passes ``inspect.isfunction()`` checks in handler registration paths (e.g., ``create_roots_callback``). """ async def wrapper(*args: Any, **kwargs: Any) -> Any: _restore_request_context(rc_ref) return await handler(*args, **kwargs) return wrapper class ProxyClient(Client[ClientTransportT]): """A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. Supports forwarding roots, sampling, elicitation, logging, and progress. """ def __init__( self, transport: ClientTransportT | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | 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: kwargs["sampling_handler"] = default_proxy_sampling_handler if "elicitation_handler" not in kwargs: kwargs["elicitation_handler"] = default_proxy_elicitation_handler if "log_handler" not in kwargs: kwargs["log_handler"] = default_proxy_log_handler if "progress_handler" not in kwargs: kwargs["progress_handler"] = default_proxy_progress_handler super().__init__(**kwargs | {"transport": transport}) class StatefulProxyClient(ProxyClient[ClientTransportT]): """A proxy client that provides a stateful client factory for the proxy server. The stateful proxy client bound its copy to the server session. And it will be disconnected when the session is exited. This is useful to proxy a stateful mcp server such as the Playwright MCP server. Note that it is essential to ensure that the proxy server itself is also stateful. Because session reuse means the receive-loop task inherits a stale ``request_ctx`` ContextVar snapshot, the default proxy handlers are replaced with versions that restore the ContextVar before forwarding. ``ProxyTool.run`` stashes the current ``RequestContext`` in ``_proxy_rc_ref`` before each backend call, and the handlers consult it to detect (and correct) staleness. """ # Mutable list shared across copies (Client.new() uses copy.copy, # which preserves references to mutable containers). ProxyTool.run # writes [0] before each backend call; handlers read it to detect # stale ContextVars and restore the correct request_ctx. # # Stores a (RequestContext, weakref[FastMCP]) tuple — never a Context # instance — because Context properties are ContextVar-dependent and # would resolve stale values in the receive loop. The restore helper # constructs a fresh Context from the weakref after setting request_ctx. _proxy_rc_ref: list[Any] def __init__(self, *args: Any, **kwargs: Any): # Install context-restoring handler wrappers BEFORE super().__init__ # registers them with the Client's session kwargs. self._proxy_rc_ref = [None] for key, default_fn in ( ("roots", default_proxy_roots_handler), ("sampling_handler", default_proxy_sampling_handler), ("elicitation_handler", default_proxy_elicitation_handler), ("log_handler", default_proxy_log_handler), ("progress_handler", default_proxy_progress_handler), ): if key not in kwargs: kwargs[key] = _make_restoring_handler(default_fn, self._proxy_rc_ref) super().__init__(*args, **kwargs) self._caches: dict[ServerSession, Client[ClientTransportT]] = {} async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] """The stateful proxy client will be forced disconnected when the session is exited. So we do nothing here. """ async def clear(self): """Clear all cached clients and force disconnect them.""" while self._caches: _, cache = self._caches.popitem() await cache._disconnect(force=True) def new_stateful(self) -> Client[ClientTransportT]: """Create a new stateful proxy client instance with the same configuration. Use this method as the client factory for stateful proxy server. """ session = get_context().session proxy_client = self._caches.get(session, None) if proxy_client is None: proxy_client = self.new() logger.debug(f"{proxy_client} created for {session}") self._caches[session] = proxy_client async def _on_session_exit(): self._caches.pop(session) logger.debug(f"{proxy_client} will be disconnect") await proxy_client._disconnect(force=True) session._exit_stack.push_async_callback(_on_session_exit) return proxy_client ================================================ FILE: src/fastmcp/server/providers/skills/__init__.py ================================================ """Skills providers for exposing agent skills as MCP resources. This module provides a two-layer architecture for skill discovery: - **SkillProvider**: Handles a single skill folder, exposing its files as resources. - **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder. - **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex, Gemini, Goose, Copilot, and OpenCode. Example: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider mcp = FastMCP("Skills Server") # Load a single skill mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing")) # Or load all skills in a directory mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ ``` """ from __future__ import annotations # Import providers from fastmcp.server.providers.skills.claude_provider import ClaudeSkillsProvider from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider from fastmcp.server.providers.skills.skill_provider import SkillProvider from fastmcp.server.providers.skills.vendor_providers import ( CodexSkillsProvider, CopilotSkillsProvider, CursorSkillsProvider, GeminiSkillsProvider, GooseSkillsProvider, OpenCodeSkillsProvider, VSCodeSkillsProvider, ) # Backwards compatibility alias SkillsProvider = SkillsDirectoryProvider __all__ = [ "ClaudeSkillsProvider", "CodexSkillsProvider", "CopilotSkillsProvider", "CursorSkillsProvider", "GeminiSkillsProvider", "GooseSkillsProvider", "OpenCodeSkillsProvider", "SkillProvider", "SkillsDirectoryProvider", "SkillsProvider", # Backwards compatibility alias "VSCodeSkillsProvider", ] ================================================ FILE: src/fastmcp/server/providers/skills/_common.py ================================================ """Shared utilities and data structures for skills providers.""" from __future__ import annotations import hashlib import re from dataclasses import dataclass, field from pathlib import Path from typing import Any @dataclass class SkillFileInfo: """Information about a file within a skill.""" path: str # Relative path within skill directory size: int hash: str # sha256 hash @dataclass class SkillInfo: """Parsed information about a skill.""" name: str # Directory name (canonical identifier) description: str # From frontmatter or first line path: Path # Absolute path to skill directory main_file: str # Name of main file (e.g., "SKILL.md") files: list[SkillFileInfo] = field(default_factory=list) frontmatter: dict[str, Any] = field(default_factory=dict) def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: """Parse YAML frontmatter from markdown content. Args: content: Markdown content potentially starting with --- Returns: Tuple of (frontmatter dict, remaining content) """ if not content.startswith("---"): return {}, content # Find the closing --- end_match = re.search(r"\n---\s*\n", content[3:]) if not end_match: return {}, content frontmatter_text = content[3 : 3 + end_match.start()] remaining = content[3 + end_match.end() :] # Parse YAML (simple key: value parsing, no complex types) frontmatter: dict[str, Any] = {} for line in frontmatter_text.strip().split("\n"): if ":" in line: key, _, value = line.partition(":") key = key.strip() value = value.strip() # Handle quoted strings if (value.startswith('"') and value.endswith('"')) or ( value.startswith("'") and value.endswith("'") ): value = value[1:-1] # Handle lists [a, b, c] if value.startswith("[") and value.endswith("]"): items = value[1:-1].split(",") value = [item.strip().strip("\"'") for item in items if item.strip()] frontmatter[key] = value return frontmatter, remaining def compute_file_hash(path: Path) -> str: """Compute SHA256 hash of a file.""" sha256 = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) return f"sha256:{sha256.hexdigest()}" def scan_skill_files(skill_dir: Path) -> list[SkillFileInfo]: """Scan a skill directory for all files.""" files = [] resolved_skill_dir = skill_dir.resolve() # Sort for deterministic ordering across platforms for file_path in sorted(skill_dir.rglob("*")): if file_path.is_file(): resolved_file_path = file_path.resolve() if not resolved_file_path.is_relative_to(resolved_skill_dir): continue rel_path = file_path.relative_to(skill_dir) files.append( SkillFileInfo( # Use POSIX paths for cross-platform URI consistency path=rel_path.as_posix(), size=resolved_file_path.stat().st_size, hash=compute_file_hash(resolved_file_path), ) ) return files ================================================ FILE: src/fastmcp/server/providers/skills/claude_provider.py ================================================ """Claude-specific skills provider for Claude Code skills.""" from __future__ import annotations from pathlib import Path from typing import Literal from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider class ClaudeSkillsProvider(SkillsDirectoryProvider): """Provider for Claude Code skills from ~/.claude/skills/. A convenience subclass that sets the default root to Claude's skills location. Args: reload: If True, re-scan on every request. Defaults to False. supporting_files: How supporting files are exposed: - "template": Accessed via ResourceTemplate, hidden from list_resources(). - "resources": Each file exposed as individual Resource in list_resources(). Example: ```python from fastmcp import FastMCP from fastmcp.server.providers.skills import ClaudeSkillsProvider mcp = FastMCP("Claude Skills") mcp.add_provider(ClaudeSkillsProvider()) # Uses default location ``` """ def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".claude" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) ================================================ FILE: src/fastmcp/server/providers/skills/directory_provider.py ================================================ """Directory scanning provider for discovering multiple skills.""" from __future__ import annotations from collections.abc import Sequence from pathlib import Path from typing import Literal from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.providers.skills.skill_provider import SkillProvider from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec logger = get_logger(__name__) class SkillsDirectoryProvider(AggregateProvider): """Provider that scans directories and creates a SkillProvider per skill folder. This extends AggregateProvider to combine multiple SkillProviders into one. Each subdirectory containing a main file (default: SKILL.md) becomes a skill. Can scan multiple root directories - if a skill name appears in multiple roots, the first one found wins. Args: roots: Root directory(ies) containing skill folders. Can be a single path or a sequence of paths. reload: If True, re-discover skills on each request. Defaults to False. main_file_name: Name of the main skill file. Defaults to "SKILL.md". supporting_files: How supporting files are exposed in child SkillProviders: - "template": Accessed via ResourceTemplate, hidden from list_resources(). - "resources": Each file exposed as individual Resource in list_resources(). Example: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider mcp = FastMCP("Skills") # Single directory mcp.add_provider(SkillsDirectoryProvider( roots=Path.home() / ".claude" / "skills", reload=True, # Re-scan on each request )) # Multiple directories mcp.add_provider(SkillsDirectoryProvider( roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], )) ``` """ def __init__( self, roots: str | Path | Sequence[str | Path], reload: bool = False, main_file_name: str = "SKILL.md", supporting_files: Literal["template", "resources"] = "template", ) -> None: super().__init__() # Normalize to sequence: single path becomes list if isinstance(roots, (str, Path)): roots = [roots] self._roots = [Path(r).resolve() for r in roots] self._reload = reload self._main_file_name = main_file_name self._supporting_files = supporting_files self._discovered = False # Discover skills at init self._discover_skills() def _discover_skills(self) -> None: """Scan root directories and create SkillProvider per valid skill folder.""" # Clear existing providers if reloading self.providers.clear() seen_skill_names: set[str] = set() for root in self._roots: if not root.exists(): logger.debug(f"Skills root does not exist: {root}") continue for skill_dir in root.iterdir(): if not skill_dir.is_dir(): continue main_file = skill_dir / self._main_file_name if not main_file.exists(): continue skill_name = skill_dir.name # Skip if we've already seen this skill name (first wins) if skill_name in seen_skill_names: logger.debug( f"Skipping duplicate skill '{skill_name}' from {root} " f"(already found in earlier root)" ) continue try: provider = SkillProvider( skill_path=skill_dir, main_file_name=self._main_file_name, supporting_files=self._supporting_files, ) self.providers.append(provider) seen_skill_names.add(skill_name) except (FileNotFoundError, PermissionError, OSError): logger.exception(f"Failed to load skill: {skill_dir.name}") self._discovered = True logger.debug( f"SkillsDirectoryProvider loaded {len(self.providers)} skills " f"from {len(self._roots)} root(s)" ) async def _ensure_discovered(self) -> None: """Ensure skills are discovered, rediscovering if reload is enabled.""" if self._reload or not self._discovered: self._discover_skills() # Override list methods to support reload async def _list_resources(self) -> Sequence[Resource]: await self._ensure_discovered() return await super()._list_resources() async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: await self._ensure_discovered() return await super()._list_resource_templates() async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: await self._ensure_discovered() return await super()._get_resource(uri, version) async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: await self._ensure_discovered() return await super()._get_resource_template(uri, version) def __repr__(self) -> str: roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots return ( f"SkillsDirectoryProvider(roots={roots_repr!r}, " f"reload={self._reload}, skills={len(self.providers)})" ) ================================================ FILE: src/fastmcp/server/providers/skills/skill_provider.py ================================================ """Basic skill provider for handling a single skill folder.""" from __future__ import annotations import json import mimetypes from collections.abc import Sequence from pathlib import Path from typing import Any, Literal, cast from pydantic import AnyUrl from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.providers.base import Provider from fastmcp.server.providers.skills._common import ( SkillInfo, parse_frontmatter, scan_skill_files, ) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.versions import VersionSpec logger = get_logger(__name__) # Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) mimetypes.add_type("text/markdown", ".md") # ----------------------------------------------------------------------------- # Skill-specific Resource and ResourceTemplate subclasses # ----------------------------------------------------------------------------- class SkillResource(Resource): """A resource representing a skill's main file or manifest.""" skill_info: SkillInfo is_manifest: bool = False def get_meta(self) -> dict[str, Any]: meta = super().get_meta() fastmcp = cast(dict[str, Any], meta["fastmcp"]) fastmcp["skill"] = { "name": self.skill_info.name, "is_manifest": self.is_manifest, } return meta async def read(self) -> str | bytes | ResourceResult: """Read the resource content.""" if self.is_manifest: return self._generate_manifest() else: main_file_path = self.skill_info.path / self.skill_info.main_file return main_file_path.read_text() def _generate_manifest(self) -> str: """Generate JSON manifest for the skill.""" manifest = { "skill": self.skill_info.name, "files": [ {"path": f.path, "size": f.size, "hash": f.hash} for f in self.skill_info.files ], } return json.dumps(manifest, indent=2) class SkillFileTemplate(ResourceTemplate): """A template for accessing files within a skill.""" skill_info: SkillInfo async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: """Read a file from the skill directory.""" file_path = arguments.get("path", "") full_path = self.skill_info.path / file_path # Security: ensure path doesn't escape skill directory try: full_path = full_path.resolve() if not full_path.is_relative_to(self.skill_info.path): raise ValueError(f"Path {file_path} escapes skill directory") except ValueError as e: raise ValueError(f"Invalid path: {e}") from e if not full_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") if not full_path.is_file(): raise ValueError(f"Not a file: {file_path}") # Determine if binary or text based on mime type mime_type, _ = mimetypes.guess_type(str(full_path)) if mime_type and mime_type.startswith("text/"): return full_path.read_text() else: return full_path.read_bytes() async def _read( # type: ignore[override] self, uri: str, params: dict[str, Any], task_meta: Any = None, ) -> ResourceResult: """Server entry point - read file directly without creating ephemeral resource. Note: task_meta is ignored - this template doesn't support background tasks. """ # Call read() directly and convert to ResourceResult result = await self.read(arguments=params) return self.convert_result(result) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource for the given URI and parameters. Note: This is not typically used since _read() handles file reading directly. Provided for compatibility with the ResourceTemplate interface. """ file_path = params.get("path", "") full_path = (self.skill_info.path / file_path).resolve() # Security: ensure path doesn't escape skill directory if not full_path.is_relative_to(self.skill_info.path): raise ValueError(f"Path {file_path} escapes skill directory") mime_type, _ = mimetypes.guess_type(str(full_path)) # Create a SkillFileResource that can read the file return SkillFileResource( uri=AnyUrl(uri), name=f"{self.skill_info.name}/{file_path}", description=f"File from {self.skill_info.name} skill", mime_type=mime_type or "application/octet-stream", skill_info=self.skill_info, file_path=file_path, ) class SkillFileResource(Resource): """A resource representing a specific file within a skill.""" skill_info: SkillInfo file_path: str def get_meta(self) -> dict[str, Any]: meta = super().get_meta() fastmcp = cast(dict[str, Any], meta["fastmcp"]) fastmcp["skill"] = { "name": self.skill_info.name, } return meta async def read(self) -> str | bytes | ResourceResult: """Read the file content.""" full_path = self.skill_info.path / self.file_path # Security check full_path = full_path.resolve() if not full_path.is_relative_to(self.skill_info.path): raise ValueError(f"Path {self.file_path} escapes skill directory") if not full_path.exists(): raise FileNotFoundError(f"File not found: {self.file_path}") mime_type, _ = mimetypes.guess_type(str(full_path)) if mime_type and mime_type.startswith("text/"): return full_path.read_text() else: return full_path.read_bytes() # ----------------------------------------------------------------------------- # SkillProvider - handles a SINGLE skill folder # ----------------------------------------------------------------------------- class SkillProvider(Provider): """Provider that exposes a single skill folder as MCP resources. Each skill folder must contain a main file (default: SKILL.md) and may contain additional supporting files. Exposes: - A Resource for the main file (skill://{name}/SKILL.md) - A Resource for the synthetic manifest (skill://{name}/_manifest) - Supporting files via ResourceTemplate or Resources (configurable) Args: skill_path: Path to the skill directory. main_file_name: Name of the main skill file. Defaults to "SKILL.md". supporting_files: How supporting files (everything except main file and manifest) are exposed to clients: - "template": Accessed via ResourceTemplate, hidden from list_resources(). Clients discover files by reading the manifest first. - "resources": Each file exposed as individual Resource in list_resources(). Full enumeration upfront. Example: ```python from pathlib import Path from fastmcp import FastMCP from fastmcp.server.providers.skills import SkillProvider mcp = FastMCP("My Skill") mcp.add_provider(SkillProvider( Path.home() / ".claude/skills/pdf-processing" )) ``` """ def __init__( self, skill_path: str | Path, main_file_name: str = "SKILL.md", supporting_files: Literal["template", "resources"] = "template", ) -> None: super().__init__() self._skill_path = Path(skill_path).resolve() self._main_file_name = main_file_name self._supporting_files = supporting_files self._skill_info: SkillInfo | None = None # Load at init to catch errors early self._load_skill() def _load_skill(self) -> None: """Load and parse the skill directory.""" main_file = self._skill_path / self._main_file_name if not self._skill_path.exists(): raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") if not main_file.exists(): raise FileNotFoundError( f"Main skill file not found: {main_file}. " f"Expected {self._main_file_name} in {self._skill_path}" ) content = main_file.read_text() frontmatter, body = parse_frontmatter(content) # Get description from frontmatter or first non-empty line description = frontmatter.get("description", "") if not description: for line in body.strip().split("\n"): line = line.strip() if line and not line.startswith("#"): description = line[:200] break elif line.startswith("#"): description = line.lstrip("#").strip()[:200] break # Scan all files in the skill directory files = scan_skill_files(self._skill_path) self._skill_info = SkillInfo( name=self._skill_path.name, description=description or f"Skill: {self._skill_path.name}", path=self._skill_path, main_file=self._main_file_name, files=files, frontmatter=frontmatter, ) logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") @property def skill_info(self) -> SkillInfo: """Get the loaded skill info.""" if self._skill_info is None: raise RuntimeError("Skill not loaded") return self._skill_info # ------------------------------------------------------------------------- # Provider interface implementation # ------------------------------------------------------------------------- async def _list_resources(self) -> Sequence[Resource]: """List skill resources.""" skill = self.skill_info resources: list[Resource] = [] # Main skill file resources.append( SkillResource( uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), name=f"{skill.name}/{self._main_file_name}", description=skill.description, mime_type="text/markdown", skill_info=skill, is_manifest=False, ) ) # Synthetic manifest resources.append( SkillResource( uri=AnyUrl(f"skill://{skill.name}/_manifest"), name=f"{skill.name}/_manifest", description=f"File listing for {skill.name}", mime_type="application/json", skill_info=skill, is_manifest=True, ) ) # If supporting_files="resources", add all supporting files as resources if self._supporting_files == "resources": for file_info in skill.files: # Skip main file and manifest (already added) if file_info.path == self._main_file_name: continue mime_type, _ = mimetypes.guess_type(file_info.path) resources.append( SkillFileResource( uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), name=f"{skill.name}/{file_info.path}", description=f"File from {skill.name} skill", mime_type=mime_type or "application/octet-stream", skill_info=skill, file_path=file_info.path, ) ) return resources async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI.""" skill = self.skill_info # Parse URI: skill://{skill_name}/{file_path} if not uri.startswith("skill://"): return None path_part = uri[len("skill://") :] parts = path_part.split("/", 1) if len(parts) != 2: return None skill_name, file_path = parts if skill_name != skill.name: return None if file_path == "_manifest": return SkillResource( uri=AnyUrl(uri), name=f"{skill_name}/_manifest", description=f"File listing for {skill_name}", mime_type="application/json", skill_info=skill, is_manifest=True, ) elif file_path == self._main_file_name: return SkillResource( uri=AnyUrl(uri), name=f"{skill_name}/{self._main_file_name}", description=skill.description, mime_type="text/markdown", skill_info=skill, is_manifest=False, ) elif self._supporting_files == "resources": # Check if it's a known supporting file for file_info in skill.files: if file_info.path == file_path: mime_type, _ = mimetypes.guess_type(file_path) return SkillFileResource( uri=AnyUrl(uri), name=f"{skill_name}/{file_path}", description=f"File from {skill_name} skill", mime_type=mime_type or "application/octet-stream", skill_info=skill, file_path=file_path, ) return None async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """List resource templates for accessing files within the skill.""" # Only expose template if supporting_files="template" if self._supporting_files != "template": return [] skill = self.skill_info return [ SkillFileTemplate( uri_template=f"skill://{skill.name}/{{path*}}", name=f"{skill.name}_files", description=f"Access files within {skill.name}", mime_type="application/octet-stream", parameters={ "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"], }, skill_info=skill, ) ] async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template that matches the given URI.""" # Only match if supporting_files="template" if self._supporting_files != "template": return None skill = self.skill_info if not uri.startswith("skill://"): return None path_part = uri[len("skill://") :] parts = path_part.split("/", 1) if len(parts) != 2: return None skill_name, file_path = parts if skill_name != skill.name: return None # Don't match known resources (main file, manifest) if file_path == "_manifest" or file_path == self._main_file_name: return None return SkillFileTemplate( uri_template=f"skill://{skill.name}/{{path*}}", name=f"{skill.name}_files", description=f"Access files within {skill.name}", mime_type="application/octet-stream", parameters={ "type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"], }, skill_info=skill, ) def __repr__(self) -> str: return ( f"SkillProvider(skill_path={self._skill_path!r}, " f"supporting_files={self._supporting_files!r})" ) ================================================ FILE: src/fastmcp/server/providers/skills/vendor_providers.py ================================================ """Vendor-specific skills providers for various AI coding platforms.""" from __future__ import annotations from pathlib import Path from typing import Literal from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider class CursorSkillsProvider(SkillsDirectoryProvider): """Cursor skills from ~/.cursor/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".cursor" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class VSCodeSkillsProvider(SkillsDirectoryProvider): """VS Code skills from ~/.copilot/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".copilot" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class CodexSkillsProvider(SkillsDirectoryProvider): """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. Scans both system-level and user-level directories. System skills take precedence if duplicates exist. """ def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: system_root = Path("/etc/codex/skills") user_root = Path.home() / ".codex" / "skills" # Include both paths (system first, then user) roots = [system_root, user_root] super().__init__( roots=roots, reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class GeminiSkillsProvider(SkillsDirectoryProvider): """Gemini skills from ~/.gemini/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".gemini" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class GooseSkillsProvider(SkillsDirectoryProvider): """Goose skills from ~/.config/agents/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".config" / "agents" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class CopilotSkillsProvider(SkillsDirectoryProvider): """GitHub Copilot skills from ~/.copilot/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".copilot" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) class OpenCodeSkillsProvider(SkillsDirectoryProvider): """OpenCode skills from ~/.config/opencode/skills/.""" def __init__( self, reload: bool = False, supporting_files: Literal["template", "resources"] = "template", ) -> None: root = Path.home() / ".config" / "opencode" / "skills" super().__init__( roots=[root], reload=reload, main_file_name="SKILL.md", supporting_files=supporting_files, ) ================================================ FILE: src/fastmcp/server/providers/wrapped_provider.py ================================================ """WrappedProvider for immutable transform composition. This module provides `_WrappedProvider`, an internal class that wraps a provider with an additional transform. Created by `Provider.wrap_transform()`. """ from __future__ import annotations from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from typing import TYPE_CHECKING from fastmcp.server.providers.base import Provider from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import Transform from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent class _WrappedProvider(Provider): """Internal provider that wraps another provider with a transform. Created by Provider.wrap_transform(). Delegates all component sourcing to the inner provider's public methods (which apply inner's transforms), then applies the wrapper's transform on top. This enables immutable transform composition - the inner provider is unchanged, and the wrapper adds its transform layer. """ def __init__(self, inner: Provider, transform: Transform) -> None: """Initialize wrapped provider. Args: inner: The provider to wrap. transform: The transform to apply on top of inner's results. """ super().__init__() self._inner = inner # Add the transform to this provider's transform list # It will be applied via the normal transform chain self._transforms.append(transform) def __repr__(self) -> str: return f"_WrappedProvider({self._inner!r}, transforms={self._transforms!r})" # ------------------------------------------------------------------------- # Delegate to inner provider's public methods (which apply inner's transforms) # ------------------------------------------------------------------------- async def _list_tools(self) -> Sequence[Tool]: """Delegate to inner's list_tools (includes inner's transforms).""" return await self._inner.list_tools() async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Delegate to inner's get_tool (includes inner's transforms).""" return await self._inner.get_tool(name, version) async def _list_resources(self) -> Sequence[Resource]: """Delegate to inner's list_resources (includes inner's transforms).""" return await self._inner.list_resources() async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Delegate to inner's get_resource (includes inner's transforms).""" return await self._inner.get_resource(uri, version) async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: """Delegate to inner's list_resource_templates (includes inner's transforms).""" return await self._inner.list_resource_templates() async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Delegate to inner's get_resource_template (includes inner's transforms).""" return await self._inner.get_resource_template(uri, version) async def _list_prompts(self) -> Sequence[Prompt]: """Delegate to inner's list_prompts (includes inner's transforms).""" return await self._inner.list_prompts() async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Delegate to inner's get_prompt (includes inner's transforms).""" return await self._inner.get_prompt(name, version) async def get_tasks(self) -> Sequence[FastMCPComponent]: """Delegate to inner's get_tasks and apply wrapper's transforms.""" # Import here to avoid circular imports from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool # Get tasks from inner (already has inner's transforms) components = list(await self._inner.get_tasks()) # Apply this wrapper's transforms to the components # We need to apply transforms per component type tools = [c for c in components if isinstance(c, Tool)] resources = [c for c in components if isinstance(c, Resource)] templates = [c for c in components if isinstance(c, ResourceTemplate)] prompts = [c for c in components if isinstance(c, Prompt)] # Apply this wrapper's transforms sequentially for transform in self.transforms: tools = await transform.list_tools(tools) resources = await transform.list_resources(resources) templates = await transform.list_resource_templates(templates) prompts = await transform.list_prompts(prompts) return [ c for c in [ *tools, *resources, *templates, *prompts, ] if c.task_config.supports_tasks() ] # ------------------------------------------------------------------------- # Lifecycle - combine with inner # ------------------------------------------------------------------------- @asynccontextmanager async def lifespan(self) -> AsyncIterator[None]: """Combine lifespan with inner provider.""" async with self._inner.lifespan(): yield ================================================ FILE: src/fastmcp/server/proxy.py ================================================ """Backwards compatibility - import from fastmcp.server.providers.proxy instead. This module re-exports all proxy-related classes from their new location at fastmcp.server.providers.proxy. Direct imports from this module are deprecated and will be removed in a future version. """ from __future__ import annotations import warnings warnings.warn( "fastmcp.server.proxy is deprecated. Use fastmcp.server.providers.proxy instead.", DeprecationWarning, stacklevel=2, ) # Re-export everything from the new location from fastmcp.server.providers.proxy import ( # noqa: E402 ClientFactoryT, FastMCPProxy, ProxyClient, ProxyPrompt, ProxyProvider, ProxyResource, ProxyTemplate, ProxyTool, StatefulProxyClient, ) __all__ = [ "ClientFactoryT", "FastMCPProxy", "ProxyClient", "ProxyPrompt", "ProxyProvider", "ProxyResource", "ProxyTemplate", "ProxyTool", "StatefulProxyClient", ] ================================================ FILE: src/fastmcp/server/sampling/__init__.py ================================================ """Sampling module for FastMCP servers.""" from fastmcp.server.sampling.run import SampleStep, SamplingResult from fastmcp.server.sampling.sampling_tool import SamplingTool __all__ = [ "SampleStep", "SamplingResult", "SamplingTool", ] ================================================ FILE: src/fastmcp/server/sampling/run.py ================================================ """Sampling types and helper functions for FastMCP servers.""" from __future__ import annotations import inspect import json from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Generic, Literal, cast import anyio from mcp.types import ( ClientCapabilities, CreateMessageResult, CreateMessageResultWithTools, ModelHint, ModelPreferences, SamplingCapability, SamplingMessage, SamplingMessageContentBlock, SamplingToolsCapability, TextContent, ToolChoice, ToolResultContent, ToolUseContent, ) from mcp.types import CreateMessageRequestParams as SamplingParams from mcp.types import Tool as SDKTool from pydantic import ValidationError from typing_extensions import TypeVar from fastmcp import settings from fastmcp.exceptions import ToolError from fastmcp.server.sampling.sampling_tool import SamplingTool from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import TransformedTool from fastmcp.utilities.async_utils import gather from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import get_cached_typeadapter logger = get_logger(__name__) if TYPE_CHECKING: from fastmcp.server.context import Context ResultT = TypeVar("ResultT") # Simplified tool choice type - just the mode string instead of the full MCP object ToolChoiceOption = Literal["auto", "required", "none"] @dataclass class SamplingResult(Generic[ResultT]): """Result of a sampling operation. Attributes: text: The text representation of the result (raw text or JSON for structured). result: The typed result (str for text, parsed object for structured output). history: All messages exchanged during sampling. """ text: str | None result: ResultT history: list[SamplingMessage] @dataclass class SampleStep: """Result of a single sampling call. Represents what the LLM returned in this step plus the message history. """ response: CreateMessageResult | CreateMessageResultWithTools history: list[SamplingMessage] @property def is_tool_use(self) -> bool: """True if the LLM is requesting tool execution.""" if isinstance(self.response, CreateMessageResultWithTools): return self.response.stopReason == "toolUse" return False @property def text(self) -> str | None: """Extract text from the response, if available.""" content = self.response.content if isinstance(content, list): for block in content: if isinstance(block, TextContent): return block.text return None elif isinstance(content, TextContent): return content.text return None @property def tool_calls(self) -> list[ToolUseContent]: """Get the list of tool calls from the response.""" content = self.response.content if isinstance(content, list): return [c for c in content if isinstance(c, ToolUseContent)] elif isinstance(content, ToolUseContent): return [content] return [] def _parse_model_preferences( model_preferences: ModelPreferences | str | list[str] | None, ) -> ModelPreferences | None: """Convert model preferences to ModelPreferences object.""" if model_preferences is None: return None elif isinstance(model_preferences, ModelPreferences): return model_preferences elif isinstance(model_preferences, str): return ModelPreferences(hints=[ModelHint(name=model_preferences)]) elif isinstance(model_preferences, list): if not all(isinstance(h, str) for h in model_preferences): raise ValueError("All elements of model_preferences list must be strings.") return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences]) else: raise ValueError( "model_preferences must be one of: ModelPreferences, str, list[str], or None." ) # --- Standalone functions for sample_step() --- def determine_handler_mode(context: Context, needs_tools: bool) -> bool: """Determine whether to use fallback handler or client for sampling. Args: context: The MCP context. needs_tools: Whether the sampling request requires tool support. Returns: True if fallback handler should be used, False to use client. Raises: ValueError: If client lacks required capability and no fallback configured. """ fastmcp = context.fastmcp session = context.session # Check what capabilities the client has has_sampling = session.check_client_capability( capability=ClientCapabilities(sampling=SamplingCapability()) ) has_tools_capability = session.check_client_capability( capability=ClientCapabilities( sampling=SamplingCapability(tools=SamplingToolsCapability()) ) ) if fastmcp.sampling_handler_behavior == "always": if fastmcp.sampling_handler is None: raise ValueError( "sampling_handler_behavior is 'always' but no handler configured" ) return True elif fastmcp.sampling_handler_behavior == "fallback": client_sufficient = has_sampling and (not needs_tools or has_tools_capability) if not client_sufficient: if fastmcp.sampling_handler is None: if needs_tools and has_sampling and not has_tools_capability: raise ValueError( "Client does not support sampling with tools. " "The client must advertise the sampling.tools capability." ) raise ValueError("Client does not support sampling") return True elif fastmcp.sampling_handler_behavior is not None: raise ValueError( f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. " "Must be 'always', 'fallback', or None." ) elif not has_sampling: raise ValueError("Client does not support sampling") elif needs_tools and not has_tools_capability: raise ValueError( "Client does not support sampling with tools. " "The client must advertise the sampling.tools capability." ) return False async def call_sampling_handler( context: Context, messages: list[SamplingMessage], *, system_prompt: str | None, temperature: float | None, max_tokens: int, model_preferences: ModelPreferences | str | list[str] | None, sdk_tools: list[SDKTool] | None, tool_choice: ToolChoice | None, ) -> CreateMessageResult | CreateMessageResultWithTools: """Make LLM call using the fallback handler. Note: This function expects the caller (sample_step) to have validated that sampling_handler is set via determine_handler_mode(). The checks below are safeguards against internal misuse. """ if context.fastmcp.sampling_handler is None: raise RuntimeError("sampling_handler is None") if context.request_context is None: raise RuntimeError("request_context is None") result = context.fastmcp.sampling_handler( messages, SamplingParams( systemPrompt=system_prompt, messages=messages, temperature=temperature, maxTokens=max_tokens, modelPreferences=_parse_model_preferences(model_preferences), tools=sdk_tools, toolChoice=tool_choice, ), context.request_context, ) if inspect.isawaitable(result): result = await result result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result) # Convert string to CreateMessageResult if isinstance(result, str): return CreateMessageResult( role="assistant", content=TextContent(type="text", text=result), model="unknown", stopReason="endTurn", ) return result async def execute_tools( tool_calls: list[ToolUseContent], tool_map: dict[str, SamplingTool], mask_error_details: bool = False, tool_concurrency: int | None = None, ) -> list[ToolResultContent]: """Execute tool calls and return results. Args: tool_calls: List of tool use requests from the LLM. tool_map: Mapping from tool name to SamplingTool. mask_error_details: If True, mask detailed error messages from tool execution. When masked, only generic error messages are returned to the LLM. Tools can explicitly raise ToolError to bypass masking when they want to provide specific error messages to the LLM. tool_concurrency: Controls parallel execution of tools: - None (default): Sequential execution (one at a time) - 0: Unlimited parallel execution - N > 0: Execute at most N tools concurrently If any tool has sequential=True, all tools execute sequentially regardless of this setting. Returns: List of tool result content blocks in the same order as tool_calls. """ if tool_concurrency is not None and tool_concurrency < 0: raise ValueError( f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " f"got {tool_concurrency}" ) async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: """Execute a single tool and return its result.""" tool = tool_map.get(tool_use.name) if tool is None: return ToolResultContent( type="tool_result", toolUseId=tool_use.id, content=[ TextContent( type="text", text=f"Error: Unknown tool '{tool_use.name}'", ) ], isError=True, ) try: result_value = await tool.run(tool_use.input) return ToolResultContent( type="tool_result", toolUseId=tool_use.id, content=[TextContent(type="text", text=str(result_value))], ) except ToolError as e: # ToolError is the escape hatch - always pass message through logger.exception(f"Error calling sampling tool '{tool_use.name}'") return ToolResultContent( type="tool_result", toolUseId=tool_use.id, content=[TextContent(type="text", text=str(e))], isError=True, ) except Exception as e: # Generic exceptions - mask based on setting logger.exception(f"Error calling sampling tool '{tool_use.name}'") if mask_error_details: error_text = f"Error executing tool '{tool_use.name}'" else: error_text = f"Error executing tool '{tool_use.name}': {e}" return ToolResultContent( type="tool_result", toolUseId=tool_use.id, content=[TextContent(type="text", text=error_text)], isError=True, ) # Check if any tool requires sequential execution requires_sequential = any( tool.sequential for tool_use in tool_calls if (tool := tool_map.get(tool_use.name)) is not None ) # Execute sequentially if required or if concurrency is None (default) if tool_concurrency is None or requires_sequential: tool_results: list[ToolResultContent] = [] for tool_use in tool_calls: result = await _execute_single_tool(tool_use) tool_results.append(result) return tool_results # Execute in parallel if tool_concurrency == 0: # Unlimited parallel execution return await gather(*[_execute_single_tool(tc) for tc in tool_calls]) else: # Bounded parallel execution with semaphore semaphore = anyio.Semaphore(tool_concurrency) async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: async with semaphore: return await _execute_single_tool(tool_use) return await gather(*[bounded_execute(tc) for tc in tool_calls]) # --- Helper functions for sampling --- def prepare_messages( messages: str | Sequence[str | SamplingMessage], ) -> list[SamplingMessage]: """Convert various message formats to a list of SamplingMessage objects.""" if isinstance(messages, str): return [ SamplingMessage( content=TextContent(text=messages, type="text"), role="user" ) ] else: return [ SamplingMessage(content=TextContent(text=m, type="text"), role="user") if isinstance(m, str) else m for m in messages ] def prepare_tools( tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None, ) -> list[SamplingTool] | None: """Convert tools to SamplingTool objects. Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances, or plain callable functions. FunctionTool and TransformedTool are converted using from_callable_tool(), while plain functions use from_function(). Args: tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool, TransformedTool, or plain callable functions. Returns: List of SamplingTool instances, or None if tools is None. """ if tools is None: return None sampling_tools: list[SamplingTool] = [] for t in tools: if isinstance(t, SamplingTool): sampling_tools.append(t) elif isinstance(t, (FunctionTool, TransformedTool)): sampling_tools.append(SamplingTool.from_callable_tool(t)) elif callable(t): sampling_tools.append(SamplingTool.from_function(t)) else: raise TypeError( f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}" ) return sampling_tools if sampling_tools else None def extract_tool_calls( response: CreateMessageResult | CreateMessageResultWithTools, ) -> list[ToolUseContent]: """Extract tool calls from a response.""" content = response.content if isinstance(content, list): return [c for c in content if isinstance(c, ToolUseContent)] elif isinstance(content, ToolUseContent): return [content] return [] def create_final_response_tool(result_type: type) -> SamplingTool: """Create a synthetic 'final_response' tool for structured output. This tool is used to capture structured responses from the LLM. The tool's schema is derived from the result_type. """ type_adapter = get_cached_typeadapter(result_type) schema = type_adapter.json_schema() schema = compress_schema(schema, prune_titles=True) # Tool parameters must be object-shaped. Wrap primitives in {"value": } if schema.get("type") != "object": schema = { "type": "object", "properties": {"value": schema}, "required": ["value"], } # The fn just returns the input as-is (validation happens in the loop) def final_response(**kwargs: Any) -> dict[str, Any]: return kwargs return SamplingTool( name="final_response", description=( "Call this tool to provide your final response. " "Use this when you have completed the task and are ready to return the result." ), parameters=schema, fn=final_response, ) # --- Implementation functions for Context methods --- async def sample_step_impl( context: Context, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None = None, tool_choice: ToolChoiceOption | str | None = None, auto_execute_tools: bool = True, mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SampleStep: """Implementation of Context.sample_step(). Make a single LLM sampling call. This is a stateless function that makes exactly one LLM call and optionally executes any requested tools. """ # Convert messages to SamplingMessage objects current_messages = prepare_messages(messages) # Convert tools to SamplingTools sampling_tools = prepare_tools(tools) sdk_tools: list[SDKTool] | None = ( [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None ) tool_map: dict[str, SamplingTool] = ( {t.name: t for t in sampling_tools} if sampling_tools else {} ) # Determine whether to use fallback handler or client use_fallback = determine_handler_mode(context, bool(sampling_tools)) # Build tool choice effective_tool_choice: ToolChoice | None = None if tool_choice is not None: if tool_choice not in ("auto", "required", "none"): raise ValueError( f"Invalid tool_choice: {tool_choice!r}. " "Must be 'auto', 'required', or 'none'." ) effective_tool_choice = ToolChoice( mode=cast(Literal["auto", "required", "none"], tool_choice) ) # Effective max_tokens effective_max_tokens = max_tokens if max_tokens is not None else 512 # Make the LLM call if use_fallback: response = await call_sampling_handler( context, current_messages, system_prompt=system_prompt, temperature=temperature, max_tokens=effective_max_tokens, model_preferences=model_preferences, sdk_tools=sdk_tools, tool_choice=effective_tool_choice, ) else: response = await context.session.create_message( messages=current_messages, system_prompt=system_prompt, temperature=temperature, max_tokens=effective_max_tokens, model_preferences=_parse_model_preferences(model_preferences), tools=sdk_tools, tool_choice=effective_tool_choice, related_request_id=context.request_id, ) # Check if this is a tool use response is_tool_use_response = ( isinstance(response, CreateMessageResultWithTools) and response.stopReason == "toolUse" ) # Always include the assistant response in history current_messages.append(SamplingMessage(role="assistant", content=response.content)) # If not a tool use, return immediately if not is_tool_use_response: return SampleStep(response=response, history=current_messages) # If not executing tools, return with assistant message but no tool results if not auto_execute_tools: return SampleStep(response=response, history=current_messages) # Execute tools and add results to history step_tool_calls = extract_tool_calls(response) if step_tool_calls: effective_mask = ( mask_error_details if mask_error_details is not None else settings.mask_error_details ) tool_results: list[ToolResultContent] = await execute_tools( step_tool_calls, tool_map, mask_error_details=effective_mask, tool_concurrency=tool_concurrency, ) if tool_results: current_messages.append( SamplingMessage( role="user", content=cast(list[SamplingMessageContentBlock], tool_results), ) ) return SampleStep(response=response, history=current_messages) async def sample_impl( context: Context, messages: str | Sequence[str | SamplingMessage], *, system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] | None = None, result_type: type[ResultT] | None = None, mask_error_details: bool | None = None, tool_concurrency: int | None = None, ) -> SamplingResult[ResultT]: """Implementation of Context.sample(). Send a sampling request to the client and await the response. This method runs to completion automatically, executing a tool loop until the LLM provides a final text response. """ # Safety limit to prevent infinite loops max_iterations = 100 # Convert tools to SamplingTools sampling_tools = prepare_tools(tools) # Handle structured output with result_type tool_choice: str | None = None if result_type is not None and result_type is not str: final_response_tool = create_final_response_tool(result_type) sampling_tools = list(sampling_tools) if sampling_tools else [] sampling_tools.append(final_response_tool) # Always require tool calls when result_type is set - the LLM must # eventually call final_response (text responses are not accepted) tool_choice = "required" # Convert messages for the loop current_messages: str | Sequence[str | SamplingMessage] = messages for _iteration in range(max_iterations): step = await sample_step_impl( context, messages=current_messages, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, model_preferences=model_preferences, tools=sampling_tools, tool_choice=tool_choice, mask_error_details=mask_error_details, tool_concurrency=tool_concurrency, ) # Check for final_response tool call for structured output if result_type is not None and result_type is not str and step.is_tool_use: for tool_call in step.tool_calls: if tool_call.name == "final_response": # Validate and return the structured result type_adapter = get_cached_typeadapter(result_type) # Unwrap if we wrapped primitives (non-object schemas) input_data = tool_call.input original_schema = compress_schema( type_adapter.json_schema(), prune_titles=True ) if ( original_schema.get("type") != "object" and isinstance(input_data, dict) and "value" in input_data ): input_data = input_data["value"] try: validated_result = type_adapter.validate_python(input_data) text = json.dumps( type_adapter.dump_python(validated_result, mode="json") ) return SamplingResult( text=text, result=validated_result, history=step.history, ) except ValidationError as e: # Validation failed - add error as tool result step.history.append( SamplingMessage( role="user", content=[ ToolResultContent( type="tool_result", toolUseId=tool_call.id, content=[ TextContent( type="text", text=( f"Validation error: {e}. " "Please try again with valid data." ), ) ], isError=True, ) ], ) ) # If not a tool use response, we're done if not step.is_tool_use: # For structured output, the LLM must use the final_response tool if result_type is not None and result_type is not str: raise RuntimeError( f"Expected structured output of type {result_type.__name__}, " "but the LLM returned a text response instead of calling " "the final_response tool." ) return SamplingResult( text=step.text, result=cast(ResultT, step.text if step.text else ""), history=step.history, ) # Continue with the updated history current_messages = step.history # After first iteration, reset tool_choice to auto (unless structured output is required) if result_type is None or result_type is str: tool_choice = None raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})") ================================================ FILE: src/fastmcp/server/sampling/sampling_tool.py ================================================ """SamplingTool for use during LLM sampling requests.""" from __future__ import annotations import inspect from collections.abc import Callable from typing import Any from mcp.types import TextContent from mcp.types import Tool as SDKTool from pydantic import ConfigDict from fastmcp.exceptions import AuthorizationError from fastmcp.server.auth.authorization import AuthContext, run_auth_checks from fastmcp.server.dependencies import get_access_token from fastmcp.tools.base import ToolResult from fastmcp.tools.function_parsing import ParsedFunction from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import TransformedTool from fastmcp.utilities.types import FastMCPBaseModel class SamplingTool(FastMCPBaseModel): """A tool that can be used during LLM sampling. SamplingTools bundle a tool's schema (name, description, parameters) with an executor function, enabling servers to execute agentic workflows where the LLM can request tool calls during sampling. In most cases, pass functions directly to ctx.sample(): def search(query: str) -> str: '''Search the web.''' return web_search(query) result = await context.sample( messages="Find info about Python", tools=[search], # Plain functions work directly ) Create a SamplingTool explicitly when you need custom name/description: tool = SamplingTool.from_function(search, name="web_search") """ name: str description: str | None = None parameters: dict[str, Any] fn: Callable[..., Any] sequential: bool = False model_config = ConfigDict(arbitrary_types_allowed=True) async def run(self, arguments: dict[str, Any] | None = None) -> Any: """Execute the tool with the given arguments. Args: arguments: Dictionary of arguments to pass to the tool function. Returns: The result of executing the tool function. """ if arguments is None: arguments = {} result = self.fn(**arguments) if inspect.isawaitable(result): result = await result return result def _to_sdk_tool(self) -> SDKTool: """Convert to an mcp.types.Tool for SDK compatibility. This is used internally when passing tools to the MCP SDK's create_message() method. """ return SDKTool( name=self.name, description=self.description, inputSchema=self.parameters, ) @classmethod def from_function( cls, fn: Callable[..., Any], *, name: str | None = None, description: str | None = None, sequential: bool = False, ) -> SamplingTool: """Create a SamplingTool from a function. The function's signature is analyzed to generate a JSON schema for the tool's parameters. Type hints are used to determine parameter types. Args: fn: The function to create a tool from. name: Optional name override. Defaults to the function's name. description: Optional description override. Defaults to the function's docstring. sequential: If True, this tool requires sequential execution and prevents parallel execution of all tools in the batch. Set to True for tools with shared state, file writes, or other operations that cannot run concurrently. Defaults to False. Returns: A SamplingTool wrapping the function. Raises: ValueError: If the function is a lambda without a name override. """ parsed = ParsedFunction.from_function(fn, validate=True) if name is None and parsed.name == "": raise ValueError("You must provide a name for lambda functions") return cls( name=name or parsed.name, description=description or parsed.description, parameters=parsed.input_schema, fn=parsed.fn, sequential=sequential, ) @classmethod def from_callable_tool( cls, tool: FunctionTool | TransformedTool, *, name: str | None = None, description: str | None = None, ) -> SamplingTool: """Create a SamplingTool from a FunctionTool or TransformedTool. Reuses existing server tools in sampling contexts. For TransformedTool, the tool's .run() method is used to ensure proper argument transformation, and the ToolResult is automatically unwrapped. Args: tool: A FunctionTool or TransformedTool to convert. name: Optional name override. Defaults to tool.name. description: Optional description override. Defaults to tool.description. Raises: TypeError: If the tool is not a FunctionTool or TransformedTool. """ # Validate that the tool is a supported type if not isinstance(tool, (FunctionTool, TransformedTool)): raise TypeError( f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. " "Only callable tools can be converted to SamplingTools." ) # Both FunctionTool and TransformedTool need .run() to ensure proper # result processing (serializers, output_schema, wrap-result flags) async def wrapper(**kwargs: Any) -> Any: # Enforce per-tool auth checks, mirroring what the server # dispatcher does for direct tool calls. Without this, an # auth-protected tool wrapped as a SamplingTool could be # invoked by the LLM during sampling without authorization. if tool.auth is not None: # Late import to avoid circular import with context.py from fastmcp.server.context import _current_transport is_stdio = _current_transport.get() == "stdio" if not is_stdio: token = get_access_token() ctx = AuthContext(token=token, component=tool) if not await run_auth_checks(tool.auth, ctx): raise AuthorizationError( f"Authorization failed for tool '{tool.name}': " "insufficient permissions" ) result = await tool.run(kwargs) # Unwrap ToolResult - extract the actual value if isinstance(result, ToolResult): # If there's structured_content, use that if result.structured_content is not None: # Check tool's schema - this is the source of truth if tool.output_schema and tool.output_schema.get( "x-fastmcp-wrap-result" ): # Tool wraps results: {"result": value} -> value return result.structured_content.get("result") else: # No wrapping: use structured_content directly return result.structured_content # Otherwise, extract from text content if result.content and len(result.content) > 0: first_content = result.content[0] if isinstance(first_content, TextContent): return first_content.text return result fn = wrapper # Extract the callable function, name, description, and parameters return cls( name=name or tool.name, description=description or tool.description, parameters=tool.parameters, fn=fn, ) ================================================ FILE: src/fastmcp/server/server.py ================================================ """FastMCP - A more ergonomic interface for MCP servers.""" from __future__ import annotations import asyncio import re import secrets import warnings from collections.abc import ( AsyncIterator, Awaitable, Callable, Sequence, ) from contextlib import ( AbstractAsyncContextManager, asynccontextmanager, ) from dataclasses import replace from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload import httpx import mcp.types from key_value.aio.adapters.pydantic import PydanticAdapter from key_value.aio.protocols import AsyncKeyValue from key_value.aio.stores.memory import MemoryStore from mcp.server.lowlevel.server import LifespanResultT from mcp.shared.exceptions import McpError from mcp.types import ( Annotations, AnyFunction, CallToolRequestParams, ToolAnnotations, ) from pydantic import AnyUrl from pydantic import ValidationError as PydanticValidationError from starlette.routing import BaseRoute from typing_extensions import Self import fastmcp import fastmcp.server from fastmcp.exceptions import ( AuthorizationError, FastMCPError, NotFoundError, PromptError, ResourceError, ToolError, ValidationError, ) from fastmcp.mcp_config import MCPConfig from fastmcp.prompts import Prompt from fastmcp.prompts.base import PromptResult from fastmcp.prompts.function_prompt import FunctionPrompt from fastmcp.resources.base import Resource, ResourceResult from fastmcp.resources.template import ResourceTemplate from fastmcp.server.apps import ( AppConfig, app_config_to_meta_dict, resolve_ui_mime_type, ) from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks from fastmcp.server.lifespan import Lifespan from fastmcp.server.low_level import LowLevelServer from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin from fastmcp.server.providers import LocalProvider, Provider from fastmcp.server.providers.aggregate import AggregateProvider from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.server.telemetry import server_span from fastmcp.server.transforms import ( ToolTransform, Transform, ) from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting from fastmcp.tools.base import Tool, ToolResult from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.components import FastMCPComponent, _coerce_version from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT from fastmcp.utilities.versions import ( VersionSpec, version_sort_key, ) if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.client.client import FastMCP1Server from fastmcp.client.sampling import SamplingHandler from fastmcp.client.transports import ClientTransport, ClientTransportT from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn from fastmcp.server.providers.openapi import RouteMap from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn from fastmcp.server.providers.proxy import FastMCPProxy logger = get_logger(__name__) F = TypeVar("F", bound=Callable[..., Any]) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] _REMOVED_KWARGS: dict[str, str] = { "host": "Pass `host` to `run_http_async()`, or set FASTMCP_HOST.", "port": "Pass `port` to `run_http_async()`, or set FASTMCP_PORT.", "sse_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_SSE_PATH.", "message_path": "Set FASTMCP_MESSAGE_PATH.", "streamable_http_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_STREAMABLE_HTTP_PATH.", "json_response": "Pass `json_response` to `run_http_async()` or `http_app()`, or set FASTMCP_JSON_RESPONSE.", "stateless_http": "Pass `stateless_http` to `run_http_async()` or `http_app()`, or set FASTMCP_STATELESS_HTTP.", "debug": "Set FASTMCP_DEBUG.", "log_level": "Pass `log_level` to `run_http_async()`, or set FASTMCP_LOG_LEVEL.", "on_duplicate_tools": "Use `on_duplicate=` instead.", "on_duplicate_resources": "Use `on_duplicate=` instead.", "on_duplicate_prompts": "Use `on_duplicate=` instead.", "tool_serializer": "Return ToolResult from your tools instead. See https://gofastmcp.com/servers/tools#custom-serialization", "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.", "exclude_tags": "Use `server.disable(tags=...)` after creating the server.", "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.", } def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: """Raise helpful TypeErrors for kwargs removed in v3.""" for key in kwargs: if key in _REMOVED_KWARGS: raise TypeError( f"FastMCP() no longer accepts `{key}`. {_REMOVED_KWARGS[key]}" ) if kwargs: raise TypeError( f"FastMCP() got unexpected keyword argument(s): {', '.join(repr(k) for k in kwargs)}" ) Transport = Literal["stdio", "http", "sse", "streamable-http"] # Compiled URI parsing regex to split a URI into protocol and path components URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") LifespanCallable = Callable[ ["FastMCP[LifespanResultT]"], AbstractAsyncContextManager[LifespanResultT] ] def _get_auth_context() -> tuple[bool, Any]: """Get auth context for the current request. Returns a tuple of (skip_auth, token) where: - skip_auth=True means auth checks should be skipped (STDIO transport) - token is the access token for HTTP transports (may be None if unauthenticated) Uses late import to avoid circular import with context.py. """ from fastmcp.server.context import _current_transport is_stdio = _current_transport.get() == "stdio" if is_stdio: return (True, None) from fastmcp.server.dependencies import get_access_token return (False, get_access_token()) @asynccontextmanager async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: """Default lifespan context manager that does nothing. Args: server: The server instance this lifespan is managing Returns: An empty dictionary as the lifespan result. """ yield {} def _lifespan_proxy( fastmcp_server: FastMCP[LifespanResultT], ) -> Callable[ [LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] ]: @asynccontextmanager async def wrap( low_level_server: LowLevelServer[LifespanResultT], ) -> AsyncIterator[LifespanResultT]: if fastmcp_server._lifespan is default_lifespan: yield {} return if not fastmcp_server._lifespan_result_set: raise RuntimeError( "FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. " + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues." ) yield fastmcp_server._lifespan_result return wrap class StateValue(FastMCPBaseModel): """Wrapper for stored context state values.""" value: Any class FastMCP( AggregateProvider, LifespanMixin, MCPOperationsMixin, TransportMixin, Generic[LifespanResultT], ): def __init__( self, name: str | None = None, instructions: str | None = None, *, version: str | int | float | None = None, website_url: str | None = None, icons: list[mcp.types.Icon] | None = None, auth: AuthProvider | None = None, middleware: Sequence[Middleware] | None = None, providers: Sequence[Provider] | None = None, transforms: Sequence[Transform] | None = None, lifespan: LifespanCallable | Lifespan | None = None, tools: Sequence[Tool | Callable[..., Any]] | None = None, on_duplicate: DuplicateBehavior | None = None, mask_error_details: bool | None = None, dereference_schemas: bool = True, strict_input_validation: bool | None = None, list_page_size: int | None = None, tasks: bool | None = None, session_state_store: AsyncKeyValue | None = None, sampling_handler: SamplingHandler | None = None, sampling_handler_behavior: Literal["always", "fallback"] | None = None, client_log_level: mcp.types.LoggingLevel | None = None, **kwargs: Any, ): _check_removed_kwargs(kwargs) # Initialize Provider (sets up _transforms) super().__init__() self._on_duplicate: DuplicateBehaviorSetting = on_duplicate or "warn" # Resolve server default for background task support self._support_tasks_by_default: bool = tasks if tasks is not None else False # Docket and Worker instances (set during lifespan for cross-task access) self._docket = None self._worker = None self._additional_http_routes: list[BaseRoute] = [] # Session-scoped state store (shared across all requests) self._state_storage: AsyncKeyValue = session_state_store or MemoryStore() self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue]( key_value=self._state_storage, pydantic_model=StateValue, default_collection="fastmcp_state", ) # Create LocalProvider for local components self._local_provider: LocalProvider = LocalProvider( on_duplicate=self._on_duplicate ) # Add providers using AggregateProvider's add_provider # LocalProvider is always first (no namespace) self.add_provider(self._local_provider) for p in providers or []: self.add_provider(p) for t in transforms or []: self.add_transform(t) # Store mask_error_details for execution error handling self._mask_error_details: bool = ( mask_error_details if mask_error_details is not None else fastmcp.settings.mask_error_details ) # Store list_page_size for pagination of list operations if list_page_size is not None and list_page_size <= 0: raise ValueError("list_page_size must be a positive integer") self._list_page_size: int | None = list_page_size # Handle Lifespan instances (they're callable) or regular lifespan functions if lifespan is not None: self._lifespan: LifespanCallable[LifespanResultT] = cast( LifespanCallable[LifespanResultT], lifespan ) else: self._lifespan = cast(LifespanCallable[LifespanResultT], default_lifespan) self._lifespan_result: LifespanResultT | None = None self._lifespan_result_set: bool = False self._lifespan_ref_count: int = 0 self._lifespan_lock: asyncio.Lock = asyncio.Lock() self._started: asyncio.Event = asyncio.Event() # Generate random ID if no name provided self._mcp_server: LowLevelServer[LifespanResultT, Any] = LowLevelServer[ LifespanResultT ]( fastmcp=self, name=name or self.generate_name(), version=_coerce_version(version) or fastmcp.__version__, instructions=instructions, website_url=website_url, icons=icons, lifespan=_lifespan_proxy(fastmcp_server=self), ) self.auth: AuthProvider | None = auth if tools: for tool in tools: if not isinstance(tool, Tool): tool = Tool.from_function(tool) self.add_tool(tool) self.strict_input_validation: bool = ( strict_input_validation if strict_input_validation is not None else fastmcp.settings.strict_input_validation ) self.client_log_level: mcp.types.LoggingLevel | None = ( client_log_level if client_log_level is not None else fastmcp.settings.client_log_level ) self.middleware: list[Middleware] = list(middleware or []) if dereference_schemas: from fastmcp.server.middleware.dereference import ( DereferenceRefsMiddleware, ) self.middleware.append(DereferenceRefsMiddleware()) # Set up MCP protocol handlers self._setup_handlers() self.sampling_handler: SamplingHandler | None = sampling_handler self.sampling_handler_behavior: Literal["always", "fallback"] = ( sampling_handler_behavior or "fallback" ) def __repr__(self) -> str: return f"{type(self).__name__}({self.name!r})" @property def name(self) -> str: return self._mcp_server.name @property def instructions(self) -> str | None: return self._mcp_server.instructions @instructions.setter def instructions(self, value: str | None) -> None: self._mcp_server.instructions = value @property def version(self) -> str | None: return self._mcp_server.version @property def website_url(self) -> str | None: return self._mcp_server.website_url @property def icons(self) -> list[mcp.types.Icon]: if self._mcp_server.icons is None: return [] else: return list(self._mcp_server.icons) @property def local_provider(self) -> LocalProvider: """The server's local provider, which stores directly-registered components. Use this to remove components: mcp.local_provider.remove_tool("my_tool") mcp.local_provider.remove_resource("data://info") mcp.local_provider.remove_prompt("my_prompt") """ return self._local_provider async def _run_middleware( self, context: MiddlewareContext[Any], call_next: Callable[[MiddlewareContext[Any]], Awaitable[Any]], ) -> Any: """Builds and executes the middleware chain.""" chain = call_next for mw in reversed(self.middleware): chain = partial(mw, call_next=chain) return await chain(context) def add_middleware(self, middleware: Middleware) -> None: self.middleware.append(middleware) def add_provider(self, provider: Provider, *, namespace: str = "") -> None: """Add a provider for dynamic tools, resources, and prompts. Providers are queried in registration order. The first provider to return a non-None result wins. Static components (registered via decorators) always take precedence over providers. Args: provider: A Provider instance that will provide components dynamically. namespace: Optional namespace prefix. When set: - Tools become "namespace_toolname" - Resources become "protocol://namespace/path" - Prompts become "namespace_promptname" """ super().add_provider(provider, namespace=namespace) # ------------------------------------------------------------------------- # Provider interface overrides - inherited from AggregateProvider # ------------------------------------------------------------------------- # _list_tools, _list_resources, _list_resource_templates, _list_prompts # are inherited from AggregateProvider which handles aggregation and namespacing async def get_tasks(self) -> Sequence[FastMCPComponent]: """Get task-eligible components with all transforms applied. Overrides AggregateProvider.get_tasks() to apply server-level transforms after aggregation. AggregateProvider handles provider-level namespacing. """ # Get tasks from AggregateProvider (handles aggregation and namespacing) components = list(await super().get_tasks()) # Separate by component type for server-level transform application tools = [c for c in components if isinstance(c, Tool)] resources = [c for c in components if isinstance(c, Resource)] templates = [c for c in components if isinstance(c, ResourceTemplate)] prompts = [c for c in components if isinstance(c, Prompt)] # Apply server-level transforms sequentially for transform in self.transforms: tools = await transform.list_tools(tools) resources = await transform.list_resources(resources) templates = await transform.list_resource_templates(templates) prompts = await transform.list_prompts(prompts) return [ *tools, *resources, *templates, *prompts, ] def add_transform(self, transform: Transform) -> None: """Add a server-level transform. Server-level transforms are applied after all providers are aggregated. They transform tools, resources, and prompts from ALL providers. Args: transform: The transform to add. Example: ```python from fastmcp.server.transforms import Namespace server = FastMCP("Server") server.add_transform(Namespace("api")) # All tools from all providers become "api_toolname" ``` """ self._transforms.append(transform) def add_tool_transformation( self, tool_name: str, transformation: ToolTransformConfig ) -> None: """Add a tool transformation. .. deprecated:: Use ``add_transform(ToolTransform({...}))`` instead. """ if fastmcp.settings.deprecation_warnings: warnings.warn( "add_tool_transformation is deprecated. Use " "server.add_transform(ToolTransform({tool_name: config})) instead.", DeprecationWarning, stacklevel=2, ) self.add_transform(ToolTransform({tool_name: transformation})) def remove_tool_transformation(self, _tool_name: str) -> None: """Remove a tool transformation. .. deprecated:: Tool transformations are now immutable. Use enable/disable controls instead. """ if fastmcp.settings.deprecation_warnings: warnings.warn( "remove_tool_transformation is deprecated and has no effect. " "Transforms are immutable once added. Use server.disable(keys=[...]) " "to hide tools instead.", DeprecationWarning, stacklevel=2, ) async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]: """List all enabled tools from providers. Overrides Provider.list_tools() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message=mcp.types.ListToolsRequest(method="tools/list"), source="client", type="request", method="tools/list", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_tools(run_middleware=False), ) # Get all tools, apply session transforms, then filter enabled tools = list(await super().list_tools()) tools = await apply_session_transforms(tools) tools = [t for t in tools if is_enabled(t)] skip_auth, token = _get_auth_context() authorized: list[Tool] = [] for tool in tools: if not skip_auth and tool.auth is not None: ctx = AuthContext(token=token, component=tool) try: if not await run_auth_checks(tool.auth, ctx): continue except AuthorizationError: continue authorized.append(tool) return authorized async def _get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name via aggregation from providers. Extends AggregateProvider._get_tool() with component-level auth checks. Args: name: The tool name. version: Version filter (None returns highest version). Returns: The tool if found and authorized, None if not found or unauthorized. """ # Get tool from AggregateProvider (handles aggregation and namespacing) tool = await super()._get_tool(name, version) if tool is None: return None # Component auth - return None if unauthorized (consistent with list filtering) skip_auth, token = _get_auth_context() if not skip_auth and tool.auth is not None: ctx = AuthContext(token=token, component=tool) try: if not await run_auth_checks(tool.auth, ctx): return None except AuthorizationError: return None return tool async def get_tool( self, name: str, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, filtering disabled tools. Overrides Provider.get_tool() to add visibility filtering after all transforms (including session-level) have been applied. This ensures session transforms can override provider-level disables. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. Args: name: The tool name. version: Version filter (None returns highest version). Returns: The tool if found and enabled, None otherwise. """ tool = await super().get_tool(name, version) if tool is None: return None # Apply session transforms to single item tools = await apply_session_transforms([tool]) if tools and is_enabled(tools[0]): return tools[0] # The highest version is disabled. If an explicit version was requested, # respect the disable. Otherwise fall back to the next-highest enabled version. if version is not None: return None all_tools = [t for t in await super().list_tools() if t.name == name] all_tools = list(await apply_session_transforms(all_tools)) enabled = [t for t in all_tools if is_enabled(t)] skip_auth, token = _get_auth_context() authorized: list[Tool] = [] for t in enabled: if not skip_auth and t.auth is not None: ctx = AuthContext(token=token, component=t) try: if not await run_auth_checks(t.auth, ctx): continue except AuthorizationError: continue authorized.append(t) if not authorized: return None return cast(Tool, max(authorized, key=version_sort_key)) async def list_resources( self, *, run_middleware: bool = True ) -> Sequence[Resource]: """List all enabled resources from providers. Overrides Provider.list_resources() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, source="client", type="request", method="resources/list", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_resources(run_middleware=False), ) # Get all resources, apply session transforms, then filter enabled resources = list(await super().list_resources()) resources = await apply_session_transforms(resources) resources = [r for r in resources if is_enabled(r)] skip_auth, token = _get_auth_context() authorized: list[Resource] = [] for resource in resources: if not skip_auth and resource.auth is not None: ctx = AuthContext(token=token, component=resource) try: if not await run_auth_checks(resource.auth, ctx): continue except AuthorizationError: continue authorized.append(resource) return authorized async def _get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI via aggregation from providers. Extends AggregateProvider._get_resource() with component-level auth checks. Args: uri: The resource URI. version: Version filter (None returns highest version). Returns: The resource if found and authorized, None if not found or unauthorized. """ # Get resource from AggregateProvider (handles aggregation and namespacing) resource = await super()._get_resource(uri, version) if resource is None: return None # Component auth - return None if unauthorized (consistent with list filtering) skip_auth, token = _get_auth_context() if not skip_auth and resource.auth is not None: ctx = AuthContext(token=token, component=resource) try: if not await run_auth_checks(resource.auth, ctx): return None except AuthorizationError: return None return resource async def get_resource( self, uri: str, version: VersionSpec | None = None ) -> Resource | None: """Get a resource by URI, filtering disabled resources. Overrides Provider.get_resource() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. Args: uri: The resource URI. version: Version filter (None returns highest version). Returns: The resource if found and enabled, None otherwise. """ resource = await super().get_resource(uri, version) if resource is None: return None # Apply session transforms to single item resources = await apply_session_transforms([resource]) if resources and is_enabled(resources[0]): return resources[0] if version is not None: return None all_resources = [r for r in await super().list_resources() if str(r.uri) == uri] all_resources = list(await apply_session_transforms(all_resources)) enabled = [r for r in all_resources if is_enabled(r)] skip_auth, token = _get_auth_context() authorized: list[Resource] = [] for r in enabled: if not skip_auth and r.auth is not None: ctx = AuthContext(token=token, component=r) try: if not await run_auth_checks(r.auth, ctx): continue except AuthorizationError: continue authorized.append(r) if not authorized: return None return cast(Resource, max(authorized, key=version_sort_key)) async def list_resource_templates( self, *, run_middleware: bool = True ) -> Sequence[ResourceTemplate]: """List all enabled resource templates from providers. Overrides Provider.list_resource_templates() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, source="client", type="request", method="resources/templates/list", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_resource_templates( run_middleware=False ), ) # Get all templates, apply session transforms, then filter enabled templates = list(await super().list_resource_templates()) templates = await apply_session_transforms(templates) templates = [t for t in templates if is_enabled(t)] skip_auth, token = _get_auth_context() authorized: list[ResourceTemplate] = [] for template in templates: if not skip_auth and template.auth is not None: ctx = AuthContext(token=token, component=template) try: if not await run_auth_checks(template.auth, ctx): continue except AuthorizationError: continue authorized.append(template) return authorized async def _get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template by URI via aggregation from providers. Extends AggregateProvider._get_resource_template() with component-level auth checks. Args: uri: The template URI to match. version: Version filter (None returns highest version). Returns: The template if found and authorized, None if not found or unauthorized. """ # Get template from AggregateProvider (handles aggregation and namespacing) template = await super()._get_resource_template(uri, version) if template is None: return None # Component auth - return None if unauthorized (consistent with list filtering) skip_auth, token = _get_auth_context() if not skip_auth and template.auth is not None: ctx = AuthContext(token=token, component=template) try: if not await run_auth_checks(template.auth, ctx): return None except AuthorizationError: return None return template async def get_resource_template( self, uri: str, version: VersionSpec | None = None ) -> ResourceTemplate | None: """Get a resource template by URI, filtering disabled templates. Overrides Provider.get_resource_template() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. Args: uri: The template URI. version: Version filter (None returns highest version). Returns: The template if found and enabled, None otherwise. """ template = await super().get_resource_template(uri, version) if template is None: return None # Apply session transforms to single item templates = await apply_session_transforms([template]) if templates and is_enabled(templates[0]): return templates[0] if version is not None: return None all_templates = [ t for t in await super().list_resource_templates() if t.matches(uri) is not None ] all_templates = list(await apply_session_transforms(all_templates)) enabled = [t for t in all_templates if is_enabled(t)] skip_auth, token = _get_auth_context() authorized: list[ResourceTemplate] = [] for t in enabled: if not skip_auth and t.auth is not None: ctx = AuthContext(token=token, component=t) try: if not await run_auth_checks(t.auth, ctx): continue except AuthorizationError: continue authorized.append(t) if not authorized: return None return cast(ResourceTemplate, max(authorized, key=version_sort_key)) async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]: """List all enabled prompts from providers. Overrides Provider.list_prompts() to add visibility filtering, auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, source="client", type="request", method="prompts/list", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.list_prompts(run_middleware=False), ) # Get all prompts, apply session transforms, then filter enabled prompts = list(await super().list_prompts()) prompts = await apply_session_transforms(prompts) prompts = [p for p in prompts if is_enabled(p)] skip_auth, token = _get_auth_context() authorized: list[Prompt] = [] for prompt in prompts: if not skip_auth and prompt.auth is not None: ctx = AuthContext(token=token, component=prompt) try: if not await run_auth_checks(prompt.auth, ctx): continue except AuthorizationError: continue authorized.append(prompt) return authorized async def _get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name via aggregation from providers. Extends AggregateProvider._get_prompt() with component-level auth checks. Args: name: The prompt name. version: Version filter (None returns highest version). Returns: The prompt if found and authorized, None if not found or unauthorized. """ # Get prompt from AggregateProvider (handles aggregation and namespacing) prompt = await super()._get_prompt(name, version) if prompt is None: return None # Component auth - return None if unauthorized (consistent with list filtering) skip_auth, token = _get_auth_context() if not skip_auth and prompt.auth is not None: ctx = AuthContext(token=token, component=prompt) try: if not await run_auth_checks(prompt.auth, ctx): return None except AuthorizationError: return None return prompt async def get_prompt( self, name: str, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name, filtering disabled prompts. Overrides Provider.get_prompt() to add visibility filtering after all transforms (including session-level) have been applied. When the highest version is disabled and no explicit version was requested, falls back to the next-highest enabled version. Args: name: The prompt name. version: Version filter (None returns highest version). Returns: The prompt if found and enabled, None otherwise. """ prompt = await super().get_prompt(name, version) if prompt is None: return None # Apply session transforms to single item prompts = await apply_session_transforms([prompt]) if prompts and is_enabled(prompts[0]): return prompts[0] if version is not None: return None all_prompts = [p for p in await super().list_prompts() if p.name == name] all_prompts = list(await apply_session_transforms(all_prompts)) enabled = [p for p in all_prompts if is_enabled(p)] skip_auth, token = _get_auth_context() authorized: list[Prompt] = [] for p in enabled: if not skip_auth and p.auth is not None: ctx = AuthContext(token=token, component=p) try: if not await run_auth_checks(p.auth, ctx): continue except AuthorizationError: continue authorized.append(p) if not authorized: return None return cast(Prompt, max(authorized, key=version_sort_key)) @overload async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: None = None, ) -> ToolResult: ... @overload async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta | None = None, ) -> ToolResult | mcp.types.CreateTaskResult: """Call a tool by name. This is the public API for executing tools. By default, middleware is applied. Args: name: The tool name arguments: Tool arguments (optional) version: Specific version to call. If None, calls highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ToolResult. Returns: ToolResult when task_meta is None. CreateTaskResult when task_meta is provided. Raises: NotFoundError: If tool not found or disabled ToolError: If tool execution fails ValidationError: If arguments fail validation """ # Note: fn_key enrichment happens here after finding the tool. # For mounted servers, the parent's provider sets fn_key to the # namespaced key before delegating, ensuring correct Docket routing. async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext[CallToolRequestParams]( message=mcp.types.CallToolRequestParams( name=name, arguments=arguments or {} ), source="client", type="request", method="tools/call", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.call_tool( context.message.name, context.message.arguments or {}, version=version, run_middleware=False, task_meta=task_meta, ), ) # Core logic: find and execute tool (providers queried in parallel) # Use get_tool to apply transforms and filter disabled with server_span( f"tools/call {name}", "tools/call", self.name, "tool", name ) as span: # Try normal provider resolution first (applies transforms, # visibility, auth). Fall back to the global key registry # so that FastMCPApp CallTool references survive namespace # transforms. Global keys contain a UUID suffix, so they # won't collide with human-written tool names. tool = await self.get_tool(name, version=version) if tool is None: from fastmcp.server.app import get_global_tool tool = get_global_tool(name) if tool is not None: # Auth still applies to global-key tools skip_auth, token = _get_auth_context() if not skip_auth and tool.auth is not None: try: ctx = AuthContext(token=token, component=tool) if not await run_auth_checks(tool.auth, ctx): raise NotFoundError(f"Unknown tool: {name!r}") except AuthorizationError: raise NotFoundError(f"Unknown tool: {name!r}") from None if tool is None: raise NotFoundError(f"Unknown tool: {name!r}") span.set_attributes(tool.get_span_attributes()) if task_meta is not None and task_meta.fn_key is None: task_meta = replace(task_meta, fn_key=tool.key) try: return await tool._run(arguments or {}, task_meta=task_meta) except FastMCPError: logger.exception(f"Error calling tool {name!r}") raise except (ValidationError, PydanticValidationError): logger.exception(f"Error validating tool {name!r}") raise except Exception as e: logger.exception(f"Error calling tool {name!r}") # Handle actionable errors that should reach the LLM # even when masking is enabled if isinstance(e, httpx.HTTPStatusError): if e.response.status_code == 429: raise ToolError( "Rate limited by upstream API, please retry later" ) from e if isinstance(e, httpx.TimeoutException): raise ToolError( "Upstream request timed out, please retry" ) from e # Standard masking logic if self._mask_error_details: raise ToolError(f"Error calling tool {name!r}") from e raise ToolError(f"Error calling tool {name!r}: {e}") from e @overload async def read_resource( self, uri: str, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: None = None, ) -> ResourceResult: ... @overload async def read_resource( self, uri: str, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def read_resource( self, uri: str, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta | None = None, ) -> ResourceResult | mcp.types.CreateTaskResult: """Read a resource by URI. This is the public API for reading resources. By default, middleware is applied. Checks concrete resources first, then templates. Args: uri: The resource URI version: Specific version to read. If None, reads highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return ResourceResult. Returns: ResourceResult when task_meta is None. CreateTaskResult when task_meta is provided. Raises: NotFoundError: If resource not found or disabled ResourceError: If resource read fails """ # Note: fn_key enrichment happens here after finding the resource/template. # Resources and templates use different key formats: # - Resources use resource.key (derived from the concrete URI) # - Templates use template.key (the template pattern) # For mounted servers, the parent's provider sets fn_key to the # namespaced key before delegating, ensuring correct Docket routing. async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: uri_param = AnyUrl(uri) mw_context = MiddlewareContext( message=mcp.types.ReadResourceRequestParams(uri=uri_param), source="client", type="request", method="resources/read", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.read_resource( str(context.message.uri), version=version, run_middleware=False, task_meta=task_meta, ), ) # Core logic: find and read resource (providers queried in parallel) with server_span( f"resources/read {uri}", "resources/read", self.name, "resource", uri, resource_uri=uri, ) as span: # Try concrete resources first (transforms + auth via _get_resource) resource = await self.get_resource(uri, version=version) if resource is not None: span.set_attributes(resource.get_span_attributes()) if task_meta is not None and task_meta.fn_key is None: task_meta = replace(task_meta, fn_key=resource.key) try: return await resource._read(task_meta=task_meta) except (FastMCPError, McpError): logger.exception(f"Error reading resource {uri!r}") raise except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM if isinstance(e, httpx.HTTPStatusError): if e.response.status_code == 429: raise ResourceError( "Rate limited by upstream API, please retry later" ) from e if isinstance(e, httpx.TimeoutException): raise ResourceError( "Upstream request timed out, please retry" ) from e # Standard masking logic if self._mask_error_details: raise ResourceError( f"Error reading resource {uri!r}" ) from e raise ResourceError( f"Error reading resource {uri!r}: {e}" ) from e # Try templates (transforms + auth via get_resource_template) template = await self.get_resource_template(uri, version=version) if template is None: if version is None: raise NotFoundError(f"Unknown resource: {uri!r}") raise NotFoundError( f"Unknown resource: {uri!r} version {version!r}" ) span.set_attributes(template.get_span_attributes()) params = template.matches(uri) assert params is not None if task_meta is not None and task_meta.fn_key is None: task_meta = replace(task_meta, fn_key=template.key) try: return await template._read(uri, params, task_meta=task_meta) except (FastMCPError, McpError): logger.exception(f"Error reading resource {uri!r}") raise except Exception as e: logger.exception(f"Error reading resource {uri!r}") # Handle actionable errors that should reach the LLM if isinstance(e, httpx.HTTPStatusError): if e.response.status_code == 429: raise ResourceError( "Rate limited by upstream API, please retry later" ) from e if isinstance(e, httpx.TimeoutException): raise ResourceError( "Upstream request timed out, please retry" ) from e # Standard masking logic if self._mask_error_details: raise ResourceError(f"Error reading resource {uri!r}") from e raise ResourceError(f"Error reading resource {uri!r}: {e}") from e @overload async def render_prompt( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: None = None, ) -> PromptResult: ... @overload async def render_prompt( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def render_prompt( self, name: str, arguments: dict[str, Any] | None = None, *, version: VersionSpec | None = None, run_middleware: bool = True, task_meta: TaskMeta | None = None, ) -> PromptResult | mcp.types.CreateTaskResult: """Render a prompt by name. This is the public API for rendering prompts. By default, middleware is applied. Use get_prompt() to retrieve the prompt definition without rendering. Args: name: The prompt name arguments: Prompt arguments (optional) version: Specific version to render. If None, renders highest version. run_middleware: If True (default), apply the middleware chain. Set to False when called from middleware to avoid re-applying. task_meta: If provided, execute as a background task and return CreateTaskResult. If None (default), execute synchronously and return PromptResult. Returns: PromptResult when task_meta is None. CreateTaskResult when task_meta is provided. Raises: NotFoundError: If prompt not found or disabled PromptError: If prompt rendering fails """ async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message=mcp.types.GetPromptRequestParams( name=name, arguments=arguments ), source="client", type="request", method="prompts/get", fastmcp_context=ctx, ) return await self._run_middleware( context=mw_context, call_next=lambda context: self.render_prompt( context.message.name, context.message.arguments, version=version, run_middleware=False, task_meta=task_meta, ), ) # Core logic: find and render prompt (providers queried in parallel) # Use get_prompt to apply transforms and filter disabled with server_span( f"prompts/get {name}", "prompts/get", self.name, "prompt", name ) as span: prompt = await self.get_prompt(name, version=version) if prompt is None: raise NotFoundError(f"Unknown prompt: {name!r}") span.set_attributes(prompt.get_span_attributes()) if task_meta is not None and task_meta.fn_key is None: task_meta = replace(task_meta, fn_key=prompt.key) try: return await prompt._render(arguments, task_meta=task_meta) except (FastMCPError, McpError): logger.exception(f"Error rendering prompt {name!r}") raise except Exception as e: logger.exception(f"Error rendering prompt {name!r}") if self._mask_error_details: raise PromptError(f"Error rendering prompt {name!r}") from e raise PromptError(f"Error rendering prompt {name!r}: {e}") from e def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool: """Add a tool to the server. The tool function can optionally request a Context object by adding a parameter with the Context type annotation. See the @tool decorator for examples. Args: tool: The Tool instance or @tool-decorated function to register Returns: The tool instance that was added to the server. """ return self._local_provider.add_tool(tool) def remove_tool(self, name: str, version: str | None = None) -> None: """Remove tool(s) from the server. .. deprecated:: Use ``mcp.local_provider.remove_tool(name)`` instead. Args: name: The name of the tool to remove. version: If None, removes ALL versions. If specified, removes only that version. Raises: NotFoundError: If no matching tool is found. """ if fastmcp.settings.deprecation_warnings: warnings.warn( "remove_tool() is deprecated. Use " "mcp.local_provider.remove_tool(name) instead.", DeprecationWarning, stacklevel=2, ) try: self._local_provider.remove_tool(name, version) except KeyError: if version is None: raise NotFoundError(f"Tool {name!r} not found") from None raise NotFoundError( f"Tool {name!r} version {version!r} not found" ) from None @overload def tool( self, name_or_fn: F, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @overload def tool( self, name_or_fn: str | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... def tool( self, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, exclude_args: list[str] | None = None, meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionTool] | FunctionTool | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] ): """Decorator to register a tool. Tools can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and resource access. This decorator supports multiple calling patterns: - @server.tool (without parentheses) - @server.tool (with empty parentheses) - @server.tool("custom_name") (with name as first argument) - @server.tool(name="custom_name") (with name as keyword argument) - server.tool(function, name="custom_name") (direct function call) Args: name_or_fn: Either a function (when used as @tool), a string name, or None name: Optional name for the tool (keyword-only, alternative to name_or_fn) description: Optional description of what the tool does tags: Optional set of tags for categorizing the tool output_schema: Optional JSON schema for the tool's output annotations: Optional annotations about the tool's behavior exclude_args: Optional list of argument names to exclude from the tool schema. Deprecated: Use `Depends()` for dependency injection instead. meta: Optional meta information about the tool Examples: Register a tool with a custom name: ```python @server.tool def my_tool(x: int) -> str: return str(x) # Register a tool with a custom name @server.tool def my_tool(x: int) -> str: return str(x) @server.tool("custom_name") def my_tool(x: int) -> str: return str(x) @server.tool(name="custom_name") def my_tool(x: int) -> str: return str(x) # Direct function call server.tool(my_function, name="custom_name") ``` """ # Merge app config into meta["ui"] (wire format) before passing to provider if app is not None and app is not False: meta = dict(meta) if meta else {} if app is True: meta["ui"] = True else: meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults result = self._local_provider.tool( name_or_fn, name=name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, exclude_args=exclude_args, meta=meta, task=task if task is not None else self._support_tasks_by_default, timeout=timeout, auth=auth, ) return result def add_resource( self, resource: Resource | Callable[..., Any] ) -> Resource | ResourceTemplate: """Add a resource to the server. Args: resource: A Resource instance or @resource-decorated function to add Returns: The resource instance that was added to the server. """ return self._local_provider.add_resource(resource) def add_template(self, template: ResourceTemplate) -> ResourceTemplate: """Add a resource template to the server. Args: template: A ResourceTemplate instance to add Returns: The template instance that was added to the server. """ return self._local_provider.add_template(template) def resource( self, uri: str, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, mime_type: str | None = None, tags: set[str] | None = None, annotations: Annotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, app: AppConfig | dict[str, Any] | bool | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: """Decorator to register a function as a resource. The function will be called when the resource is read to generate its content. The function can return: - str for text content - bytes for binary content - other types will be converted to JSON Resources can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and session information. If the URI contains parameters (e.g. "resource://{param}") or the function has parameters, it will be registered as a template resource. Args: uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") name: Optional name for the resource description: Optional description of the resource mime_type: Optional MIME type for the resource tags: Optional set of tags for categorizing the resource annotations: Optional annotations about the resource's behavior meta: Optional meta information about the resource Examples: Register a resource with a custom name: ```python @server.resource("resource://my-resource") def get_data() -> str: return "Hello, world!" @server.resource("resource://my-resource") async get_data() -> str: data = await fetch_data() return f"Hello, world! {data}" @server.resource("resource://{city}/weather") def get_weather(city: str) -> str: return f"Weather for {city}" @server.resource("resource://{city}/weather") async def get_weather_with_context(city: str, ctx: Context) -> str: await ctx.info(f"Fetching weather for {city}") return f"Weather for {city}" @server.resource("resource://{city}/weather") async def get_weather(city: str) -> str: data = await fetch_weather(city) return f"Weather for {city}: {data}" ``` """ # Catch incorrect decorator usage early (before any processing) if not isinstance(uri, str): raise TypeError( "The @resource decorator was used incorrectly. " "It requires a URI as the first argument. " "Use @resource('uri') instead of @resource" ) # Apply default MIME type for ui:// scheme resources mime_type = resolve_ui_mime_type(uri, mime_type) # Validate app config for resources — resource_uri and visibility # don't apply since the resource itself is the UI if isinstance(app, AppConfig): if app.resource_uri is not None: raise ValueError( "resource_uri cannot be set on resources — " "the resource itself is the UI. " "Use resource_uri on tools to point to a UI resource." ) if app.visibility is not None: raise ValueError( "visibility cannot be set on resources — it only applies to tools." ) # Merge app config into meta["ui"] (wire format) before passing to provider if app is not None and app is not False: meta = dict(meta) if meta else {} if app is True: meta["ui"] = True else: meta["ui"] = app_config_to_meta_dict(app) # Delegate to LocalProvider with server-level defaults inner_decorator = self._local_provider.resource( uri, name=name, version=version, title=title, description=description, icons=icons, mime_type=mime_type, tags=tags, annotations=annotations, meta=meta, task=task if task is not None else self._support_tasks_by_default, auth=auth, ) return inner_decorator def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt: """Add a prompt to the server. Args: prompt: A Prompt instance or @prompt-decorated function to add Returns: The prompt instance that was added to the server. """ return self._local_provider.add_prompt(prompt) @overload def prompt( self, name_or_fn: F, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> F: ... @overload def prompt( self, name_or_fn: str | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... def prompt( self, name_or_fn: str | AnyFunction | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[mcp.types.Icon] | None = None, tags: set[str] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> ( Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] ): """Decorator to register a prompt. Prompts can optionally request a Context object by adding a parameter with the Context type annotation. The context provides access to MCP capabilities like logging, progress reporting, and session information. This decorator supports multiple calling patterns: - @server.prompt (without parentheses) - @server.prompt() (with empty parentheses) - @server.prompt("custom_name") (with name as first argument) - @server.prompt(name="custom_name") (with name as keyword argument) - server.prompt(function, name="custom_name") (direct function call) Args: name_or_fn: Either a function (when used as @prompt), a string name, or None name: Optional name for the prompt (keyword-only, alternative to name_or_fn) description: Optional description of what the prompt does tags: Optional set of tags for categorizing the prompt meta: Optional meta information about the prompt Examples: ```python @server.prompt def analyze_table(table_name: str) -> list[Message]: schema = read_table_schema(table_name) return [ { "role": "user", "content": f"Analyze this schema:\n{schema}" } ] @server.prompt() async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: await ctx.info(f"Analyzing table {table_name}") schema = read_table_schema(table_name) return [ { "role": "user", "content": f"Analyze this schema:\n{schema}" } ] @server.prompt("custom_name") async def analyze_file(path: str) -> list[Message]: content = await read_file(path) return [ { "role": "user", "content": { "type": "resource", "resource": { "uri": f"file://{path}", "text": content } } } ] @server.prompt(name="custom_name") def another_prompt(data: str) -> list[Message]: return [{"role": "user", "content": data}] # Direct function call server.prompt(my_function, name="custom_name") ``` """ # Delegate to LocalProvider with server-level defaults return self._local_provider.prompt( name_or_fn, name=name, version=version, title=title, description=description, icons=icons, tags=tags, meta=meta, task=task if task is not None else self._support_tasks_by_default, auth=auth, ) def mount( self, server: FastMCP[LifespanResultT], namespace: str | None = None, as_proxy: bool | None = None, tool_names: dict[str, str] | None = None, prefix: str | None = None, # deprecated, use namespace ) -> None: """Mount another FastMCP server on this server with an optional namespace. Unlike importing (with import_server), mounting establishes a dynamic connection between servers. When a client interacts with a mounted server's objects through the parent server, requests are forwarded to the mounted server in real-time. This means changes to the mounted server are immediately reflected when accessed through the parent. When a server is mounted with a namespace: - Tools from the mounted server are accessible with namespaced names. Example: If server has a tool named "get_weather", it will be available as "namespace_get_weather". - Resources are accessible with namespaced URIs. Example: If server has a resource with URI "weather://forecast", it will be available as "weather://namespace/forecast". - Templates are accessible with namespaced URI templates. Example: If server has a template with URI "weather://location/{id}", it will be available as "weather://namespace/location/{id}". - Prompts are accessible with namespaced names. Example: If server has a prompt named "weather_prompt", it will be available as "namespace_weather_prompt". When a server is mounted without a namespace (namespace=None), its tools, resources, templates, and prompts are accessible with their original names. Multiple servers can be mounted without namespaces, and they will be tried in order until a match is found. The mounted server's lifespan is executed when the parent server starts, and its middleware chain is invoked for all operations (tool calls, resource reads, prompts). Args: server: The FastMCP server to mount. namespace: Optional namespace to use for the mounted server's objects. If None, the server's objects are accessible with their original names. as_proxy: Deprecated. Mounted servers now always have their lifespan and middleware invoked. To create a proxy server, use create_proxy() explicitly before mounting. tool_names: Optional mapping of original tool names to custom names. Use this to override namespaced names. Keys are the original tool names from the mounted server. prefix: Deprecated. Use namespace instead. """ import warnings from fastmcp.server.providers.fastmcp_provider import FastMCPProvider # Handle deprecated prefix parameter if prefix is not None: warnings.warn( "The 'prefix' parameter is deprecated, use 'namespace' instead", DeprecationWarning, stacklevel=2, ) if namespace is None: namespace = prefix else: raise ValueError("Cannot specify both 'prefix' and 'namespace'") if as_proxy is not None: warnings.warn( "as_proxy is deprecated and will be removed in a future version. " "Mounted servers now always have their lifespan and middleware invoked. " "To create a proxy server, use create_proxy() explicitly.", DeprecationWarning, stacklevel=2, ) # Still honor the flag for backward compatibility if as_proxy: from fastmcp.server.providers.proxy import FastMCPProxy if not isinstance(server, FastMCPProxy): server = FastMCP.as_proxy(server) # Create provider and add it with namespace provider: Provider = FastMCPProvider(server) # Apply tool renames first (scoped to this provider), then namespace # So foo → bar with namespace="baz" becomes baz_bar if tool_names: transforms = { old_name: ToolTransformConfig(name=new_name) for old_name, new_name in tool_names.items() } provider = provider.wrap_transform(ToolTransform(transforms)) # Use add_provider with namespace (applies namespace in AggregateProvider) self.add_provider(provider, namespace=namespace or "") async def import_server( self, server: FastMCP[LifespanResultT], prefix: str | None = None, ) -> None: """ Import the MCP objects from another FastMCP server into this one, optionally with a given prefix. .. deprecated:: Use :meth:`mount` instead. ``import_server`` will be removed in a future version. Note that when a server is *imported*, its objects are immediately registered to the importing server. This is a one-time operation and future changes to the imported server will not be reflected in the importing server. Server-level configurations and lifespans are not imported. When a server is imported with a prefix: - The tools are imported with prefixed names Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather" - The resources are imported with prefixed URIs using the new format Example: If server has a resource with URI "weather://forecast", it will be available as "weather://prefix/forecast" - The templates are imported with prefixed URI templates using the new format Example: If server has a template with URI "weather://location/{id}", it will be available as "weather://prefix/location/{id}" - The prompts are imported with prefixed names Example: If server has a prompt named "weather_prompt", it will be available as "prefix_weather_prompt" When a server is imported without a prefix (prefix=None), its tools, resources, templates, and prompts are imported with their original names. Args: server: The FastMCP server to import prefix: Optional prefix to use for the imported server's objects. If None, objects are imported with their original names. """ import warnings warnings.warn( "import_server is deprecated, use mount() instead", DeprecationWarning, stacklevel=2, ) def add_resource_prefix(uri: str, prefix: str) -> str: """Add prefix to resource URI: protocol://path → protocol://prefix/path.""" match = URI_PATTERN.match(uri) if match: protocol, path = match.groups() return f"{protocol}{prefix}/{path}" return uri # Import tools from the server for tool in await server.list_tools(): if prefix: tool = tool.model_copy(update={"name": f"{prefix}_{tool.name}"}) self.add_tool(tool) # Import resources and templates from the server for resource in await server.list_resources(): if prefix: new_uri = add_resource_prefix(str(resource.uri), prefix) resource = resource.model_copy(update={"uri": new_uri}) self.add_resource(resource) for template in await server.list_resource_templates(): if prefix: new_uri_template = add_resource_prefix(template.uri_template, prefix) template = template.model_copy( update={"uri_template": new_uri_template} ) self.add_template(template) # Import prompts from the server for prompt in await server.list_prompts(): if prefix: prompt = prompt.model_copy(update={"name": f"{prefix}_{prompt.name}"}) self.add_prompt(prompt) if server._lifespan != default_lifespan: from warnings import warn warn( message="When importing from a server with a lifespan, the lifespan from the imported server will not be used.", category=RuntimeWarning, stacklevel=2, ) if prefix: logger.debug( f"[{self.name}] Imported server {server.name} with prefix '{prefix}'" ) else: logger.debug(f"[{self.name}] Imported server {server.name}") @classmethod def from_openapi( cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient | None = None, name: str = "OpenAPI Server", route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, tags: set[str] | None = None, validate_output: bool = True, **settings: Any, ) -> Self: """ Create a FastMCP server from an OpenAPI specification. Args: openapi_spec: OpenAPI schema as a dictionary client: Optional httpx AsyncClient for making HTTP requests. If not provided, a default client is created using the first server URL from the OpenAPI spec with a 30-second timeout. name: Name for the MCP server route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names tags: Optional set of tags to add to all components validate_output: If True (default), tools use the output schema extracted from the OpenAPI spec for response validation. If False, a permissive schema is used instead, allowing any response structure while still returning structured JSON. **settings: Additional settings passed to FastMCP Returns: A FastMCP server with an OpenAPIProvider attached. """ from .providers.openapi import OpenAPIProvider provider: Provider = OpenAPIProvider( openapi_spec=openapi_spec, client=client, route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, validate_output=validate_output, ) return cls(name=name, providers=[provider], **settings) @classmethod def from_fastapi( cls, app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, mcp_names: dict[str, str] | None = None, httpx_client_kwargs: dict[str, Any] | None = None, tags: set[str] | None = None, **settings: Any, ) -> Self: """ Create a FastMCP server from a FastAPI application. Args: app: FastAPI application instance name: Name for the MCP server (defaults to app.title) route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping mcp_component_fn: Optional callable for component customization mcp_names: Optional dictionary mapping operationId to component names httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient. Use this to configure timeout and other client settings. tags: Optional set of tags to add to all components **settings: Additional settings passed to FastMCP Returns: A FastMCP server with an OpenAPIProvider attached. """ from .providers.openapi import OpenAPIProvider if httpx_client_kwargs is None: httpx_client_kwargs = {} httpx_client_kwargs.setdefault("base_url", "http://fastapi") client = httpx.AsyncClient( transport=httpx.ASGITransport(app=app), **httpx_client_kwargs, ) server_name = name or app.title provider: Provider = OpenAPIProvider( openapi_spec=app.openapi(), client=client, route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, mcp_names=mcp_names, tags=tags, ) return cls(name=server_name, providers=[provider], **settings) @classmethod def as_proxy( cls, backend: ( Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str ), **settings: Any, ) -> FastMCPProxy: """Create a FastMCP proxy server for the given backend. .. deprecated:: Use :func:`fastmcp.server.create_proxy` instead. This method will be removed in a future version. The `backend` argument can be either an existing `fastmcp.client.Client` instance or any value accepted as the `transport` argument of `fastmcp.client.Client`. This mirrors the convenience of the `fastmcp.client.Client` constructor. """ if fastmcp.settings.deprecation_warnings: warnings.warn( "FastMCP.as_proxy() is deprecated. Use create_proxy() from " "fastmcp.server instead: `from fastmcp.server import create_proxy`", DeprecationWarning, stacklevel=2, ) # Call the module-level create_proxy function directly return create_proxy(backend, **settings) @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)}" # ----------------------------------------------------------------------------- # Module-level Factory Functions # ----------------------------------------------------------------------------- def create_proxy( target: ( Client[ClientTransportT] | ClientTransport | FastMCP[Any] | FastMCP1Server | AnyUrl | Path | MCPConfig | dict[str, Any] | str ), **settings: Any, ) -> FastMCPProxy: """Create a FastMCP proxy server for the given target. This is the recommended way to create a proxy server. For lower-level control, use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.proxy`. Args: target: The backend to proxy to. Can be: - A Client instance (connected or disconnected) - A ClientTransport - A FastMCP server instance - A URL string or AnyUrl - A Path to a server script - An MCPConfig or dict **settings: Additional settings passed to FastMCPProxy (name, etc.) Returns: A FastMCPProxy server that proxies to the target. Example: ```python from fastmcp.server import create_proxy # Create a proxy to a remote server proxy = create_proxy("http://remote-server/mcp") # Create a proxy to another FastMCP server proxy = create_proxy(other_server) ``` """ from fastmcp.server.providers.proxy import ( FastMCPProxy, _create_client_factory, ) client_factory = _create_client_factory(target) return FastMCPProxy( client_factory=client_factory, **settings, ) ================================================ FILE: src/fastmcp/server/tasks/__init__.py ================================================ """MCP SEP-1686 background tasks support. This module implements protocol-level background task execution for MCP servers. """ from fastmcp.server.tasks.capabilities import get_task_capabilities from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode from fastmcp.server.tasks.elicitation import ( elicit_for_task, handle_task_input, relay_elicitation, ) from fastmcp.server.tasks.keys import ( build_task_key, get_client_task_id_from_key, parse_task_key, ) from fastmcp.server.tasks.notifications import ( ensure_subscriber_running, push_notification, stop_subscriber, ) __all__ = [ "TaskConfig", "TaskMeta", "TaskMode", "build_task_key", "elicit_for_task", "ensure_subscriber_running", "get_client_task_id_from_key", "get_task_capabilities", "handle_task_input", "parse_task_key", "push_notification", "relay_elicitation", "stop_subscriber", ] ================================================ FILE: src/fastmcp/server/tasks/capabilities.py ================================================ """SEP-1686 task capabilities declaration.""" from importlib.util import find_spec from mcp.types import ( ServerTasksCapability, ServerTasksRequestsCapability, TasksCallCapability, TasksCancelCapability, TasksListCapability, TasksToolsCapability, ) def _is_docket_available() -> bool: """Check if pydocket is installed (local to avoid circular import).""" return find_spec("docket") is not None def get_task_capabilities() -> ServerTasksCapability | None: """Return the SEP-1686 task capabilities. Returns task capabilities as a first-class ServerCapabilities field, declaring support for list, cancel, and request operations per SEP-1686. Returns None if pydocket is not installed (no task support). Note: prompts/resources are passed via extra_data since the SDK types don't include them yet (FastMCP supports them ahead of the spec). """ if not _is_docket_available(): return None return ServerTasksCapability( list=TasksListCapability(), cancel=TasksCancelCapability(), requests=ServerTasksRequestsCapability( tools=TasksToolsCapability(call=TasksCallCapability()), prompts={"get": {}}, # type: ignore[call-arg] # extra_data for forward compat resources={"read": {}}, # type: ignore[call-arg] # extra_data for forward compat ), ) ================================================ FILE: src/fastmcp/server/tasks/config.py ================================================ """TaskConfig for MCP SEP-1686 background task execution modes. This module defines the configuration for how tools, resources, and prompts handle task-augmented execution as specified in SEP-1686. """ from __future__ import annotations import functools import inspect from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from typing import Any, Literal from fastmcp.utilities.async_utils import is_coroutine_function # Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport TaskMode = Literal["forbidden", "optional", "required"] # Default values for task metadata (single source of truth) DEFAULT_POLL_INTERVAL = timedelta(seconds=5) # Default poll interval DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000) DEFAULT_TTL_MS = 60_000 # Default TTL in milliseconds @dataclass class TaskMeta: """Metadata for task-augmented execution requests. When passed to call_tool/read_resource/get_prompt, signals that the operation should be submitted as a background task. Attributes: ttl: Client-requested TTL in milliseconds. If None, uses server default. fn_key: Docket routing key. Auto-derived from component name if None. """ ttl: int | None = None fn_key: str | None = None @dataclass class TaskConfig: """Configuration for MCP background task execution (SEP-1686). Controls how a component handles task-augmented requests: - "forbidden": Component does not support task execution. Clients must not request task augmentation; server returns -32601 if they do. - "optional": Component supports both synchronous and task execution. Client may request task augmentation or call normally. - "required": Component requires task execution. Clients must request task augmentation; server returns -32601 if they don't. Important: Task-enabled components must be available at server startup to be registered with all Docket workers. Components added dynamically after startup will not be registered for background execution. Example: ```python from fastmcp import FastMCP from fastmcp.server.tasks import TaskConfig mcp = FastMCP("MyServer") # Background execution required @mcp.tool(task=TaskConfig(mode="required")) async def long_running_task(): ... # Supports both modes (default when task=True) @mcp.tool(task=TaskConfig(mode="optional")) async def flexible_task(): ... ``` """ mode: TaskMode = "optional" poll_interval: timedelta = DEFAULT_POLL_INTERVAL @classmethod def from_bool(cls, value: bool) -> TaskConfig: """Convert boolean task flag to TaskConfig. Args: value: True for "optional" mode, False for "forbidden" mode. Returns: TaskConfig with appropriate mode. """ return cls(mode="optional" if value else "forbidden") def supports_tasks(self) -> bool: """Check if this component supports task execution. Returns: True if mode is "optional" or "required", False if "forbidden". """ return self.mode != "forbidden" def validate_function(self, fn: Callable[..., Any], name: str) -> None: """Validate that function is compatible with this task config. Task execution requires: 1. fastmcp[tasks] to be installed (pydocket) 2. Async functions Raises ImportError if mode is "optional" or "required" but pydocket is not installed. Raises ValueError if function is synchronous. Args: fn: The function to validate (handles callable classes and staticmethods). name: Name for error messages. Raises: ImportError: If task execution is enabled but pydocket not installed. ValueError: If task execution is enabled but function is sync. """ if not self.supports_tasks(): return # Check that docket is available for task execution # Lazy import to avoid circular: dependencies.py → http.py → tasks/__init__.py → config.py from fastmcp.server.dependencies import require_docket require_docket(f"`task=True` on function '{name}'") # Unwrap callable classes and staticmethods fn_to_check = fn if ( not inspect.isroutine(fn) and not isinstance(fn, functools.partial) and callable(fn) ): fn_to_check = fn.__call__ if isinstance(fn_to_check, staticmethod): fn_to_check = fn_to_check.__func__ if not is_coroutine_function(fn_to_check): raise ValueError( f"'{name}' uses a sync function but has task execution enabled. " "Background tasks require async functions." ) # Note: Context IS now available in background task workers (SEP-1686) # The wiring in _CurrentContext creates a task-aware Context with task_id # and session from the registry. No warning needed. ================================================ FILE: src/fastmcp/server/tasks/elicitation.py ================================================ """Background task elicitation support (SEP-1686). This module provides elicitation capabilities for background tasks running in Docket workers. Unlike regular MCP requests, background tasks don't have an active request context, so elicitation requires special handling: 1. Set task status to "input_required" via Redis 2. Send notifications/tasks/status with elicitation metadata 3. Wait for client to send input via tasks/sendInput 4. Resume task execution with the provided input This uses the public MCP SDK APIs where possible, with minimal use of internal APIs for background task coordination. """ from __future__ import annotations import json import logging import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, cast import mcp.types from mcp import ServerSession logger = logging.getLogger(__name__) if TYPE_CHECKING: from fastmcp.server.server import FastMCP # Redis key patterns for task elicitation state ELICIT_REQUEST_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:request" ELICIT_RESPONSE_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:response" ELICIT_STATUS_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:status" # TTL for elicitation state (1 hour) ELICIT_TTL_SECONDS = 3600 async def elicit_for_task( task_id: str, session: ServerSession | None, message: str, schema: dict[str, Any], fastmcp: FastMCP, ) -> mcp.types.ElicitResult: """Send an elicitation request from a background task. This function handles the complexity of eliciting user input when running in a Docket worker context where there's no active MCP request. Args: task_id: The background task ID session: The MCP ServerSession for this task message: The message to display to the user schema: The JSON schema for the expected response fastmcp: The FastMCP server instance Returns: ElicitResult containing the user's response Raises: RuntimeError: If Docket is not available McpError: If the elicitation request fails """ docket = fastmcp._docket if docket is None: raise RuntimeError( "Background task elicitation requires Docket. " "Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components." ) # Generate a unique request ID for this elicitation request_id = str(uuid.uuid4()) # Get session ID from task context (authoritative source for background tasks) # This is extracted from the Docket execution key: {session_id}:{task_id}:... from fastmcp.server.dependencies import get_task_context task_context = get_task_context() if task_context is not None: session_id = task_context.session_id else: # Fallback: try to get from session attribute (shouldn't happen in background) session_id = getattr(session, "_fastmcp_state_prefix", None) if session_id is None: raise RuntimeError( "Cannot determine session_id for elicitation. " "This typically means elicit_for_task() was called outside a Docket worker context." ) # Store elicitation request in Redis request_key = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id) response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) elicit_request = { "request_id": request_id, "message": message, "schema": schema, } async with docket.redis() as redis: # Store the elicitation request await redis.set( docket.key(request_key), json.dumps(elicit_request), ex=ELICIT_TTL_SECONDS, ) # Set status to "waiting" await redis.set( docket.key(status_key), "waiting", ex=ELICIT_TTL_SECONDS, ) # Send task status update notification with input_required status. # Use notifications/tasks/status so typed MCP clients can consume it. # # NOTE: We use the distributed notification queue instead of session.send_notification() # This enables notifications to work when workers run in separate processes # (Azure Web PubSub / Service Bus inspired pattern) timestamp = datetime.now(timezone.utc).isoformat() notification_dict = { "method": "notifications/tasks/status", "params": { "taskId": task_id, "status": "input_required", "statusMessage": message, "createdAt": timestamp, "lastUpdatedAt": timestamp, "ttl": ELICIT_TTL_SECONDS * 1000, }, "_meta": { "io.modelcontextprotocol/related-task": { "taskId": task_id, "status": "input_required", "statusMessage": message, "elicitation": { "requestId": request_id, "message": message, "requestedSchema": schema, }, } }, } # Push notification to Redis queue (works from any process) # Server's subscriber loop will forward to client from fastmcp.server.tasks.notifications import push_notification try: await push_notification(session_id, notification_dict, docket) except Exception as e: # Fail fast: if notification can't be queued, client won't know to respond # Return cancel immediately rather than waiting for 1-hour timeout logger.warning( "Failed to queue input_required notification for task %s, cancelling elicitation: %s", task_id, e, ) # Best-effort cleanup try: async with docket.redis() as redis: await redis.delete( docket.key(request_key), docket.key(status_key), ) except Exception: pass # Keys will expire via TTL return mcp.types.ElicitResult(action="cancel", content=None) # Wait for response using BLPOP (blocking pop) # This is much more efficient than polling - single Redis round-trip # that blocks until a response is pushed, vs 7,200 round-trips/hour with polling max_wait_seconds = ELICIT_TTL_SECONDS try: async with docket.redis() as redis: # BLPOP blocks until an item is pushed to the list or timeout # Returns tuple of (key, value) or None on timeout result = await cast( Any, redis.blpop( [docket.key(response_key)], timeout=max_wait_seconds, ), ) if result: # result is (key, value) tuple _key, response_data = result response = json.loads(response_data) # Clean up Redis keys await redis.delete( docket.key(request_key), docket.key(status_key), ) # Convert to ElicitResult return mcp.types.ElicitResult( action=response.get("action", "accept"), content=response.get("content"), ) except Exception as e: logger.warning( "BLPOP failed for task %s elicitation, falling back to cancel: %s", task_id, e, ) # Timeout or error - treat as cancellation # Best-effort cleanup - if Redis is unavailable, keys will expire via TTL try: async with docket.redis() as redis: await redis.delete( docket.key(request_key), docket.key(response_key), docket.key(status_key), ) except Exception as cleanup_error: logger.debug( "Failed to clean up elicitation keys for task %s (will expire via TTL): %s", task_id, cleanup_error, ) return mcp.types.ElicitResult(action="cancel", content=None) async def relay_elicitation( session: ServerSession, session_id: str, task_id: str, elicitation: dict[str, Any], fastmcp: FastMCP, ) -> None: """Relay elicitation from a background task worker to the client. Called by the notification subscriber when it detects an input_required notification with elicitation metadata. Sends a standard elicitation/create request to the client session, then uses handle_task_input() to push the response to Redis so the blocked worker can resume. Args: session: MCP ServerSession session_id: Session identifier task_id: Background task ID elicitation: Elicitation metadata (message, requestedSchema) fastmcp: FastMCP server instance """ try: result = await session.elicit( message=elicitation["message"], requestedSchema=elicitation["requestedSchema"], ) await handle_task_input( task_id=task_id, session_id=session_id, action=result.action, content=result.content, fastmcp=fastmcp, ) logger.debug( "Relayed elicitation response for task %s (action=%s)", task_id, result.action, ) except Exception as e: logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) # Push a cancel response so the worker's BLPOP doesn't block forever success = await handle_task_input( task_id=task_id, session_id=session_id, action="cancel", content=None, fastmcp=fastmcp, ) if not success: logger.warning( "Failed to push cancel response for task %s " "(worker may block until TTL)", task_id, ) async def handle_task_input( task_id: str, session_id: str, action: str, content: dict[str, Any] | None, fastmcp: FastMCP, ) -> bool: """Handle input sent to a background task via tasks/sendInput. This is called when a client sends input in response to an elicitation request from a background task. Args: task_id: The background task ID session_id: The MCP session ID action: The elicitation action ("accept", "decline", "cancel") content: The response content (for "accept" action) fastmcp: The FastMCP server instance Returns: True if the input was successfully stored, False otherwise """ docket = fastmcp._docket if docket is None: return False response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) response = { "action": action, "content": content, } async with docket.redis() as redis: # Check if there's a pending elicitation status = await redis.get(docket.key(status_key)) if status is None or status.decode("utf-8") != "waiting": return False # Push response to list - this wakes up the BLPOP in elicit_for_task # Using LPUSH instead of SET enables the efficient blocking wait pattern await redis.lpush( # type: ignore[invalid-await] # redis-py union type (sync/async) docket.key(response_key), json.dumps(response), ) # Set TTL on the response list (in case BLPOP doesn't consume it) await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS) # Update status to "responded" await redis.set( docket.key(status_key), "responded", ex=ELICIT_TTL_SECONDS, ) return True ================================================ FILE: src/fastmcp/server/tasks/handlers.py ================================================ """SEP-1686 task execution handlers. Handles queuing tool/prompt/resource executions to Docket as background tasks. """ from __future__ import annotations import uuid from contextlib import suppress from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, Literal import mcp.types from mcp.shared.exceptions import McpError from mcp.types import INTERNAL_ERROR, ErrorData from fastmcp.server.dependencies import _current_docket, get_access_token, get_context from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.keys import build_task_key from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool logger = get_logger(__name__) # Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60 async def submit_to_docket( task_type: Literal["tool", "resource", "template", "prompt"], key: str, component: Tool | Resource | ResourceTemplate | Prompt, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None, ) -> mcp.types.CreateTaskResult: """Submit any component to Docket for background execution (SEP-1686). Unified handler for all component types. Called by component's internal methods (_run, _read, _render) when task metadata is present and mode allows. Queues the component's method to Docket, stores raw return values, and converts to MCP types on retrieval. Args: task_type: Component type for task key construction key: The component key as seen by MCP layer (with namespace prefix) component: The component instance (Tool, Resource, ResourceTemplate, Prompt) arguments: Arguments/params (None for Resource which has no args) task_meta: Task execution metadata. If task_meta.ttl is provided, it overrides the server default (docket.execution_ttl). Returns: CreateTaskResult: Task stub with proper Task object """ # Generate server-side task ID per SEP-1686 final spec (line 375-377) # Server MUST generate task IDs, clients no longer provide them server_task_id = str(uuid.uuid4()) # Record creation timestamp per SEP-1686 final spec (line 430) created_at = datetime.now(timezone.utc) # Get session ID - use "internal" for programmatic calls without MCP session ctx = get_context() try: session_id = ctx.session_id except RuntimeError: session_id = "internal" docket = _current_docket.get() if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require a running FastMCP server context", ) ) # Build full task key with embedded metadata task_key = build_task_key(session_id, server_task_id, task_type, key) # Determine TTL: use task_meta.ttl if provided, else docket default if task_meta is not None and task_meta.ttl is not None: ttl_ms = task_meta.ttl else: ttl_ms = int(docket.execution_ttl.total_seconds() * 1000) ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS # Store task metadata in Redis for protocol handlers task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}") created_at_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:created_at" ) poll_interval_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:poll_interval" ) origin_request_id_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:origin_request_id" ) poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) origin_request_id = ( str(ctx.request_context.request_id) if ctx.request_context is not None else None ) # Snapshot the current access token (if any) for background task access (#3095) access_token = get_access_token() access_token_key = docket.key( f"fastmcp:task:{session_id}:{server_task_id}:access_token" ) async with docket.redis() as redis: await redis.set(task_meta_key, task_key, ex=ttl_seconds) await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) if origin_request_id is not None: await redis.set(origin_request_id_key, origin_request_id, ex=ttl_seconds) if access_token is not None: await redis.set( access_token_key, access_token.model_dump_json(), ex=ttl_seconds ) # Register session for Context access in background workers (SEP-1686) # This enables elicitation/sampling from background tasks via weakref # Skip for "internal" sessions (programmatic calls without MCP session) if session_id != "internal": from fastmcp.server.dependencies import register_task_session register_task_session(session_id, ctx.session) # Send an initial tasks/status notification before queueing. # This guarantees clients can observe task creation immediately. notification = mcp.types.TaskStatusNotification.model_validate( { "method": "notifications/tasks/status", "params": { "taskId": server_task_id, "status": "working", "statusMessage": "Task submitted", "createdAt": created_at, "lastUpdatedAt": created_at, "ttl": ttl_ms, "pollInterval": poll_interval_ms, }, "_meta": { "io.modelcontextprotocol/related-task": { "taskId": server_task_id, } }, } ) server_notification = mcp.types.ServerNotification(notification) with suppress(Exception): # Don't let notification failures break task creation await ctx.session.send_notification(server_notification) # Queue function to Docket by key (result storage via execution_ttl) # Use component.add_to_docket() which handles calling conventions # `fn_key` is the function lookup key (e.g., "child_multiply") # `task_key` is the task result key (e.g., "fastmcp:task:{session}:{task_id}:tool:child_multiply") # Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty) if task_type == "resource": await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] else: await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # Spawn subscription task to send status notifications (SEP-1686 optional feature) from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates # Start subscription in session's task group (persists for connection lifetime) if hasattr(ctx.session, "_subscription_task_group"): tg = ctx.session._subscription_task_group if tg: tg.start_soon( # type: ignore[union-attr] subscribe_to_task_updates, server_task_id, task_key, ctx.session, docket, poll_interval_ms, ) # Start notification subscriber for distributed elicitation (idempotent) # This enables ctx.elicit() to work when workers run in separate processes # Subscriber forwards notifications from Redis queue to client session from fastmcp.server.tasks.notifications import ( ensure_subscriber_running, stop_subscriber, ) try: await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp) # Register cleanup callback on session exit (once per session) # This ensures subscriber is stopped when the session disconnects if ( hasattr(ctx.session, "_exit_stack") and ctx.session._exit_stack is not None and not getattr(ctx.session, "_notification_cleanup_registered", False) ): async def _cleanup_subscriber() -> None: await stop_subscriber(session_id) ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] except Exception as e: # Non-fatal: elicitation will still work via polling fallback logger.debug("Failed to start notification subscriber: %s", e) # Return CreateTaskResult with proper Task object # Tasks MUST begin in "working" status per SEP-1686 final spec (line 381) return mcp.types.CreateTaskResult( task=mcp.types.Task( taskId=server_task_id, status="working", createdAt=created_at, lastUpdatedAt=created_at, ttl=ttl_ms, pollInterval=poll_interval_ms, ) ) ================================================ FILE: src/fastmcp/server/tasks/keys.py ================================================ """Task key management for SEP-1686 background tasks. Task keys encode security scoping and metadata in the Docket key format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` This format provides: - Session-based security scoping (prevents cross-session access) - Task type identification (tool/prompt/resource) - Component identification (name or URI for result conversion) """ from urllib.parse import quote, unquote def build_task_key( session_id: str, client_task_id: str, task_type: str, component_identifier: str, ) -> str: """Build Docket task key with embedded metadata. Format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.). Args: session_id: Session ID for security scoping client_task_id: Client-provided task ID task_type: Type of task ("tool", "prompt", "resource") component_identifier: Tool name, prompt name, or resource URI Returns: Encoded task key for Docket Examples: >>> build_task_key("session123", "task456", "tool", "my_tool") 'session123:task456:tool:my_tool' >>> build_task_key("session123", "task456", "resource", "file://data.txt") 'session123:task456:resource:file%3A%2F%2Fdata.txt' """ encoded_identifier = quote(component_identifier, safe="") return f"{session_id}:{client_task_id}:{task_type}:{encoded_identifier}" def parse_task_key(task_key: str) -> dict[str, str]: """Parse Docket task key to extract metadata. Args: task_key: Encoded task key from Docket Returns: Dict with keys: session_id, client_task_id, task_type, component_identifier Examples: >>> parse_task_key("session123:task456:tool:my_tool") `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` >>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt") `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'resource', 'component_identifier': 'file://data.txt'}` """ parts = task_key.split(":", 3) if len(parts) != 4: raise ValueError( f"Invalid task key format: {task_key}. " f"Expected: {{session_id}}:{{client_task_id}}:{{task_type}}:{{component_identifier}}" ) return { "session_id": parts[0], "client_task_id": parts[1], "task_type": parts[2], "component_identifier": unquote(parts[3]), } def get_client_task_id_from_key(task_key: str) -> str: """Extract just the client task ID from a task key. Args: task_key: Full encoded task key Returns: Client-provided task ID (second segment) Example: >>> get_client_task_id_from_key("session123:task456:tool:my_tool") 'task456' """ return task_key.split(":", 3)[1] ================================================ FILE: src/fastmcp/server/tasks/notifications.py ================================================ """Distributed notification queue for background task events (SEP-1686). Enables distributed Docket workers to send MCP notifications to clients without holding session references. Workers push to a Redis queue, the MCP server process subscribes and forwards to the client's session. Pattern: Fire-and-forward with retry - One queue per session_id - LPUSH/BRPOP for reliable ordered delivery - Retry up to 3 times on delivery failure, then discard - TTL-based expiration for stale messages Note: Docket's execution.subscribe() handles task state/progress events via Redis Pub/Sub. This module handles elicitation-specific notifications that require reliable delivery (input_required prompts, cancel signals). """ from __future__ import annotations import asyncio import json import logging import weakref from contextlib import suppress from datetime import datetime, timezone from typing import TYPE_CHECKING, Any, cast import mcp.types if TYPE_CHECKING: from docket import Docket from mcp.server.session import ServerSession from fastmcp.server.server import FastMCP logger = logging.getLogger(__name__) # Redis key patterns NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}" NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active" # Configuration NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window) MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval) async def push_notification( session_id: str, notification: dict[str, Any], docket: Docket, ) -> None: """Push notification to session's queue (called from Docket worker). Used for elicitation-specific notifications (input_required, cancel) that need reliable delivery across distributed processes. Args: session_id: Target session's identifier notification: MCP notification dict (method, params, _meta) docket: Docket instance for Redis access """ key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) message = json.dumps( { "notification": notification, "attempt": 0, "enqueued_at": datetime.now(timezone.utc).isoformat(), } ) async with docket.redis() as redis: await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async) await redis.expire(key, NOTIFICATION_TTL_SECONDS) async def notification_subscriber_loop( session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP, ) -> None: """Subscribe to notification queue and forward to session. Runs in the MCP server process. Bridges distributed workers to clients. This loop: 1. Maintains a heartbeat (active subscriber marker for debugging) 2. Blocks on BRPOP waiting for notifications 3. Forwards notifications to the client's session 4. Retries failed deliveries, then discards (no dead-letter queue) Args: session_id: Session identifier to subscribe to session: MCP ServerSession for sending notifications docket: Docket instance for Redis access fastmcp: FastMCP server instance (for elicitation relay) """ queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id)) logger.debug("Starting notification subscriber for session %s", session_id) while True: try: async with docket.redis() as redis: # Heartbeat: mark subscriber as active (for distributed debugging) await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2) # Blocking wait for notification (timeout refreshes heartbeat) # Using BRPOP (right pop) for FIFO order with LPUSH (left push) result = await cast( Any, redis.brpop([queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS) ) if not result: continue # Timeout - refresh heartbeat and retry _, message_bytes = result message = json.loads(message_bytes) notification_dict = message["notification"] attempt = message.get("attempt", 0) try: # Reconstruct and send MCP notification await _send_mcp_notification( session, notification_dict, session_id, docket, fastmcp ) logger.debug( "Delivered notification to session %s (attempt %d)", session_id, attempt + 1, ) except Exception as send_error: # Delivery failed - retry or discard if attempt < MAX_DELIVERY_ATTEMPTS - 1: # Re-queue with incremented attempt (back of queue) message["attempt"] = attempt + 1 message["last_error"] = str(send_error) await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await] logger.debug( "Requeued notification for session %s (attempt %d): %s", session_id, attempt + 2, send_error, ) else: # Discard after max attempts (session likely disconnected) logger.warning( "Discarding notification for session %s after %d attempts: %s", session_id, MAX_DELIVERY_ATTEMPTS, send_error, ) except asyncio.CancelledError: # Graceful shutdown - leave pending messages in queue for reconnect logger.debug("Notification subscriber cancelled for session %s", session_id) break except Exception as e: logger.debug( "Notification subscriber error for session %s: %s", session_id, e ) await asyncio.sleep(1) # Backoff on error async def _send_mcp_notification( session: ServerSession, notification_dict: dict[str, Any], session_id: str, docket: Docket, fastmcp: FastMCP, ) -> None: """Reconstruct MCP notification from dict and send to session. For input_required notifications with elicitation metadata, also sends a standard elicitation/create request to the client and relays the response back to the worker via Redis. Args: session: MCP ServerSession notification_dict: Notification as dict (method, params, _meta) session_id: Session identifier (for elicitation relay) docket: Docket instance (for notification delivery) fastmcp: FastMCP server instance (for elicitation relay) """ method = notification_dict.get("method", "notifications/tasks/status") if method != "notifications/tasks/status": raise ValueError(f"Unsupported notification method for subscriber: {method}") notification = mcp.types.TaskStatusNotification.model_validate( { "method": "notifications/tasks/status", "params": notification_dict.get("params", {}), "_meta": notification_dict.get("_meta"), } ) server_notification = mcp.types.ServerNotification(notification) await session.send_notification(server_notification) # If this is an input_required notification with elicitation metadata, # relay the elicitation to the client via standard elicitation/create params = notification_dict.get("params", {}) if params.get("status") == "input_required": meta = notification_dict.get("_meta", {}) related_task = meta.get("io.modelcontextprotocol/related-task", {}) elicitation = related_task.get("elicitation") if elicitation: task_id = params.get("taskId") if not task_id: logger.warning( "input_required notification missing taskId, skipping relay" ) return from fastmcp.server.tasks.elicitation import relay_elicitation task = asyncio.create_task( relay_elicitation(session, session_id, task_id, elicitation, fastmcp), name=f"elicitation-relay-{task_id[:8]}", ) _background_tasks.add(task) task.add_done_callback(_background_tasks.discard) # ============================================================================= # Subscriber Management # ============================================================================= # Strong references to fire-and-forget relay tasks (prevent GC mid-flight) _background_tasks: set[asyncio.Task[None]] = set() # Registry of active subscribers per session (prevents duplicates) # Uses weakref to session to detect disconnects _active_subscribers: dict[ str, tuple[asyncio.Task[None], weakref.ref[ServerSession]] ] = {} async def ensure_subscriber_running( session_id: str, session: ServerSession, docket: Docket, fastmcp: FastMCP, ) -> None: """Start notification subscriber if not already running (idempotent). Subscriber is created on first task submission and cleaned up on disconnect. Safe to call multiple times for the same session. Args: session_id: Session identifier session: MCP ServerSession docket: Docket instance fastmcp: FastMCP server instance (for elicitation relay) """ # Check if subscriber already running for this session if session_id in _active_subscribers: task, session_ref = _active_subscribers[session_id] # Check if task is still running AND session is still alive if not task.done() and session_ref() is not None: return # Already running # Task finished or session dead - clean up if not task.done(): task.cancel() with suppress(asyncio.CancelledError): await task del _active_subscribers[session_id] # Start new subscriber task task = asyncio.create_task( notification_subscriber_loop(session_id, session, docket, fastmcp), name=f"notification-subscriber-{session_id[:8]}", ) _active_subscribers[session_id] = (task, weakref.ref(session)) logger.debug("Started notification subscriber for session %s", session_id) async def stop_subscriber(session_id: str) -> None: """Stop notification subscriber for a session. Called when session disconnects. Pending messages remain in queue for delivery if client reconnects (with TTL expiration). Args: session_id: Session identifier """ if session_id not in _active_subscribers: return task, _ = _active_subscribers.pop(session_id) if not task.done(): task.cancel() with suppress(asyncio.CancelledError): await task logger.debug("Stopped notification subscriber for session %s", session_id) def get_subscriber_count() -> int: """Get number of active subscribers (for monitoring).""" return len(_active_subscribers) ================================================ FILE: src/fastmcp/server/tasks/requests.py ================================================ """SEP-1686 task request handlers. Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel. These handlers query and manage existing tasks (contrast with handlers.py which creates tasks). This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any, Literal import mcp.types from docket.execution import ExecutionState from mcp.shared.exceptions import McpError from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, CancelTaskResult, ErrorData, GetTaskResult, ListTasksResult, ) import fastmcp.server.context from fastmcp.exceptions import NotFoundError from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS from fastmcp.server.tasks.keys import parse_task_key from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.server.server import FastMCP # Map Docket execution states to MCP task status strings # Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = { ExecutionState.SCHEDULED: "working", # Initial state per spec ExecutionState.QUEUED: "working", # Initial state per spec ExecutionState.RUNNING: "working", ExecutionState.COMPLETED: "completed", ExecutionState.FAILED: "failed", ExecutionState.CANCELLED: "cancelled", } def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: """Parse a key suffix into (name_or_uri, version). Keys always contain @ as a version delimiter (sentinel pattern): - "add@1.0" → ("add", "1.0") # versioned - "add@" → ("add", None) # unversioned - "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI Uses rsplit to split on the LAST @ which is always the version delimiter. Falls back to treating the whole string as the name if @ is not present (for backwards compatibility with legacy task keys). """ if "@" not in key_suffix: # Legacy key without version sentinel - treat as unversioned return key_suffix, None name_or_uri, version = key_suffix.rsplit("@", 1) return name_or_uri, version if version else None async def _lookup_task_execution( docket: Any, session_id: str, client_task_id: str, ) -> tuple[Any, str | None, int]: """Look up task execution and metadata from Redis. Consolidates the common pattern of fetching task metadata from Redis, validating it exists, and retrieving the Docket execution. Args: docket: Docket instance session_id: Session ID client_task_id: Client-provided task ID Returns: Tuple of (execution, created_at, poll_interval_ms) Raises: McpError: If task not found or execution not found """ task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") created_at_key = docket.key( f"fastmcp:task:{session_id}:{client_task_id}:created_at" ) poll_interval_key = docket.key( f"fastmcp:task:{session_id}:{client_task_id}:poll_interval" ) # Fetch metadata (single round-trip with mget) async with docket.redis() as redis: task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget( task_meta_key, created_at_key, poll_interval_key ) # Decode and validate task_key task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None if not task_key: raise McpError( ErrorData(code=INVALID_PARAMS, message=f"Task {client_task_id} not found") ) # Get execution execution = await docket.get_execution(task_key) if not execution: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Task {client_task_id} execution not found", ) ) # Parse metadata with defaults created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None try: poll_interval_ms = ( int(poll_interval_bytes.decode("utf-8")) if poll_interval_bytes else DEFAULT_POLL_INTERVAL_MS ) except (ValueError, UnicodeDecodeError): poll_interval_ms = DEFAULT_POLL_INTERVAL_MS return execution, created_at, poll_interval_ms async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult: """Handle MCP 'tasks/get' request (SEP-1686). Args: server: FastMCP server instance params: Request params containing taskId Returns: GetTaskResult: Task status response with spec-compliant fields """ async with fastmcp.server.context.Context(fastmcp=server) as ctx: client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get session ID from Context session_id = ctx.session_id # Get Docket instance docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( docket, session_id, client_task_id ) # Sync state from Redis await execution.sync() # Map Docket state to MCP state state_map = DOCKET_TO_MCP_STATE mcp_state: Literal[ "working", "input_required", "completed", "failed", "cancelled" ] = state_map.get(execution.state, "failed") # type: ignore[assignment] # Build response (use default ttl since we don't track per-task values) # createdAt is REQUIRED per SEP-1686 final spec (line 430) # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get error_message = None status_message = None if execution.state == ExecutionState.FAILED: try: await execution.get_result(timeout=timedelta(seconds=0)) except Exception as error: error_message = str(error) status_message = f"Task failed: {error_message}" elif execution.progress and execution.progress.message: # Extract progress message from Docket if available (spec line 403) status_message = execution.progress.message # createdAt is required per spec, but can be None from Redis # Parse ISO string to datetime, or use current time as fallback if created_at: try: created_at_dt = datetime.fromisoformat( created_at.replace("Z", "+00:00") ) except (ValueError, AttributeError): created_at_dt = datetime.now(timezone.utc) else: created_at_dt = datetime.now(timezone.utc) return GetTaskResult( taskId=client_task_id, status=mcp_state, createdAt=created_at_dt, lastUpdatedAt=datetime.now(timezone.utc), ttl=DEFAULT_TTL_MS, pollInterval=poll_interval_ms, statusMessage=status_message, ) async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: """Handle MCP 'tasks/result' request (SEP-1686). Converts raw task return values to MCP types based on task type. Args: server: FastMCP server instance params: Request params containing taskId Returns: MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) """ async with fastmcp.server.context.Context(fastmcp=server) as ctx: client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get session ID from Context session_id = ctx.session_id # Get execution from Docket (use instance attribute for cross-task access) docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up full task key from Redis task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") async with docket.redis() as redis: task_key_bytes = await redis.get(task_meta_key) task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8") if task_key is None: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Invalid taskId: {client_task_id} not found", ) ) execution = await docket.get_execution(task_key) if execution is None: raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Invalid taskId: {client_task_id} not found", ) ) # Sync state from Redis await execution.sync() # Check if completed state_map = DOCKET_TO_MCP_STATE if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED): mcp_state = state_map.get(execution.state, "failed") raise McpError( ErrorData( code=INVALID_PARAMS, message=f"Task not completed yet (current state: {mcp_state})", ) ) # Get result from Docket try: raw_value = await execution.get_result(timeout=timedelta(seconds=0)) except Exception as error: # Task failed - return error result return mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=str(error))], isError=True, _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } }, ) # Parse task key to get component key key_parts = parse_task_key(task_key) component_key = key_parts["component_identifier"] # Look up component by its prefixed key (inlined from deleted get_component) component: Tool | Resource | ResourceTemplate | Prompt | None = None try: if component_key.startswith("tool:"): name, version_str = _parse_key_version(component_key[5:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_tool(name, version) elif component_key.startswith("resource:"): uri, version_str = _parse_key_version(component_key[9:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_resource(uri, version) elif component_key.startswith("template:"): uri, version_str = _parse_key_version(component_key[9:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_resource_template(uri, version) elif component_key.startswith("prompt:"): name, version_str = _parse_key_version(component_key[7:]) version = VersionSpec(eq=version_str) if version_str else None component = await server.get_prompt(name, version) except NotFoundError: component = None if component is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message=f"Component not found for task: {component_key}", ) ) # Build related-task metadata related_task_meta = { "io.modelcontextprotocol/related-task": { "taskId": client_task_id, } } # Convert based on component type. # Each branch merges related_task_meta with any existing _meta # (e.g. fastmcp.wrap_result) rather than overwriting it. if isinstance(component, Tool): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result() if isinstance(mcp_result, mcp.types.CallToolResult): merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] elif isinstance(mcp_result, tuple): content, structured_content = mcp_result mcp_result = mcp.types.CallToolResult( content=content, structuredContent=structured_content, _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) else: mcp_result = mcp.types.CallToolResult( content=mcp_result, _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) return mcp_result elif isinstance(component, Prompt): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_prompt_result() merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] return mcp_result elif isinstance(component, ResourceTemplate): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(component.uri_template) merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] return mcp_result elif isinstance(component, Resource): fastmcp_result = component.convert_result(raw_value) mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) merged = {**(mcp_result.meta or {}), **related_task_meta} mcp_result._meta = merged # type: ignore[attr-defined] return mcp_result else: raise McpError( ErrorData( code=INTERNAL_ERROR, message=f"Internal error: Unknown component type: {type(component).__name__}", ) ) async def tasks_list_handler( server: FastMCP, params: dict[str, Any] ) -> ListTasksResult: """Handle MCP 'tasks/list' request (SEP-1686). Note: With client-side tracking, this returns minimal info. Args: server: FastMCP server instance params: Request params (cursor, limit) Returns: ListTasksResult: Response with tasks list and pagination """ # Return empty list - client tracks tasks locally return ListTasksResult(tasks=[], nextCursor=None) async def tasks_cancel_handler( server: FastMCP, params: dict[str, Any] ) -> CancelTaskResult: """Handle MCP 'tasks/cancel' request (SEP-1686). Cancels a running task, transitioning it to cancelled state. Args: server: FastMCP server instance params: Request params containing taskId Returns: CancelTaskResult: Task status response showing cancelled state """ async with fastmcp.server.context.Context(fastmcp=server) as ctx: client_task_id = params.get("taskId") if not client_task_id: raise McpError( ErrorData( code=INVALID_PARAMS, message="Missing required parameter: taskId" ) ) # Get session ID from Context session_id = ctx.session_id # Get Docket instance docket = server._docket if docket is None: raise McpError( ErrorData( code=INTERNAL_ERROR, message="Background tasks require Docket", ) ) # Look up task execution and metadata execution, created_at, poll_interval_ms = await _lookup_task_execution( docket, session_id, client_task_id ) # Cancel via Docket (now sets CANCELLED state natively) # Note: We need to get task_key from execution.key for cancellation await docket.cancel(execution.key) # Return task status with cancelled state # createdAt is REQUIRED per SEP-1686 final spec (line 430) # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel return CancelTaskResult( taskId=client_task_id, status="cancelled", createdAt=datetime.fromisoformat(created_at) if created_at else datetime.now(timezone.utc), lastUpdatedAt=datetime.now(timezone.utc), ttl=DEFAULT_TTL_MS, pollInterval=poll_interval_ms, statusMessage="Task cancelled", ) ================================================ FILE: src/fastmcp/server/tasks/routing.py ================================================ """Task routing helper for MCP components. Provides unified task mode enforcement and docket routing logic. """ from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal import mcp.types from mcp.shared.exceptions import McpError from mcp.types import METHOD_NOT_FOUND, ErrorData from fastmcp.server.tasks.config import TaskMeta from fastmcp.server.tasks.handlers import submit_to_docket if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool TaskType = Literal["tool", "resource", "template", "prompt"] async def check_background_task( component: Tool | Resource | ResourceTemplate | Prompt, task_type: TaskType, arguments: dict[str, Any] | None = None, task_meta: TaskMeta | None = None, ) -> mcp.types.CreateTaskResult | None: """Check task mode and submit to background if requested. Args: component: The MCP component task_type: Type of task ("tool", "resource", "template", "prompt") arguments: Arguments for tool/prompt/template execution task_meta: Task execution metadata. If provided, execute as background task. Returns: CreateTaskResult if submitted to docket, None for sync execution Raises: McpError: If mode="required" but no task metadata, or mode="forbidden" but task metadata is present """ task_config = component.task_config # Infer label from component entity_label = f"{type(component).__name__} '{component.title or component.key}'" # Enforce mode="required" - must have task metadata if task_config.mode == "required" and not task_meta: raise McpError( ErrorData( code=METHOD_NOT_FOUND, message=f"{entity_label} requires task-augmented execution", ) ) # Enforce mode="forbidden" - cannot be called with task metadata if not task_config.supports_tasks() and task_meta: raise McpError( ErrorData( code=METHOD_NOT_FOUND, message=f"{entity_label} does not support task-augmented execution", ) ) # No task metadata - synchronous execution if not task_meta: return None # fn_key is expected to be set; fall back to component.key for direct calls fn_key = task_meta.fn_key or component.key return await submit_to_docket(task_type, fn_key, component, arguments, task_meta) ================================================ FILE: src/fastmcp/server/tasks/subscriptions.py ================================================ """Task subscription helpers for sending MCP notifications (SEP-1686). Subscribes to Docket execution state changes and sends notifications/tasks/status to clients when their tasks change state. This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. """ from __future__ import annotations from contextlib import suppress from datetime import datetime, timezone from typing import TYPE_CHECKING from docket.execution import ExecutionState from mcp.types import TaskStatusNotification, TaskStatusNotificationParams from fastmcp.server.tasks.config import DEFAULT_TTL_MS from fastmcp.server.tasks.keys import parse_task_key from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from mcp.server.session import ServerSession logger = get_logger(__name__) async def subscribe_to_task_updates( task_id: str, task_key: str, session: ServerSession, docket: Docket, poll_interval_ms: int = 5000, ) -> None: """Subscribe to Docket execution events and send MCP notifications. Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status when task state changes. This is an optional optimization that reduces client polling frequency. Args: task_id: Client-visible task ID (server-generated UUID) task_key: Internal Docket execution key (includes session, type, component) session: MCP ServerSession for sending notifications docket: Docket instance for subscribing to execution events poll_interval_ms: Poll interval in milliseconds to include in notifications """ try: execution = await docket.get_execution(task_key) if execution is None: logger.warning(f"No execution found for task {task_id}") return # Subscribe to state and progress events from Docket terminal_states = { ExecutionState.COMPLETED, ExecutionState.FAILED, ExecutionState.CANCELLED, } async for event in execution.subscribe(): if event["type"] == "state": state = ExecutionState(event["state"]) # Send notifications/tasks/status when state changes await _send_status_notification( session=session, task_id=task_id, task_key=task_key, docket=docket, state=state, poll_interval_ms=poll_interval_ms, ) # Stop subscribing once the task reaches a terminal state if state in terminal_states: break elif event["type"] == "progress": # Send notification when progress message changes await _send_progress_notification( session=session, task_id=task_id, task_key=task_key, docket=docket, execution=execution, poll_interval_ms=poll_interval_ms, ) except Exception as e: logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True) async def _send_status_notification( session: ServerSession, task_id: str, task_key: str, docket: Docket, state: ExecutionState, poll_interval_ms: int = 5000, ) -> None: """Send notifications/tasks/status to client. Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata (taskId is already in params). Args: session: MCP ServerSession task_id: Client-visible task ID task_key: Internal task key (for metadata lookup) docket: Docket instance state: Docket execution state (enum) poll_interval_ms: Poll interval in milliseconds """ # Map Docket state to MCP status state_map = DOCKET_TO_MCP_STATE mcp_status = state_map.get(state, "failed") # Extract session_id from task_key for Redis lookup key_parts = parse_task_key(task_key) session_id = key_parts["session_id"] created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") async with docket.redis() as redis: created_at_bytes = await redis.get(created_at_key) created_at = ( created_at_bytes.decode("utf-8") if created_at_bytes else datetime.now(timezone.utc).isoformat() ) # Build status message status_message = None if state == ExecutionState.COMPLETED: status_message = "Task completed successfully" elif state == ExecutionState.FAILED: status_message = "Task failed" elif state == ExecutionState.CANCELLED: status_message = "Task cancelled" params_dict = { "taskId": task_id, "status": mcp_status, "createdAt": created_at, "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), "ttl": DEFAULT_TTL_MS, "pollInterval": poll_interval_ms, } if status_message: params_dict["statusMessage"] = status_message # Create notification (no related-task metadata per spec line 454) notification = TaskStatusNotification( params=TaskStatusNotificationParams.model_validate(params_dict), ) # Send notification (don't let failures break the subscription) with suppress(Exception): await session.send_notification(notification) # type: ignore[arg-type] async def _send_progress_notification( session: ServerSession, task_id: str, task_key: str, docket: Docket, execution: Execution, poll_interval_ms: int = 5000, ) -> None: """Send notifications/tasks/status when progress updates. Args: session: MCP ServerSession task_id: Client-visible task ID task_key: Internal task key docket: Docket instance execution: Execution object with current progress poll_interval_ms: Poll interval in milliseconds """ # Sync execution to get latest progress await execution.sync() # Only send if there's a progress message if not execution.progress or not execution.progress.message: return # Map Docket state to MCP status state_map = DOCKET_TO_MCP_STATE mcp_status = state_map.get(execution.state, "failed") # Extract session_id from task_key for Redis lookup key_parts = parse_task_key(task_key) session_id = key_parts["session_id"] created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") async with docket.redis() as redis: created_at_bytes = await redis.get(created_at_key) created_at = ( created_at_bytes.decode("utf-8") if created_at_bytes else datetime.now(timezone.utc).isoformat() ) params_dict = { "taskId": task_id, "status": mcp_status, "createdAt": created_at, "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), "ttl": DEFAULT_TTL_MS, "pollInterval": poll_interval_ms, "statusMessage": execution.progress.message, } # Create and send notification notification = TaskStatusNotification( params=TaskStatusNotificationParams.model_validate(params_dict), ) with suppress(Exception): await session.send_notification(notification) # type: ignore[arg-type] ================================================ FILE: src/fastmcp/server/telemetry.py ================================================ """Server-side telemetry helpers.""" from collections.abc import Generator from contextlib import contextmanager from mcp.server.lowlevel.server import request_ctx from opentelemetry.context import Context from opentelemetry.trace import Span, SpanKind, Status, StatusCode from fastmcp.telemetry import extract_trace_context, get_tracer def get_auth_span_attributes() -> dict[str, str]: """Get auth attributes for the current request, if authenticated.""" from fastmcp.server.dependencies import get_access_token attrs: dict[str, str] = {} try: token = get_access_token() if token: if token.client_id: attrs["enduser.id"] = token.client_id if token.scopes: attrs["enduser.scope"] = " ".join(token.scopes) except RuntimeError: pass return attrs def get_session_span_attributes() -> dict[str, str]: """Get session attributes for the current request.""" from fastmcp.server.dependencies import get_context attrs: dict[str, str] = {} try: ctx = get_context() if ctx.request_context is not None and ctx.session_id is not None: attrs["mcp.session.id"] = ctx.session_id except RuntimeError: pass return attrs def _get_parent_trace_context() -> Context | None: """Get parent trace context from request meta for distributed tracing.""" try: req_ctx = request_ctx.get() if req_ctx and hasattr(req_ctx, "meta") and req_ctx.meta: return extract_trace_context(dict(req_ctx.meta)) except LookupError: pass return None @contextmanager def server_span( name: str, method: str, server_name: str, component_type: str, component_key: str, resource_uri: str | None = None, ) -> Generator[Span, None, None]: """Create a SERVER span with standard MCP attributes and auth context. Automatically records any exception on the span and sets error status. """ tracer = get_tracer() with tracer.start_as_current_span( name, context=_get_parent_trace_context(), kind=SpanKind.SERVER, ) as span: attrs: dict[str, str] = { # RPC semantic conventions "rpc.system": "mcp", "rpc.service": server_name, "rpc.method": method, # MCP semantic conventions "mcp.method.name": method, # FastMCP-specific attributes "fastmcp.server.name": server_name, "fastmcp.component.type": component_type, "fastmcp.component.key": component_key, **get_auth_span_attributes(), **get_session_span_attributes(), } if resource_uri is not None: attrs["mcp.resource.uri"] = resource_uri span.set_attributes(attrs) try: yield span except Exception as e: span.record_exception(e) span.set_status(Status(StatusCode.ERROR)) raise @contextmanager def delegate_span( name: str, provider_type: str, component_key: str, ) -> Generator[Span, None, None]: """Create an INTERNAL span for provider delegation. Used by FastMCPProvider when delegating to mounted servers. Automatically records any exception on the span and sets error status. """ tracer = get_tracer() with tracer.start_as_current_span(f"delegate {name}") as span: span.set_attributes( { "fastmcp.provider.type": provider_type, "fastmcp.component.key": component_key, } ) try: yield span except Exception as e: span.record_exception(e) span.set_status(Status(StatusCode.ERROR)) raise __all__ = [ "delegate_span", "get_auth_span_attributes", "get_session_span_attributes", "server_span", ] ================================================ FILE: src/fastmcp/server/transforms/__init__.py ================================================ """Transform system for component transformations. Transforms modify components (tools, resources, prompts). List operations use a pure function pattern where transforms receive sequences and return transformed sequences. Get operations use a middleware pattern with `call_next` to chain lookups. Unlike middleware (which operates on requests), transforms are observable by the system for task registration, tag filtering, and component introspection. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import Namespace server = FastMCP("Server") mount = server.mount(other_server) mount.add_transform(Namespace("api")) # Tools become api_toolname ``` """ from __future__ import annotations from collections.abc import Awaitable, Sequence from typing import TYPE_CHECKING, Protocol from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool # Get methods use Protocol to express keyword-only version parameter class GetToolNext(Protocol): """Protocol for get_tool call_next functions.""" def __call__( self, name: str, *, version: VersionSpec | None = None ) -> Awaitable[Tool | None]: ... class GetResourceNext(Protocol): """Protocol for get_resource call_next functions.""" def __call__( self, uri: str, *, version: VersionSpec | None = None ) -> Awaitable[Resource | None]: ... class GetResourceTemplateNext(Protocol): """Protocol for get_resource_template call_next functions.""" def __call__( self, uri: str, *, version: VersionSpec | None = None ) -> Awaitable[ResourceTemplate | None]: ... class GetPromptNext(Protocol): """Protocol for get_prompt call_next functions.""" def __call__( self, name: str, *, version: VersionSpec | None = None ) -> Awaitable[Prompt | None]: ... class Transform: """Base class for component transformations. List operations use a pure function pattern: transforms receive sequences and return transformed sequences. Get operations use a middleware pattern with `call_next` to chain lookups. Example: ```python class MyTransform(Transform): async def list_tools(self, tools): return [transform(t) for t in tools] # Transform sequence async def get_tool(self, name, call_next, *, version=None): original = self.reverse_name(name) # Map to original name tool = await call_next(original, version=version) # Get from downstream return transform(tool) if tool else None ``` """ def __repr__(self) -> str: return f"{self.__class__.__name__}()" # ------------------------------------------------------------------------- # Tools # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """List tools with transformation applied. Args: tools: Sequence of tools to transform. Returns: Transformed sequence of tools. """ return tools async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name. Args: name: The requested tool name (may be transformed). call_next: Callable to get tool from downstream. version: Optional version filter to apply. Returns: The tool if found, None otherwise. """ return await call_next(name, version=version) # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: """List resources with transformation applied. Args: resources: Sequence of resources to transform. Returns: Transformed sequence of resources. """ return resources async def get_resource( self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None, ) -> Resource | None: """Get a resource by URI. Args: uri: The requested resource URI (may be transformed). call_next: Callable to get resource from downstream. version: Optional version filter to apply. Returns: The resource if found, None otherwise. """ return await call_next(uri, version=version) # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: """List resource templates with transformation applied. Args: templates: Sequence of resource templates to transform. Returns: Transformed sequence of resource templates. """ return templates async def get_resource_template( self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None, ) -> ResourceTemplate | None: """Get a resource template by URI. Args: uri: The requested template URI (may be transformed). call_next: Callable to get template from downstream. version: Optional version filter to apply. Returns: The resource template if found, None otherwise. """ return await call_next(uri, version=version) # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: """List prompts with transformation applied. Args: prompts: Sequence of prompts to transform. Returns: Transformed sequence of prompts. """ return prompts async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None ) -> Prompt | None: """Get a prompt by name. Args: name: The requested prompt name (may be transformed). call_next: Callable to get prompt from downstream. version: Optional version filter to apply. Returns: The prompt if found, None otherwise. """ return await call_next(name, version=version) # Re-export built-in transforms (must be after Transform class to avoid circular imports) from fastmcp.server.transforms.visibility import Visibility, is_enabled # noqa: E402 from fastmcp.server.transforms.namespace import Namespace # noqa: E402 from fastmcp.server.transforms.prompts_as_tools import PromptsAsTools # noqa: E402 from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402 from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 __all__ = [ "Namespace", "PromptsAsTools", "ResourcesAsTools", "ToolTransform", "Transform", "VersionFilter", "VersionSpec", "Visibility", "is_enabled", ] ================================================ FILE: src/fastmcp/server/transforms/catalog.py ================================================ """Base class for transforms that need to read the real component catalog. Some transforms replace ``list_tools()`` output with synthetic components (e.g. a search interface) while still needing access to the *real* (auth-filtered) catalog at call time. ``CatalogTransform`` provides the bypass machinery so subclasses can call ``get_tool_catalog()`` without triggering their own replacement logic. Re-entrancy problem ------------------- When a synthetic tool handler calls ``get_tool_catalog()``, that calls ``ctx.fastmcp.list_tools()`` which re-enters the transform pipeline — including *this* transform's ``list_tools()``. If the subclass overrides ``list_tools()`` directly, the re-entrant call would hit the subclass's replacement logic again (returning synthetic tools instead of the real catalog). A ``super()`` call can't prevent this because Python can't short-circuit a method after ``super()`` returns. Solution: ``CatalogTransform`` owns ``list_tools()`` and uses a per-instance ``ContextVar`` to detect re-entrant calls. During bypass, it passes through to the base ``Transform.list_tools()`` (a no-op). Otherwise, it delegates to ``transform_tools()`` — the subclass hook where replacement logic lives. Same pattern for resources, prompts, and resource templates. This is *not* the same as the ``Provider._list_tools()`` convention (which produces raw components with no arguments). ``transform_tools()`` receives the current catalog and returns a transformed version. The distinct name avoids confusion between the two patterns. Usage:: class MyTransform(CatalogTransform): async def transform_tools(self, tools): return [self._make_search_tool()] def _make_search_tool(self): async def search(ctx: Context = None): real_tools = await self.get_tool_catalog(ctx) ... return Tool.from_function(fn=search, name="search") """ from __future__ import annotations import itertools from collections.abc import Sequence from contextvars import ContextVar from typing import TYPE_CHECKING from fastmcp.server.transforms import Transform from fastmcp.utilities.versions import dedupe_with_versions if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.context import Context from fastmcp.tools.base import Tool _instance_counter = itertools.count() class CatalogTransform(Transform): """Transform that needs access to the real component catalog. Subclasses override ``transform_tools()`` / ``transform_resources()`` / ``transform_prompts()`` / ``transform_resource_templates()`` instead of the ``list_*()`` methods. The base class owns ``list_*()`` and handles re-entrant bypass automatically — subclasses never see re-entrant calls from ``get_*_catalog()``. The ``get_*_catalog()`` methods fetch the real (auth-filtered) catalog by temporarily setting a bypass flag so that this transform's ``list_*()`` passes through without calling the subclass hook. """ def __init__(self) -> None: self._instance_id: int = next(_instance_counter) self._bypass: ContextVar[bool] = ContextVar( f"_catalog_bypass_{self._instance_id}", default=False ) # ------------------------------------------------------------------ # list_* (bypass-aware — subclasses override transform_* instead) # ------------------------------------------------------------------ async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: if self._bypass.get(): return await super().list_tools(tools) return await self.transform_tools(tools) async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: if self._bypass.get(): return await super().list_resources(resources) return await self.transform_resources(resources) async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: if self._bypass.get(): return await super().list_resource_templates(templates) return await self.transform_resource_templates(templates) async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: if self._bypass.get(): return await super().list_prompts(prompts) return await self.transform_prompts(prompts) # ------------------------------------------------------------------ # Subclass hooks (override these, not list_*) # ------------------------------------------------------------------ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Transform the tool catalog. Override this method to replace, filter, or augment the tool listing. The default implementation passes through unchanged. Do NOT override ``list_tools()`` directly — the base class uses it to handle re-entrant bypass when ``get_tool_catalog()`` reads the real catalog. """ return tools async def transform_resources( self, resources: Sequence[Resource] ) -> Sequence[Resource]: """Transform the resource catalog. Override this method to replace, filter, or augment the resource listing. The default implementation passes through unchanged. Do NOT override ``list_resources()`` directly — the base class uses it to handle re-entrant bypass when ``get_resource_catalog()`` reads the real catalog. """ return resources async def transform_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: """Transform the resource template catalog. Override this method to replace, filter, or augment the template listing. The default implementation passes through unchanged. Do NOT override ``list_resource_templates()`` directly — the base class uses it to handle re-entrant bypass when ``get_resource_template_catalog()`` reads the real catalog. """ return templates async def transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: """Transform the prompt catalog. Override this method to replace, filter, or augment the prompt listing. The default implementation passes through unchanged. Do NOT override ``list_prompts()`` directly — the base class uses it to handle re-entrant bypass when ``get_prompt_catalog()`` reads the real catalog. """ return prompts # ------------------------------------------------------------------ # Catalog accessors # ------------------------------------------------------------------ async def get_tool_catalog( self, ctx: Context, *, run_middleware: bool = True ) -> Sequence[Tool]: """Fetch the real tool catalog, bypassing this transform. The result is deduplicated by name so that only the highest version of each tool is returned — matching what protocol handlers expose on the wire. Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_tools middleware has not yet run. """ token = self._bypass.set(True) try: tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware) finally: self._bypass.reset(token) return dedupe_with_versions(tools, lambda t: t.name) async def get_resource_catalog( self, ctx: Context, *, run_middleware: bool = True ) -> Sequence[Resource]: """Fetch the real resource catalog, bypassing this transform. Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_resources middleware has not yet run. """ token = self._bypass.set(True) try: return await ctx.fastmcp.list_resources(run_middleware=run_middleware) finally: self._bypass.reset(token) async def get_prompt_catalog( self, ctx: Context, *, run_middleware: bool = True ) -> Sequence[Prompt]: """Fetch the real prompt catalog, bypassing this transform. Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_prompts middleware has not yet run. """ token = self._bypass.set(True) try: return await ctx.fastmcp.list_prompts(run_middleware=run_middleware) finally: self._bypass.reset(token) async def get_resource_template_catalog( self, ctx: Context, *, run_middleware: bool = True ) -> Sequence[ResourceTemplate]: """Fetch the real resource template catalog, bypassing this transform. Args: ctx: The current request context. run_middleware: Whether to run middleware on the inner call. Defaults to True because this is typically called from a tool handler where list_resource_templates middleware has not yet run. """ token = self._bypass.set(True) try: return await ctx.fastmcp.list_resource_templates( run_middleware=run_middleware ) finally: self._bypass.reset(token) ================================================ FILE: src/fastmcp/server/transforms/namespace.py ================================================ """Namespace transform for prefixing component names.""" from __future__ import annotations import re from collections.abc import Sequence from typing import TYPE_CHECKING from fastmcp.server.transforms import ( GetPromptNext, GetResourceNext, GetResourceTemplateNext, GetToolNext, Transform, ) from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool # Pattern for matching URIs: protocol://path _URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") class Namespace(Transform): """Prefixes component names with a namespace. - Tools: name → namespace_name - Prompts: name → namespace_name - Resources: protocol://path → protocol://namespace/path - Resource Templates: same as resources Example: ```python transform = Namespace("math") # Tool "add" becomes "math_add" # Resource "file://data.txt" becomes "file://math/data.txt" ``` """ def __init__(self, prefix: str) -> None: """Initialize Namespace transform. Args: prefix: The namespace prefix to apply. """ self._prefix = prefix self._name_prefix = f"{prefix}_" def __repr__(self) -> str: return f"Namespace({self._prefix!r})" # ------------------------------------------------------------------------- # Name transformation helpers # ------------------------------------------------------------------------- def _transform_name(self, name: str) -> str: """Apply namespace prefix to a name.""" return f"{self._name_prefix}{name}" def _reverse_name(self, name: str) -> str | None: """Remove namespace prefix from a name, or None if no match.""" if name.startswith(self._name_prefix): return name[len(self._name_prefix) :] return None # ------------------------------------------------------------------------- # URI transformation helpers # ------------------------------------------------------------------------- def _transform_uri(self, uri: str) -> str: """Apply namespace to a URI: protocol://path → protocol://namespace/path.""" match = _URI_PATTERN.match(uri) if match: protocol, path = match.groups() return f"{protocol}{self._prefix}/{path}" return uri def _reverse_uri(self, uri: str) -> str | None: """Remove namespace from a URI, or None if no match.""" match = _URI_PATTERN.match(uri) if match: protocol, path = match.groups() prefix = f"{self._prefix}/" if path.startswith(prefix): return f"{protocol}{path[len(prefix) :]}" return None return None # ------------------------------------------------------------------------- # Tools # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Prefix tool names with namespace.""" return [ t.model_copy(update={"name": self._transform_name(t.name)}) for t in tools ] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get tool by namespaced name.""" original = self._reverse_name(name) if original is None: return None tool = await call_next(original, version=version) if tool: return tool.model_copy(update={"name": name}) return None # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: """Add namespace path segment to resource URIs.""" return [ r.model_copy(update={"uri": self._transform_uri(str(r.uri))}) for r in resources ] async def get_resource( self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None, ) -> Resource | None: """Get resource by namespaced URI.""" original = self._reverse_uri(uri) if original is None: return None resource = await call_next(original, version=version) if resource: return resource.model_copy(update={"uri": uri}) return None # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: """Add namespace path segment to template URIs.""" return [ t.model_copy(update={"uri_template": self._transform_uri(t.uri_template)}) for t in templates ] async def get_resource_template( self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None, ) -> ResourceTemplate | None: """Get resource template by namespaced URI.""" original = self._reverse_uri(uri) if original is None: return None template = await call_next(original, version=version) if template: return template.model_copy( update={"uri_template": self._transform_uri(template.uri_template)} ) return None # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: """Prefix prompt names with namespace.""" return [ p.model_copy(update={"name": self._transform_name(p.name)}) for p in prompts ] async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None ) -> Prompt | None: """Get prompt by namespaced name.""" original = self._reverse_name(name) if original is None: return None prompt = await call_next(original, version=version) if prompt: return prompt.model_copy(update={"name": name}) return None ================================================ FILE: src/fastmcp/server/transforms/prompts_as_tools.py ================================================ """Transform that exposes prompts as tools. This transform generates tools for listing and getting prompts, enabling clients that only support tools to access prompt functionality. The generated tools route through `ctx.fastmcp` at runtime, so all server middleware (auth, visibility, rate limiting, etc.) applies to prompt operations exactly as it would for direct `prompts/get` calls. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import PromptsAsTools mcp = FastMCP("Server") mcp.add_transform(PromptsAsTools(mcp)) # Now has list_prompts and get_prompt tools ``` """ from __future__ import annotations import json from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any from mcp.types import TextContent from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.server.providers.base import Provider class PromptsAsTools(Transform): """Transform that adds tools for listing and getting prompts. Generates two tools: - `list_prompts`: Lists all prompts - `get_prompt`: Gets a specific prompt with optional arguments The generated tools route through the server at runtime, so auth, middleware, and visibility apply automatically. This transform should be applied to a FastMCP server instance, not a raw Provider, because the generated tools need the server's middleware chain for auth and visibility filtering. Example: ```python mcp = FastMCP("Server") mcp.add_transform(PromptsAsTools(mcp)) # Now has list_prompts and get_prompt tools ``` """ def __init__(self, provider: Provider) -> None: from fastmcp.server.server import FastMCP if not isinstance(provider, FastMCP): raise TypeError( "PromptsAsTools requires a FastMCP server instance, not a" f" {type(provider).__name__}. The generated tools route through" " the server's middleware chain at runtime for auth and" " visibility. Pass your FastMCP server: PromptsAsTools(mcp)" ) self._provider = provider def __repr__(self) -> str: return f"PromptsAsTools({self._provider!r})" async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Add prompt tools to the tool list.""" return [ *tools, self._make_list_prompts_tool(), self._make_get_prompt_tool(), ] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated prompt tools.""" if name == "list_prompts": return self._make_list_prompts_tool() if name == "get_prompt": return self._make_get_prompt_tool() return await call_next(name, version=version) def _make_list_prompts_tool(self) -> Tool: """Create the list_prompts tool.""" async def list_prompts() -> str: """List all available prompts. Returns JSON with prompt metadata including name, description, and optional arguments. """ ctx = get_context() prompts = await ctx.fastmcp.list_prompts() result: list[dict[str, Any]] = [] for p in prompts: result.append( { "name": p.name, "description": p.description, "arguments": [ { "name": arg.name, "description": arg.description, "required": arg.required, } for arg in (p.arguments or []) ], } ) return json.dumps(result, indent=2) return Tool.from_function(fn=list_prompts) def _make_get_prompt_tool(self) -> Tool: """Create the get_prompt tool.""" async def get_prompt( name: Annotated[str, "The name of the prompt to get"], arguments: Annotated[ dict[str, Any] | None, "Optional arguments for the prompt", ] = None, ) -> str: """Get a prompt by name with optional arguments. Returns the rendered prompt as JSON with a messages array. Arguments should be provided as a dict mapping argument names to values. """ ctx = get_context() result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) return _format_prompt_result(result) return Tool.from_function(fn=get_prompt) def _format_prompt_result(result: Any) -> str: """Format PromptResult for tool output. Returns JSON with the messages array. Preserves embedded resources as structured JSON objects. """ messages = [] for msg in result.messages: if isinstance(msg.content, TextContent): content = msg.content.text else: content = msg.content.model_dump(mode="json", exclude_none=True) messages.append( { "role": msg.role, "content": content, } ) return json.dumps({"messages": messages}, indent=2) ================================================ FILE: src/fastmcp/server/transforms/resources_as_tools.py ================================================ """Transform that exposes resources as tools. This transform generates tools for listing and reading resources, enabling clients that only support tools to access resource functionality. The generated tools route through `ctx.fastmcp` at runtime, so all server middleware (auth, visibility, rate limiting, etc.) applies to resource operations exactly as it would for direct `resources/read` calls. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms import ResourcesAsTools mcp = FastMCP("Server") mcp.add_transform(ResourcesAsTools(mcp)) # Now has list_resources and read_resource tools ``` """ from __future__ import annotations import base64 import json from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any from mcp.types import ToolAnnotations from fastmcp.server.dependencies import get_context from fastmcp.server.transforms import GetToolNext, Transform from fastmcp.tools.base import Tool from fastmcp.utilities.versions import VersionSpec _DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True) if TYPE_CHECKING: from fastmcp.server.providers.base import Provider class ResourcesAsTools(Transform): """Transform that adds tools for listing and reading resources. Generates two tools: - `list_resources`: Lists all resources and templates - `read_resource`: Reads a resource by URI The generated tools route through the server at runtime, so auth, middleware, and visibility apply automatically. This transform should be applied to a FastMCP server instance, not a raw Provider, because the generated tools need the server's middleware chain for auth and visibility filtering. Example: ```python mcp = FastMCP("Server") mcp.add_transform(ResourcesAsTools(mcp)) # Now has list_resources and read_resource tools ``` """ def __init__(self, provider: Provider) -> None: from fastmcp.server.server import FastMCP if not isinstance(provider, FastMCP): raise TypeError( "ResourcesAsTools requires a FastMCP server instance, not a" f" {type(provider).__name__}. The generated tools route through" " the server's middleware chain at runtime for auth and" " visibility. Pass your FastMCP server: ResourcesAsTools(mcp)" ) self._provider = provider def __repr__(self) -> str: return f"ResourcesAsTools({self._provider!r})" async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Add resource tools to the tool list.""" return [ *tools, self._make_list_resources_tool(), self._make_read_resource_tool(), ] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get a tool by name, including generated resource tools.""" if name == "list_resources": return self._make_list_resources_tool() if name == "read_resource": return self._make_read_resource_tool() return await call_next(name, version=version) def _make_list_resources_tool(self) -> Tool: """Create the list_resources tool.""" async def list_resources() -> str: """List all available resources and resource templates. Returns JSON with resource metadata. Static resources have a 'uri' field, while templates have a 'uri_template' field with placeholders like {name}. """ ctx = get_context() resources = await ctx.fastmcp.list_resources() templates = await ctx.fastmcp.list_resource_templates() result: list[dict[str, Any]] = [] for r in resources: result.append( { "uri": str(r.uri), "name": r.name, "description": r.description, "mime_type": r.mime_type, } ) for t in templates: result.append( { "uri_template": t.uri_template, "name": t.name, "description": t.description, } ) return json.dumps(result, indent=2) return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS) def _make_read_resource_tool(self) -> Tool: """Create the read_resource tool.""" async def read_resource( uri: Annotated[str, "The URI of the resource to read"], ) -> str: """Read a resource by its URI. For static resources, provide the exact URI. For templated resources, provide the URI with template parameters filled in. Returns the resource content as a string. Binary content is base64-encoded. """ ctx = get_context() result = await ctx.fastmcp.read_resource(uri) return _format_result(result) return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) def _format_result(result: Any) -> str: """Format ResourceResult for tool output. Single text content is returned as-is. Single binary content is base64-encoded. Multiple contents are JSON-encoded. """ if len(result.contents) == 1: content = result.contents[0].content if isinstance(content, bytes): return base64.b64encode(content).decode() return content return json.dumps( [ { "content": ( c.content if isinstance(c.content, str) else base64.b64encode(c.content).decode() ), "mime_type": c.mime_type, } for c in result.contents ] ) ================================================ FILE: src/fastmcp/server/transforms/search/__init__.py ================================================ """Search transforms for tool discovery. Search transforms collapse a large tool catalog into a search interface, letting LLMs discover tools on demand instead of seeing the full list. Example: ```python from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("Server") mcp.add_transform(RegexSearchTransform()) # list_tools now returns only search_tools + call_tool ``` """ from fastmcp.server.transforms.search.base import ( SearchResultSerializer, serialize_tools_for_output_json, serialize_tools_for_output_markdown, ) from fastmcp.server.transforms.search.bm25 import BM25SearchTransform from fastmcp.server.transforms.search.regex import RegexSearchTransform __all__ = [ "BM25SearchTransform", "RegexSearchTransform", "SearchResultSerializer", "serialize_tools_for_output_json", "serialize_tools_for_output_markdown", ] ================================================ FILE: src/fastmcp/server/transforms/search/base.py ================================================ """Base class for search transforms. Search transforms replace ``list_tools()`` output with a small set of synthetic tools — a search tool and a call-tool proxy — so LLMs can discover tools on demand instead of receiving the full catalog. All concrete search transforms (``RegexSearchTransform``, ``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and implement ``_make_search_tool()`` and ``_search()`` to provide their specific search strategy. Example:: from fastmcp import FastMCP from fastmcp.server.transforms.search import RegexSearchTransform mcp = FastMCP("Server") @mcp.tool def add(a: int, b: int) -> int: ... @mcp.tool def multiply(x: float, y: float) -> float: ... # Clients now see only ``search_tools`` and ``call_tool``. # The original tools are discoverable via search. mcp.add_transform(RegexSearchTransform()) """ from abc import abstractmethod from collections.abc import Awaitable, Callable, Sequence from typing import Annotated, Any from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform from fastmcp.tools.base import Tool, ToolResult from fastmcp.utilities.versions import VersionSpec def _extract_searchable_text(tool: Tool) -> str: """Combine tool name, description, and parameter info into searchable text.""" parts = [tool.name] if tool.description: parts.append(tool.description) schema = tool.parameters if schema: properties = schema.get("properties", {}) for param_name, param_info in properties.items(): parts.append(param_name) if isinstance(param_info, dict): desc = param_info.get("description", "") if desc: parts.append(desc) return " ".join(parts) def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]: """Serialize tools to the same dict format as ``list_tools`` output.""" return [ tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools ] SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]] async def _invoke_serializer( serializer: SearchResultSerializer, tools: Sequence[Tool] ) -> Any: """Call a serializer and await the result if it returns a coroutine.""" result = serializer(tools) if isinstance(result, Awaitable): return await result return result def _union_type(branches: list[Any]) -> str: branch_types = list(dict.fromkeys(_schema_type(b) for b in branches)) if "null" not in branch_types: return " | ".join(branch_types) if branch_types else "any" non_null = [b for b in branch_types if b != "null"] if not non_null: return "null" return f"{' | '.join(non_null)}?" def _schema_type(schema: Any) -> str: # Intentionally heuristic: the goal is a concise readable label, not a # complete type system. Malformed schemas (e.g. {"type": ""}) → "any". if not isinstance(schema, dict): return "any" t = schema.get("type") if isinstance(t, str) and t: if t == "array": return f"{_schema_type(schema.get('items'))}[]" if t == "null": return "null" return t if "$ref" in schema: return "object" if "anyOf" in schema: return _union_type(schema["anyOf"]) if "oneOf" in schema: return _union_type(schema["oneOf"]) if "allOf" in schema: # allOf = intersection / Pydantic composed model → always an object return "object" return "object" if "properties" in schema else "any" def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]: lines = [f"**{title}**"] if not isinstance(schema, dict): lines.append("- `value` (any)") return lines props = schema.get("properties") raw_required = schema.get("required") req = set(raw_required) if isinstance(raw_required, list) else set() if props is None: # Not a properties-based schema — treat as a single unnamed value. lines.append(f"- `value` ({_schema_type(schema)})") return lines if not props: # Object schema with no properties — zero-argument tool. lines.append("*(no parameters)*") return lines for name, field in props.items(): required = ", required" if name in req else "" lines.append(f"- `{name}` ({_schema_type(field)}{required})") return lines def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str: """Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON.""" if not tools: return "No tools matched the query." blocks: list[str] = [] for tool in tools: lines = [f"### {tool.name}"] if tool.description: lines.extend(["", tool.description.strip()]) lines.extend(["", *_schema_section(tool.parameters, "Parameters")]) if tool.output_schema is not None: lines.extend(["", *_schema_section(tool.output_schema, "Returns")]) blocks.append("\n".join(lines)) return "\n\n".join(blocks) class BaseSearchTransform(CatalogTransform): """Replace the tool listing with a search interface. When this transform is active, ``list_tools()`` returns only: * Any tools listed in ``always_visible`` (pinned). * A **search tool** that finds tools matching a query. * A **call_tool** proxy that executes tools discovered via search. Hidden tools remain callable — ``get_tool()`` delegates unknown names downstream, so direct calls and the call-tool proxy both work. Search results respect the full auth pipeline: middleware, visibility transforms, and component-level auth checks all apply. Args: max_results: Maximum number of tools returned per search. always_visible: Tool names that stay in the ``list_tools`` output alongside the synthetic search/call tools. search_tool_name: Name of the generated search tool. call_tool_name: Name of the generated call-tool proxy. """ def __init__( self, *, max_results: int = 5, always_visible: list[str] | None = None, search_tool_name: str = "search_tools", call_tool_name: str = "call_tool", search_result_serializer: SearchResultSerializer | None = None, ) -> None: super().__init__() self._max_results = max_results self._always_visible = set(always_visible or []) self._search_tool_name = search_tool_name self._call_tool_name = call_tool_name self._search_result_serializer: SearchResultSerializer = ( search_result_serializer or serialize_tools_for_output_json ) # ------------------------------------------------------------------ # Transform interface # ------------------------------------------------------------------ async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Replace the catalog with pinned + synthetic search/call tools.""" pinned = [t for t in tools if t.name in self._always_visible] return [*pinned, self._make_search_tool(), self._make_call_tool()] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Intercept synthetic tool names; delegate everything else.""" if name == self._search_tool_name: return self._make_search_tool() if name == self._call_tool_name: return self._make_call_tool() return await call_next(name, version=version) # ------------------------------------------------------------------ # Synthetic tools # ------------------------------------------------------------------ @abstractmethod def _make_search_tool(self) -> Tool: """Create the search tool. Subclasses define the parameter schema.""" ... def _make_call_tool(self) -> Tool: """Create the call_tool proxy that executes discovered tools.""" transform = self async def call_tool( name: Annotated[str, "The name of the tool to call"], arguments: Annotated[ dict[str, Any] | None, "Arguments to pass to the tool" ] = None, ctx: Context = None, # type: ignore[assignment] ) -> ToolResult: """Call a tool by name with the given arguments. Use this to execute tools discovered via search_tools. """ if name in {transform._call_tool_name, transform._search_tool_name}: raise ValueError( f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" ) return await ctx.fastmcp.call_tool(name, arguments) return Tool.from_function(fn=call_tool, name=self._call_tool_name) # ------------------------------------------------------------------ # Serialization # ------------------------------------------------------------------ async def _render_results(self, tools: Sequence[Tool]) -> Any: return await _invoke_serializer(self._search_result_serializer, tools) # ------------------------------------------------------------------ # Catalog access # ------------------------------------------------------------------ async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]: """Get the auth-filtered tool catalog, excluding pinned tools.""" tools = await self.get_tool_catalog(ctx) return [t for t in tools if t.name not in self._always_visible] # ------------------------------------------------------------------ # Abstract search # ------------------------------------------------------------------ @abstractmethod async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: """Search the given tools and return matches.""" ... ================================================ FILE: src/fastmcp/server/transforms/search/bm25.py ================================================ """BM25-based search transform.""" import hashlib import math import re from collections.abc import Sequence from typing import Annotated, Any from fastmcp.server.context import Context from fastmcp.server.transforms.search.base import ( BaseSearchTransform, SearchResultSerializer, _extract_searchable_text, ) from fastmcp.tools.base import Tool def _tokenize(text: str) -> list[str]: """Lowercase, split on non-alphanumeric, filter short tokens.""" return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1] class _BM25Index: """Self-contained BM25 Okapi index.""" def __init__(self, k1: float = 1.5, b: float = 0.75) -> None: self.k1 = k1 self.b = b self._doc_tokens: list[list[str]] = [] self._doc_lengths: list[int] = [] self._avg_dl: float = 0.0 self._df: dict[str, int] = {} self._tf: list[dict[str, int]] = [] self._n: int = 0 def build(self, documents: list[str]) -> None: self._doc_tokens = [_tokenize(doc) for doc in documents] self._doc_lengths = [len(tokens) for tokens in self._doc_tokens] self._n = len(documents) self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0 self._df = {} self._tf = [] for tokens in self._doc_tokens: tf: dict[str, int] = {} seen: set[str] = set() for token in tokens: tf[token] = tf.get(token, 0) + 1 if token not in seen: self._df[token] = self._df.get(token, 0) + 1 seen.add(token) self._tf.append(tf) def query(self, text: str, top_k: int) -> list[int]: """Return indices of top_k documents sorted by BM25 score.""" query_tokens = _tokenize(text) if not query_tokens or not self._n: return [] scores: list[float] = [0.0] * self._n for token in query_tokens: if token not in self._df: continue idf = math.log( (self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0 ) for i in range(self._n): tf = self._tf[i].get(token, 0) if tf == 0: continue dl = self._doc_lengths[i] numerator = tf * (self.k1 + 1) denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl) scores[i] += idf * numerator / denominator ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True) return [i for i in ranked[:top_k] if scores[i] > 0] def _catalog_hash(tools: Sequence[Tool]) -> str: """SHA256 hash of sorted tool searchable text for staleness detection.""" key = "|".join(sorted(_extract_searchable_text(t) for t in tools)) return hashlib.sha256(key.encode()).hexdigest() class BM25SearchTransform(BaseSearchTransform): """Search transform using BM25 Okapi relevance ranking. Maintains an in-memory index that is lazily rebuilt when the tool catalog changes (detected via a hash of tool names). """ def __init__( self, *, max_results: int = 5, always_visible: list[str] | None = None, search_tool_name: str = "search_tools", call_tool_name: str = "call_tool", search_result_serializer: SearchResultSerializer | None = None, ) -> None: super().__init__( max_results=max_results, always_visible=always_visible, search_tool_name=search_tool_name, call_tool_name=call_tool_name, search_result_serializer=search_result_serializer, ) self._index = _BM25Index() self._indexed_tools: Sequence[Tool] = () self._last_hash: str = "" def _make_search_tool(self) -> Tool: transform = self async def search_tools( query: Annotated[str, "Natural language query to search for tools"], ctx: Context = None, # type: ignore[assignment] ) -> str | list[dict[str, Any]]: """Search for tools using natural language. Returns matching tool definitions ranked by relevance, in the same format as list_tools. """ hidden = await transform._get_visible_tools(ctx) results = await transform._search(hidden, query) return await transform._render_results(results) return Tool.from_function(fn=search_tools, name=self._search_tool_name) async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: current_hash = _catalog_hash(tools) if current_hash != self._last_hash: documents = [_extract_searchable_text(t) for t in tools] new_index = _BM25Index(self._index.k1, self._index.b) new_index.build(documents) self._index, self._indexed_tools, self._last_hash = ( new_index, tools, current_hash, ) indices = self._index.query(query, self._max_results) return [self._indexed_tools[i] for i in indices] ================================================ FILE: src/fastmcp/server/transforms/search/regex.py ================================================ """Regex-based search transform.""" import re from collections.abc import Sequence from typing import Annotated, Any from fastmcp.server.context import Context from fastmcp.server.transforms.search.base import ( BaseSearchTransform, _extract_searchable_text, ) from fastmcp.tools.base import Tool class RegexSearchTransform(BaseSearchTransform): """Search transform using regex pattern matching. Tools are matched against their name, description, and parameter information using ``re.search`` with ``re.IGNORECASE``. """ def _make_search_tool(self) -> Tool: transform = self async def search_tools( pattern: Annotated[ str, "Regex pattern to match against tool names, descriptions, and parameters", ], ctx: Context = None, # type: ignore[assignment] ) -> str | list[dict[str, Any]]: """Search for tools matching a regex pattern. Returns matching tool definitions in the same format as list_tools. """ hidden = await transform._get_visible_tools(ctx) results = await transform._search(hidden, pattern) return await transform._render_results(results) return Tool.from_function(fn=search_tools, name=self._search_tool_name) async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: try: compiled = re.compile(query, re.IGNORECASE) except re.error: return [] matches: list[Tool] = [] for tool in tools: text = _extract_searchable_text(tool) if compiled.search(text): matches.append(tool) if len(matches) >= self._max_results: break return matches ================================================ FILE: src/fastmcp/server/transforms/tool_transform.py ================================================ """Transform for applying tool transformations.""" from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING from fastmcp.server.transforms import GetToolNext, Transform from fastmcp.tools.tool_transform import ToolTransformConfig from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.tools.base import Tool class ToolTransform(Transform): """Applies tool transformations to modify tool schemas. Wraps ToolTransformConfig to apply argument renames, schema changes, hidden arguments, and other transformations at the transform level. Example: ```python transform = ToolTransform({ "my_tool": ToolTransformConfig( name="renamed_tool", arguments={"old_arg": ArgTransformConfig(name="new_arg")} ) }) ``` """ def __init__(self, transforms: dict[str, ToolTransformConfig]) -> None: """Initialize ToolTransform. Args: transforms: Map of original tool name → transform config. """ self._transforms = transforms # Build reverse mapping: final_name → original_name self._name_reverse: dict[str, str] = {} for original_name, config in transforms.items(): final_name = config.name if config.name else original_name self._name_reverse[final_name] = original_name # Validate no duplicate target names seen_targets: dict[str, str] = {} for original_name, config in transforms.items(): target = config.name if config.name else original_name if target in seen_targets: raise ValueError( f"ToolTransform has duplicate target name {target!r}: " f"both {seen_targets[target]!r} and {original_name!r} map to it" ) seen_targets[target] = original_name def __repr__(self) -> str: names = list(self._transforms.keys()) if len(names) <= 3: return f"ToolTransform({names!r})" return f"ToolTransform({names[:3]!r}... +{len(names) - 3} more)" async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Apply transforms to matching tools.""" result: list[Tool] = [] for tool in tools: if tool.name in self._transforms: transformed = self._transforms[tool.name].apply(tool) result.append(transformed) else: result.append(tool) return result async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Get tool by transformed name.""" # Check if this name is a transformed name original_name = self._name_reverse.get(name, name) # Get the original tool tool = await call_next(original_name, version=version) if tool is None: return None # Apply transform if applicable if original_name in self._transforms: transformed = self._transforms[original_name].apply(tool) # Only return if requested name matches transformed name if transformed.name == name: return transformed return None # No transform, return as-is only if name matches return tool if tool.name == name else None ================================================ FILE: src/fastmcp/server/transforms/version_filter.py ================================================ """Version filter transform for filtering components by version range.""" from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING from fastmcp.server.transforms import ( GetPromptNext, GetResourceNext, GetResourceTemplateNext, GetToolNext, Transform, ) from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.tools.base import Tool class VersionFilter(Transform): """Filters components by version range. When applied to a provider or server, components within the version range are visible, and unversioned components are included by default. Within that filtered set, the highest version of each component is exposed to clients (standard deduplication behavior). Set ``include_unversioned=False`` to exclude unversioned components. Parameters mirror comparison operators for clarity: # Versions < 3.0 (v1 and v2) server.add_transform(VersionFilter(version_lt="3.0")) # Versions >= 2.0 and < 3.0 (only v2.x) server.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0")) Works with any version string - PEP 440 (1.0, 2.0) or dates (2025-01-01). Args: version_gte: Versions >= this value pass through. version_lt: Versions < this value pass through. include_unversioned: Whether unversioned components (``version=None``) should pass through the filter. Defaults to True. """ def __init__( self, *, version_gte: str | None = None, version_lt: str | None = None, include_unversioned: bool = True, ) -> None: if version_gte is None and version_lt is None: raise ValueError( "At least one of version_gte or version_lt must be specified" ) self.version_gte = version_gte self.version_lt = version_lt self.include_unversioned = include_unversioned self._spec = VersionSpec(gte=version_gte, lt=version_lt) def __repr__(self) -> str: parts = [] if self.version_gte: parts.append(f"version_gte={self.version_gte!r}") if self.version_lt: parts.append(f"version_lt={self.version_lt!r}") if not self.include_unversioned: parts.append("include_unversioned=False") return f"VersionFilter({', '.join(parts)})" # ------------------------------------------------------------------------- # Tools # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: return [ t for t in tools if self._spec.matches(t.version, match_none=self.include_unversioned) ] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: return await call_next(name, version=self._spec.intersect(version)) # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: return [ r for r in resources if self._spec.matches(r.version, match_none=self.include_unversioned) ] async def get_resource( self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None, ) -> Resource | None: return await call_next(uri, version=self._spec.intersect(version)) # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: return [ t for t in templates if self._spec.matches(t.version, match_none=self.include_unversioned) ] async def get_resource_template( self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None, ) -> ResourceTemplate | None: return await call_next(uri, version=self._spec.intersect(version)) # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: return [ p for p in prompts if self._spec.matches(p.version, match_none=self.include_unversioned) ] async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None ) -> Prompt | None: return await call_next(name, version=self._spec.intersect(version)) ================================================ FILE: src/fastmcp/server/transforms/visibility.py ================================================ """Visibility transform for marking component visibility state. Each Visibility instance marks components via internal metadata. Multiple visibility transforms can be stacked - later transforms override earlier ones. Final filtering happens at the Provider level. """ from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING, Any, Literal, TypeVar import mcp.types from fastmcp.resources.base import Resource from fastmcp.resources.template import ResourceTemplate from fastmcp.server.transforms import ( GetPromptNext, GetResourceNext, GetResourceTemplateNext, GetToolNext, Transform, ) from fastmcp.utilities.versions import VersionSpec if TYPE_CHECKING: from fastmcp.prompts.base import Prompt from fastmcp.server.context import Context from fastmcp.tools.base import Tool from fastmcp.utilities.components import FastMCPComponent T = TypeVar("T", bound="FastMCPComponent") # Visibility state stored at meta["fastmcp"]["_internal"]["visibility"] _FASTMCP_KEY = "fastmcp" _INTERNAL_KEY = "_internal" class Visibility(Transform): """Sets visibility state on matching components. Does NOT filter inline - just marks components with visibility state. Later transforms in the chain can override earlier marks. Final filtering happens at the Provider level after all transforms run. Example: ```python # Disable components tagged "internal" Visibility(False, tags={"internal"}) # Re-enable specific tool (override earlier disable) Visibility(True, names={"safe_tool"}) # Allowlist via composition: Visibility(False, match_all=True) # disable everything Visibility(True, tags={"public"}) # enable public ``` """ def __init__( self, enabled: bool, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Initialize a visibility marker. Args: enabled: If True, mark matching as enabled; if False, mark as disabled. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. Unversioned components (version=None) will NOT match a version spec. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ self._enabled = enabled self.names = names self.keys = keys self.version = version self.tags = tags # e.g., {"internal", "deprecated"} self.components = components # e.g., {"tool", "prompt"} self.match_all = match_all def __repr__(self) -> str: action = "enable" if self._enabled else "disable" if self.match_all: return f"Visibility({self._enabled}, match_all=True)" parts = [] if self.names: parts.append(f"names={set(self.names)}") if self.keys: parts.append(f"keys={set(self.keys)}") if self.version: parts.append(f"version={self.version!r}") if self.components: parts.append(f"components={set(self.components)}") if self.tags: parts.append(f"tags={set(self.tags)}") if parts: return f"Visibility({action}, {', '.join(parts)})" return f"Visibility({action})" def _matches(self, component: FastMCPComponent) -> bool: """Check if this transform applies to the component. All specified criteria must match (intersection semantics). An empty rule (no criteria) matches nothing. Use match_all=True to match everything. Args: component: Component to check. Returns: True if this transform should mark the component. """ # Match-all flag matches everything if self.match_all: return True # Empty criteria matches nothing (safe default) if ( self.names is None and self.keys is None and self.version is None and self.components is None and self.tags is None ): return False # Check component type if specified if self.components is not None: component_type = component.key.split(":")[ 0 ] # e.g., "tool" from "tool:foo@" if component_type not in self.components: return False # Check keys if specified (exact match only) if self.keys is not None: if component.key not in self.keys: return False # Check names if specified if self.names is not None: # For resources, also check URI; for templates, check uri_template matches_name = component.name in self.names matches_uri = False if isinstance(component, Resource): matches_uri = str(component.uri) in self.names elif isinstance(component, ResourceTemplate): matches_uri = component.uri_template in self.names if not (matches_name or matches_uri): return False # Check version if specified # Note: match_none=False means unversioned components don't match a version spec if self.version is not None and not self.version.matches( component.version, match_none=False ): return False # Check tags if specified (component must have at least one matching tag) return self.tags is None or bool(component.tags & self.tags) def _mark_component(self, component: T) -> T: """Set visibility state in component metadata if rule matches. Returns a copy of the component with updated metadata to avoid mutating shared objects cached in providers. """ if not self._matches(component): return component if component.meta is None: new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}} else: old_fastmcp = component.meta.get(_FASTMCP_KEY, {}) old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) new_internal = {**old_internal, "visibility": self._enabled} new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp} return component.model_copy(update={"meta": new_meta}) # ------------------------------------------------------------------------- # Transform methods (mark components, don't filter) # ------------------------------------------------------------------------- async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: """Mark tools by visibility state.""" return [self._mark_component(t) for t in tools] async def get_tool( self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None ) -> Tool | None: """Mark tool if found.""" tool = await call_next(name, version=version) if tool is None: return None return self._mark_component(tool) # ------------------------------------------------------------------------- # Resources # ------------------------------------------------------------------------- async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: """Mark resources by visibility state.""" return [self._mark_component(r) for r in resources] async def get_resource( self, uri: str, call_next: GetResourceNext, *, version: VersionSpec | None = None, ) -> Resource | None: """Mark resource if found.""" resource = await call_next(uri, version=version) if resource is None: return None return self._mark_component(resource) # ------------------------------------------------------------------------- # Resource Templates # ------------------------------------------------------------------------- async def list_resource_templates( self, templates: Sequence[ResourceTemplate] ) -> Sequence[ResourceTemplate]: """Mark resource templates by visibility state.""" return [self._mark_component(t) for t in templates] async def get_resource_template( self, uri: str, call_next: GetResourceTemplateNext, *, version: VersionSpec | None = None, ) -> ResourceTemplate | None: """Mark resource template if found.""" template = await call_next(uri, version=version) if template is None: return None return self._mark_component(template) # ------------------------------------------------------------------------- # Prompts # ------------------------------------------------------------------------- async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: """Mark prompts by visibility state.""" return [self._mark_component(p) for p in prompts] async def get_prompt( self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None ) -> Prompt | None: """Mark prompt if found.""" prompt = await call_next(name, version=version) if prompt is None: return None return self._mark_component(prompt) def is_enabled(component: FastMCPComponent) -> bool: """Check if component is enabled. Returns True if: - No visibility mark exists (default is enabled) - Visibility mark is True Returns False if visibility mark is False. Args: component: Component to check. Returns: True if component should be enabled/visible to clients. """ meta = component.meta or {} fastmcp = meta.get(_FASTMCP_KEY, {}) internal = fastmcp.get(_INTERNAL_KEY, {}) return internal.get("visibility", True) # Default True if not set # ------------------------------------------------------------------------- # Session visibility control # ------------------------------------------------------------------------- if TYPE_CHECKING: from fastmcp.server.context import Context async def get_visibility_rules(context: Context) -> list[dict[str, Any]]: """Load visibility rule dicts from session state.""" return await context.get_state("_visibility_rules") or [] async def save_visibility_rules( context: Context, rules: list[dict[str, Any]], *, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, ) -> None: """Save visibility rule dicts to session state and send notifications. Args: context: The context to save rules for. rules: The visibility rules to save. components: Optional hint about which component types are affected. If None, sends notifications for all types (safe default). If provided, only sends notifications for specified types. """ await context.set_state("_visibility_rules", rules) # Send notifications based on components hint # Note: MCP has no separate template notification - templates use ResourceListChangedNotification if components is None or "tool" in components: await context.send_notification(mcp.types.ToolListChangedNotification()) if components is None or "resource" in components or "template" in components: await context.send_notification(mcp.types.ResourceListChangedNotification()) if components is None or "prompt" in components: await context.send_notification(mcp.types.PromptListChangedNotification()) def create_visibility_transforms(rules: list[dict[str, Any]]) -> list[Visibility]: """Convert rule dicts to Visibility transforms.""" transforms = [] for params in rules: version = None if params.get("version"): version_dict = params["version"] version = VersionSpec( gte=version_dict.get("gte"), lt=version_dict.get("lt"), eq=version_dict.get("eq"), ) transforms.append( Visibility( params["enabled"], names=set(params["names"]) if params.get("names") else None, keys=set(params["keys"]) if params.get("keys") else None, version=version, tags=set(params["tags"]) if params.get("tags") else None, components=( set(params["components"]) if params.get("components") else None ), match_all=params.get("match_all", False), ) ) return transforms async def get_session_transforms(context: Context) -> list[Visibility]: """Get session-specific Visibility transforms from state store.""" try: # Will raise RuntimeError if no session available _ = context.session_id except RuntimeError: return [] rules = await get_visibility_rules(context) return create_visibility_transforms(rules) async def enable_components( context: Context, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Enable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ # Normalize empty sets to None (empty = match all) components = components if components else None # Load current rules rules = await get_visibility_rules(context) # Create new rule dict rule: dict[str, Any] = { "enabled": True, "names": list(names) if names else None, "keys": list(keys) if keys else None, "version": ( {"gte": version.gte, "lt": version.lt, "eq": version.eq} if version else None ), "tags": list(tags) if tags else None, "components": list(components) if components else None, "match_all": match_all, } # Add and save (notifications sent by save_visibility_rules) rules.append(rule) await save_visibility_rules(context, rules, components=components) async def disable_components( context: Context, *, names: set[str] | None = None, keys: set[str] | None = None, version: VersionSpec | None = None, tags: set[str] | None = None, components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, match_all: bool = False, ) -> None: """Disable components matching criteria for this session only. Session rules override global transforms. Rules accumulate - each call adds a new rule to the session. Later marks override earlier ones (Visibility transform semantics). Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. names: Component names or URIs to match. keys: Component keys to match (e.g., {"tool:my_tool@v1"}). version: Component version spec to match. tags: Tags to match (component must have at least one). components: Component types to match (e.g., {"tool", "prompt"}). match_all: If True, matches all components regardless of other criteria. """ # Normalize empty sets to None (empty = match all) components = components if components else None # Load current rules rules = await get_visibility_rules(context) # Create new rule dict rule: dict[str, Any] = { "enabled": False, "names": list(names) if names else None, "keys": list(keys) if keys else None, "version": ( {"gte": version.gte, "lt": version.lt, "eq": version.eq} if version else None ), "tags": list(tags) if tags else None, "components": list(components) if components else None, "match_all": match_all, } # Add and save (notifications sent by save_visibility_rules) rules.append(rule) await save_visibility_rules(context, rules, components=components) async def reset_visibility(context: Context) -> None: """Clear all session visibility rules. Use this to reset session visibility back to global defaults. Sends notifications to this session only: ToolListChangedNotification, ResourceListChangedNotification, and PromptListChangedNotification. Args: context: The context for this session. """ await save_visibility_rules(context, []) ComponentT = TypeVar("ComponentT", bound="FastMCPComponent") async def apply_session_transforms( components: Sequence[ComponentT], ) -> Sequence[ComponentT]: """Apply session-specific visibility transforms to components. This helper applies session-level enable/disable rules by marking components with their visibility state. Session transforms override global transforms due to mark-based semantics (later marks win). Args: components: The components to apply session transforms to. Returns: The components with session transforms applied. """ from fastmcp.server.context import _current_context current_ctx = _current_context.get() if current_ctx is None: return components session_transforms = await get_session_transforms(current_ctx) if not session_transforms: return components # Apply each transform's marking to each component result = list(components) for transform in session_transforms: result = [transform._mark_component(c) for c in result] return result ================================================ FILE: src/fastmcp/settings.py ================================================ from __future__ import annotations as _annotations import inspect import os from datetime import timedelta from pathlib import Path from typing import Annotated, Any, Literal from platformdirs import user_data_dir from pydantic import Field, field_validator from pydantic_settings import ( BaseSettings, SettingsConfigDict, ) from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] MCP_LOG_LEVEL = Literal[ "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" ] DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] TEN_MB_IN_BYTES = 1024 * 1024 * 10 class DocketSettings(BaseSettings): """Docket worker configuration.""" model_config = SettingsConfigDict( env_prefix="FASTMCP_DOCKET_", extra="ignore", ) name: Annotated[ str, Field( description=inspect.cleandoc( """ Name for the Docket queue. All servers/workers sharing the same name and backend URL will share a task queue. """ ), ), ] = "fastmcp" url: Annotated[ str, Field( description=inspect.cleandoc( """ URL for the Docket backend. Supports: - memory:// - In-memory backend (single process only) - redis://host:port/db - Redis/Valkey backend (distributed, multi-process) Example: redis://localhost:6379/0 Default is memory:// for single-process scenarios. Use Redis or Valkey when coordinating tasks across multiple processes (e.g., additional workers via the fastmcp tasks CLI). """ ), ), ] = "memory://" worker_name: Annotated[ str | None, Field( description=inspect.cleandoc( """ Name for the Docket worker. If None, Docket will auto-generate a unique worker name. """ ), ), ] = None concurrency: Annotated[ int, Field( description=inspect.cleandoc( """ Maximum number of tasks the worker can process concurrently. """ ), ), ] = 10 redelivery_timeout: Annotated[ timedelta, Field( description=inspect.cleandoc( """ Task redelivery timeout. If a worker doesn't complete a task within this time, the task will be redelivered to another worker. """ ), ), ] = timedelta(seconds=300) reconnection_delay: Annotated[ timedelta, Field( description=inspect.cleandoc( """ Delay between reconnection attempts when the worker loses connection to the Docket backend. """ ), ), ] = timedelta(seconds=5) minimum_check_interval: Annotated[ timedelta, Field( description=inspect.cleandoc( """ How frequently the worker polls for new tasks. Lower values reduce latency for task pickup at the cost of more CPU usage. The default of 50ms is a good balance; increase for high-volume production deployments where tasks are long-running. """ ), ), ] = timedelta(milliseconds=50) class Settings(BaseSettings): """FastMCP settings.""" model_config = SettingsConfigDict( env_prefix="FASTMCP_", env_file=ENV_FILE, extra="ignore", env_nested_delimiter="__", nested_model_default_partial_update=True, validate_assignment=True, ) def get_setting(self, attr: str) -> Any: """ Get a setting. If the setting contains one or more `__`, it will be treated as a nested setting. """ settings = self while "__" in attr: parent_attr, attr = attr.split("__", 1) if not hasattr(settings, parent_attr): raise AttributeError(f"Setting {parent_attr} does not exist.") settings = getattr(settings, parent_attr) return getattr(settings, attr) def set_setting(self, attr: str, value: Any) -> None: """ Set a setting. If the setting contains one or more `__`, it will be treated as a nested setting. """ settings = self while "__" in attr: parent_attr, attr = attr.split("__", 1) if not hasattr(settings, parent_attr): raise AttributeError(f"Setting {parent_attr} does not exist.") settings = getattr(settings, parent_attr) setattr(settings, attr, value) home: Path = Path(user_data_dir("fastmcp", appauthor=False)) test_mode: bool = False log_enabled: bool = True log_level: LOG_LEVEL = "INFO" @field_validator("log_level", mode="before") @classmethod def normalize_log_level(cls, v): if isinstance(v, str): return v.upper() return v docket: DocketSettings = DocketSettings() enable_rich_logging: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, will use rich formatting for log output. If False, will use standard Python logging without rich formatting. """ ) ), ] = True enable_rich_tracebacks: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, will use rich tracebacks for logging. """ ) ), ] = True deprecation_warnings: Annotated[ bool, Field( description=inspect.cleandoc( """ Whether to show deprecation warnings. You can completely reset Python's warning behavior by running `warnings.resetwarnings()`. Note this will NOT apply to deprecation warnings from the settings class itself. """, ) ), ] = True client_raise_first_exceptiongroup_error: Annotated[ bool, Field( description=inspect.cleandoc( """ Many MCP components operate in anyio taskgroups, and raise ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients will `raise` the first error in any ExceptionGroup instead of raising the ExceptionGroup as a whole. This is useful for debugging, but may mask other errors. """ ), ), ] = True client_init_timeout: Annotated[ float | None, Field( description="The timeout for the client's initialization handshake, in seconds. Set to None or 0 to disable.", ), ] = None client_disconnect_timeout: Annotated[ float, Field( description="Maximum time to wait for a clean disconnect before giving up, in seconds.", ), ] = 5 # Transport settings transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio" # HTTP settings host: str = "127.0.0.1" port: int = 8000 sse_path: str = "/sse" message_path: str = "/messages/" streamable_http_path: str = "/mcp" debug: bool = False # error handling mask_error_details: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, error details from user-supplied functions (tool, resource, prompt) will be masked before being sent to clients. Only error messages from explicitly raised ToolError, ResourceError, or PromptError will be included in responses. If False (default), all error details will be included in responses, but prefixed with appropriate context. """ ), ), ] = False client_log_level: Annotated[ MCP_LOG_LEVEL | None, Field( description=inspect.cleandoc( """ Default minimum log level for messages sent to MCP clients. When set, log messages below this level are suppressed. Individual clients can override this per-session using the MCP logging/setLevel request. """ ), ), ] = None strict_input_validation: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, tool inputs are strictly validated against the input JSON schema. For example, providing the string \"10\" to an integer field will raise an error. If False, compatible inputs will be coerced to match the schema, which can increase compatibility. For example, providing the string \"10\" to an integer field will be coerced to 10. Defaults to False. """ ), ), ] = False server_dependencies: list[str] = Field( default_factory=list, description="List of dependencies to install in the server environment", ) # StreamableHTTP settings json_response: bool = False stateless_http: bool = ( False # If True, uses true stateless mode (new transport per request) ) mounted_components_raise_on_load_error: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, errors encountered when loading mounted components (tools, resources, prompts) will be raised instead of logged as warnings. This is useful for debugging but will interrupt normal operation. """ ), ), ] = False show_server_banner: Annotated[ bool, Field( description=inspect.cleandoc( """ If True, the server banner will be displayed when running the server. This setting can be overridden by the --no-banner CLI flag or by passing show_banner=False to server.run(). Set to False via FASTMCP_SHOW_SERVER_BANNER=false to suppress the banner. """ ), ), ] = True check_for_updates: Annotated[ Literal["stable", "prerelease", "off"], Field( description=inspect.cleandoc( """ Controls update checking when displaying the CLI banner. - "stable": Check for stable releases only (default) - "prerelease": Also check for pre-release versions (alpha, beta, rc) - "off": Disable update checking entirely Set via FASTMCP_CHECK_FOR_UPDATES environment variable. """ ), ), ] = "stable" decorator_mode: Annotated[ Literal["function", "object"], Field( description=inspect.cleandoc( """ Controls what decorators (@tool, @resource, @prompt) return. - "function" (default): Decorators return the original function unchanged. The function remains callable and is registered with the server normally. - "object" (deprecated): Decorators return component objects (FunctionTool, FunctionResource, FunctionPrompt). This was the default behavior in v2 and will be removed in a future version. """ ), ), ] = "function" ================================================ FILE: src/fastmcp/telemetry.py ================================================ """OpenTelemetry instrumentation for FastMCP. This module provides native OpenTelemetry integration for FastMCP servers and clients. It uses only the opentelemetry-api package, so telemetry is a no-op unless the user installs an OpenTelemetry SDK and configures exporters. Example usage with SDK: ```python from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor # Configure the SDK (user responsibility) provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) trace.set_tracer_provider(provider) # Now FastMCP will emit traces from fastmcp import FastMCP mcp = FastMCP("my-server") ``` """ from typing import Any from opentelemetry import context as otel_context from opentelemetry import propagate, trace from opentelemetry.context import Context from opentelemetry.trace import Span, Status, StatusCode, Tracer from opentelemetry.trace import get_tracer as otel_get_tracer INSTRUMENTATION_NAME = "fastmcp" TRACE_PARENT_KEY = "traceparent" TRACE_STATE_KEY = "tracestate" def get_tracer(version: str | None = None) -> Tracer: """Get the FastMCP tracer for creating spans. Args: version: Optional version string for the instrumentation Returns: A tracer instance. Returns a no-op tracer if no SDK is configured. """ return otel_get_tracer(INSTRUMENTATION_NAME, version) def inject_trace_context( meta: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """Inject current trace context into a meta dict for MCP request propagation. Args: meta: Optional existing meta dict to merge with trace context Returns: A new dict containing the original meta (if any) plus trace context keys, or None if no trace context to inject and meta was None """ carrier: dict[str, str] = {} propagate.inject(carrier) trace_meta: dict[str, Any] = {} if "traceparent" in carrier: trace_meta[TRACE_PARENT_KEY] = carrier["traceparent"] if "tracestate" in carrier: trace_meta[TRACE_STATE_KEY] = carrier["tracestate"] if trace_meta: return {**(meta or {}), **trace_meta} return meta def record_span_error(span: Span, exception: BaseException) -> None: """Record an exception on a span and set error status.""" span.record_exception(exception) span.set_status(Status(StatusCode.ERROR)) def extract_trace_context(meta: dict[str, Any] | None) -> Context: """Extract trace context from an MCP request meta dict. If already in a valid trace (e.g., from HTTP propagation), the existing trace context is preserved and meta is not used. Args: meta: The meta dict from an MCP request (ctx.request_context.meta) Returns: An OpenTelemetry Context with the extracted trace context, or the current context if no trace context found or already in a trace """ # Don't override existing trace context (e.g., from HTTP propagation) current_span = trace.get_current_span() if current_span.get_span_context().is_valid: return otel_context.get_current() if not meta: return otel_context.get_current() carrier: dict[str, str] = {} if TRACE_PARENT_KEY in meta: carrier["traceparent"] = str(meta[TRACE_PARENT_KEY]) if TRACE_STATE_KEY in meta: carrier["tracestate"] = str(meta[TRACE_STATE_KEY]) if carrier: return propagate.extract(carrier) return otel_context.get_current() __all__ = [ "INSTRUMENTATION_NAME", "TRACE_PARENT_KEY", "TRACE_STATE_KEY", "extract_trace_context", "get_tracer", "inject_trace_context", "record_span_error", ] ================================================ FILE: src/fastmcp/tools/__init__.py ================================================ import sys from .function_tool import FunctionTool, tool from .base import Tool, ToolResult from .tool_transform import forward, forward_raw # Backward compat: tool.py was renamed to base.py to stop Pyright from resolving # `from fastmcp.tools import tool` as the submodule instead of the decorator function. # This shim keeps `from fastmcp.tools.tool import Tool` working at runtime. # Safe to remove once we're confident no external code imports from the old path. sys.modules[f"{__name__}.tool"] = sys.modules[f"{__name__}.base"] __all__ = [ "FunctionTool", "Tool", "ToolResult", "forward", "forward_raw", "tool", ] ================================================ FILE: src/fastmcp/tools/base.py ================================================ from __future__ import annotations import warnings from collections.abc import Callable from typing import ( TYPE_CHECKING, Annotated, Any, ClassVar, TypeAlias, overload, ) import mcp.types import pydantic_core from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.types import ( CallToolResult, ContentBlock, Icon, TextContent, ToolAnnotations, ToolExecution, ) from mcp.types import Tool as MCPTool from pydantic import BaseModel, Field, model_validator from pydantic.json_schema import SkipJsonSchema from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.tasks.config import TaskConfig, TaskMeta from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Audio, File, Image, NotSet, NotSetT, ) try: from prefab_ui.app import PrefabApp as _PrefabApp from prefab_ui.components.base import Component as _PrefabComponent _HAS_PREFAB = True except ImportError: _HAS_PREFAB = False if TYPE_CHECKING: from docket import Docket from docket.execution import Execution from fastmcp.tools.function_tool import FunctionTool from fastmcp.tools.tool_transform import ArgTransform, TransformedTool # Re-export from function_tool module logger = get_logger(__name__) ToolResultSerializerType: TypeAlias = Callable[[Any], str] def default_serializer(data: Any) -> str: return pydantic_core.to_json(data, fallback=str).decode() class ToolResult(BaseModel): content: list[ContentBlock] = Field( description="List of content blocks for the tool result" ) structured_content: dict[str, Any] | None = Field( default=None, description="Structured content matching the tool's output schema" ) meta: dict[str, Any] | None = Field( default=None, description="Runtime metadata about the tool execution" ) def __init__( self, content: list[ContentBlock] | Any | None = None, structured_content: dict[str, Any] | Any | None = None, meta: dict[str, Any] | None = None, ): if content is None and structured_content is None: raise ValueError("Either content or structured_content must be provided") elif content is None: content = structured_content converted_content: list[ContentBlock] = _convert_to_content(result=content) if structured_content is not None: # Convert Prefab types to their wire-format envelope before # generic serialization, so the renderer gets the right shape. if _HAS_PREFAB: if isinstance(structured_content, _PrefabApp): structured_content = _prefab_to_json(structured_content) elif isinstance(structured_content, _PrefabComponent): structured_content = _prefab_to_json( _PrefabApp(view=structured_content) ) try: structured_content = pydantic_core.to_jsonable_python( value=structured_content ) except pydantic_core.PydanticSerializationError as e: logger.error( f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}" ) raise if not isinstance(structured_content, dict): raise ValueError( "structured_content must be a dict or None. " f"Got {type(structured_content).__name__}: {structured_content!r}. " "Tools should wrap non-dict values based on their output_schema." ) super().__init__( content=converted_content, structured_content=structured_content, meta=meta ) def to_mcp_result( self, ) -> ( list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult ): if self.meta is not None: return CallToolResult( structuredContent=self.structured_content, content=self.content, _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field ) if self.structured_content is None: return self.content return self.content, self.structured_content class Tool(FastMCPComponent): """Internal tool registration info.""" KEY_PREFIX: ClassVar[str] = "tool" parameters: Annotated[ dict[str, Any], Field(description="JSON schema for tool parameters") ] output_schema: Annotated[ dict[str, Any] | None, Field(description="JSON schema for tool output") ] = None annotations: Annotated[ ToolAnnotations | None, Field(description="Additional annotations about the tool"), ] = None execution: Annotated[ ToolExecution | None, Field(description="Task execution configuration (SEP-1686)"), ] = None serializer: Annotated[ SkipJsonSchema[ToolResultSerializerType | None], Field( description="Deprecated. Return ToolResult from your tools for full control over serialization." ), ] = None auth: Annotated[ SkipJsonSchema[AuthCheck | list[AuthCheck] | None], Field(description="Authorization checks for this tool", exclude=True), ] = None timeout: Annotated[ float | None, Field( description="Execution timeout in seconds. If None, no timeout is applied." ), ] = None @model_validator(mode="after") def _validate_tool_name(self) -> Tool: """Validate tool name according to MCP specification (SEP-986).""" validate_and_warn_tool_name(self.name) return self def to_mcp_tool( self, **overrides: Any, ) -> MCPTool: """Convert the FastMCP tool to an MCP tool.""" title = None if self.title: title = self.title elif self.annotations and self.annotations.title: title = self.annotations.title return MCPTool( name=overrides.get("name", self.name), title=overrides.get("title", title), description=overrides.get("description", self.description), inputSchema=overrides.get("inputSchema", self.parameters), outputSchema=overrides.get("outputSchema", self.output_schema), icons=overrides.get("icons", self.icons), annotations=overrides.get("annotations", self.annotations), execution=overrides.get("execution", self.execution), _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field "_meta", self.get_meta() ), ) @classmethod def from_function( cls, fn: Callable[..., Any], *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, # Deprecated meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: """Create a Tool from a function.""" from fastmcp.tools.function_tool import FunctionTool return FunctionTool.from_function( fn=fn, name=name, version=version, title=title, description=description, icons=icons, tags=tags, annotations=annotations, exclude_args=exclude_args, output_schema=output_schema, serializer=serializer, meta=meta, task=task, timeout=timeout, auth=auth, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: """ Run the tool with arguments. This method is not implemented in the base Tool class and must be implemented by subclasses. `run()` can EITHER return a list of ContentBlocks, or a tuple of (list of ContentBlocks, dict of structured output). """ raise NotImplementedError("Subclasses must implement run()") def convert_result(self, raw_value: Any) -> ToolResult: """Convert a raw result to ToolResult. Handles ToolResult passthrough and converts raw values using the tool's attributes (serializer, output_schema) for proper conversion. """ if isinstance(raw_value, ToolResult): return raw_value if _HAS_PREFAB: if isinstance(raw_value, _PrefabApp): return _prefab_to_tool_result(raw_value) if isinstance(raw_value, _PrefabComponent): return _prefab_to_tool_result(_PrefabApp(view=raw_value)) content = _convert_to_content(raw_value, serializer=self.serializer) # Skip structured content for ContentBlock types only if no output_schema # (if output_schema exists, MCP SDK requires structured_content) if self.output_schema is None and ( isinstance(raw_value, ContentBlock | Audio | Image | File) or ( isinstance(raw_value, list | tuple) and any(isinstance(item, ContentBlock) for item in raw_value) ) ): return ToolResult(content=content) try: structured = pydantic_core.to_jsonable_python(raw_value) except pydantic_core.PydanticSerializationError: return ToolResult(content=content) if self.output_schema is None: # No schema - only use structured_content for dicts if isinstance(structured, dict): return ToolResult(content=content, structured_content=structured) return ToolResult(content=content) # Has output_schema - wrap if x-fastmcp-wrap-result is set wrap_result = self.output_schema.get("x-fastmcp-wrap-result") return ToolResult( content=content, structured_content={"result": structured} if wrap_result else structured, meta={"fastmcp": {"wrap_result": True}} if wrap_result else None, ) @overload async def _run( self, arguments: dict[str, Any], task_meta: None = None, ) -> ToolResult: ... @overload async def _run( self, arguments: dict[str, Any], task_meta: TaskMeta, ) -> mcp.types.CreateTaskResult: ... async def _run( self, arguments: dict[str, Any], task_meta: TaskMeta | None = None, ) -> ToolResult | mcp.types.CreateTaskResult: """Server entry point that handles task routing. This allows ANY Tool subclass to support background execution by setting task_config.mode to "supported" or "required". The server calls this method instead of run() directly. Args: arguments: Tool arguments task_meta: If provided, execute as background task and return CreateTaskResult. If None (default), execute synchronously and return ToolResult. Returns: ToolResult when task_meta is None. CreateTaskResult when task_meta is provided. Subclasses can override this to customize task routing behavior. For example, FastMCPProviderTool overrides to delegate to child middleware without submitting to Docket. """ from fastmcp.server.tasks.routing import check_background_task task_result = await check_background_task( component=self, task_type="tool", arguments=arguments, task_meta=task_meta, ) if task_result: return task_result return await self.run(arguments) def register_with_docket(self, docket: Docket) -> None: """Register this tool with docket for background execution.""" if not self.task_config.supports_tasks(): return docket.register(self.run, names=[self.key]) async def add_to_docket( # type: ignore[override] self, docket: Docket, arguments: dict[str, Any], *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this tool for background execution via docket. Args: docket: The Docket instance arguments: Tool arguments fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(arguments) @classmethod def from_tool( cls, tool: Tool | Callable[..., Any], *, name: str | None = None, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, # Deprecated meta: dict[str, Any] | NotSetT | None = NotSet, transform_args: dict[str, ArgTransform] | None = None, transform_fn: Callable[..., Any] | None = None, ) -> TransformedTool: from fastmcp.tools.tool_transform import TransformedTool tool = cls._ensure_tool(tool) return TransformedTool.from_tool( tool=tool, transform_fn=transform_fn, name=name, title=title, transform_args=transform_args, description=description, tags=tags, annotations=annotations, output_schema=output_schema, serializer=serializer, meta=meta, ) @classmethod def _ensure_tool(cls, tool: Tool | Callable[..., Any]) -> Tool: """Coerce a callable into a Tool, respecting @tool decorator metadata.""" if isinstance(tool, Tool): return tool from fastmcp.decorators import get_fastmcp_meta from fastmcp.tools.function_tool import FunctionTool, ToolMeta fmeta = get_fastmcp_meta(tool) if isinstance(fmeta, ToolMeta): return FunctionTool.from_function(tool, metadata=fmeta) return cls.from_function(tool) def get_span_attributes(self) -> dict[str, Any]: return super().get_span_attributes() | { "fastmcp.component.type": "tool", "fastmcp.provider.type": "LocalProvider", } def _serialize_with_fallback( result: Any, serializer: ToolResultSerializerType | None = None ) -> str: if serializer is not None: try: return serializer(result) except Exception as e: logger.warning( "Error serializing tool result: %s", e, exc_info=True, ) return default_serializer(result) def _convert_to_single_content_block( item: Any, serializer: ToolResultSerializerType | None = None, ) -> ContentBlock: if isinstance(item, ContentBlock): return item if isinstance(item, Image): return item.to_image_content() if isinstance(item, Audio): return item.to_audio_content() if isinstance(item, File): return item.to_resource_content() if isinstance(item, str): return TextContent(type="text", text=item) return TextContent(type="text", text=_serialize_with_fallback(item, serializer)) _PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]" def _get_tool_resolver() -> Callable[..., str] | None: """Get the FastMCPApp callable resolver, if available.""" try: from fastmcp.server.app import _resolve_tool_ref return _resolve_tool_ref except ImportError: return None def _prefab_to_json(app: Any) -> dict[str, Any]: """Call PrefabApp.to_json() with the FastMCPApp callable resolver.""" return app.to_json(tool_resolver=_get_tool_resolver()) def _prefab_to_tool_result(app: Any) -> ToolResult: """Convert a PrefabApp to a FastMCP ToolResult.""" return ToolResult( content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)], structured_content=_prefab_to_json(app), ) def _convert_to_content( result: Any, serializer: ToolResultSerializerType | None = None, ) -> list[ContentBlock]: """Convert a result to a sequence of content objects.""" if result is None: return [] if not isinstance(result, (list | tuple)): return [_convert_to_single_content_block(result, serializer)] # If all items are ContentBlocks, return them as is if all(isinstance(item, ContentBlock) for item in result): return result # If any item is a ContentBlock, convert non-ContentBlock items to TextContent # without aggregating them if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result): return [ _convert_to_single_content_block(item, serializer) if not isinstance(item, ContentBlock) else item for item in result ] # If none of the items are ContentBlocks, aggregate all items into a single TextContent return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))] __all__ = ["Tool", "ToolResult"] def __getattr__(name: str) -> Any: """Deprecated re-exports for backwards compatibility.""" deprecated_exports = { "FunctionTool": "FunctionTool", "ParsedFunction": "ParsedFunction", "tool": "tool", } if name in deprecated_exports: import fastmcp if fastmcp.settings.deprecation_warnings: warnings.warn( f"Importing {name} from fastmcp.tools.tool is deprecated. " f"Import from fastmcp.tools.function_tool instead.", DeprecationWarning, stacklevel=2, ) from fastmcp.tools import function_tool return getattr(function_tool, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ================================================ FILE: src/fastmcp/tools/function_parsing.py ================================================ """Function introspection and schema generation for FastMCP tools.""" from __future__ import annotations import functools import inspect import types from collections.abc import Callable from dataclasses import dataclass from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints import mcp.types from pydantic import PydanticSchemaGenerationError from typing_extensions import TypeVar as TypeVarExt from fastmcp.server.dependencies import ( transform_context_annotations, without_injected_parameters, ) from fastmcp.tools.base import ToolResult from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( Audio, File, Image, create_function_without_params, get_cached_typeadapter, is_class_member_of_type, replace_type, ) try: from prefab_ui.app import PrefabApp as _PrefabApp from prefab_ui.components.base import Component as _PrefabComponent _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent) except ImportError: _PREFAB_TYPES = () def _contains_prefab_type(tp: Any) -> bool: """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES): return True origin = get_origin(tp) if origin is Union or origin is types.UnionType or origin is Annotated: return any(_contains_prefab_type(a) for a in get_args(tp)) return False T = TypeVarExt("T", default=Any) logger = get_logger(__name__) @dataclass class _WrappedResult(Generic[T]): """Generic wrapper for non-object return types.""" result: T class _UnserializableType: pass def _is_object_schema( schema: dict[str, Any], *, _root_schema: dict[str, Any] | None = None, _seen_refs: set[str] | None = None, ) -> bool: """Check if a JSON schema represents an object type.""" root_schema = _root_schema or schema seen_refs = _seen_refs or set() # Direct object type if schema.get("type") == "object": return True # Schema with properties but no explicit type is treated as object if "properties" in schema: return True # Resolve local $ref definitions and recurse into the target schema. ref = schema.get("$ref") if not isinstance(ref, str) or not ref.startswith("#/"): return False if ref in seen_refs: return False # Walk the JSON Pointer path from the root schema, unescaping each # token per RFC 6901 (~1 → /, ~0 → ~). pointer = ref.removeprefix("#/") segments = pointer.split("/") target: Any = root_schema for segment in segments: unescaped = segment.replace("~1", "/").replace("~0", "~") if not isinstance(target, dict) or unescaped not in target: return False target = target[unescaped] target_schema = target if not isinstance(target_schema, dict): return False return _is_object_schema( target_schema, _root_schema=root_schema, _seen_refs=seen_refs | {ref}, ) @dataclass class ParsedFunction: fn: Callable[..., Any] name: str description: str | None input_schema: dict[str, Any] output_schema: dict[str, Any] | None return_type: Any = None @classmethod def from_function( cls, fn: Callable[..., Any], exclude_args: list[str] | None = None, validate: bool = True, wrap_non_object_output_schema: bool = True, ) -> ParsedFunction: if validate: sig = inspect.signature(fn) # Reject functions with *args or **kwargs for param in sig.parameters.values(): if param.kind == inspect.Parameter.VAR_POSITIONAL: raise ValueError("Functions with *args are not supported as tools") if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError( "Functions with **kwargs are not supported as tools" ) # Reject exclude_args that don't exist in the function or don't have a default value if exclude_args: for arg_name in exclude_args: if arg_name not in sig.parameters: raise ValueError( f"Parameter '{arg_name}' in exclude_args does not exist in function." ) param = sig.parameters[arg_name] if param.default == inspect.Parameter.empty: raise ValueError( f"Parameter '{arg_name}' in exclude_args must have a default value." ) # collect name and doc before we potentially modify the function fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__ fn_doc = inspect.getdoc(fn) # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): fn = fn.__call__ # if the fn is a staticmethod, we need to work with the underlying function if isinstance(fn, staticmethod): fn = fn.__func__ # Transform Context type annotations to Depends() for unified DI fn = transform_context_annotations(fn) # Handle injected parameters (Context, Docket dependencies) wrapper_fn = without_injected_parameters(fn) # Also handle exclude_args with non-serializable types (issue #2431) # This must happen before Pydantic tries to serialize the parameters if exclude_args: wrapper_fn = create_function_without_params(wrapper_fn, list(exclude_args)) input_type_adapter = get_cached_typeadapter(wrapper_fn) input_schema = input_type_adapter.json_schema() # Compress and handle exclude_args prune_params = list(exclude_args) if exclude_args else None input_schema = compress_schema( input_schema, prune_params=prune_params, prune_titles=True ) output_schema = None # Get the return annotation from the signature sig = inspect.signature(fn) output_type = sig.return_annotation # If the annotation is a string (from __future__ annotations), resolve it if isinstance(output_type, str): try: # Use get_type_hints to resolve the return type # include_extras=True preserves Annotated metadata type_hints = get_type_hints(fn, include_extras=True) output_type = type_hints.get("return", output_type) except Exception as e: # If resolution fails, keep the string annotation logger.debug("Failed to resolve type hint for return annotation: %s", e) # Save original for return_type before any schema-related replacement original_output_type = output_type if output_type not in (inspect._empty, None, Any, ...): # Prefab component subclasses (Column, Card, etc.) shouldn't # produce output schemas — replace_type only does exact matching, # so we handle subclass matching explicitly here. We also need # to handle composite types like ``Column | None`` and # ``Annotated[PrefabApp, ...]`` by recursing into their args. if _PREFAB_TYPES and _contains_prefab_type(output_type): output_type = _UnserializableType # ToolResult subclasses should suppress schema generation just # like ToolResult itself — replace_type only does exact matching. if is_class_member_of_type(output_type, ToolResult): output_type = _UnserializableType # there are a variety of types that we don't want to attempt to # serialize because they are either used by FastMCP internally, # or are MCP content types that explicitly don't form structured # content. By replacing them with an explicitly unserializable type, # we ensure that no output schema is automatically generated. clean_output_type = replace_type( output_type, dict.fromkeys( ( Image, Audio, File, ToolResult, mcp.types.TextContent, mcp.types.ImageContent, mcp.types.AudioContent, mcp.types.ResourceLink, mcp.types.EmbeddedResource, *_PREFAB_TYPES, ), _UnserializableType, ), ) try: type_adapter = get_cached_typeadapter(clean_output_type) base_schema = type_adapter.json_schema(mode="serialization") # Generate schema for wrapped type if it's non-object # because MCP requires that output schemas are objects # Check if schema is an object type, resolving $ref references # (self-referencing types use $ref at root level) if wrap_non_object_output_schema and not _is_object_schema(base_schema): # Use the wrapped result schema directly wrapped_type = _WrappedResult[clean_output_type] wrapped_adapter = get_cached_typeadapter(wrapped_type) output_schema = wrapped_adapter.json_schema(mode="serialization") output_schema["x-fastmcp-wrap-result"] = True else: output_schema = base_schema output_schema = compress_schema(output_schema, prune_titles=True) except PydanticSchemaGenerationError as e: if "_UnserializableType" not in str(e): logger.debug(f"Unable to generate schema for type {output_type!r}") return cls( fn=fn, name=fn_name, description=fn_doc, input_schema=input_schema, output_schema=output_schema or None, return_type=original_output_type, ) ================================================ FILE: src/fastmcp/tools/function_tool.py ================================================ """Standalone @tool decorator for FastMCP.""" from __future__ import annotations import inspect import warnings from collections.abc import Callable from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, Annotated, Any, Literal, Protocol, TypeVar, overload, runtime_checkable, ) import anyio import mcp.types from mcp.shared.exceptions import McpError from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution from pydantic import Field from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.decorators import resolve_task_config from fastmcp.server.auth.authorization import AuthCheck from fastmcp.server.dependencies import without_injected_parameters from fastmcp.server.tasks.config import TaskConfig from fastmcp.tools.base import ( Tool, ToolResult, ToolResultSerializerType, ) from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema from fastmcp.utilities.async_utils import ( call_sync_fn_in_threadpool, is_coroutine_function, ) from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( NotSet, NotSetT, get_cached_typeadapter, ) logger = get_logger(__name__) if TYPE_CHECKING: from docket import Docket from docket.execution import Execution F = TypeVar("F", bound=Callable[..., Any]) @runtime_checkable class DecoratedTool(Protocol): """Protocol for functions decorated with @tool.""" __fastmcp__: ToolMeta def __call__(self, *args: Any, **kwargs: Any) -> Any: ... @dataclass(frozen=True, kw_only=True) class ToolMeta: """Metadata attached to functions by the @tool decorator.""" type: Literal["tool"] = field(default="tool", init=False) name: str | None = None version: str | int | None = None title: str | None = None description: str | None = None icons: list[Icon] | None = None tags: set[str] | None = None output_schema: dict[str, Any] | NotSetT | None = NotSet annotations: ToolAnnotations | None = None meta: dict[str, Any] | None = None app: Any = None task: bool | TaskConfig | None = None exclude_args: list[str] | None = None serializer: Any | None = None timeout: float | None = None auth: AuthCheck | list[AuthCheck] | None = None enabled: bool = True class FunctionTool(Tool): fn: SkipJsonSchema[Callable[..., Any]] return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None def to_mcp_tool( self, **overrides: Any, ) -> mcp.types.Tool: """Convert the FastMCP tool to an MCP tool. Extends the base implementation to add task execution mode if enabled. """ # Get base MCP tool from parent mcp_tool = super().to_mcp_tool(**overrides) # Add task execution mode per SEP-1686 # Only set execution if not overridden and task execution is supported if self.task_config.supports_tasks() and "execution" not in overrides: mcp_tool.execution = ToolExecution(taskSupport=self.task_config.mode) return mcp_tool @classmethod def from_function( cls, fn: Callable[..., Any], *, metadata: ToolMeta | None = None, # Keep individual params for backwards compat name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, exclude_args: list[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: ToolResultSerializerType | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> FunctionTool: """Create a FunctionTool from a function. Args: fn: The function to wrap metadata: ToolMeta object with all configuration. If provided, individual parameters must not be passed. name, title, etc.: Individual parameters for backwards compatibility. Cannot be used together with metadata parameter. """ # Check mutual exclusion individual_params_provided = ( any( x is not None and x is not NotSet for x in [ name, version, title, description, icons, tags, annotations, meta, task, serializer, timeout, auth, ] ) or output_schema is not NotSet or exclude_args is not None ) if metadata is not None and individual_params_provided: raise TypeError( "Cannot pass both 'metadata' and individual parameters to from_function(). " "Use metadata alone or individual parameters alone." ) # Build metadata from kwargs if not provided if metadata is None: metadata = ToolMeta( name=name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, meta=meta, task=task, exclude_args=exclude_args, serializer=serializer, timeout=timeout, auth=auth, ) if metadata.serializer is not None and fastmcp.settings.deprecation_warnings: warnings.warn( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", DeprecationWarning, stacklevel=2, ) if metadata.exclude_args and fastmcp.settings.deprecation_warnings: warnings.warn( "The `exclude_args` parameter is deprecated as of FastMCP 2.14. " "Use dependency injection with `Depends()` instead for better lifecycle management. " "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.", DeprecationWarning, stacklevel=2, ) parsed_fn = ParsedFunction.from_function(fn, exclude_args=metadata.exclude_args) func_name = metadata.name or parsed_fn.name if func_name == "": raise ValueError("You must provide a name for lambda functions") # Normalize task to TaskConfig task_value = metadata.task if task_value is None: task_config = TaskConfig(mode="forbidden") elif isinstance(task_value, bool): task_config = TaskConfig.from_bool(task_value) else: task_config = task_value task_config.validate_function(fn, func_name) # Handle output_schema if isinstance(metadata.output_schema, NotSetT): final_output_schema = parsed_fn.output_schema else: final_output_schema = metadata.output_schema if final_output_schema is not None and isinstance(final_output_schema, dict): if not _is_object_schema(final_output_schema): raise ValueError( f"Output schemas must represent object types due to MCP spec limitations. " f"Received: {final_output_schema!r}" ) return cls( fn=parsed_fn.fn, return_type=parsed_fn.return_type, name=metadata.name or parsed_fn.name, version=str(metadata.version) if metadata.version is not None else None, title=metadata.title, description=metadata.description or parsed_fn.description, icons=metadata.icons, parameters=parsed_fn.input_schema, output_schema=final_output_schema, annotations=metadata.annotations, tags=metadata.tags or set(), serializer=metadata.serializer, meta=metadata.meta, task_config=task_config, timeout=metadata.timeout, auth=metadata.auth, ) async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with arguments.""" wrapper_fn = without_injected_parameters(self.fn) type_adapter = get_cached_typeadapter(wrapper_fn) # Apply timeout if configured if self.timeout is not None: try: with anyio.fail_after(self.timeout): # Thread pool execution for sync functions, direct await for async if is_coroutine_function(wrapper_fn): result = await type_adapter.validate_python(arguments) else: # Sync function: run in threadpool to avoid blocking result = await call_sync_fn_in_threadpool( type_adapter.validate_python, arguments ) # Handle sync wrappers that return awaitables if inspect.isawaitable(result): result = await result except TimeoutError: logger.warning( f"Tool '{self.name}' timed out after {self.timeout}s. " f"Consider using task=True for long-running operations. " f"See https://gofastmcp.com/servers/tasks" ) raise McpError( ErrorData( code=-32000, message=f"Tool '{self.name}' execution timed out after {self.timeout}s", ) ) from None else: # No timeout: use existing execution path if is_coroutine_function(wrapper_fn): result = await type_adapter.validate_python(arguments) else: result = await call_sync_fn_in_threadpool( type_adapter.validate_python, arguments ) if inspect.isawaitable(result): result = await result return self.convert_result(result) def register_with_docket(self, docket: Docket) -> None: """Register this tool with docket for background execution. FunctionTool registers the underlying function, which has the user's Depends parameters for docket to resolve. """ if not self.task_config.supports_tasks(): return docket.register(self.fn, names=[self.key]) async def add_to_docket( self, docket: Docket, arguments: dict[str, Any], *, fn_key: str | None = None, task_key: str | None = None, **kwargs: Any, ) -> Execution: """Schedule this tool for background execution via docket. FunctionTool splats the arguments dict since .fn expects **kwargs. Args: docket: The Docket instance arguments: Tool arguments fn_key: Function lookup key in Docket registry (defaults to self.key) task_key: Redis storage key for the result **kwargs: Additional kwargs passed to docket.add() """ lookup_key = fn_key or self.key if task_key: kwargs["key"] = task_key return await docket.add(lookup_key, **kwargs)(**arguments) @overload def tool(fn: F) -> F: ... @overload def tool( name_or_fn: str, *, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... @overload def tool( name_or_fn: None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Callable[[F], F]: ... def tool( name_or_fn: str | Callable[..., Any] | None = None, *, name: str | None = None, version: str | int | None = None, title: str | None = None, description: str | None = None, icons: list[Icon] | None = None, tags: set[str] | None = None, output_schema: dict[str, Any] | NotSetT | None = NotSet, annotations: ToolAnnotations | dict[str, Any] | None = None, meta: dict[str, Any] | None = None, task: bool | TaskConfig | None = None, exclude_args: list[str] | None = None, serializer: Any | None = None, timeout: float | None = None, auth: AuthCheck | list[AuthCheck] | None = None, ) -> Any: """Standalone decorator to mark a function as an MCP tool. Returns the original function with metadata attached. Register with a server using mcp.add_tool(). """ if isinstance(annotations, dict): annotations = ToolAnnotations(**annotations) if isinstance(name_or_fn, classmethod): raise TypeError( "To decorate a classmethod, use @classmethod above @tool. " "See https://gofastmcp.com/servers/tools#using-with-methods" ) def create_tool(fn: Callable[..., Any], tool_name: str | None) -> FunctionTool: # Create metadata first, then pass it tool_meta = ToolMeta( name=tool_name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, meta=meta, task=resolve_task_config(task), exclude_args=exclude_args, serializer=serializer, timeout=timeout, auth=auth, ) return FunctionTool.from_function(fn, metadata=tool_meta) def attach_metadata(fn: F, tool_name: str | None) -> F: metadata = ToolMeta( name=tool_name, version=version, title=title, description=description, icons=icons, tags=tags, output_schema=output_schema, annotations=annotations, meta=meta, task=task, exclude_args=exclude_args, serializer=serializer, timeout=timeout, auth=auth, ) target = fn.__func__ if hasattr(fn, "__func__") else fn target.__fastmcp__ = metadata return fn def decorator(fn: F, tool_name: str | None) -> F: if fastmcp.settings.decorator_mode == "object": warnings.warn( "decorator_mode='object' is deprecated and will be removed in a future version. " "Decorators now return the original function with metadata attached.", DeprecationWarning, stacklevel=4, ) return create_tool(fn, tool_name) # type: ignore[return-value] return attach_metadata(fn, tool_name) if inspect.isroutine(name_or_fn): return decorator(name_or_fn, name) elif isinstance(name_or_fn, str): if name is not None: raise TypeError("Cannot specify name both as first argument and keyword") tool_name = name_or_fn elif name_or_fn is None: tool_name = name else: raise TypeError(f"Invalid first argument: {type(name_or_fn)}") def wrapper(fn: F) -> F: return decorator(fn, tool_name) return wrapper ================================================ FILE: src/fastmcp/tools/tool_transform.py ================================================ from __future__ import annotations import inspect import warnings from collections.abc import Callable from contextvars import ContextVar from copy import deepcopy from dataclasses import dataclass from typing import Annotated, Any, Literal, cast import pydantic_core from mcp.types import ToolAnnotations from pydantic import ConfigDict from pydantic.fields import Field from pydantic.functional_validators import BeforeValidator from pydantic.json_schema import SkipJsonSchema import fastmcp from fastmcp.tools.base import Tool, ToolResult, _convert_to_content from fastmcp.tools.function_parsing import ParsedFunction from fastmcp.utilities.components import _convert_set_default_none from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import ( FastMCPBaseModel, NotSet, NotSetT, get_cached_typeadapter, issubclass_safe, ) logger = get_logger(__name__) # Context variable to store current transformed tool _current_tool: ContextVar[TransformedTool | None] = ContextVar( "_current_tool", default=None ) async def forward(**kwargs: Any) -> ToolResult: """Forward to parent tool with argument transformation applied. This function can only be called from within a transformed tool's custom function. It applies argument transformation (renaming, validation) before calling the parent tool. For example, if the parent tool has args `x` and `y`, but the transformed tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to `a` and `y` to `b`, then `forward(a=1, b=2)` will call the parent tool with `x=1` and `y=2`. Args: **kwargs: Arguments to forward to the parent tool (using transformed names). Returns: The ToolResult from the parent tool execution. Raises: RuntimeError: If called outside a transformed tool context. TypeError: If provided arguments don't match the transformed schema. """ tool = _current_tool.get() if tool is None: raise RuntimeError("forward() can only be called within a transformed tool") # Use the forwarding function that handles mapping return await tool.forwarding_fn(**kwargs) async def forward_raw(**kwargs: Any) -> ToolResult: """Forward directly to parent tool without transformation. This function bypasses all argument transformation and validation, calling the parent tool directly with the provided arguments. Use this when you need to call the parent with its original parameter names and structure. For example, if the parent tool has args `x` and `y`, then `forward_raw(x=1, y=2)` will call the parent tool with `x=1` and `y=2`. Args: **kwargs: Arguments to pass directly to the parent tool (using original names). Returns: The ToolResult from the parent tool execution. Raises: RuntimeError: If called outside a transformed tool context. """ tool = _current_tool.get() if tool is None: raise RuntimeError("forward_raw() can only be called within a transformed tool") return await tool.parent_tool.run(kwargs) @dataclass(kw_only=True) class ArgTransform: """Configuration for transforming a parent tool's argument. This class allows fine-grained control over how individual arguments are transformed when creating a new tool from an existing one. You can rename arguments, change their descriptions, add default values, or hide them from clients while passing constants. Attributes: name: New name for the argument. Use None to keep original name, or ... for no change. description: New description for the argument. Use None to remove description, or ... for no change. default: New default value for the argument. Use ... for no change. default_factory: Callable that returns a default value. Cannot be used with default. type: New type for the argument. Use ... for no change. hide: If True, hide this argument from clients but pass a constant value to parent. required: If True, make argument required (remove default). Use ... for no change. examples: Examples for the argument. Use ... for no change. Examples: Rename argument 'old_name' to 'new_name' ```python ArgTransform(name="new_name") ``` Change description only ```python ArgTransform(description="Updated description") ``` Add a default value (makes argument optional) ```python ArgTransform(default=42) ``` Add a default factory (makes argument optional) ```python ArgTransform(default_factory=lambda: time.time()) ``` Change the type ```python ArgTransform(type=str) ``` Hide the argument entirely from clients ```python ArgTransform(hide=True) ``` Hide argument but pass a constant value to parent ```python ArgTransform(hide=True, default="constant_value") ``` Hide argument but pass a factory-generated value to parent ```python ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex) ``` Make an optional parameter required (removes any default) ```python ArgTransform(required=True) ``` Combine multiple transformations ```python ArgTransform(name="new_name", description="New desc", default=None, type=int) ``` """ name: str | NotSetT = NotSet description: str | NotSetT = NotSet default: Any | NotSetT = NotSet default_factory: Callable[[], Any] | NotSetT = NotSet type: Any | NotSetT = NotSet hide: bool = False required: Literal[True] | NotSetT = NotSet examples: Any | NotSetT = NotSet def __post_init__(self): """Validate that only one of default or default_factory is provided.""" has_default = self.default is not NotSet has_factory = self.default_factory is not NotSet if has_default and has_factory: raise ValueError( "Cannot specify both 'default' and 'default_factory' in ArgTransform. " "Use either 'default' for a static value or 'default_factory' for a callable." ) if has_factory and not self.hide: raise ValueError( "default_factory can only be used with hide=True. " "Visible parameters must use static 'default' values since JSON schema " "cannot represent dynamic factories." ) if self.required is True and (has_default or has_factory): raise ValueError( "Cannot specify 'required=True' with 'default' or 'default_factory'. " "Required parameters cannot have defaults." ) if self.hide and self.required is True: raise ValueError( "Cannot specify both 'hide=True' and 'required=True'. " "Hidden parameters cannot be required since clients cannot provide them." ) if self.required is False: raise ValueError( "Cannot specify 'required=False'. Set a default value instead." ) class ArgTransformConfig(FastMCPBaseModel): """A model for requesting a single argument transform.""" name: str | None = Field(default=None, description="The new name for the argument.") description: str | None = Field( default=None, description="The new description for the argument." ) default: str | int | float | bool | None = Field( default=None, description="The new default value for the argument." ) hide: bool = Field( default=False, description="Whether to hide the argument from the tool." ) required: Literal[True] | None = Field( default=None, description="Whether the argument is required." ) examples: Any | None = Field(default=None, description="Examples of the argument.") def to_arg_transform(self) -> ArgTransform: """Convert the argument transform to a FastMCP argument transform.""" return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny] class TransformedTool(Tool): """A tool that is transformed from another tool. This class represents a tool that has been created by transforming another tool. It supports argument renaming, schema modification, custom function injection, structured output control, and provides context for the forward() and forward_raw() functions. The transformation can be purely schema-based (argument renaming, dropping, etc.) or can include a custom function that uses forward() to call the parent tool with transformed arguments. Output schemas and structured outputs are automatically inherited from the parent tool but can be overridden or disabled. Attributes: parent_tool: The original tool that this tool was transformed from. fn: The function to execute when this tool is called (either the forwarding function for pure transformations or a custom user function). forwarding_fn: Internal function that handles argument transformation and validation when forward() is called from custom functions. """ model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) parent_tool: SkipJsonSchema[Tool] fn: SkipJsonSchema[Callable[..., Any]] forwarding_fn: SkipJsonSchema[ Callable[..., Any] ] # Always present, handles arg transformation transform_args: dict[str, ArgTransform] async def run(self, arguments: dict[str, Any]) -> ToolResult: """Run the tool with context set for forward() functions. This method executes the tool's function while setting up the context that allows forward() and forward_raw() to work correctly within custom functions. Args: arguments: Dictionary of arguments to pass to the tool's function. Returns: ToolResult object containing content and optional structured output. """ # Fill in missing arguments with schema defaults to ensure # ArgTransform defaults take precedence over function defaults arguments = arguments.copy() properties = self.parameters.get("properties", {}) for param_name, param_schema in properties.items(): if param_name not in arguments and "default" in param_schema: # Check if this parameter has a default_factory from transform_args # We need to call the factory for each run, not use the cached schema value has_factory_default = False if self.transform_args: # Find the original parameter name that maps to this param_name for orig_name, transform in self.transform_args.items(): transform_name = ( transform.name if transform.name is not NotSet else orig_name ) if ( transform_name == param_name and transform.default_factory is not NotSet ): # Type check to ensure default_factory is callable if callable(transform.default_factory): arguments[param_name] = transform.default_factory() has_factory_default = True break if not has_factory_default: arguments[param_name] = param_schema["default"] token = _current_tool.set(self) try: result = await self.fn(**arguments) # If transform function returns ToolResult, respect our output_schema setting if isinstance(result, ToolResult): if self.output_schema is None: return result elif self.output_schema.get( "type" ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"): # Non-object explicit schemas disable structured content return ToolResult( content=result.content, structured_content=None, ) else: return result # Otherwise convert to content and create ToolResult with proper structured content unstructured_result = _convert_to_content( result, serializer=self.serializer ) structured_output = None # First handle structured content based on output schema, if any if self.output_schema is not None: if self.output_schema.get("x-fastmcp-wrap-result"): # Schema says wrap - always wrap in result key structured_output = {"result": result} else: structured_output = result # If no output schema, try to serialize the result. If it is a dict, use # it as structured content. If it is not a dict, ignore it. if structured_output is None: try: structured_output = pydantic_core.to_jsonable_python(result) if not isinstance(structured_output, dict): structured_output = None except Exception: pass return ToolResult( content=unstructured_result, structured_content=structured_output, ) finally: _current_tool.reset(token) @classmethod def from_tool( cls, tool: Tool | Callable[..., Any], name: str | None = None, version: str | NotSetT | None = NotSet, title: str | NotSetT | None = NotSet, description: str | NotSetT | None = NotSet, tags: set[str] | None = None, transform_fn: Callable[..., Any] | None = None, transform_args: dict[str, ArgTransform] | None = None, annotations: ToolAnnotations | NotSetT | None = NotSet, output_schema: dict[str, Any] | NotSetT | None = NotSet, serializer: Callable[[Any], str] | NotSetT | None = NotSet, # Deprecated meta: dict[str, Any] | NotSetT | None = NotSet, ) -> TransformedTool: """Create a transformed tool from a parent tool. Args: tool: The parent tool to transform. transform_fn: Optional custom function. Can use forward() and forward_raw() to call the parent tool. Functions with **kwargs receive transformed argument names. name: New name for the tool. Defaults to parent tool's name. version: New version for the tool. Defaults to parent tool's version. title: New title for the tool. Defaults to parent tool's title. transform_args: Optional transformations for parent tool arguments. Only specified arguments are transformed, others pass through unchanged: - Simple rename (str) - Complex transformation (rename/description/default/drop) (ArgTransform) - Drop the argument (None) description: New description. Defaults to parent's description. tags: New tags. Defaults to parent's tags. annotations: New annotations. Defaults to parent's annotations. output_schema: Control output schema for structured outputs: - None (default): Inherit from transform_fn if available, then parent tool - dict: Use custom output schema - False: Disable output schema and structured outputs serializer: Deprecated. Return ToolResult from your tools for full control over serialization. meta: Control meta information: - NotSet (default): Inherit from parent tool - dict: Use custom meta information - None: Remove meta information Returns: TransformedTool with the specified transformations. Examples: # Transform specific arguments only ```python Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged ``` # Custom function with partial transforms ```python async def custom(x: int, y: int) -> str: result = await forward(x=x, y=y) return f"Custom: {result}" Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"}) ``` # Using **kwargs (gets all args, transformed and untransformed) ```python async def flexible(**kwargs) -> str: result = await forward(**kwargs) return f"Got: {kwargs}" Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"}) ``` # Control structured outputs and schemas ```python # Custom output schema Tool.from_tool(parent, output_schema={ "type": "object", "properties": {"status": {"type": "string"}} }) # Disable structured outputs Tool.from_tool(parent, output_schema=None) # Return ToolResult for full control async def custom_output(**kwargs) -> ToolResult: result = await forward(**kwargs) return ToolResult( content=[TextContent(text="Summary")], structured_content={"processed": True} ) ``` """ tool = Tool._ensure_tool(tool) if ( serializer is not NotSet and serializer is not None and fastmcp.settings.deprecation_warnings ): warnings.warn( "The `serializer` parameter is deprecated. " "Return ToolResult from your tools for full control over serialization. " "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", DeprecationWarning, stacklevel=2, ) transform_args = transform_args or {} if transform_fn is not None: parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) else: parsed_fn = None # Validate transform_args parent_params = set(tool.parameters.get("properties", {}).keys()) unknown_args = set(transform_args.keys()) - parent_params if unknown_args: raise ValueError( f"Unknown arguments in transform_args: {', '.join(sorted(unknown_args))}. " f"Parent tool `{tool.name}` has: {', '.join(sorted(parent_params))}" ) # Always create the forwarding transform schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args) # Handle output schema if output_schema is NotSet: # Use smart fallback: try custom function, then parent if transform_fn is not None: # parsed fn is not none here final_output_schema = cast(ParsedFunction, parsed_fn).output_schema if final_output_schema is None: # Check if function returns ToolResult (or subclass) - if so, don't fall back to parent. # Use parsed_fn.return_type (resolved via get_type_hints) instead of # inspect.signature, which returns strings under `from __future__ import annotations`. return_type = cast(ParsedFunction, parsed_fn).return_type if issubclass_safe(return_type, ToolResult): final_output_schema = None else: final_output_schema = tool.output_schema else: final_output_schema = tool.output_schema else: final_output_schema = cast(dict | None, output_schema) if transform_fn is None: # User wants pure transformation - use forwarding_fn as the main function final_fn = forwarding_fn final_schema = schema else: # parsed fn is not none here parsed_fn = cast(ParsedFunction, parsed_fn) # User provided custom function - merge schemas final_fn = transform_fn has_kwargs = cls._function_has_kwargs(transform_fn) # Validate function parameters against transformed schema fn_params = set(parsed_fn.input_schema.get("properties", {}).keys()) transformed_params = set(schema.get("properties", {}).keys()) if not has_kwargs: # Without **kwargs, function must declare all transformed params # Check if function is missing any parameters required after transformation missing_params = transformed_params - fn_params if missing_params: raise ValueError( f"Function missing parameters required after transformation: " f"{', '.join(sorted(missing_params))}. " f"Function declares: {', '.join(sorted(fn_params))}" ) # ArgTransform takes precedence over function signature # Start with function schema as base, then override with transformed schema final_schema = cls._merge_schema_with_precedence( parsed_fn.input_schema, schema ) else: # With **kwargs, function can access all transformed params # ArgTransform takes precedence over function signature # No validation needed - kwargs makes everything accessible # Start with function schema as base, then override with transformed schema final_schema = cls._merge_schema_with_precedence( parsed_fn.input_schema, schema ) # Additional validation: check for naming conflicts after transformation if transform_args: new_names = [] for old_name in parent_params: transform = transform_args.get(old_name, ArgTransform()) if transform.hide: continue if transform.name is not NotSet: new_names.append(transform.name) else: new_names.append(old_name) # Check for duplicate names after transformation name_counts = {} for arg_name in new_names: name_counts[arg_name] = name_counts.get(arg_name, 0) + 1 duplicates = [ arg_name for arg_name, count in name_counts.items() if count > 1 ] if duplicates: raise ValueError( f"Multiple arguments would be mapped to the same names: " f"{', '.join(sorted(duplicates))}" ) final_name = name or tool.name final_version = version if not isinstance(version, NotSetT) else tool.version final_description = ( description if not isinstance(description, NotSetT) else tool.description ) final_title = title if not isinstance(title, NotSetT) else tool.title final_meta = meta if not isinstance(meta, NotSetT) else tool.meta final_annotations = ( annotations if not isinstance(annotations, NotSetT) else tool.annotations ) final_serializer = ( serializer if not isinstance(serializer, NotSetT) else tool.serializer ) transformed_tool = cls( fn=final_fn, forwarding_fn=forwarding_fn, parent_tool=tool, name=final_name, version=final_version, title=final_title, description=final_description, parameters=final_schema, output_schema=final_output_schema, tags=tags or tool.tags, annotations=final_annotations, serializer=final_serializer, meta=final_meta, transform_args=transform_args, auth=tool.auth, ) return transformed_tool @classmethod def _create_forwarding_transform( cls, parent_tool: Tool, transform_args: dict[str, ArgTransform] | None, ) -> tuple[dict[str, Any], Callable[..., Any]]: """Create schema and forwarding function that encapsulates all transformation logic. This method builds a new JSON schema for the transformed tool and creates a forwarding function that validates arguments against the new schema and maps them back to the parent tool's expected arguments. Args: parent_tool: The original tool to transform. transform_args: Dictionary defining how to transform each argument. Returns: A tuple containing: - The new JSON schema for the transformed tool as a dictionary - Async function that validates and forwards calls to the parent tool """ # Build transformed schema and mapping # Deep copy to prevent compress_schema from mutating parent tool's $defs parent_defs = deepcopy(parent_tool.parameters.get("$defs", {})) parent_props = parent_tool.parameters.get("properties", {}).copy() parent_required = set(parent_tool.parameters.get("required", [])) new_props = {} new_required = set() new_to_old = {} hidden_defaults = {} # Track hidden parameters with constant values for old_name, old_schema in parent_props.items(): # Check if parameter is in transform_args if transform_args and old_name in transform_args: transform = transform_args[old_name] else: # Default behavior - pass through (no transformation) transform = ArgTransform() # Default ArgTransform with no changes # Handle hidden parameters with defaults if transform.hide: # Validate that hidden parameters without user defaults have parent defaults has_user_default = ( transform.default is not NotSet or transform.default_factory is not NotSet ) if not has_user_default and old_name in parent_required: raise ValueError( f"Hidden parameter '{old_name}' has no default value in parent tool " f"and no default or default_factory provided in ArgTransform. Either provide a default " f"or default_factory in ArgTransform or don't hide required parameters." ) if has_user_default: # Store info for later factory calling or direct value hidden_defaults[old_name] = transform # Skip adding to schema (not exposed to clients) continue transform_result = cls._apply_single_transform( old_name, old_schema, transform, old_name in parent_required, ) if transform_result: new_name, new_schema, is_required = transform_result new_props[new_name] = new_schema new_to_old[new_name] = old_name if is_required: new_required.add(new_name) schema = { "type": "object", "properties": new_props, "required": list(new_required), "additionalProperties": False, } if parent_defs: schema["$defs"] = parent_defs schema = compress_schema(schema) # Create forwarding function that closes over everything it needs async def _forward(**kwargs: Any): # Validate arguments valid_args = set(new_props.keys()) provided_args = set(kwargs.keys()) unknown_args = provided_args - valid_args if unknown_args: raise TypeError( f"Got unexpected keyword argument(s): {', '.join(sorted(unknown_args))}" ) # Check required arguments missing_args = new_required - provided_args if missing_args: raise TypeError( f"Missing required argument(s): {', '.join(sorted(missing_args))}" ) # Map arguments to parent names parent_args = {} for new_name, value in kwargs.items(): old_name = new_to_old.get(new_name, new_name) parent_args[old_name] = value # Add hidden defaults (constant values for hidden parameters) for old_name, transform in hidden_defaults.items(): if transform.default is not NotSet: parent_args[old_name] = transform.default elif transform.default_factory is not NotSet: # Type check to ensure default_factory is callable if callable(transform.default_factory): parent_args[old_name] = transform.default_factory() return await parent_tool.run(parent_args) return schema, _forward @staticmethod def _apply_single_transform( old_name: str, old_schema: dict[str, Any], transform: ArgTransform, is_required: bool, ) -> tuple[str, dict[str, Any], bool] | None: """Apply transformation to a single parameter. This method handles the transformation of a single argument according to the specified transformation rules. Args: old_name: Original name of the parameter. old_schema: Original JSON schema for the parameter. transform: ArgTransform object specifying how to transform the parameter. is_required: Whether the original parameter was required. Returns: Tuple of (new_name, new_schema, new_is_required) if parameter should be kept, None if parameter should be dropped. """ if transform.hide: return None # Handle name transformation - ensure we always have a string if transform.name is not NotSet: new_name = transform.name if transform.name is not None else old_name else: new_name = old_name # Ensure new_name is always a string if not isinstance(new_name, str): new_name = old_name new_schema = old_schema.copy() # Handle description transformation if transform.description is not NotSet: if transform.description is None: new_schema.pop("description", None) # Remove description else: new_schema["description"] = transform.description # Handle required transformation first if transform.required is not NotSet: is_required = bool(transform.required) if transform.required is True: # Remove any existing default when making required new_schema.pop("default", None) # Handle default value transformation (only if not making required) if transform.default is not NotSet and transform.required is not True: new_schema["default"] = transform.default is_required = False # Handle type transformation if transform.type is not NotSet: # Use TypeAdapter to get proper JSON schema for the type type_schema = get_cached_typeadapter(transform.type).json_schema() # Update the schema with the type information from TypeAdapter new_schema.update(type_schema) # Handle examples transformation if transform.examples is not NotSet: new_schema["examples"] = transform.examples return new_name, new_schema, is_required @staticmethod def _merge_schema_with_precedence( base_schema: dict[str, Any], override_schema: dict[str, Any] ) -> dict[str, Any]: """Merge two schemas, with the override schema taking precedence. Args: base_schema: Base schema to start with override_schema: Schema that takes precedence for overlapping properties Returns: Merged schema with override taking precedence """ merged_props = base_schema.get("properties", {}).copy() merged_required = set(base_schema.get("required", [])) override_props = override_schema.get("properties", {}) override_required = set(override_schema.get("required", [])) # Override properties for param_name, param_schema in override_props.items(): if param_name in merged_props: # Merge the schemas, with override taking precedence base_param = merged_props[param_name].copy() base_param.update(param_schema) merged_props[param_name] = base_param else: merged_props[param_name] = param_schema.copy() # Handle required parameters - override takes complete precedence # Start with override's required set final_required = override_required.copy() # For parameters not in override, inherit base requirement status # but only if they don't have a default in the final merged properties for param_name in merged_required: if param_name not in override_props: # Parameter not mentioned in override, keep base requirement status final_required.add(param_name) elif ( param_name in override_props and "default" not in merged_props[param_name] ): # Parameter in override but no default, keep required if it was required in base if param_name not in override_required: # Override doesn't specify it as required, and it has no default, # so inherit from base final_required.add(param_name) # Remove any parameters that have defaults (they become optional) for param_name, param_schema in merged_props.items(): if "default" in param_schema: final_required.discard(param_name) # Merge $defs from both schemas, with override taking precedence merged_defs = base_schema.get("$defs", {}).copy() override_defs = override_schema.get("$defs", {}) for def_name, def_schema in override_defs.items(): if def_name in merged_defs: base_def = merged_defs[def_name].copy() base_def.update(def_schema) merged_defs[def_name] = base_def else: merged_defs[def_name] = def_schema.copy() result = { "type": "object", "properties": merged_props, "required": list(final_required), "additionalProperties": False, } if merged_defs: result["$defs"] = merged_defs result = compress_schema(result) return result @staticmethod def _function_has_kwargs(fn: Callable[..., Any]) -> bool: """Check if function accepts **kwargs. This determines whether a custom function can accept arbitrary keyword arguments, which affects how schemas are merged during tool transformation. Args: fn: Function to inspect. Returns: True if the function has a **kwargs parameter, False otherwise. """ sig = inspect.signature(fn) return any( p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() ) def _set_visibility_metadata(tool: Tool, *, enabled: bool) -> None: """Set visibility state in tool metadata. This uses the same metadata format as the Visibility transform, so tools marked here will be filtered by the standard visibility system. Args: tool: Tool to mark. enabled: Whether the tool should be visible to clients. """ # Import here to avoid circular imports from fastmcp.server.transforms.visibility import _FASTMCP_KEY, _INTERNAL_KEY if tool.meta is None: tool.meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": enabled}}} else: old_fastmcp = tool.meta.get(_FASTMCP_KEY, {}) old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) new_internal = {**old_internal, "visibility": enabled} new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} tool.meta = {**tool.meta, _FASTMCP_KEY: new_fastmcp} class ToolTransformConfig(FastMCPBaseModel): """Provides a way to transform a tool.""" name: str | None = Field(default=None, description="The new name for the tool.") version: str | None = Field( default=None, description="The new version for the tool." ) title: str | None = Field( default=None, description="The new title of the tool.", ) description: str | None = Field( default=None, description="The new description of the tool.", ) tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field( default_factory=set, description="The new tags for the tool.", ) meta: dict[str, Any] | None = Field( default=None, description="The new meta information for the tool.", ) enabled: bool = Field( default=True, description="Whether the tool is enabled. If False, the tool will be hidden from clients.", ) arguments: dict[str, ArgTransformConfig] = Field( default_factory=dict, description="A dictionary of argument transforms to apply to the tool.", ) def apply(self, tool: Tool) -> TransformedTool: """Create a TransformedTool from a provided tool and this transformation configuration.""" tool_changes: dict[str, Any] = self.model_dump( exclude_unset=True, exclude={"arguments", "enabled"} ) transformed = TransformedTool.from_tool( tool=tool, **tool_changes, transform_args={k: v.to_arg_transform() for k, v in self.arguments.items()}, ) # Set visibility metadata if enabled was explicitly provided. # This allows enabled=True to override an earlier disable (later transforms win). if "enabled" in self.model_fields_set: _set_visibility_metadata(transformed, enabled=self.enabled) return transformed def apply_transformations_to_tools( tools: dict[str, Tool], transformations: dict[str, ToolTransformConfig], ) -> dict[str, Tool]: """Apply a list of transformations to a list of tools. Tools that do not have any transformations are left unchanged. Note: tools dict is keyed by prefixed key (e.g., "tool:my_tool"), but transformations are keyed by tool name (e.g., "my_tool"). """ transformed_tools: dict[str, Tool] = {} for tool_key, tool in tools.items(): # Look up transformation by tool name, not prefixed key if transformation := transformations.get(tool.name): transformed = transformation.apply(tool) transformed_tools[transformed.key] = transformed continue transformed_tools[tool_key] = tool return transformed_tools ================================================ FILE: src/fastmcp/utilities/__init__.py ================================================ """FastMCP utility modules.""" ================================================ FILE: src/fastmcp/utilities/async_utils.py ================================================ """Async utilities for FastMCP.""" import asyncio import functools import inspect from collections.abc import Awaitable, Callable from typing import Any, Literal, TypeVar, overload import anyio from anyio.to_thread import run_sync as run_sync_in_threadpool T = TypeVar("T") def is_coroutine_function(fn: Any) -> bool: """Check if a callable is a coroutine function, unwrapping functools.partial. ``inspect.iscoroutinefunction`` returns ``False`` for ``functools.partial`` objects wrapping an async function on Python < 3.12. This helper unwraps any layers of ``partial`` before checking. """ while isinstance(fn, functools.partial): fn = fn.func return inspect.iscoroutinefunction(fn) or asyncio.iscoroutinefunction(fn) async def call_sync_fn_in_threadpool( fn: Callable[..., Any], *args: Any, **kwargs: Any ) -> Any: """Call a sync function in a threadpool to avoid blocking the event loop. Uses anyio.to_thread.run_sync which properly propagates contextvars, making this safe for functions that depend on context (like dependency injection). """ return await run_sync_in_threadpool(functools.partial(fn, *args, **kwargs)) @overload async def gather( *awaitables: Awaitable[T], return_exceptions: Literal[True], ) -> list[T | BaseException]: ... @overload async def gather( *awaitables: Awaitable[T], return_exceptions: Literal[False] = ..., ) -> list[T]: ... async def gather( *awaitables: Awaitable[T], return_exceptions: bool = False, ) -> list[T] | list[T | BaseException]: """Run awaitables concurrently and return results in order. Uses anyio TaskGroup for structured concurrency. Args: *awaitables: Awaitables to run concurrently return_exceptions: If True, exceptions are returned in results. If False, first exception cancels all and raises. Returns: List of results in the same order as input awaitables. """ results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] async def run_at(i: int, aw: Awaitable[T]) -> None: try: results[i] = await aw except BaseException as e: if return_exceptions: results[i] = e else: raise async with anyio.create_task_group() as tg: for i, aw in enumerate(awaitables): tg.start_soon(run_at, i, aw) return results ================================================ FILE: src/fastmcp/utilities/auth.py ================================================ """Authentication utility helpers.""" from __future__ import annotations import base64 import json from typing import Any def _decode_jwt_part(token: str, part_index: int) -> dict[str, Any]: """Decode a JWT part (header or payload) without signature verification. Args: token: JWT token string (header.payload.signature) part_index: 0 for header, 1 for payload Returns: Decoded part as a dictionary Raises: ValueError: If token is not a valid JWT format """ parts = token.split(".") if len(parts) != 3: raise ValueError("Invalid JWT format (expected 3 parts)") part_b64 = parts[part_index] part_b64 += "=" * (-len(part_b64) % 4) # Add padding return json.loads(base64.urlsafe_b64decode(part_b64)) def decode_jwt_header(token: str) -> dict[str, Any]: """Decode JWT header without signature verification. Useful for extracting the key ID (kid) for JWKS lookup. Args: token: JWT token string (header.payload.signature) Returns: Decoded header as a dictionary Raises: ValueError: If token is not a valid JWT format """ return _decode_jwt_part(token, 0) def decode_jwt_payload(token: str) -> dict[str, Any]: """Decode JWT payload without signature verification. Use only for tokens received directly from trusted sources (e.g., IdP token endpoints). Args: token: JWT token string (header.payload.signature) Returns: Decoded payload as a dictionary Raises: ValueError: If token is not a valid JWT format """ return _decode_jwt_part(token, 1) def parse_scopes(value: Any) -> list[str] | None: """Parse scopes from environment variables or settings values. Accepts either a JSON array string, a comma- or space-separated string, a list of strings, or ``None``. Returns a list of scopes or ``None`` if no value is provided. """ if value is None or value == "": return None if value is None else [] if isinstance(value, list): return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, str): value = value.strip() if not value: return [] # Try JSON array first if value.startswith("["): try: data = json.loads(value) if isinstance(data, list): return [str(v).strip() for v in data if str(v).strip()] except Exception: pass # Fallback to comma/space separated list return [s.strip() for s in value.replace(",", " ").split() if s.strip()] return value ================================================ FILE: src/fastmcp/utilities/cli.py ================================================ from __future__ import annotations import json import os from pathlib import Path from typing import TYPE_CHECKING, Any from pydantic import ValidationError from rich.align import Align from rich.console import Console, Group from rich.panel import Panel from rich.table import Table from rich.text import Text import fastmcp from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource from fastmcp.utilities.types import get_cached_typeadapter from fastmcp.utilities.version_check import check_for_newer_version if TYPE_CHECKING: from fastmcp import FastMCP logger = get_logger("cli.config") def is_already_in_uv_subprocess() -> bool: """Check if we're already running in a FastMCP uv subprocess.""" return bool(os.environ.get("FASTMCP_UV_SPAWNED")) def load_and_merge_config( server_spec: str | None, **cli_overrides, ) -> tuple[MCPServerConfig, str]: """Load config from server_spec and apply CLI overrides. This consolidates the config parsing logic that was duplicated across run, inspect, and dev commands. Args: server_spec: Python file, config file, URL, or None to auto-detect cli_overrides: CLI arguments that override config values Returns: Tuple of (MCPServerConfig, resolved_server_spec) """ config = None config_path = None # Auto-detect fastmcp.json if no server_spec provided if server_spec is None: config_path = Path("fastmcp.json") if not config_path.exists(): found_config = MCPServerConfig.find_config() if found_config: config_path = found_config else: logger.error( "No server specification provided and no fastmcp.json found in current directory.\n" "Please specify a server file or create a fastmcp.json configuration." ) raise FileNotFoundError("No server specification or fastmcp.json found") resolved_spec = str(config_path) logger.info(f"Using configuration from {config_path}") else: resolved_spec = server_spec # Load config if server_spec is a .json file if resolved_spec.endswith(".json"): config_path = Path(resolved_spec) if config_path.exists(): try: 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: # MCPConfig - we don't process these here, just pass through pass else: # Try to parse as MCPServerConfig try: adapter = get_cached_typeadapter(MCPServerConfig) config = adapter.validate_python(data) # Apply deployment settings if config.deployment: config.deployment.apply_runtime_settings(config_path) except ValidationError: # Not a valid MCPServerConfig, just pass through pass except (json.JSONDecodeError, FileNotFoundError): # Not a valid JSON file, just pass through pass # If we don't have a config object yet, create one from filesystem source if config is None: source = FileSystemSource(path=resolved_spec) config = MCPServerConfig(source=source) # Convert to dict for immutable transformation config_dict = config.model_dump() # Apply CLI overrides to config's environment (always exists due to default_factory) if python_override := cli_overrides.get("python"): config_dict["environment"]["python"] = python_override if packages_override := cli_overrides.get("with_packages"): # Merge packages - CLI packages are added to config packages existing = config_dict["environment"].get("dependencies") or [] config_dict["environment"]["dependencies"] = packages_override + existing if requirements_override := cli_overrides.get("with_requirements"): config_dict["environment"]["requirements"] = str(requirements_override) if project_override := cli_overrides.get("project"): config_dict["environment"]["project"] = str(project_override) if editable_override := cli_overrides.get("editable"): config_dict["environment"]["editable"] = editable_override # Apply deployment CLI overrides (always exists due to default_factory) if transport_override := cli_overrides.get("transport"): config_dict["deployment"]["transport"] = transport_override if host_override := cli_overrides.get("host"): config_dict["deployment"]["host"] = host_override if port_override := cli_overrides.get("port"): config_dict["deployment"]["port"] = port_override if path_override := cli_overrides.get("path"): config_dict["deployment"]["path"] = path_override if log_level_override := cli_overrides.get("log_level"): config_dict["deployment"]["log_level"] = log_level_override if server_args_override := cli_overrides.get("server_args"): config_dict["deployment"]["args"] = server_args_override # Create new config from modified dict new_config = MCPServerConfig(**config_dict) return new_config, resolved_spec LOGO_ASCII_1 = r""" _ __ ___ _____ __ __ _____________ ____ ____ _ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \ _ __ ___ / /_ / __ `/ ___/ __/ /|_/ / / / /_/ / ___/ / / / / / _ __ ___ / __/ / /_/ (__ ) /_/ / / / /___/ ____/ / __/_/ /_/ / _ __ ___ /_/ \____/____/\__/_/ /_/\____/_/ /_____(*)____/ """.lstrip("\n") # This prints the below in a blue gradient # █▀▀ ▄▀█ █▀▀ ▀█▀ █▀▄▀█ █▀▀ █▀█ # █▀ █▀█ ▄▄█ █ █ ▀ █ █▄▄ █▀▀ LOGO_ASCII_2 = ( "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m▀\x1b[38;2;0;186;255m " "\x1b[38;2;0;184;255m▄\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " "\x1b[38;2;0;172;255m█\x1b[38;2;0;169;255m▀\x1b[38;2;0;166;255m▀\x1b[38;2;0;163;255m " "\x1b[38;2;0;160;255m▀\x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m▀\x1b[38;2;0;152;255m " "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m▀\x1b[38;2;0;143;255m▄\x1b[38;2;0;140;255m▀\x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▀\x1b[38;2;0;126;255m▀\x1b[38;2;0;123;255m " "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m█\x1b[39m\n" "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m \x1b[38;2;0;186;255m " "\x1b[38;2;0;184;255m█\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " "\x1b[38;2;0;172;255m▄\x1b[38;2;0;169;255m▄\x1b[38;2;0;166;255m█\x1b[38;2;0;163;255m " "\x1b[38;2;0;160;255m \x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m \x1b[38;2;0;152;255m " "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m \x1b[38;2;0;143;255m▀\x1b[38;2;0;140;255m \x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▄\x1b[38;2;0;126;255m▄\x1b[38;2;0;123;255m " "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m▀\x1b[39m" ).strip() # Prints the below in a blue gradient - stylized F # ▄▀▀▀ # █▀▀ # ▀ LOGO_ASCII_3 = ( " \x1b[38;2;0;170;255m▄\x1b[38;2;0;142;255m▀\x1b[38;2;0;114;255m▀\x1b[38;2;0;86;255m▀\x1b[39m\n" " \x1b[38;2;0;170;255m█\x1b[38;2;0;142;255m▀\x1b[38;2;0;114;255m▀\x1b[39m\n" "\x1b[38;2;0;170;255m▀\x1b[39m\n" "\x1b[0m" ) # Prints the below in a blue gradient - block logo with slightly stylized F # ▄▀▀ ▄▀█ █▀▀ ▀█▀ █▀▄▀█ █▀▀ █▀█ # █▀ █▀█ ▄▄█ █ █ ▀ █ █▄▄ █▀▀ LOGO_ASCII_4 = ( "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m▄\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m▀\x1b[38;2;0;186;255m \x1b[38;2;0;184;255m▄\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " "\x1b[38;2;0;172;255m█\x1b[38;2;0;169;255m▀\x1b[38;2;0;166;255m▀\x1b[38;2;0;163;255m " "\x1b[38;2;0;160;255m▀\x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m▀\x1b[38;2;0;152;255m " "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m▀\x1b[38;2;0;143;255m▄\x1b[38;2;0;140;255m▀\x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▀\x1b[38;2;0;126;255m▀\x1b[38;2;0;123;255m " "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m█\x1b[39m\n" "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m \x1b[38;2;0;186;255m \x1b[38;2;0;184;255m█\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " "\x1b[38;2;0;172;255m▄\x1b[38;2;0;169;255m▄\x1b[38;2;0;166;255m█\x1b[38;2;0;163;255m " "\x1b[38;2;0;160;255m \x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m \x1b[38;2;0;152;255m " "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m \x1b[38;2;0;143;255m▀\x1b[38;2;0;140;255m \x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▄\x1b[38;2;0;126;255m▄\x1b[38;2;0;123;255m " "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m▀\x1b[39m\n" ) def log_server_banner(server: FastMCP[Any]) -> None: """Creates and logs a formatted banner with server information and logo.""" # Check for updates (non-blocking, fails silently) newer_version = check_for_newer_version() # Create the logo text # Use Text with no_wrap and markup disabled to preserve ANSI escape codes logo_text = Text.from_ansi(LOGO_ASCII_4, no_wrap=True) # Create the main title title_text = Text(f"FastMCP {fastmcp.__version__}", style="bold blue") # Create the information table info_table = Table.grid(padding=(0, 1)) info_table.add_column(style="bold", justify="center") # Emoji column info_table.add_column(style="cyan", justify="left") # Label column info_table.add_column(style="dim", justify="left") # Value column server_info = server.name if server.version: server_info += f", {server.version}" info_table.add_row("🖥", "Server:", Text(server_info, style="dim")) info_table.add_row("🚀", "Deploy free:", "https://horizon.prefect.io") # Create panel with logo, title, and information using Group docs_url = Text("https://gofastmcp.com", style="dim") panel_content = Group( "", Align.center(logo_text), "", "", Align.center(title_text), Align.center(docs_url), "", Align.center(info_table), ) panel = Panel( panel_content, border_style="dim", padding=(1, 4), # expand=False, width=80, # Set max width for the panel ) console = Console(stderr=True) # Build output elements output_elements: list[Align | Panel | str] = ["\n", Align.center(panel)] # Add update notice if a newer version is available (shown last for visibility) if newer_version: update_line1 = Text.assemble( ("🎉 Update available: ", "bold"), (newer_version, "bold green"), ) update_line2 = Text("Run: pip install --upgrade fastmcp", style="dim") update_notice = Panel( Group(Align.center(update_line1), Align.center(update_line2)), border_style="blue", padding=(0, 2), width=80, ) output_elements.append(Align.center(update_notice)) output_elements.append("\n") console.print(Group(*output_elements)) ================================================ FILE: src/fastmcp/utilities/components.py ================================================ from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast from mcp.types import Icon from pydantic import BeforeValidator, Field from typing_extensions import Self, TypeVar from fastmcp.server.tasks.config import TaskConfig from fastmcp.utilities.types import FastMCPBaseModel if TYPE_CHECKING: from docket import Docket from docket.execution import Execution T = TypeVar("T", default=Any) class FastMCPMeta(TypedDict, total=False): tags: list[str] version: str versions: list[str] def get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta: """Extract FastMCP metadata from a component's meta dict. Handles both the current `fastmcp` namespace and the legacy `_fastmcp` namespace for compatibility with older FastMCP servers. """ if not meta: return {} for key in ("fastmcp", "_fastmcp"): metadata = meta.get(key) if isinstance(metadata, dict): return cast(FastMCPMeta, metadata) return {} def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: """Convert a sequence to a set, defaulting to an empty set if None.""" if maybe_set is None: return set() if isinstance(maybe_set, set): return maybe_set return set(maybe_set) def _coerce_version(v: str | int | float | None) -> str | None: """Coerce version to string, accepting int, float, or str. Raises TypeError for non-scalar types (list, dict, set, etc.). Raises ValueError if version contains '@' (used as key delimiter). """ if v is None: return None if isinstance(v, bool): raise TypeError(f"Version must be a string, int, or float, got bool: {v!r}") if not isinstance(v, (str, int, float)): raise TypeError( f"Version must be a string, int, or float, got {type(v).__name__}: {v!r}" ) version = str(v) if "@" in version: raise ValueError( f"Version string cannot contain '@' (used as key delimiter): {version!r}" ) return version class FastMCPComponent(FastMCPBaseModel): """Base class for FastMCP tools, prompts, resources, and resource templates.""" KEY_PREFIX: ClassVar[str] = "" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) # Warn if a subclass doesn't define KEY_PREFIX (inherited or its own) if not cls.KEY_PREFIX: import warnings warnings.warn( f"{cls.__name__} does not define KEY_PREFIX. " f"Component keys will not be type-prefixed, which may cause collisions.", UserWarning, stacklevel=2, ) name: str = Field( description="The name of the component.", ) version: Annotated[str | None, BeforeValidator(_coerce_version)] = Field( default=None, description="Optional version identifier for this component. " "Multiple versions of the same component (same name) can coexist.", ) title: str | None = Field( default=None, description="The title of the component for display purposes.", ) description: str | None = Field( default=None, description="The description of the component.", ) icons: list[Icon] | None = Field( default=None, description="Optional list of icons for this component to display in user interfaces.", ) tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field( default_factory=set, description="Tags for the component.", ) meta: dict[str, Any] | None = Field( default=None, description="Meta information about the component" ) task_config: Annotated[ TaskConfig, Field(description="Background task execution configuration (SEP-1686)."), ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) @classmethod def make_key(cls, identifier: str) -> str: """Construct the lookup key for this component type. Args: identifier: The raw identifier (name for tools/prompts, uri for resources) Returns: A prefixed key like "tool:name" or "resource:uri" """ if cls.KEY_PREFIX: return f"{cls.KEY_PREFIX}:{identifier}" return identifier @property def key(self) -> str: """The globally unique lookup key for this component. Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@" e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@" The @ suffix is ALWAYS present to enable unambiguous parsing of keys (URIs may contain @ characters, so we always include the delimiter). Subclasses should override this to use their specific identifier. Base implementation uses name. """ base_key = self.make_key(self.name) return f"{base_key}@{self.version or ''}" def get_meta(self) -> dict[str, Any]: """Get the meta information about the component. Returns a dict that always includes a `fastmcp` key containing: - `tags`: sorted list of component tags - `version`: component version (only if set) Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. """ meta = dict(self.meta) if self.meta else {} fastmcp_meta: FastMCPMeta = {"tags": sorted(self.tags)} if self.version is not None: fastmcp_meta["version"] = self.version # Merge with upstream fastmcp meta, stripping internal keys if (upstream_meta := meta.get("fastmcp")) is not None: if not isinstance(upstream_meta, dict): raise TypeError("meta['fastmcp'] must be a dict") # Filter out internal keys (e.g., _internal used for enabled state) public_upstream = { k: v for k, v in upstream_meta.items() if not k.startswith("_") } fastmcp_meta = cast(FastMCPMeta, public_upstream | fastmcp_meta) meta["fastmcp"] = fastmcp_meta return meta def __eq__(self, other: object) -> bool: if type(self) is not type(other): return False if not isinstance(other, type(self)): return False return self.model_dump() == other.model_dump() def __repr__(self) -> str: parts = [f"name={self.name!r}"] if self.version: parts.append(f"version={self.version!r}") parts.extend( [ f"title={self.title!r}", f"description={self.description!r}", f"tags={self.tags}", ] ) return f"{self.__class__.__name__}({', '.join(parts)})" def enable(self) -> None: """Removed in 3.0. Use server.enable(keys=[...]) instead.""" raise NotImplementedError( f"Component.enable() was removed in FastMCP 3.0. " f"Use server.enable(keys=['{self.key}']) instead." ) def disable(self) -> None: """Removed in 3.0. Use server.disable(keys=[...]) instead.""" raise NotImplementedError( f"Component.disable() was removed in FastMCP 3.0. " f"Use server.disable(keys=['{self.key}']) instead." ) def copy(self) -> Self: # type: ignore[override] """Create a copy of the component.""" return self.model_copy() def register_with_docket(self, docket: Docket) -> None: """Register this component with docket for background execution. No-ops if task_config.mode is "forbidden". Subclasses override to register their callable (self.run, self.read, self.render, or self.fn). """ # Base implementation: no-op (subclasses override) async def add_to_docket( self, docket: Docket, *args: Any, **kwargs: Any ) -> Execution: """Schedule this component for background execution via docket. Subclasses override this to handle their specific calling conventions: - Tool: add_to_docket(docket, arguments: dict, **kwargs) - Resource: add_to_docket(docket, **kwargs) - ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) - Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) The **kwargs are passed through to docket.add() (e.g., key=task_key). """ if not self.task_config.supports_tasks(): raise RuntimeError( f"Cannot add {self.__class__.__name__} '{self.name}' to docket: " f"task execution not supported" ) raise NotImplementedError( f"{self.__class__.__name__} does not implement add_to_docket()" ) def get_span_attributes(self) -> dict[str, Any]: """Return span attributes for telemetry. Subclasses should call super() and merge their specific attributes. """ return {"fastmcp.component.key": self.key} ================================================ FILE: src/fastmcp/utilities/exceptions.py ================================================ from collections.abc import Callable, Iterable, Mapping from typing import Any import httpx import mcp.types from exceptiongroup import BaseExceptionGroup from mcp import McpError import fastmcp def iter_exc(group: BaseExceptionGroup): for exc in group.exceptions: if isinstance(exc, BaseExceptionGroup): yield from iter_exc(exc) else: yield exc def _exception_handler(group: BaseExceptionGroup): for leaf in iter_exc(group): if isinstance(leaf, httpx.ConnectTimeout): raise McpError( error=mcp.types.ErrorData( code=httpx.codes.REQUEST_TIMEOUT, message="Timed out while waiting for response.", ) ) raise leaf # this catch handler is used to catch taskgroup exception groups and raise the # first exception. This allows more sane debugging. _catch_handlers: Mapping[ type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any], ] = { Exception: _exception_handler, } def get_catch_handlers() -> Mapping[ type[BaseException] | Iterable[type[BaseException]], Callable[[BaseExceptionGroup[Any]], Any], ]: if fastmcp.settings.client_raise_first_exceptiongroup_error: return _catch_handlers else: return {} ================================================ FILE: src/fastmcp/utilities/http.py ================================================ import socket def find_available_port() -> int: """Find an available port by letting the OS assign one.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] ================================================ FILE: src/fastmcp/utilities/inspect.py ================================================ """Utilities for inspecting FastMCP instances.""" from __future__ import annotations import importlib.metadata from dataclasses import dataclass from enum import Enum from typing import Any, Literal, cast import pydantic_core from mcp.server.fastmcp import FastMCP as FastMCP1x import fastmcp from fastmcp import Client from fastmcp.server.server import FastMCP @dataclass class ToolInfo: """Information about a tool.""" key: str name: str description: str | None input_schema: dict[str, Any] output_schema: dict[str, Any] | None = None annotations: dict[str, Any] | None = None tags: list[str] | None = None title: str | None = None icons: list[dict[str, Any]] | None = None meta: dict[str, Any] | None = None @dataclass class PromptInfo: """Information about a prompt.""" key: str name: str description: str | None arguments: list[dict[str, Any]] | None = None tags: list[str] | None = None title: str | None = None icons: list[dict[str, Any]] | None = None meta: dict[str, Any] | None = None @dataclass class ResourceInfo: """Information about a resource.""" key: str uri: str name: str | None description: str | None mime_type: str | None = None annotations: dict[str, Any] | None = None tags: list[str] | None = None title: str | None = None icons: list[dict[str, Any]] | None = None meta: dict[str, Any] | None = None @dataclass class TemplateInfo: """Information about a resource template.""" key: str uri_template: str name: str | None description: str | None mime_type: str | None = None parameters: dict[str, Any] | None = None annotations: dict[str, Any] | None = None tags: list[str] | None = None title: str | None = None icons: list[dict[str, Any]] | None = None meta: dict[str, Any] | None = None @dataclass class FastMCPInfo: """Information extracted from a FastMCP instance.""" name: str instructions: str | None version: str | None # The server's own version string (if specified) website_url: str | None icons: list[dict[str, Any]] | None fastmcp_version: str # Version of FastMCP generating this manifest mcp_version: str # Version of MCP protocol library server_generation: int # Server generation: 1 (mcp package) or 2 (fastmcp) tools: list[ToolInfo] prompts: list[PromptInfo] resources: list[ResourceInfo] templates: list[TemplateInfo] capabilities: dict[str, Any] async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: """Extract information from a FastMCP v2.x instance. Args: mcp: The FastMCP v2.x instance to inspect Returns: FastMCPInfo dataclass containing the extracted information """ # Get all components (list_* includes middleware, enabled/auth filtering) tools_list = await mcp.list_tools() prompts_list = await mcp.list_prompts() resources_list = await mcp.list_resources() templates_list = await mcp.list_resource_templates() # Extract detailed tool information tool_infos = [] for tool in tools_list: mcp_tool = tool.to_mcp_tool(name=tool.name) tool_infos.append( ToolInfo( key=tool.key, name=tool.name or tool.key, description=tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, output_schema=tool.output_schema, annotations=tool.annotations.model_dump() if tool.annotations else None, tags=list(tool.tags) if tool.tags else None, title=tool.title, icons=[icon.model_dump() for icon in tool.icons] if tool.icons else None, meta=tool.meta, ) ) # Extract detailed prompt information prompt_infos = [] for prompt in prompts_list: prompt_infos.append( PromptInfo( key=prompt.key, name=prompt.name or prompt.key, description=prompt.description, arguments=[arg.model_dump() for arg in prompt.arguments] if prompt.arguments else None, tags=list(prompt.tags) if prompt.tags else None, title=prompt.title, icons=[icon.model_dump() for icon in prompt.icons] if prompt.icons else None, meta=prompt.meta, ) ) # Extract detailed resource information resource_infos = [] for resource in resources_list: resource_infos.append( ResourceInfo( key=resource.key, uri=str(resource.uri), name=resource.name, description=resource.description, mime_type=resource.mime_type, annotations=resource.annotations.model_dump() if resource.annotations else None, tags=list(resource.tags) if resource.tags else None, title=resource.title, icons=[icon.model_dump() for icon in resource.icons] if resource.icons else None, meta=resource.meta, ) ) # Extract detailed template information template_infos = [] for template in templates_list: template_infos.append( TemplateInfo( key=template.key, uri_template=template.uri_template, name=template.name, description=template.description, mime_type=template.mime_type, parameters=template.parameters, annotations=template.annotations.model_dump() if template.annotations else None, tags=list(template.tags) if template.tags else None, title=template.title, icons=[icon.model_dump() for icon in template.icons] if template.icons else None, meta=template.meta, ) ) # Basic MCP capabilities that FastMCP supports capabilities = { "tools": {"listChanged": True}, "resources": {"subscribe": False, "listChanged": False}, "prompts": {"listChanged": False}, "logging": {}, } # Extract server-level icons and website_url server_icons = ( [icon.model_dump() for icon in mcp._mcp_server.icons] if hasattr(mcp._mcp_server, "icons") and mcp._mcp_server.icons else None ) server_website_url = ( mcp._mcp_server.website_url if hasattr(mcp._mcp_server, "website_url") else None ) return FastMCPInfo( name=mcp.name, instructions=mcp.instructions, version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version), website_url=server_website_url, icons=server_icons, fastmcp_version=fastmcp.__version__, mcp_version=importlib.metadata.version("mcp"), server_generation=2, # FastMCP v2 tools=tool_infos, prompts=prompt_infos, resources=resource_infos, templates=template_infos, capabilities=capabilities, ) async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: """Extract information from a FastMCP v1.x instance using a Client. Args: mcp: The FastMCP v1.x instance to inspect Returns: FastMCPInfo dataclass containing the extracted information """ # Use a client to interact with the FastMCP1x server async with Client(mcp) as client: # Get components via client calls (these return MCP objects) mcp_tools = await client.list_tools() mcp_prompts = await client.list_prompts() mcp_resources = await client.list_resources() # Try to get resource templates (FastMCP 1.x does have templates) try: mcp_templates = await client.list_resource_templates() except Exception: mcp_templates = [] # Extract detailed tool information from MCP Tool objects tool_infos = [] for mcp_tool in mcp_tools: tool_infos.append( ToolInfo( key=mcp_tool.name, name=mcp_tool.name, description=mcp_tool.description, input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, output_schema=None, # v1 doesn't have output_schema annotations=None, # v1 doesn't have annotations tags=None, # v1 doesn't have tags title=None, # v1 doesn't have title icons=[icon.model_dump() for icon in mcp_tool.icons] if hasattr(mcp_tool, "icons") and mcp_tool.icons else None, meta=None, # v1 doesn't have meta field ) ) # Extract detailed prompt information from MCP Prompt objects prompt_infos = [] for mcp_prompt in mcp_prompts: # Convert arguments if they exist arguments = None if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments: arguments = [arg.model_dump() for arg in mcp_prompt.arguments] prompt_infos.append( PromptInfo( key=mcp_prompt.name, name=mcp_prompt.name, description=mcp_prompt.description, arguments=arguments, tags=None, # v1 doesn't have tags title=None, # v1 doesn't have title icons=[icon.model_dump() for icon in mcp_prompt.icons] if hasattr(mcp_prompt, "icons") and mcp_prompt.icons else None, meta=None, # v1 doesn't have meta field ) ) # Extract detailed resource information from MCP Resource objects resource_infos = [] for mcp_resource in mcp_resources: resource_infos.append( ResourceInfo( key=str(mcp_resource.uri), uri=str(mcp_resource.uri), name=mcp_resource.name, description=mcp_resource.description, mime_type=mcp_resource.mimeType, annotations=None, # v1 doesn't have annotations tags=None, # v1 doesn't have tags title=None, # v1 doesn't have title icons=[icon.model_dump() for icon in mcp_resource.icons] if hasattr(mcp_resource, "icons") and mcp_resource.icons else None, meta=None, # v1 doesn't have meta field ) ) # Extract detailed template information from MCP ResourceTemplate objects template_infos = [] for mcp_template in mcp_templates: template_infos.append( TemplateInfo( key=str(mcp_template.uriTemplate), uri_template=str(mcp_template.uriTemplate), name=mcp_template.name, description=mcp_template.description, mime_type=mcp_template.mimeType, parameters=None, # v1 doesn't expose template parameters annotations=None, # v1 doesn't have annotations tags=None, # v1 doesn't have tags title=None, # v1 doesn't have title icons=[icon.model_dump() for icon in mcp_template.icons] if hasattr(mcp_template, "icons") and mcp_template.icons else None, meta=None, # v1 doesn't have meta field ) ) # Basic MCP capabilities capabilities = { "tools": {"listChanged": True}, "resources": {"subscribe": False, "listChanged": False}, "prompts": {"listChanged": False}, "logging": {}, } # Extract server-level icons and website_url from serverInfo server_info = client.initialize_result.serverInfo server_icons = ( [icon.model_dump() for icon in server_info.icons] if hasattr(server_info, "icons") and server_info.icons else None ) server_website_url = ( server_info.websiteUrl if hasattr(server_info, "websiteUrl") else None ) return FastMCPInfo( name=mcp._mcp_server.name, instructions=mcp._mcp_server.instructions, version=mcp._mcp_server.version, website_url=server_website_url, icons=server_icons, fastmcp_version=fastmcp.__version__, # Version generating this manifest mcp_version=importlib.metadata.version("mcp"), server_generation=1, # MCP v1 tools=tool_infos, prompts=prompt_infos, resources=resource_infos, templates=template_infos, capabilities=capabilities, ) async def inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo: """Extract information from a FastMCP instance into a dataclass. This function automatically detects whether the instance is FastMCP v1.x or v2.x and uses the appropriate extraction method. Args: mcp: The FastMCP instance to inspect (v1.x or v2.x) Returns: FastMCPInfo dataclass containing the extracted information """ if isinstance(mcp, FastMCP1x): return await inspect_fastmcp_v1(mcp) else: return await inspect_fastmcp_v2(cast(FastMCP[Any], mcp)) class InspectFormat(str, Enum): """Output format for inspect command.""" FASTMCP = "fastmcp" MCP = "mcp" def format_fastmcp_info(info: FastMCPInfo) -> bytes: """Format FastMCPInfo as FastMCP-specific JSON. This includes FastMCP-specific fields like tags, enabled, annotations, etc. """ # Build the output dict with nested structure result = { "server": { "name": info.name, "instructions": info.instructions, "version": info.version, "website_url": info.website_url, "icons": info.icons, "generation": info.server_generation, "capabilities": info.capabilities, }, "environment": { "fastmcp": info.fastmcp_version, "mcp": info.mcp_version, }, "tools": info.tools, "prompts": info.prompts, "resources": info.resources, "templates": info.templates, } return pydantic_core.to_json(result, indent=2) async def format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes: """Format server info as standard MCP protocol JSON. Uses Client to get the standard MCP protocol format with camelCase fields. Includes version metadata at the top level. """ async with Client(mcp) as client: # Get all the MCP protocol objects tools_result = await client.list_tools_mcp() prompts_result = await client.list_prompts_mcp() resources_result = await client.list_resources_mcp() templates_result = await client.list_resource_templates_mcp() # Get server info from the initialize result server_info = client.initialize_result.serverInfo # Combine into MCP protocol structure with environment metadata result = { "environment": { "fastmcp": fastmcp.__version__, # Version generating this manifest "mcp": importlib.metadata.version("mcp"), # MCP protocol version }, "serverInfo": server_info, "capabilities": {}, # MCP format doesn't include capabilities at top level "tools": tools_result.tools, "prompts": prompts_result.prompts, "resources": resources_result.resources, "resourceTemplates": templates_result.resourceTemplates, } return pydantic_core.to_json(result, indent=2) async def format_info( mcp: FastMCP[Any] | FastMCP1x, format: InspectFormat | Literal["fastmcp", "mcp"], info: FastMCPInfo | None = None, ) -> bytes: """Format server information according to the specified format. Args: mcp: The FastMCP instance format: Output format ("fastmcp" or "mcp") info: Pre-extracted FastMCPInfo (optional, will be extracted if not provided) Returns: JSON bytes in the requested format """ # Convert string to enum if needed if isinstance(format, str): format = InspectFormat(format) if format == InspectFormat.MCP: # MCP format doesn't need FastMCPInfo, it uses Client directly return await format_mcp_info(mcp) elif format == InspectFormat.FASTMCP: # For FastMCP format, we need the FastMCPInfo # This works for both v1 and v2 servers if info is None: info = await inspect_fastmcp(mcp) return format_fastmcp_info(info) else: raise ValueError(f"Unknown format: {format}") ================================================ FILE: src/fastmcp/utilities/json_schema.py ================================================ from __future__ import annotations from collections import defaultdict from typing import Any from jsonref import JsonRefError, replace_refs def _defs_have_cycles(defs: dict[str, Any]) -> bool: """Check whether any definitions in ``$defs`` form a reference cycle. A cycle means a definition directly or transitively references itself (e.g. Node → children → Node, or A → B → A). ``jsonref.replace_refs`` silently produces Python-level object cycles for these, which Pydantic's serializer rejects. """ if not defs: return False # Build adjacency: def_name -> set of def_names it references. edges: dict[str, set[str]] = defaultdict(set) def _collect_refs(obj: Any, source: str) -> None: if isinstance(obj, dict): ref = obj.get("$ref") if isinstance(ref, str) and ref.startswith("#/$defs/"): edges[source].add(ref.split("/")[-1]) for v in obj.values(): _collect_refs(v, source) elif isinstance(obj, list): for item in obj: _collect_refs(item, source) for name, definition in defs.items(): _collect_refs(definition, name) # DFS cycle detection. UNVISITED, IN_STACK, DONE = 0, 1, 2 state: dict[str, int] = defaultdict(int) def _has_cycle(node: str) -> bool: state[node] = IN_STACK for neighbor in edges.get(node, ()): if neighbor not in defs: continue if state[neighbor] == IN_STACK: return True if state[neighbor] == UNVISITED and _has_cycle(neighbor): return True state[node] = DONE return False return any(state[name] == UNVISITED and _has_cycle(name) for name in defs) def _strip_remote_refs(obj: Any) -> Any: """Return a deep copy of *obj* with non-local ``$ref`` values removed. Local refs (starting with ``#``) are kept intact. Remote refs (``http://``, ``https://``, ``file://``, or any other URI scheme) are stripped so that ``jsonref.replace_refs`` never attempts to fetch an external resource. This prevents SSRF / LFI when proxying schemas from untrusted servers. """ if isinstance(obj, dict): ref = obj.get("$ref") if isinstance(ref, str) and not ref.startswith("#"): # Drop the remote $ref key; keep all other keys. return {k: _strip_remote_refs(v) for k, v in obj.items() if k != "$ref"} return {k: _strip_remote_refs(v) for k, v in obj.items()} if isinstance(obj, list): return [_strip_remote_refs(item) for item in obj] return obj def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: """Resolve all $ref references in a JSON schema by inlining definitions. This function resolves $ref references that point to $defs, replacing them with the actual definition content while preserving sibling keywords (like description, default, examples) that Pydantic places alongside $ref. This is necessary because some MCP clients (e.g., VS Code Copilot) don't properly handle $ref in tool input schemas. For self-referencing/circular schemas where full dereferencing is not possible, this function falls back to resolving only the root-level $ref while preserving $defs for nested references. Only local ``$ref`` values (those starting with ``#``) are resolved. Remote URIs (``http://``, ``file://``, etc.) are stripped before resolution to prevent SSRF / local-file-inclusion attacks when proxying schemas from untrusted servers. Args: schema: JSON schema dict that may contain $ref references Returns: A new schema dict with $ref resolved where possible and $defs removed when no longer needed Example: >>> schema = { ... "$defs": {"Category": {"enum": ["a", "b"], "type": "string"}}, ... "properties": {"cat": {"$ref": "#/$defs/Category", "default": "a"}} ... } >>> resolved = dereference_refs(schema) >>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}} """ # Strip any remote $ref values before processing to prevent SSRF / LFI. schema = _strip_remote_refs(schema) # Circular $defs can't be fully inlined — jsonref.replace_refs produces # Python dicts with object-identity cycles that Pydantic's model_dump # rejects with "Circular reference detected (id repeated)". # Detect cycles up front and fall back to root-only resolution. if _defs_have_cycles(schema.get("$defs", {})): return resolve_root_ref(schema) try: # Use jsonref to resolve all $ref references # proxies=False returns plain dicts (not proxy objects) # lazy_load=False resolves immediately dereferenced = replace_refs(schema, proxies=False, lazy_load=False) # Merge sibling keywords that were lost during dereferencing # Pydantic puts description, default, examples as siblings to $ref defs = schema.get("$defs", {}) merged = _merge_ref_siblings(schema, dereferenced, defs) # Type assertion: top-level schema is always a dict assert isinstance(merged, dict) dereferenced = merged # Remove $defs since all references have been resolved if "$defs" in dereferenced: dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"} return dereferenced except JsonRefError: # Self-referencing/circular schemas can't be fully dereferenced # Fall back to resolving only root-level $ref (for MCP spec compliance) return resolve_root_ref(schema) def _merge_ref_siblings( original: Any, dereferenced: Any, defs: dict[str, Any], visited: set[str] | None = None, ) -> Any: """Merge sibling keywords from original $ref nodes into dereferenced schema. When jsonref resolves $ref, it replaces the entire node with the referenced definition, losing any sibling keywords like description, default, or examples. This function walks both trees in parallel and merges those siblings back. Args: original: The original schema with $ref and potential siblings dereferenced: The schema after jsonref processing defs: The $defs from the original schema, for looking up referenced definitions visited: Set of definition names already being processed (prevents cycles) Returns: The dereferenced schema with sibling keywords restored """ if visited is None: visited = set() if isinstance(original, dict) and isinstance(dereferenced, dict): # Check if original had a $ref if "$ref" in original: ref = original["$ref"] siblings = {k: v for k, v in original.items() if k not in ("$ref", "$defs")} # Look up the referenced definition to process its nested siblings if isinstance(ref, str) and ref.startswith("#/$defs/"): def_name = ref.split("/")[-1] # Prevent infinite recursion on circular references if def_name in defs and def_name not in visited: # Recursively process the definition's content for nested siblings dereferenced = _merge_ref_siblings( defs[def_name], dereferenced, defs, visited | {def_name} ) if siblings: # Merge local siblings, which take precedence merged = dict(dereferenced) merged.update(siblings) return merged return dereferenced # Recurse into nested structures result = {} for key, value in dereferenced.items(): if key in original: result[key] = _merge_ref_siblings(original[key], value, defs, visited) else: result[key] = value return result elif isinstance(original, list) and isinstance(dereferenced, list): # Process list items in parallel min_len = min(len(original), len(dereferenced)) return [ _merge_ref_siblings(o, d, defs, visited) for o, d in zip(original[:min_len], dereferenced[:min_len], strict=False) ] + dereferenced[min_len:] return dereferenced def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]: """Resolve $ref at root level to meet MCP spec requirements. MCP specification requires outputSchema to have "type": "object" at the root level. When Pydantic generates schemas for self-referential models, it uses $ref at the root level pointing to $defs. This function resolves such references by inlining the referenced definition while preserving $defs for nested references. Args: schema: JSON schema dict that may have $ref at root level Returns: A new schema dict with root-level $ref resolved, or the original schema if no resolution is needed Example: >>> schema = { ... "$defs": {"Node": {"type": "object", "properties": {...}}}, ... "$ref": "#/$defs/Node" ... } >>> resolved = resolve_root_ref(schema) >>> # Result: {"type": "object", "properties": {...}, "$defs": {...}} """ # Only resolve if we have $ref at root level with $defs but no explicit type if "$ref" in schema and "$defs" in schema and "type" not in schema: ref = schema["$ref"] # Only handle local $defs references if isinstance(ref, str) and ref.startswith("#/$defs/"): def_name = ref.split("/")[-1] defs = schema["$defs"] if def_name in defs: # Create a new schema by copying the referenced definition resolved = dict(defs[def_name]) # Preserve $defs for nested references (other fields may still use them) resolved["$defs"] = defs return resolved return schema def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: """Return a new schema with *param* removed from `properties`, `required`, and (if no longer referenced) `$defs`. """ # ── 1. drop from properties/required ────────────────────────────── props = schema.get("properties", {}) removed = props.pop(param, None) if removed is None: # nothing to do return schema # Keep empty properties object rather than removing it entirely schema["properties"] = props if param in schema.get("required", []): schema["required"].remove(param) if not schema["required"]: schema.pop("required") return schema def _single_pass_optimize( schema: dict[str, Any], prune_titles: bool = False, prune_additional_properties: bool = False, prune_defs: bool = True, ) -> dict[str, Any]: """ Optimize JSON schemas in a single traversal for better performance. This function combines three schema cleanup operations that would normally require separate tree traversals: 1. **Remove unused definitions** (prune_defs): Finds and removes `$defs` entries that aren't referenced anywhere in the schema, reducing schema size. 2. **Remove titles** (prune_titles): Strips `title` fields throughout the schema to reduce verbosity while preserving functional information. 3. **Remove restrictive additionalProperties** (prune_additional_properties): Removes `"additionalProperties": false` constraints to make schemas more flexible. **Performance Benefits:** - Single tree traversal instead of multiple passes (2-3x faster) - Immutable design prevents shared reference bugs - Early termination prevents runaway recursion on deeply nested schemas **Algorithm Overview:** 1. Traverse main schema, collecting $ref references and applying cleanups 2. Traverse $defs section to map inter-definition dependencies 3. Remove unused definitions based on reference analysis Args: schema: JSON schema dict to optimize (not modified) prune_titles: Remove title fields for cleaner output prune_additional_properties: Remove "additionalProperties": false constraints prune_defs: Remove unused $defs entries to reduce size Returns: A new optimized schema dict Example: >>> schema = { ... "type": "object", ... "title": "MySchema", ... "additionalProperties": False, ... "$defs": {"UnusedDef": {"type": "string"}} ... } >>> result = _single_pass_optimize(schema, prune_titles=True, prune_defs=True) >>> # Result: {"type": "object", "additionalProperties": False} """ if not (prune_defs or prune_titles or prune_additional_properties): return schema # Nothing to do # Phase 1: Collect references and apply simple cleanups # Track which $defs are referenced from the main schema and from other $defs root_refs: set[str] = set() # $defs referenced directly from main schema def_dependencies: defaultdict[str, list[str]] = defaultdict( list ) # def A references def B defs = schema.get("$defs") def traverse_and_clean( node: object, current_def_name: str | None = None, skip_defs_section: bool = False, depth: int = 0, ) -> None: """Traverse schema tree, collecting $ref info and applying cleanups.""" if depth > 50: # Prevent infinite recursion return if isinstance(node, dict): # Collect $ref references for unused definition removal if prune_defs: ref = node.get("$ref") # type: ignore if isinstance(ref, str) and ref.startswith("#/$defs/"): referenced_def = ref.split("/")[-1] if current_def_name: # We're inside a $def, so this is a def->def reference def_dependencies[referenced_def].append(current_def_name) else: # We're in the main schema, so this is a root reference root_refs.add(referenced_def) # Apply cleanups # Only remove "title" if it's a schema metadata field # Schema objects have keywords like "type", "properties", "$ref", etc. # If we see these, then "title" is metadata, not a property name if prune_titles and "title" in node: # Check if this looks like a schema node if any( k in node for k in [ "type", "properties", "$ref", "items", "allOf", "oneOf", "anyOf", "required", ] ): node.pop("title") # type: ignore if ( prune_additional_properties and node.get("additionalProperties") is False # type: ignore ): node.pop("additionalProperties") # type: ignore # Recursive traversal for key, value in node.items(): if skip_defs_section and key == "$defs": continue # Skip $defs during main schema traversal # Handle schema composition keywords with special traversal if key in ["allOf", "oneOf", "anyOf"] and isinstance(value, list): for item in value: traverse_and_clean(item, current_def_name, depth=depth + 1) else: traverse_and_clean(value, current_def_name, depth=depth + 1) elif isinstance(node, list): for item in node: traverse_and_clean(item, current_def_name, depth=depth + 1) # Phase 2: Traverse main schema (excluding $defs section) traverse_and_clean(schema, skip_defs_section=True) # Phase 3: Traverse $defs to find inter-definition references if prune_defs and defs: for def_name, def_schema in defs.items(): traverse_and_clean(def_schema, current_def_name=def_name) # Phase 4: Remove unused definitions def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool: """Check if a definition is used, handling circular references.""" if def_name in root_refs: return True # Used directly from main schema # Check if any definition that references this one is itself used referencing_defs = def_dependencies.get(def_name, []) if referencing_defs: if visiting is None: visiting = set() # Avoid infinite recursion on circular references if def_name in visiting: return False visiting = visiting | {def_name} # If any referencing def is used, then this def is used for referencing_def in referencing_defs: if referencing_def not in visiting and is_def_used( referencing_def, visiting ): return True return False # Remove unused definitions for def_name in list(defs.keys()): if not is_def_used(def_name): defs.pop(def_name) # Clean up empty $defs section if not defs: schema.pop("$defs", None) return schema def compress_schema( schema: dict[str, Any], prune_params: list[str] | None = None, prune_additional_properties: bool = False, prune_titles: bool = False, dereference: bool = False, ) -> dict[str, Any]: """ Compress and optimize a JSON schema for MCP compatibility. Args: schema: The schema to compress prune_params: List of parameter names to remove from properties prune_additional_properties: Whether to remove additionalProperties: false. Defaults to False to maintain MCP client compatibility, as some clients (e.g., Claude) require additionalProperties: false for strict validation. prune_titles: Whether to remove title fields from the schema dereference: Whether to dereference $ref by inlining definitions. Defaults to False; dereferencing is typically handled by middleware at serve-time instead. """ if dereference: schema = dereference_refs(schema) # Resolve root-level $ref for MCP spec compliance (requires type: object at root) schema = resolve_root_ref(schema) # Remove specific parameters if requested for param in prune_params or []: schema = _prune_param(schema, param=param) # Apply combined optimizations in a single tree traversal. # Always prune unused $defs to keep schemas clean after parameter removal. schema = _single_pass_optimize( schema, prune_titles=prune_titles, prune_additional_properties=prune_additional_properties, prune_defs=True, ) return schema ================================================ FILE: src/fastmcp/utilities/json_schema_type.py ================================================ """Convert JSON Schema to Python types with validation. The json_schema_to_type function converts a JSON Schema into a Python type that can be used for validation with Pydantic. It supports: - Basic types (string, number, integer, boolean, null) - Complex types (arrays, objects) - Format constraints (date-time, email, uri) - Numeric constraints (minimum, maximum, multipleOf) - String constraints (minLength, maxLength, pattern) - Array constraints (minItems, maxItems, uniqueItems) - Object properties with defaults - References and recursive schemas - Enums and constants - Union types Example: ```python schema = { "type": "object", "properties": { "name": {"type": "string", "minLength": 1}, "age": {"type": "integer", "minimum": 0}, "email": {"type": "string", "format": "email"} }, "required": ["name", "age"] } # Name is optional and will be inferred from schema's "title" property if not provided Person = json_schema_to_type(schema) # Creates a validated dataclass with name, age, and optional email fields ``` """ from __future__ import annotations import hashlib import json import re from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import MISSING, field, make_dataclass from datetime import datetime from typing import ( Annotated, Any, ForwardRef, Literal, Union, cast, ) from pydantic import ( AnyUrl, BaseModel, ConfigDict, EmailStr, Field, Json, StringConstraints, model_validator, ) from typing_extensions import NotRequired, TypedDict __all__ = ["JSONSchema", "json_schema_to_type"] FORMAT_TYPES: dict[str, Any] = { "date-time": datetime, "email": EmailStr, "uri": AnyUrl, "json": Json, } _classes: dict[tuple[str, Any], type | None] = {} class JSONSchema(TypedDict): type: NotRequired[str | list[str]] properties: NotRequired[dict[str, JSONSchema]] required: NotRequired[list[str]] additionalProperties: NotRequired[bool | JSONSchema] items: NotRequired[JSONSchema | list[JSONSchema]] enum: NotRequired[list[Any]] const: NotRequired[Any] default: NotRequired[Any] description: NotRequired[str] title: NotRequired[str] examples: NotRequired[list[Any]] format: NotRequired[str] allOf: NotRequired[list[JSONSchema]] anyOf: NotRequired[list[JSONSchema]] oneOf: NotRequired[list[JSONSchema]] not_: NotRequired[JSONSchema] definitions: NotRequired[dict[str, JSONSchema]] dependencies: NotRequired[dict[str, JSONSchema | list[str]]] pattern: NotRequired[str] minLength: NotRequired[int] maxLength: NotRequired[int] minimum: NotRequired[int | float] maximum: NotRequired[int | float] exclusiveMinimum: NotRequired[int | float] exclusiveMaximum: NotRequired[int | float] multipleOf: NotRequired[int | float] uniqueItems: NotRequired[bool] minItems: NotRequired[int] maxItems: NotRequired[int] additionalItems: NotRequired[bool | JSONSchema] def json_schema_to_type( schema: Mapping[str, Any], name: str | None = None, ) -> type: """Convert JSON schema to appropriate Python type with validation. Args: schema: A JSON Schema dictionary defining the type structure and validation rules name: Optional name for object schemas. Only allowed when schema type is "object". If not provided for objects, name will be inferred from schema's "title" property or default to "Root". Returns: A Python type (typically a dataclass for objects) with Pydantic validation Raises: ValueError: If a name is provided for a non-object schema Examples: Create a dataclass from an object schema: ```python schema = { "type": "object", "title": "Person", "properties": { "name": {"type": "string", "minLength": 1}, "age": {"type": "integer", "minimum": 0}, "email": {"type": "string", "format": "email"} }, "required": ["name", "age"] } Person = json_schema_to_type(schema) # Creates a dataclass with name, age, and optional email fields: # @dataclass # class Person: # name: str # age: int # email: str | None = None ``` Person(name="John", age=30) Create a scalar type with constraints: ```python schema = { "type": "string", "minLength": 3, "pattern": "^[A-Z][a-z]+$" } NameType = json_schema_to_type(schema) # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")] @dataclass class Name: name: NameType ``` """ # Always use the top-level schema for references if schema.get("type") == "object": # If no properties defined but has additionalProperties, return typed dict if not schema.get("properties") and schema.get("additionalProperties"): additional_props = schema["additionalProperties"] if additional_props is True: return dict[str, Any] else: # Handle typed dictionaries like dict[str, str] value_type = _schema_to_type(additional_props, schemas=schema) # value_type might be ForwardRef or type - cast to Any for dynamic type construction return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # If no properties and no additionalProperties, default to dict[str, Any] for safety elif not schema.get("properties") and not schema.get("additionalProperties"): return dict[str, Any] # If has properties AND additionalProperties is True, use Pydantic BaseModel elif schema.get("properties") and schema.get("additionalProperties") is True: return _create_pydantic_model(schema, name, schemas=schema) # Otherwise use fast dataclass return _create_dataclass(schema, name, schemas=schema) elif name: raise ValueError(f"Can not apply name to non-object schema: {name}") result = _schema_to_type(schema, schemas=schema) return result # type: ignore[return-value] def _hash_schema(schema: Mapping[str, Any]) -> str: """Generate a deterministic hash for schema caching.""" return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest() def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]: """Resolve JSON Schema reference to target schema.""" path = ref.replace("#/", "").split("/") current = schemas for part in path: current = current.get(part, {}) return current def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]: """Create string type with optional constraints.""" if "const" in schema: return Literal[schema["const"]] # type: ignore if fmt := schema.get("format"): if fmt == "uri": return AnyUrl elif fmt == "uri-reference": return str return FORMAT_TYPES.get(fmt, str) constraints = { k: v for k, v in { "min_length": schema.get("minLength"), "max_length": schema.get("maxLength"), "pattern": schema.get("pattern"), }.items() if v is not None } return Annotated[str, StringConstraints(**constraints)] if constraints else str def _create_numeric_type( base: type[int | float], schema: Mapping[str, Any] ) -> type | Annotated[Any, ...]: """Create numeric type with optional constraints.""" if "const" in schema: return Literal[schema["const"]] # type: ignore constraints = { k: v for k, v in { "gt": schema.get("exclusiveMinimum"), "ge": schema.get("minimum"), "lt": schema.get("exclusiveMaximum"), "le": schema.get("maximum"), "multiple_of": schema.get("multipleOf"), }.items() if v is not None } return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] def _create_enum(name: str, values: list[Any]) -> type: """Create enum type from list of values.""" # Always return Literal for enum fields to preserve the literal nature return Literal[tuple(values)] # type: ignore[return-value] def _create_array_type( schema: Mapping[str, Any], schemas: Mapping[str, Any] ) -> type | Annotated[Any, ...]: """Create list/set type with optional constraints.""" items = schema.get("items", {}) if isinstance(items, list): # Handle positional item schemas item_types = [_schema_to_type(s, schemas) for s in items] combined = Union[tuple(item_types)] # noqa: UP007 base = list[combined] # type: ignore[valid-type] else: # Handle single item schema item_type = _schema_to_type(items, schemas) base_class = set if schema.get("uniqueItems") else list base = base_class[item_type] constraints = { k: v for k, v in { "min_length": schema.get("minItems"), "max_length": schema.get("maxItems"), }.items() if v is not None } return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] def _return_Any() -> Any: return Any def _get_from_type_handler( schema: Mapping[str, Any], schemas: Mapping[str, Any] ) -> Callable[..., Any]: """Get the appropriate type handler for the schema.""" type_handlers: dict[str, Callable[..., Any]] = { # TODO "string": lambda s: _create_string_type(s), "integer": lambda s: _create_numeric_type(int, s), "number": lambda s: _create_numeric_type(float, s), "boolean": lambda _: bool, "null": lambda _: type(None), "array": lambda s: _create_array_type(s, schemas), "object": lambda s: ( _create_pydantic_model(s, s.get("title"), schemas) if s.get("properties") and s.get("additionalProperties") is True else _create_dataclass(s, s.get("title"), schemas) ), } return type_handlers.get(schema.get("type", None), _return_Any) def _schema_to_type( schema: Mapping[str, Any], schemas: Mapping[str, Any], ) -> type | ForwardRef: """Convert schema to appropriate Python type.""" if not schema: return object if "type" not in schema and "properties" in schema: return _create_dataclass(schema, schema.get("title", ""), schemas) # Handle references first if "$ref" in schema: ref = schema["$ref"] # Handle self-reference if ref == "#": return ForwardRef(schema.get("title", "Root")) return _schema_to_type(_resolve_ref(ref, schemas), schemas) if "const" in schema: return Literal[schema["const"]] # type: ignore if "enum" in schema: return _create_enum(f"Enum_{len(_classes)}", schema["enum"]) # Handle anyOf unions if "anyOf" in schema: types: list[type | Any] = [] for subschema in schema["anyOf"]: # Special handling for dict-like objects in unions if ( subschema.get("type") == "object" and not subschema.get("properties") and subschema.get("additionalProperties") ): # This is a dict type, handle it directly additional_props = subschema["additionalProperties"] if additional_props is True: types.append(dict[str, Any]) else: value_type = _schema_to_type(additional_props, schemas) types.append(dict[str, value_type]) # type: ignore else: types.append(_schema_to_type(subschema, schemas)) # Check if one of the types is None (null) has_null = type(None) in types types = [t for t in types if t is not type(None)] if len(types) == 0: return type(None) elif len(types) == 1: if has_null: return types[0] | None # type: ignore else: return types[0] else: if has_null: return Union[(*types, type(None))] # type: ignore else: return Union[tuple(types)] # type: ignore # noqa: UP007 schema_type = schema.get("type") if not schema_type: return Any if isinstance(schema_type, list): # Create a copy of the schema for each type, but keep all constraints types: list[type | Any] = [] for t in schema_type: type_schema = dict(schema) type_schema["type"] = t types.append(_schema_to_type(type_schema, schemas)) has_null = type(None) in types types = [t for t in types if t is not type(None)] if has_null: if len(types) == 1: return types[0] | None # type: ignore else: return Union[(*types, type(None))] # type: ignore return Union[tuple(types)] # type: ignore # noqa: UP007 return _get_from_type_handler(schema, schemas)(schema) def _sanitize_name(name: str) -> str: """Convert string to valid Python identifier.""" original_name = name # Step 1: replace everything except [0-9a-zA-Z_] with underscores cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name) # Step 2: deduplicate underscores cleaned = re.sub(r"__+", "_", cleaned) # Step 3: if the first char of original name isn't a letter or underscore, prepend field_ if not name or not re.match(r"[a-zA-Z_]", name[0]): cleaned = f"field_{cleaned}" # Step 4: deduplicate again cleaned = re.sub(r"__+", "_", cleaned) # Step 5: only strip trailing underscores if they weren't in the original name if not original_name.endswith("_"): cleaned = cleaned.rstrip("_") return cleaned def _get_default_value( schema: dict[str, Any], prop_name: str, parent_default: dict[str, Any] | None = None, ) -> Any: """Get default value with proper priority ordering. 1. Value from parent's default if it exists 2. Property's own default if it exists 3. None """ if parent_default is not None and prop_name in parent_default: return parent_default[prop_name] return schema.get("default") def _create_field_with_default( field_type: type, default_value: Any, schema: dict[str, Any], ) -> Any: """Create a field with simplified default handling.""" # Always use None as default for complex types if isinstance(default_value, dict | list) or default_value is None: return field(default=None) # For simple types, use the value directly return field(default=default_value) def _create_pydantic_model( schema: Mapping[str, Any], name: str | None = None, schemas: Mapping[str, Any] | None = None, ) -> type: """Create Pydantic BaseModel from object schema with additionalProperties.""" name = name or schema.get("title", "Root") if name is None: raise ValueError("Name is required") sanitized_name = _sanitize_name(name) schema_hash = _hash_schema(schema) cache_key = (schema_hash, sanitized_name) # Return existing class if already built if cache_key in _classes: existing = _classes[cache_key] if existing is None: return ForwardRef(sanitized_name) # type: ignore[return-value] return existing # Place placeholder for recursive references _classes[cache_key] = None properties = schema.get("properties", {}) required = schema.get("required", []) # Build field annotations and defaults annotations = {} defaults = {} for prop_name, prop_schema in properties.items(): field_type = _schema_to_type(prop_schema, schemas or {}) # Handle defaults default_value = prop_schema.get("default", MISSING) if default_value is not MISSING: defaults[prop_name] = default_value annotations[prop_name] = field_type elif prop_name in required: annotations[prop_name] = field_type else: annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007 defaults[prop_name] = None # Create Pydantic model class cls_dict = { "__annotations__": annotations, "model_config": ConfigDict(extra="allow"), **defaults, } cls = type(sanitized_name, (BaseModel,), cls_dict) # Store completed class _classes[cache_key] = cls return cls def _create_dataclass( schema: Mapping[str, Any], name: str | None = None, schemas: Mapping[str, Any] | None = None, ) -> type: """Create dataclass from object schema.""" name = name or schema.get("title", "Root") # Sanitize name for class creation if name is None: raise ValueError("Name is required") sanitized_name = _sanitize_name(name) schema_hash = _hash_schema(schema) cache_key = (schema_hash, sanitized_name) original_schema = dict(schema) # Store copy for validator # Return existing class if already built if cache_key in _classes: existing = _classes[cache_key] if existing is None: return ForwardRef(sanitized_name) # type: ignore[return-value] return existing # Place placeholder for recursive references _classes[cache_key] = None if "$ref" in schema: ref = schema["$ref"] if ref == "#": return ForwardRef(sanitized_name) # type: ignore[return-value] schema = _resolve_ref(ref, schemas or {}) properties = schema.get("properties", {}) required = schema.get("required", []) fields: list[tuple[Any, ...]] = [] for prop_name, prop_schema in properties.items(): field_name = _sanitize_name(prop_name) # Check for self-reference in property if prop_schema.get("$ref") == "#": field_type = ForwardRef(sanitized_name) else: field_type = _schema_to_type(prop_schema, schemas or {}) default_val = prop_schema.get("default", MISSING) is_required = prop_name in required # Include alias in field metadata meta = {"alias": prop_name} if default_val is not MISSING: if isinstance(default_val, dict | list): field_def = field( default_factory=lambda d=default_val: deepcopy(d), metadata=meta ) else: field_def = field(default=default_val, metadata=meta) else: if is_required: field_def = field(metadata=meta) else: field_def = field(default=None, metadata=meta) if is_required or default_val is not MISSING: fields.append((field_name, field_type, field_def)) else: fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007 cls = make_dataclass(sanitized_name, fields, kw_only=True) # Add model validator for defaults @model_validator(mode="before") @classmethod def _apply_defaults(cls, data: Mapping[str, Any]): if isinstance(data, dict): return _merge_defaults(data, original_schema) return data cls._apply_defaults = _apply_defaults # type: ignore[attr-defined] # Store completed class _classes[cache_key] = cls return cls def _merge_defaults( data: Mapping[str, Any], schema: Mapping[str, Any], parent_default: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Merge defaults with provided data at all levels.""" # If we have no data if not data: # Start with parent default if available if parent_default: result = dict(parent_default) # Otherwise use schema default if available elif "default" in schema: result = dict(schema["default"]) # Otherwise start empty else: result = {} # If we have data and a parent default, merge them elif parent_default: result = dict(parent_default) for key, value in data.items(): if ( isinstance(value, dict) and key in result and isinstance(result[key], dict) ): # recursively merge nested dicts result[key] = _merge_defaults(value, {"properties": {}}, result[key]) else: result[key] = value # Otherwise just use the data else: result = dict(data) # For each property in the schema for prop_name, prop_schema in schema.get("properties", {}).items(): # If property is missing, apply defaults in priority order if prop_name not in result: if parent_default and prop_name in parent_default: result[prop_name] = parent_default[prop_name] elif "default" in prop_schema: result[prop_name] = prop_schema["default"] # If property exists and is an object, recursively merge if ( prop_name in result and isinstance(result[prop_name], dict) and prop_schema.get("type") == "object" ): # Get the appropriate default for this nested object nested_default = None if parent_default and prop_name in parent_default: nested_default = parent_default[prop_name] elif "default" in prop_schema: nested_default = prop_schema["default"] result[prop_name] = _merge_defaults( result[prop_name], prop_schema, nested_default ) return result ================================================ FILE: src/fastmcp/utilities/lifespan.py ================================================ """Lifespan utilities for combining async context manager lifespans.""" from __future__ import annotations from collections.abc import AsyncIterator, Callable, Mapping from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from typing import Any, TypeVar AppT = TypeVar("AppT") def combine_lifespans( *lifespans: Callable[[AppT], AbstractAsyncContextManager[Mapping[str, Any] | None]], ) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]]: """Combine multiple lifespans into a single lifespan. Useful when mounting FastMCP into FastAPI and you need to run both your app's lifespan and the MCP server's lifespan. Works with both FastAPI-style lifespans (yield None) and FastMCP-style lifespans (yield dict). Results are merged; later lifespans override earlier ones on key conflicts. Lifespans are entered in order and exited in reverse order (LIFO). Example: ```python from fastmcp import FastMCP from fastmcp.utilities.lifespan import combine_lifespans from fastapi import FastAPI mcp = FastMCP("Tools") mcp_app = mcp.http_app() app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) app.mount("/mcp", mcp_app) # MCP endpoint at /mcp ``` Args: *lifespans: Lifespan context manager factories to combine. Returns: A combined lifespan context manager factory. """ @asynccontextmanager async def combined(app: AppT) -> AsyncIterator[dict[str, Any]]: merged: dict[str, Any] = {} async with AsyncExitStack() as stack: for ls in lifespans: result = await stack.enter_async_context(ls(app)) if result is not None: merged.update(result) yield merged return combined ================================================ FILE: src/fastmcp/utilities/logging.py ================================================ """Logging utilities for FastMCP.""" import contextlib import logging from typing import Any, Literal, cast from rich.console import Console from rich.logging import RichHandler from typing_extensions import override import fastmcp def get_logger(name: str) -> logging.Logger: """Get a logger nested under FastMCP namespace. Args: name: the name of the logger, which will be prefixed with 'FastMCP.' Returns: a configured logger instance """ if name.startswith("fastmcp."): return logging.getLogger(name=name) return logging.getLogger(name=f"fastmcp.{name}") def configure_logging( level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any, ) -> None: """ Configure logging for FastMCP. Args: logger: the logger to configure level: the log level to use rich_kwargs: the parameters to use for creating RichHandler """ # Check if logging is disabled in settings if not fastmcp.settings.log_enabled: return # Use settings default if not specified if enable_rich_tracebacks is None: enable_rich_tracebacks = fastmcp.settings.enable_rich_tracebacks if logger is None: logger = logging.getLogger("fastmcp") formatter = logging.Formatter("%(message)s") # Don't propagate to the root logger logger.propagate = False logger.setLevel(level) # Remove any existing handlers to avoid duplicates on reconfiguration for hdlr in logger.handlers[:]: logger.removeHandler(hdlr) # Use standard logging handlers if rich logging is disabled if not fastmcp.settings.enable_rich_logging: # Create a standard StreamHandler for stderr handler = logging.StreamHandler() handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) logger.addHandler(handler) return # Configure the handler for normal logs handler = RichHandler( console=Console(stderr=True), **rich_kwargs, ) handler.setFormatter(formatter) # filter to exclude tracebacks handler.addFilter(lambda record: record.exc_info is None) # Configure the handler for tracebacks, for tracebacks we use a compressed format: # no path or level name to maximize width available for the traceback # suppress framework frames and limit the number of frames to 3 import mcp import pydantic # Build traceback kwargs with defaults that can be overridden traceback_kwargs = { "console": Console(stderr=True), "show_path": False, "show_level": False, "rich_tracebacks": enable_rich_tracebacks, "tracebacks_max_frames": 3, "tracebacks_suppress": [fastmcp, mcp, pydantic], } # Override defaults with user-provided values traceback_kwargs.update(rich_kwargs) traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type] traceback_handler.setFormatter(formatter) traceback_handler.addFilter(lambda record: record.exc_info is not None) logger.addHandler(handler) logger.addHandler(traceback_handler) @contextlib.contextmanager def temporary_log_level( level: str | None, logger: logging.Logger | None = None, enable_rich_tracebacks: bool | None = None, **rich_kwargs: Any, ): """Context manager to temporarily set log level and restore it afterwards. Args: level: The temporary log level to set (e.g., "DEBUG", "INFO") logger: Optional logger to configure (defaults to FastMCP logger) enable_rich_tracebacks: Whether to enable rich tracebacks **rich_kwargs: Additional parameters for RichHandler Usage: with temporary_log_level("DEBUG"): # Code that runs with DEBUG logging pass # Original log level is restored here """ if level: # Get the original log level from settings original_level = fastmcp.settings.log_level # Configure with new level # Cast to proper type for type checker log_level_literal = cast( Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], level.upper(), ) configure_logging( level=log_level_literal, logger=logger, enable_rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) try: yield finally: # Restore original configuration using configure_logging # This will respect the log_enabled setting configure_logging( level=original_level, logger=logger, enable_rich_tracebacks=enable_rich_tracebacks, **rich_kwargs, ) else: yield _level_to_no: dict[ Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None ] = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, None: None, } class _ClampedLogFilter(logging.Filter): min_level: tuple[int, str] | None max_level: tuple[int, str] | None def __init__( self, min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, ): self.min_level = None self.max_level = None if min_level_no := _level_to_no.get(min_level): self.min_level = (min_level_no, str(min_level)) if max_level_no := _level_to_no.get(max_level): self.max_level = (max_level_no, str(max_level)) super().__init__() @override def filter(self, record: logging.LogRecord) -> bool: if self.max_level: max_level_no, max_level_name = self.max_level if record.levelno > max_level_no: record.levelno = max_level_no record.levelname = max_level_name return True if self.min_level: min_level_no, min_level_name = self.min_level if record.levelno < min_level_no: record.levelno = min_level_no record.levelname = min_level_name return True return True def _clamp_logger( logger: logging.Logger, min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, ) -> None: """Clamp the logger to a minimum and maximum level. If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`. If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`. Args: min_level: The lower bound of the clamp max_level: The upper bound of the clamp """ _unclamp_logger(logger=logger) logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level)) def _unclamp_logger(logger: logging.Logger) -> None: """Remove all clamped log filters from the logger.""" for filter in logger.filters[:]: if isinstance(filter, _ClampedLogFilter): logger.removeFilter(filter) ================================================ FILE: src/fastmcp/utilities/mcp_server_config/__init__.py ================================================ """FastMCP Configuration module. This module provides versioned configuration support for FastMCP servers. The current version is v1, which is re-exported here for convenience. """ from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( Deployment, MCPServerConfig, generate_schema, ) from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource __all__ = [ "Deployment", "Environment", "FileSystemSource", "MCPServerConfig", "Source", "UVEnvironment", "generate_schema", ] ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/__init__.py ================================================ ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py ================================================ """Environment configuration for MCP servers.""" from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment __all__ = ["Environment", "UVEnvironment"] ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/environments/base.py ================================================ from abc import ABC, abstractmethod from pathlib import Path from pydantic import BaseModel, Field class Environment(BaseModel, ABC): """Base class for environment configuration.""" type: str = Field(description="Environment type identifier") @abstractmethod def build_command(self, command: list[str]) -> list[str]: """Build the full command with environment setup. Args: command: Base command to wrap with environment setup Returns: Full command ready for subprocess execution """ async def prepare(self, output_dir: Path | None = None) -> None: """Prepare the environment (optional, can be no-op). Args: output_dir: Directory for persistent environment setup """ # Default no-op implementation ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py ================================================ import shutil import subprocess from pathlib import Path from typing import Literal from pydantic import Field from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment logger = get_logger("cli.config") class UVEnvironment(Environment): """Configuration for Python environment setup.""" type: Literal["uv"] = "uv" python: str | None = Field( default=None, description="Python version constraint", examples=["3.10", "3.11", "3.12"], ) dependencies: list[str] | None = Field( default=None, description="Python packages to install with PEP 508 specifiers", examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], ) requirements: Path | None = Field( default=None, description="Path to requirements.txt file", examples=["requirements.txt", "../requirements/prod.txt"], ) project: Path | None = Field( default=None, description="Path to project directory containing pyproject.toml", examples=[".", "../my-project"], ) editable: list[Path] | None = Field( default=None, description="Directories to install in editable mode", examples=[[".", "../my-package"], ["/path/to/package"]], ) def build_command(self, command: list[str]) -> list[str]: """Build complete uv run command with environment args and command to execute. Args: command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) Returns: Complete command ready for subprocess.run, including "uv" prefix if needed. If no environment configuration is set, returns the command unchanged. """ # If no environment setup is needed, return command as-is if not self._must_run_with_uv(): return command args = ["uv", "run"] # Add project if specified if self.project: args.extend(["--project", str(self.project.resolve())]) # Add Python version if specified (only if no project, as project has its own Python) if self.python and not self.project: args.extend(["--python", self.python]) # Always add dependencies, requirements, and editable packages # These work with --project to add additional packages on top of the project env if self.dependencies: for dep in sorted(set(self.dependencies)): args.extend(["--with", dep]) # Add requirements file if self.requirements: args.extend(["--with-requirements", str(self.requirements.resolve())]) # Add editable packages if self.editable: for editable_path in self.editable: args.extend(["--with-editable", str(editable_path.resolve())]) # Add the command args.extend(command) return args def _must_run_with_uv(self) -> bool: """Check if this environment config requires uv to set up. Returns: True if any environment settings require uv run """ 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, ] ) async def prepare(self, output_dir: Path | None = None) -> None: """Prepare the Python environment using uv. Args: output_dir: Directory where the persistent uv project will be created. If None, creates a temporary directory for ephemeral use. """ # Check if uv is available if not shutil.which("uv"): raise RuntimeError( "uv is not installed. Please install it with: " "curl -LsSf https://astral.sh/uv/install.sh | sh" ) # Only prepare environment if there are actual settings to apply if not self._must_run_with_uv(): logger.debug("No environment settings configured, skipping preparation") return # Handle None case for ephemeral use if output_dir is None: import tempfile output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-")) logger.info(f"Creating ephemeral environment in {output_dir}") else: logger.info(f"Creating persistent environment in {output_dir}") output_dir = Path(output_dir).resolve() # Initialize the project logger.debug(f"Initializing uv project in {output_dir}") try: subprocess.run( [ "uv", "init", "--project", str(output_dir), "--name", "fastmcp-env", ], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: # If project already exists, that's fine - continue if "already initialized" in e.stderr.lower(): logger.debug( f"Project already initialized at {output_dir}, continuing..." ) else: logger.error(f"Failed to initialize project: {e.stderr}") raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e # Pin Python version if specified if self.python: logger.debug(f"Pinning Python version to {self.python}") try: subprocess.run( [ "uv", "python", "pin", self.python, "--project", str(output_dir), ], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: logger.error(f"Failed to pin Python version: {e.stderr}") raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e # Add dependencies with --no-sync to defer installation # dependencies ALWAYS include fastmcp; this is compatible with # specific fastmcp versions that might be in the dependencies list dependencies = (self.dependencies or []) + ["fastmcp"] logger.debug(f"Adding dependencies: {', '.join(dependencies)}") try: subprocess.run( [ "uv", "add", *dependencies, "--no-sync", "--project", str(output_dir), ], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: logger.error(f"Failed to add dependencies: {e.stderr}") raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e # Add requirements file if specified if self.requirements: logger.debug(f"Adding requirements from {self.requirements}") # Resolve requirements path relative to current directory req_path = Path(self.requirements).resolve() try: subprocess.run( [ "uv", "add", "-r", str(req_path), "--no-sync", "--project", str(output_dir), ], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: logger.error(f"Failed to add requirements: {e.stderr}") raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e # Add editable packages if specified if self.editable: editable_paths = [str(Path(e).resolve()) for e in self.editable] logger.debug(f"Adding editable packages: {', '.join(editable_paths)}") try: subprocess.run( [ "uv", "add", "--editable", *editable_paths, "--no-sync", "--project", str(output_dir), ], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: logger.error(f"Failed to add editable packages: {e.stderr}") raise RuntimeError( f"Failed to add editable packages: {e.stderr}" ) from e # Final sync to install everything logger.info("Installing dependencies...") try: subprocess.run( ["uv", "sync", "--project", str(output_dir)], check=True, capture_output=True, text=True, ) except subprocess.CalledProcessError as e: logger.error(f"Failed to sync dependencies: {e.stderr}") raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e logger.info(f"Environment prepared successfully in {output_dir}") ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py ================================================ """FastMCP Configuration File Support. This module provides support for fastmcp.json configuration files that allow users to specify server settings in a declarative format instead of using command-line arguments. """ from __future__ import annotations import json import os import re from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload from pydantic import BaseModel, Field, field_validator from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.base import Source from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource logger = get_logger("cli.config") # JSON Schema for IDE support FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" # Type alias for source union (will expand with GitSource, etc. in future) SourceType: TypeAlias = FileSystemSource # Type alias for environment union (will expand with other environments in future) EnvironmentType: TypeAlias = UVEnvironment class Deployment(BaseModel): """Configuration for server deployment and runtime settings.""" transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field( default=None, description="Transport protocol to use", ) host: str | None = Field( default=None, description="Host to bind to when using HTTP transport", examples=["127.0.0.1", "0.0.0.0", "localhost"], ) port: int | None = Field( default=None, description="Port to bind to when using HTTP transport", examples=[8000, 3000, 5000], ) path: str | None = Field( default=None, description="URL path for the server endpoint", examples=["/mcp/", "/api/mcp/", "/sse/"], ) log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = Field( default=None, description="Log level for the server", ) cwd: str | None = Field( default=None, description="Working directory for the server process", examples=[".", "./src", "/app"], ) env: dict[str, str] | None = Field( default=None, description="Environment variables to set when running the server", examples=[{"API_KEY": "secret", "DEBUG": "true"}], ) args: list[str] | None = Field( default=None, description="Arguments to pass to the server (after --)", examples=[["--config", "config.json", "--debug"]], ) def apply_runtime_settings(self, config_path: Path | None = None) -> None: """Apply runtime settings like environment variables and working directory. Args: config_path: Path to config file for resolving relative paths Environment variables support interpolation with ${VAR_NAME} syntax. For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" will substitute the value of the ENVIRONMENT variable at runtime. """ import os from pathlib import Path # Set environment variables with interpolation support if self.env: for key, value in self.env.items(): # Interpolate environment variables in the value interpolated_value = self._interpolate_env_vars(value) os.environ[key] = interpolated_value # Change working directory if self.cwd: cwd_path = Path(self.cwd) if not cwd_path.is_absolute() and config_path: cwd_path = (config_path.parent / cwd_path).resolve() os.chdir(cwd_path) def _interpolate_env_vars(self, value: str) -> str: """Interpolate environment variables in a string. Replaces ${VAR_NAME} with the value of VAR_NAME from the environment. If the variable is not set, the placeholder is left unchanged. Args: value: String potentially containing ${VAR_NAME} placeholders Returns: String with environment variables interpolated """ def replace_var(match: re.Match) -> str: var_name = match.group(1) # Return the environment variable value if it exists, otherwise keep the placeholder return os.environ.get(var_name, match.group(0)) # Match ${VAR_NAME} pattern and replace with environment variable values return re.sub(r"\$\{([^}]+)\}", replace_var, value) class MCPServerConfig(BaseModel): """Configuration for a FastMCP server. This configuration file allows you to specify all settings needed to run a FastMCP server in a declarative format. """ # Schema field for IDE support schema_: str | None = Field( default="https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", alias="$schema", description="JSON schema for IDE support and validation", ) # Server source - defines where and how to load the server source: SourceType = Field( description="Source configuration for the server", examples=[ {"path": "server.py"}, {"path": "server.py", "entrypoint": "app"}, {"type": "filesystem", "path": "src/server.py", "entrypoint": "mcp"}, ], ) # Environment configuration environment: EnvironmentType = Field( default_factory=lambda: UVEnvironment(), description="Python environment setup configuration", ) # Deployment configuration deployment: Deployment = Field( default_factory=lambda: Deployment(), description="Server deployment and runtime settings", ) # purely for static type checkers to avoid issues with providing dict source if TYPE_CHECKING: @overload def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ... @overload def __init__(self, *, environment: dict | UVEnvironment, **data) -> None: ... @overload def __init__(self, *, deployment: dict | Deployment, **data) -> None: ... def __init__(self, **data) -> None: ... @field_validator("source", mode="before") @classmethod def validate_source(cls, v: dict | Source) -> SourceType: """Validate and convert source to proper format. Supports: - Dict format: `{"path": "server.py", "entrypoint": "app"}` - FileSystemSource instance (passed through) No string parsing happens here - that's only at CLI boundaries. MCPServerConfig works only with properly typed objects. """ if isinstance(v, dict): return FileSystemSource(**v) return v # type: ignore[return-value] @field_validator("environment", mode="before") @classmethod def validate_environment(cls, v: dict | Any) -> EnvironmentType: """Ensure environment has a type field for discrimination. For backward compatibility, if no type is specified, default to "uv". """ if isinstance(v, dict): return UVEnvironment(**v) return v @field_validator("deployment", mode="before") @classmethod def validate_deployment(cls, v: dict | Deployment) -> Deployment: """Validate and convert deployment to Deployment. Accepts: - Deployment instance - dict that can be converted to Deployment """ if isinstance(v, dict): return Deployment(**v) return cast(Deployment, v) # type: ignore[return-value] @classmethod def from_file(cls, file_path: Path) -> MCPServerConfig: """Load configuration from a JSON file. Args: file_path: Path to the configuration file Returns: MCPServerConfig instance Raises: FileNotFoundError: If the file doesn't exist json.JSONDecodeError: If the file is not valid JSON pydantic.ValidationError: If the configuration is invalid """ if not file_path.exists(): raise FileNotFoundError(f"Configuration file not found: {file_path}") with file_path.open("r", encoding="utf-8") as f: data = json.load(f) return cls.model_validate(data) @classmethod def from_cli_args( cls, source: FileSystemSource, transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, host: str | None = None, port: int | None = None, path: str | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, python: str | None = None, dependencies: list[str] | None = None, requirements: str | None = None, project: str | None = None, editable: str | None = None, env: dict[str, str] | None = None, cwd: str | None = None, args: list[str] | None = None, ) -> MCPServerConfig: """Create a config from CLI arguments. This allows us to have a single code path where everything goes through a config object. Args: source: Server source (FileSystemSource instance) transport: Transport protocol host: Host for HTTP transport port: Port for HTTP transport path: URL path for server log_level: Logging level python: Python version dependencies: Python packages to install requirements: Path to requirements file project: Path to project directory editable: Path to install in editable mode env: Environment variables cwd: Working directory args: Server arguments Returns: MCPServerConfig instance """ # Build environment config if any env args provided environment = None if any([python, dependencies, requirements, project, editable]): environment = UVEnvironment( python=python, dependencies=dependencies, requirements=Path(requirements) if requirements else None, project=Path(project) if project else None, editable=[Path(editable)] if editable else None, ) # Build deployment config if any deployment args provided deployment = None if any([transport, host, port, path, log_level, env, cwd, args]): # Convert streamable-http to http for backward compatibility if transport == "streamable-http": transport = "http" deployment = Deployment( transport=transport, host=host, port=port, path=path, log_level=log_level, env=env, cwd=cwd, args=args, ) return cls( source=source, environment=environment, deployment=deployment, ) @classmethod def find_config(cls, start_path: Path | None = None) -> Path | None: """Find a fastmcp.json file in the specified directory. Args: start_path: Directory to look in (defaults to current directory) Returns: Path to the configuration file, or None if not found """ if start_path is None: start_path = Path.cwd() config_path = start_path / "fastmcp.json" if config_path.exists(): logger.debug(f"Found configuration file: {config_path}") return config_path return None async def prepare( self, skip_source: bool = False, output_dir: Path | None = None, ) -> None: """Prepare environment and source for execution. When output_dir is provided, creates a persistent uv project. When output_dir is None, does ephemeral caching (for backwards compatibility). Args: skip_source: Skip source preparation if True output_dir: Directory to create the persistent uv project in (optional) """ # Prepare environment (persistent if output_dir provided, ephemeral otherwise) if self.environment: await self.prepare_environment(output_dir=output_dir) if not skip_source: await self.prepare_source() async def prepare_environment(self, output_dir: Path | None = None) -> None: """Prepare the Python environment. Args: output_dir: If provided, creates a persistent uv project in this directory. If None, just populates uv's cache for ephemeral use. Delegates to the environment's prepare() method """ await self.environment.prepare(output_dir=output_dir) async def prepare_source(self) -> None: """Prepare the source for loading. Delegates to the source's prepare() method. """ await self.source.prepare() async def run_server(self, **kwargs: Any) -> None: """Load and run the server with this configuration. Args: **kwargs: Additional arguments to pass to server.run_async() These override config settings """ # 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 = {} if self.deployment: if self.deployment.transport: run_args["transport"] = self.deployment.transport if self.deployment.host: run_args["host"] = self.deployment.host if self.deployment.port: run_args["port"] = self.deployment.port if self.deployment.path: run_args["path"] = self.deployment.path if self.deployment.log_level: run_args["log_level"] = self.deployment.log_level # Override with any provided kwargs run_args.update(kwargs) # Run the server await server.run_async(**run_args) 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 if output_path is None, otherwise None """ schema = MCPServerConfig.model_json_schema() # Add some metadata schema["$id"] = FASTMCP_JSON_SCHEMA 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 ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/schema.json ================================================ { "$defs": { "Deployment": { "description": "Configuration for server deployment and runtime settings.", "properties": { "transport": { "anyOf": [ { "enum": [ "stdio", "http", "sse", "streamable-http" ], "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" }, "FileSystemSource": { "description": "Source for local Python files.", "properties": { "type": { "const": "filesystem", "default": "filesystem", "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" }, "UVEnvironment": { "description": "Configuration for Python environment setup.", "properties": { "type": { "const": "uv", "default": "uv", "title": "Type", "type": "string" }, "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": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to requirements.txt file", "examples": [ "requirements.txt", "../requirements/prod.txt" ], "title": "Requirements" }, "project": { "anyOf": [ { "format": "path", "type": "string" }, { "type": "null" } ], "default": null, "description": "Path to project directory containing pyproject.toml", "examples": [ ".", "../my-project" ], "title": "Project" }, "editable": { "anyOf": [ { "items": { "format": "path", "type": "string" }, "type": "array" }, { "type": "null" } ], "default": null, "description": "Directories to install in editable mode", "examples": [ [ ".", "../my-package" ], [ "/path/to/package" ] ], "title": "Editable" } }, "title": "UVEnvironment", "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/UVEnvironment", "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" } ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py ================================================ ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/sources/base.py ================================================ from abc import ABC, abstractmethod from typing import Any from pydantic import BaseModel, Field class Source(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 @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. """ ... ================================================ FILE: src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py ================================================ 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.async_utils import is_coroutine_function from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_server_config.v1.sources.base import Source logger = get_logger(__name__) class FileSystemSource(Source): """Source for local Python files.""" type: Literal["filesystem"] = "filesystem" 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) sys.modules["server_module"] = module # Register in sys.modules spec.loader.exec_module(module) 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 is_coroutine_function(obj): logger.debug(f"Found factory function '{name}' in {file_path}") try: if is_coroutine_function(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 ================================================ FILE: src/fastmcp/utilities/openapi/README.md ================================================ # OpenAPI Utilities This directory contains the OpenAPI integration utilities for FastMCP. ## Architecture Overview The implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance. ### Core Components 1. **`director.py`** - `RequestDirector` for stateless HTTP request building 2. **`parser.py`** - OpenAPI spec parsing and route extraction with pre-calculated schemas 3. **`schemas.py`** - Schema processing with parameter mapping for collision handling 4. **`models.py`** - Enhanced data models with pre-calculated fields for performance 5. **`formatters.py`** - Response formatting and processing utilities ### Key Architecture Principles #### 1. Stateless Request Building - Uses `openapi-core` library for robust OpenAPI parameter serialization - Builds HTTP requests on-demand with zero startup latency - Offloads OpenAPI compliance to a well-tested library without code generation overhead #### 2. Pre-calculated Optimization - **Schema Pre-calculation**: Combined schemas calculated once during parsing - **Parameter Mapping**: Collision resolution mapping calculated upfront - **Zero Runtime Overhead**: All complex processing done during initialization #### 3. Performance-First Design - **No Code Generation**: Eliminates 100-200ms startup latency - **Serverless Friendly**: Ideal for cold-start environments - **Minimal Dependencies**: Uses lightweight `openapi-core` instead of full client generation ## Data Flow ### Initialization Process ``` OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector + SchemaPath ``` 1. **Input**: Raw OpenAPI specification (dict) 2. **Parsing**: Extract operations to `HTTPRoute` models 3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing 4. **Director Setup**: Create `RequestDirector` with `SchemaPath` for request building ### Request Processing ``` MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output ``` 1. **Tool Invocation**: FastMCP receives tool call with parameters 2. **Request Building**: RequestDirector builds HTTP request using parameter map 3. **Parameter Handling**: openapi-core handles all OpenAPI serialization rules 4. **Response Processing**: Parse response into structured format with proper error handling ## Key Features ### 1. High-Performance Request Building - Zero startup latency - no code generation required - Stateless request building scales infinitely - Uses proven `openapi-core` library for OpenAPI compliance - Perfect for serverless and cold-start environments ### 2. Comprehensive Parameter Support - **Parameter Collisions**: Intelligent collision resolution with suffixing - **DeepObject Style**: Full support for deepObject parameters with explode=true/false - **Complex Schemas**: Handles nested objects, arrays, and all OpenAPI types - **Pre-calculated Mapping**: Parameter location mapping done upfront for performance ### 3. Enhanced Error Handling - HTTP status code mapping to MCP errors - Structured error responses with detailed information - Graceful handling of network timeouts and connection errors - Proper error context preservation ### 4. Advanced Schema Processing - **Pre-calculated Schemas**: Combined parameter and body schemas calculated once - **Collision-aware**: Automatically handles parameter name collisions - **Type Safety**: Full Pydantic model validation - **Performance**: Zero runtime schema processing overhead ## Component Integration ### Server Components (`/server/openapi/`) 1. **`OpenAPITool`** - Simplified tool implementation using RequestDirector 2. **`OpenAPIResource`** - Resource implementation with RequestDirector 3. **`OpenAPIResourceTemplate`** - Resource template with RequestDirector support 4. **`FastMCPOpenAPI`** - Main server class with stateless request building ### RequestDirector Integration All components use the same RequestDirector approach: - Consistent parameter handling across all component types - Uniform error handling and response processing - Simplified architecture without fallback complexity - High performance for all operation types ## Usage Examples ### Basic Server Setup ```python import httpx from fastmcp.server.openapi import FastMCPOpenAPI # OpenAPI spec (can be loaded from file/URL) openapi_spec = {...} # Create HTTP client async with httpx.AsyncClient() as client: # Create server with stateless request building server = FastMCPOpenAPI( openapi_spec=openapi_spec, client=client, name="My API Server" ) # Server automatically creates RequestDirector and pre-calculates schemas ``` ### Direct RequestDirector Usage ```python from fastmcp.utilities.openapi.director import RequestDirector from jsonschema_path import SchemaPath # Create RequestDirector manually spec = SchemaPath.from_dict(openapi_spec) director = RequestDirector(spec) # Build HTTP request request = director.build(route, flat_arguments, base_url) # Execute with httpx async with httpx.AsyncClient() as client: response = await client.send(request) ``` ## Testing Strategy Tests are located in `/tests/server/openapi/`: ### Test Categories 1. **Core Functionality** - `test_server.py` - Server initialization and RequestDirector integration 2. **OpenAPI Features** - `test_parameter_collisions.py` - Parameter name collision handling - `test_deepobject_style.py` - DeepObject parameter style support - `test_openapi_features.py` - General OpenAPI feature compliance ### Testing Philosophy - **Real Objects**: Use real HTTPRoute models and OpenAPI specifications - **Minimal Mocking**: Only mock external HTTP endpoints - **Performance Focus**: Test that initialization is fast and stateless - **Behavioral Testing**: Verify OpenAPI compliance without implementation details ## Future Enhancements ### Planned Features 1. **Response Streaming**: Handle streaming API responses 2. **Enhanced Authentication**: More auth provider integrations 3. **Advanced Metrics**: Detailed request/response monitoring 4. **Schema Validation**: Enhanced input/output validation 5. **Batch Operations**: Optimized multi-operation requests ### Performance Improvements 1. **Schema Caching**: More aggressive schema pre-calculation 2. **Memory Optimization**: Further reduce memory footprint 3. **Request Batching**: Smart batching for bulk operations 4. **Connection Optimization**: Enhanced connection pooling strategies ## Troubleshooting ### Common Issues 1. **RequestDirector Initialization Fails** - Check OpenAPI spec validity with `jsonschema-path` - Verify spec format is correct JSON/YAML - Ensure all required OpenAPI fields are present 2. **Parameter Mapping Issues** - Check parameter collision resolution in debug logs - Verify parameter names match OpenAPI spec exactly - Review pre-calculated parameter map in HTTPRoute 3. **Request Building Errors** - Check network connectivity to target API - Verify base URL configuration - Review parameter validation and type mismatches ### Debugging - Enable debug logging: `logger.setLevel(logging.DEBUG)` - Check RequestDirector initialization logs - Review parameter mapping in HTTPRoute models - Monitor request building and API response patterns ## Dependencies - `openapi-core` - OpenAPI specification processing and validation - `httpx` - HTTP client library - `pydantic` - Data validation and serialization - `urllib.parse` - URL building and manipulation ================================================ FILE: src/fastmcp/utilities/openapi/__init__.py ================================================ """OpenAPI utilities for FastMCP - refactored for better maintainability.""" # Import from models from .models import ( HTTPRoute, HttpMethod, JsonSchema, ParameterInfo, ParameterLocation, RequestBodyInfo, ResponseInfo, ) # Import from parser from .parser import parse_openapi_to_http_routes # Import from formatters from .formatters import ( format_array_parameter, format_deep_object_parameter, format_description_with_responses, format_json_for_description, generate_example_from_schema, ) # Import from schemas from .schemas import ( _combine_schemas, extract_output_schema_from_responses, clean_schema_for_display, _make_optional_parameter_nullable, ) # Import from json_schema_converter from .json_schema_converter import ( convert_openapi_schema_to_json_schema, convert_schema_definitions, ) # Export public symbols - maintaining backward compatibility __all__ = [ "HTTPRoute", "HttpMethod", "JsonSchema", "ParameterInfo", "ParameterLocation", "RequestBodyInfo", "ResponseInfo", "_combine_schemas", "_make_optional_parameter_nullable", "clean_schema_for_display", "convert_openapi_schema_to_json_schema", "convert_schema_definitions", "extract_output_schema_from_responses", "format_array_parameter", "format_deep_object_parameter", "format_description_with_responses", "format_json_for_description", "generate_example_from_schema", "parse_openapi_to_http_routes", ] ================================================ FILE: src/fastmcp/utilities/openapi/director.py ================================================ """Request director using openapi-core for stateless HTTP request building.""" from typing import Any from urllib.parse import quote, urljoin import httpx from jsonschema_path import SchemaPath from fastmcp.utilities.logging import get_logger from .models import HTTPRoute logger = get_logger(__name__) class RequestDirector: """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core.""" def __init__(self, spec: SchemaPath): """Initialize with a parsed SchemaPath object.""" self._spec = spec def build( self, route: HTTPRoute, flat_args: dict[str, Any], base_url: str = "http://localhost", ) -> httpx.Request: """ Constructs a final httpx.Request object, handling all OpenAPI serialization. Args: route: HTTPRoute containing OpenAPI operation details flat_args: Flattened arguments from LLM (may include suffixed parameters) base_url: Base URL for the request Returns: httpx.Request: Properly formatted HTTP request """ logger.debug( f"Building request for {route.method} {route.path} with args: {flat_args}" ) # Step 1: Un-flatten arguments into path, query, body, etc. using parameter map path_params, query_params, header_params, body = self._unflatten_arguments( route, flat_args ) logger.debug( f"Unflattened - path: {path_params}, query: {query_params}, headers: {header_params}, body: {body}" ) # Step 2: Build base URL with path parameters url = self._build_url(route.path, path_params, base_url) # Step 3: Prepare request data method: str = route.method.upper() params = query_params if query_params else None headers = header_params if header_params else None json_body: dict[str, Any] | list[Any] | None = None content: str | bytes | None = None # Step 4: Handle request body if body is not None: if isinstance(body, dict | list): json_body = body else: content = body # Step 5: Create httpx.Request return httpx.Request( method=method, url=url, params=params, headers=headers, json=json_body, content=content, ) def _unflatten_arguments( self, route: HTTPRoute, flat_args: dict[str, Any] ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Any]: """ Maps flat arguments back to their OpenAPI locations using the parameter map. Args: route: HTTPRoute with parameter_map containing location mappings flat_args: Flat arguments from LLM call Returns: Tuple of (path_params, query_params, header_params, body) """ path_params = {} query_params = {} header_params = {} body_props = {} # Use parameter map to route arguments to correct locations if hasattr(route, "parameter_map") and route.parameter_map: for arg_name, value in flat_args.items(): if value is None: continue # Skip None values for optional parameters if arg_name not in route.parameter_map: logger.warning( f"Argument '{arg_name}' not found in parameter map for {route.operation_id}" ) continue mapping = route.parameter_map[arg_name] location = mapping["location"] openapi_name = mapping["openapi_name"] if location == "path": path_params[openapi_name] = value elif location == "query": query_params[openapi_name] = value elif location == "header": header_params[openapi_name] = value elif location == "body": body_props[openapi_name] = value else: logger.warning( f"Unknown parameter location '{location}' for {arg_name}" ) else: # Fallback: try to map arguments based on parameter definitions logger.debug("No parameter map available, using fallback mapping") # Create a mapping from parameter names to their locations param_locations = {} for param in route.parameters: param_locations[param.name] = param.location # Map arguments to locations for arg_name, value in flat_args.items(): if value is None: continue # Check if it's a suffixed parameter (e.g., id__path) if "__" in arg_name: base_name, location = arg_name.rsplit("__", 1) if location in ["path", "query", "header"]: if location == "path": path_params[base_name] = value elif location == "query": query_params[base_name] = value elif location == "header": header_params[base_name] = value continue # Check if it's a known parameter if arg_name in param_locations: location = param_locations[arg_name] if location == "path": path_params[arg_name] = value elif location == "query": query_params[arg_name] = value elif location == "header": header_params[arg_name] = value else: # Assume it's a body property body_props[arg_name] = value # Handle body construction body = None if body_props: # If we have body properties, construct the body object if ( route.request_body and route.request_body.content_schema and len(route.request_body.content_schema) > 0 ): content_type = next(iter(route.request_body.content_schema)) body_schema = route.request_body.content_schema[content_type] if ( isinstance(body_schema, dict) and body_schema.get("type") == "object" ): body = body_props elif len(body_props) == 1: # If body schema is not an object and we have exactly one property, # use the property value directly body = next(iter(body_props.values())) else: # Multiple properties but schema is not object - wrap in object body = body_props else: body = body_props return path_params, query_params, header_params, body def _build_url( self, path_template: str, path_params: dict[str, Any], base_url: str ) -> str: """ Build URL by substituting path parameters in the template. Args: path_template: OpenAPI path template (e.g., "/users/{id}") path_params: Path parameter values base_url: Base URL to prepend Returns: Complete URL with path parameters substituted """ # Substitute path parameters with URL-encoding to prevent # path traversal and SSRF via crafted parameter values url_path = path_template for param_name, param_value in path_params.items(): placeholder = f"{{{param_name}}}" if placeholder in url_path: safe_value = quote(str(param_value), safe="").replace(".", "%2E") url_path = url_path.replace(placeholder, safe_value) # Combine with base URL return urljoin(base_url.rstrip("/") + "/", url_path.lstrip("/")) # Export public symbols __all__ = ["RequestDirector"] ================================================ FILE: src/fastmcp/utilities/openapi/formatters.py ================================================ """Parameter formatting functions for OpenAPI operations.""" import json import logging from typing import Any from .models import JsonSchema, ParameterInfo, RequestBodyInfo logger = logging.getLogger(__name__) def format_array_parameter( values: list, parameter_name: str, is_query_parameter: bool = False ) -> str | list: """ Format an array parameter according to OpenAPI specifications. Args: values: List of values to format parameter_name: Name of the parameter (for error messages) is_query_parameter: If True, can return list for explode=True behavior Returns: String (comma-separated) or list (for query params with explode=True) """ # For arrays of simple types (strings, numbers, etc.), join with commas if all(isinstance(item, str | int | float | bool) for item in values): return ",".join(str(v) for v in values) # For complex types, try to create a simpler representation try: # Try to create a simple string representation formatted_parts = [] for item in values: if isinstance(item, dict): # For objects, serialize key-value pairs item_parts = [] for k, v in item.items(): item_parts.append(f"{k}:{v}") formatted_parts.append(".".join(item_parts)) else: formatted_parts.append(str(item)) return ",".join(formatted_parts) except Exception as e: param_type = "query" if is_query_parameter else "path" logger.warning( f"Failed to format complex array {param_type} parameter '{parameter_name}': {e}" ) if is_query_parameter: # For query parameters, fallback to original list return values else: # For path parameters, fallback to string representation without Python syntax str_value = ( str(values) .replace("[", "") .replace("]", "") .replace("'", "") .replace('"', "") ) return str_value def format_deep_object_parameter( param_value: dict, parameter_name: str ) -> dict[str, str]: """ Format a dictionary parameter for deep-object style serialization. According to OpenAPI 3.0 spec, deepObject style with explode=true serializes object properties as separate query parameters with bracket notation. For example, `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`. Args: param_value: Dictionary value to format parameter_name: Name of the parameter Returns: Dictionary with bracketed parameter names as keys """ if not isinstance(param_value, dict): logger.warning( f"Deep-object style parameter '{parameter_name}' expected dict, got {type(param_value)}" ) return {} result = {} for key, value in param_value.items(): # Format as param[key]=value bracketed_key = f"{parameter_name}[{key}]" result[bracketed_key] = str(value) return result def generate_example_from_schema(schema: JsonSchema | None) -> Any: """ Generate a simple example value from a JSON schema dictionary. Very basic implementation focusing on types. """ if not schema or not isinstance(schema, dict): return "unknown" # Or None? # Use default value if provided if "default" in schema: return schema["default"] # Use first enum value if provided if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]: return schema["enum"][0] # Use first example if provided if ( "examples" in schema and isinstance(schema["examples"], list) and schema["examples"] ): return schema["examples"][0] if "example" in schema: return schema["example"] schema_type = schema.get("type") if schema_type == "object": result = {} properties = schema.get("properties", {}) if isinstance(properties, dict): # Generate example for first few properties or required ones? Limit complexity. required_props = set(schema.get("required", [])) props_to_include = list(properties.keys())[ :3 ] # Limit to first 3 for brevity for prop_name in props_to_include: if prop_name in properties: result[prop_name] = generate_example_from_schema( properties[prop_name] ) # Ensure required props are present if possible for req_prop in required_props: if req_prop not in result and req_prop in properties: result[req_prop] = generate_example_from_schema( properties[req_prop] ) return result if result else {"key": "value"} # Basic object if no props elif schema_type == "array": items_schema = schema.get("items") if isinstance(items_schema, dict): # Generate one example item item_example = generate_example_from_schema(items_schema) return [item_example] if item_example is not None else [] return ["example_item"] # Fallback elif schema_type == "string": format_type = schema.get("format") if format_type == "date-time": return "2024-01-01T12:00:00Z" if format_type == "date": return "2024-01-01" if format_type == "email": return "user@example.com" if format_type == "uuid": return "123e4567-e89b-12d3-a456-426614174000" if format_type == "byte": return "ZXhhbXBsZQ==" # "example" base64 return "string" elif schema_type == "integer": return 1 elif schema_type == "number": return 1.5 elif schema_type == "boolean": return True elif schema_type == "null": return None # Fallback if type is unknown or missing return "unknown_type" def format_json_for_description(data: Any, indent: int = 2) -> str: """Formats Python data as a JSON string block for Markdown.""" try: json_str = json.dumps(data, indent=indent) return f"```json\n{json_str}\n```" except TypeError: return f"```\nCould not serialize to JSON: {data}\n```" def format_description_with_responses( base_description: str, responses: dict[ str, Any ], # Changed from specific ResponseInfo type to avoid circular imports parameters: list[ParameterInfo] | None = None, # Add parameters parameter request_body: RequestBodyInfo | None = None, # Add request_body parameter ) -> str: """ Formats the base description string with response, parameter, and request body information. Args: base_description (str): The initial description to be formatted. responses (dict[str, Any]): A dictionary of response information, keyed by status code. parameters (list[ParameterInfo] | None, optional): A list of parameter information, including path and query parameters. Each parameter includes details such as name, location, whether it is required, and a description. request_body (RequestBodyInfo | None, optional): Information about the request body, including its description, whether it is required, and its content schema. Returns: str: The formatted description string with additional details about responses, parameters, and the request body. """ desc_parts = [base_description] # Add parameter information if parameters: # Process path parameters path_params = [p for p in parameters if p.location == "path"] if path_params: param_section = "\n\n**Path Parameters:**" desc_parts.append(param_section) for param in path_params: required_marker = " (Required)" if param.required else "" param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" desc_parts.append(param_desc) # Process query parameters query_params = [p for p in parameters if p.location == "query"] if query_params: param_section = "\n\n**Query Parameters:**" desc_parts.append(param_section) for param in query_params: required_marker = " (Required)" if param.required else "" param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" desc_parts.append(param_desc) # Add request body information if present if request_body and request_body.description: req_body_section = "\n\n**Request Body:**" desc_parts.append(req_body_section) required_marker = " (Required)" if request_body.required else "" desc_parts.append(f"\n{request_body.description}{required_marker}") # Add request body property descriptions if available if request_body.content_schema: media_type = ( "application/json" if "application/json" in request_body.content_schema else next(iter(request_body.content_schema), None) ) if media_type: schema = request_body.content_schema.get(media_type, {}) if isinstance(schema, dict) and "properties" in schema: desc_parts.append("\n\n**Request Properties:**") for prop_name, prop_schema in schema["properties"].items(): if ( isinstance(prop_schema, dict) and "description" in prop_schema ): required = prop_name in schema.get("required", []) req_mark = " (Required)" if required else "" desc_parts.append( f"\n- **{prop_name}**{req_mark}: {prop_schema['description']}" ) # Add response information if responses: response_section = "\n\n**Responses:**" added_response_section = False # Determine success codes (common ones) success_codes = {"200", "201", "202", "204"} # As strings success_status = next((s for s in success_codes if s in responses), None) # Process all responses responses_to_process = responses.items() for status_code, resp_info in sorted(responses_to_process): if not added_response_section: desc_parts.append(response_section) added_response_section = True status_marker = " (Success)" if status_code == success_status else "" desc_parts.append( f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}" ) # Process content schemas for this response if resp_info.content_schema: # Prioritize json, then take first available media_type = ( "application/json" if "application/json" in resp_info.content_schema else next(iter(resp_info.content_schema), None) ) if media_type: schema = resp_info.content_schema.get(media_type) desc_parts.append(f" - Content-Type: `{media_type}`") # Add response property descriptions if isinstance(schema, dict): # Handle array responses if schema.get("type") == "array" and "items" in schema: items_schema = schema["items"] if ( isinstance(items_schema, dict) and "properties" in items_schema ): desc_parts.append("\n - **Response Item Properties:**") for prop_name, prop_schema in items_schema[ "properties" ].items(): if ( isinstance(prop_schema, dict) and "description" in prop_schema ): desc_parts.append( f"\n - **{prop_name}**: {prop_schema['description']}" ) # Handle object responses elif "properties" in schema: desc_parts.append("\n - **Response Properties:**") for prop_name, prop_schema in schema["properties"].items(): if ( isinstance(prop_schema, dict) and "description" in prop_schema ): desc_parts.append( f"\n - **{prop_name}**: {prop_schema['description']}" ) # Generate Example if schema: example = generate_example_from_schema(schema) if example != "unknown_type" and example is not None: desc_parts.append("\n - **Example:**") desc_parts.append( format_json_for_description(example, indent=2) ) return "\n".join(desc_parts) # Export public symbols __all__ = [ "format_array_parameter", "format_deep_object_parameter", "format_description_with_responses", "format_json_for_description", "generate_example_from_schema", ] ================================================ FILE: src/fastmcp/utilities/openapi/json_schema_converter.py ================================================ """ Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser. This module provides a systematic approach to converting OpenAPI 3.0 schemas to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized for our specific use case. """ from typing import Any from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) # OpenAPI-specific fields that should be removed from JSON Schema OPENAPI_SPECIFIC_FIELDS = { "nullable", # Handled by converting to type arrays "discriminator", # OpenAPI-specific "readOnly", # OpenAPI-specific metadata "writeOnly", # OpenAPI-specific metadata "xml", # OpenAPI-specific metadata "externalDocs", # OpenAPI-specific metadata "deprecated", # Can be kept but not part of JSON Schema core } # Fields that should be recursively processed RECURSIVE_FIELDS = { "properties": dict, "items": dict, "additionalProperties": dict, "allOf": list, "anyOf": list, "oneOf": list, "not": dict, } def convert_openapi_schema_to_json_schema( schema: dict[str, Any], openapi_version: str | None = None, remove_read_only: bool = False, remove_write_only: bool = False, convert_one_of_to_any_of: bool = True, ) -> dict[str, Any]: """ Convert an OpenAPI schema to JSON Schema format. This is a clean, systematic approach that: 1. Removes OpenAPI-specific fields 2. Converts nullable fields to type arrays (for OpenAPI 3.0 only) 3. Converts oneOf to anyOf for overlapping union handling 4. Recursively processes nested schemas 5. Optionally removes readOnly/writeOnly properties Args: schema: OpenAPI schema dictionary openapi_version: OpenAPI version for optimization remove_read_only: Whether to remove readOnly properties remove_write_only: Whether to remove writeOnly properties convert_one_of_to_any_of: Whether to convert oneOf to anyOf Returns: JSON Schema-compatible dictionary """ if not isinstance(schema, dict): return schema # Early exit optimization - check if conversion is needed needs_conversion = ( any(field in schema for field in OPENAPI_SPECIFIC_FIELDS) or (remove_read_only and _has_read_only_properties(schema)) or (remove_write_only and _has_write_only_properties(schema)) or (convert_one_of_to_any_of and "oneOf" in schema) or _needs_recursive_processing( schema, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) ) if not needs_conversion: return schema # Work on a copy to avoid mutation result = schema.copy() # Step 1: Handle nullable field conversion (OpenAPI 3.0 only) if openapi_version and openapi_version.startswith("3.0"): result = _convert_nullable_field(result) # Step 2: Convert oneOf to anyOf if requested if convert_one_of_to_any_of and "oneOf" in result: result["anyOf"] = result.pop("oneOf") # Step 3: Remove OpenAPI-specific fields for field in OPENAPI_SPECIFIC_FIELDS: result.pop(field, None) # Step 4: Handle readOnly/writeOnly property removal if remove_read_only or remove_write_only: result = _filter_properties_by_access( result, remove_read_only, remove_write_only ) # Step 5: Recursively process nested schemas for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in result: if field_type is dict and isinstance(result[field_name], dict): if field_name == "properties": # Handle properties specially - each property is a schema result[field_name] = { prop_name: convert_openapi_schema_to_json_schema( prop_schema, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) if isinstance(prop_schema, dict) else prop_schema for prop_name, prop_schema in result[field_name].items() } else: result[field_name] = convert_openapi_schema_to_json_schema( result[field_name], openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) elif field_type is list and isinstance(result[field_name], list): result[field_name] = [ convert_openapi_schema_to_json_schema( item, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) if isinstance(item, dict) else item for item in result[field_name] ] return result def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]: """Convert OpenAPI nullable field to JSON Schema type array.""" if "nullable" not in schema: return schema result = schema.copy() nullable_value = result.pop("nullable") # Only convert if nullable is True and we have a type structure if not nullable_value: return result if "type" in result: current_type = result["type"] if isinstance(current_type, str): result["type"] = [current_type, "null"] elif isinstance(current_type, list) and "null" not in current_type: result["type"] = [*current_type, "null"] elif "oneOf" in result: # Convert oneOf to anyOf with null result["anyOf"] = [*result.pop("oneOf"), {"type": "null"}] elif "anyOf" in result: # Add null to anyOf if not present if not any(item.get("type") == "null" for item in result["anyOf"]): result["anyOf"].append({"type": "null"}) elif "allOf" in result: # Wrap allOf in anyOf with null option result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}] # Handle enum fields - add null to enum values if present if "enum" in result and None not in result["enum"]: result["enum"] = result["enum"] + [None] return result def _has_read_only_properties(schema: dict[str, Any]) -> bool: """Quick check if schema has any readOnly properties.""" if "properties" not in schema: return False return any( isinstance(prop, dict) and prop.get("readOnly") for prop in schema["properties"].values() ) def _has_write_only_properties(schema: dict[str, Any]) -> bool: """Quick check if schema has any writeOnly properties.""" if "properties" not in schema: return False return any( isinstance(prop, dict) and prop.get("writeOnly") for prop in schema["properties"].values() ) def _needs_recursive_processing( schema: dict[str, Any], openapi_version: str | None, remove_read_only: bool, remove_write_only: bool, convert_one_of_to_any_of: bool, ) -> bool: """Check if the schema needs recursive processing (smarter than just checking for recursive fields).""" for field_name, field_type in RECURSIVE_FIELDS.items(): if field_name in schema: if field_type is dict and isinstance(schema[field_name], dict): if field_name == "properties": # Check if any property needs conversion for prop_schema in schema[field_name].values(): if isinstance(prop_schema, dict): nested_needs_conversion = ( any( field in prop_schema for field in OPENAPI_SPECIFIC_FIELDS ) or (remove_read_only and prop_schema.get("readOnly")) or (remove_write_only and prop_schema.get("writeOnly")) or (convert_one_of_to_any_of and "oneOf" in prop_schema) or _needs_recursive_processing( prop_schema, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) ) if nested_needs_conversion: return True else: # Check if nested schema needs conversion nested_needs_conversion = ( any( field in schema[field_name] for field in OPENAPI_SPECIFIC_FIELDS ) or ( remove_read_only and _has_read_only_properties(schema[field_name]) ) or ( remove_write_only and _has_write_only_properties(schema[field_name]) ) or (convert_one_of_to_any_of and "oneOf" in schema[field_name]) or _needs_recursive_processing( schema[field_name], openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) ) if nested_needs_conversion: return True elif field_type is list and isinstance(schema[field_name], list): # Check if any list item needs conversion for item in schema[field_name]: if isinstance(item, dict): nested_needs_conversion = ( any(field in item for field in OPENAPI_SPECIFIC_FIELDS) or (remove_read_only and _has_read_only_properties(item)) or (remove_write_only and _has_write_only_properties(item)) or (convert_one_of_to_any_of and "oneOf" in item) or _needs_recursive_processing( item, openapi_version, remove_read_only, remove_write_only, convert_one_of_to_any_of, ) ) if nested_needs_conversion: return True return False def _filter_properties_by_access( schema: dict[str, Any], remove_read_only: bool, remove_write_only: bool ) -> dict[str, Any]: """Remove readOnly and/or writeOnly properties from schema.""" if "properties" not in schema: return schema result = schema.copy() filtered_properties = {} for prop_name, prop_schema in result["properties"].items(): if not isinstance(prop_schema, dict): filtered_properties[prop_name] = prop_schema continue should_remove = (remove_read_only and prop_schema.get("readOnly")) or ( remove_write_only and prop_schema.get("writeOnly") ) if not should_remove: filtered_properties[prop_name] = prop_schema result["properties"] = filtered_properties # Clean up required array if properties were removed if "required" in result and filtered_properties: result["required"] = [ prop for prop in result["required"] if prop in filtered_properties ] if not result["required"]: result.pop("required") return result def convert_schema_definitions( schema_definitions: dict[str, Any] | None, openapi_version: str | None = None, **kwargs, ) -> dict[str, Any]: """ Convert a dictionary of OpenAPI schema definitions to JSON Schema. Args: schema_definitions: Dictionary of schema definitions openapi_version: OpenAPI version for optimization **kwargs: Additional arguments passed to convert_openapi_schema_to_json_schema Returns: Dictionary of converted schema definitions """ if not schema_definitions: return {} return { name: convert_openapi_schema_to_json_schema(schema, openapi_version, **kwargs) for name, schema in schema_definitions.items() } ================================================ FILE: src/fastmcp/utilities/openapi/models.py ================================================ """Intermediate Representation (IR) models for OpenAPI operations.""" from typing import Any, Literal from pydantic import Field from fastmcp.utilities.types import FastMCPBaseModel # Type definitions HttpMethod = Literal[ "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE" ] ParameterLocation = Literal["path", "query", "header", "cookie"] JsonSchema = dict[str, Any] class ParameterInfo(FastMCPBaseModel): """Represents a single parameter for an HTTP operation in our IR.""" name: str location: ParameterLocation # Mapped from 'in' field of openapi-pydantic Parameter required: bool = False schema_: JsonSchema = Field(..., alias="schema") # Target name in IR description: str | None = None explode: bool | None = None # OpenAPI explode property for array parameters style: str | None = None # OpenAPI style property for parameter serialization class RequestBodyInfo(FastMCPBaseModel): """Represents the request body for an HTTP operation in our IR.""" required: bool = False content_schema: dict[str, JsonSchema] = Field( default_factory=dict ) # Key: media type description: str | None = None class ResponseInfo(FastMCPBaseModel): """Represents response information in our IR.""" description: str | None = None # Store schema per media type, key is media type content_schema: dict[str, JsonSchema] = Field(default_factory=dict) class HTTPRoute(FastMCPBaseModel): """Intermediate Representation for a single OpenAPI operation.""" path: str method: HttpMethod operation_id: str | None = None summary: str | None = None description: str | None = None tags: list[str] = Field(default_factory=list) parameters: list[ParameterInfo] = Field(default_factory=list) request_body: RequestBodyInfo | None = None responses: dict[str, ResponseInfo] = Field( default_factory=dict ) # Key: status code str request_schemas: dict[str, JsonSchema] = Field( default_factory=dict ) # Store schemas needed for input (parameters/request body) response_schemas: dict[str, JsonSchema] = Field( default_factory=dict ) # Store schemas needed for output (responses) extensions: dict[str, Any] = Field(default_factory=dict) openapi_version: str | None = None # Pre-calculated fields for performance flat_param_schema: JsonSchema = Field( default_factory=dict ) # Combined schema for MCP tools parameter_map: dict[str, dict[str, str]] = Field( default_factory=dict ) # Maps flat args to locations # Export public symbols __all__ = [ "HTTPRoute", "HttpMethod", "JsonSchema", "ParameterInfo", "ParameterLocation", "RequestBodyInfo", "ResponseInfo", ] ================================================ FILE: src/fastmcp/utilities/openapi/parser.py ================================================ """OpenAPI parsing logic for converting OpenAPI specs to HTTPRoute objects.""" from typing import Any, Generic, TypeVar, cast from openapi_pydantic import ( OpenAPI, Operation, Parameter, PathItem, Reference, RequestBody, Response, Schema, ) # Import OpenAPI 3.0 models as well from openapi_pydantic.v3.v3_0 import OpenAPI as OpenAPI_30 from openapi_pydantic.v3.v3_0 import Operation as Operation_30 from openapi_pydantic.v3.v3_0 import Parameter as Parameter_30 from openapi_pydantic.v3.v3_0 import PathItem as PathItem_30 from openapi_pydantic.v3.v3_0 import Reference as Reference_30 from openapi_pydantic.v3.v3_0 import RequestBody as RequestBody_30 from openapi_pydantic.v3.v3_0 import Response as Response_30 from openapi_pydantic.v3.v3_0 import Schema as Schema_30 from pydantic import BaseModel, ValidationError from fastmcp.utilities.logging import get_logger from .models import ( HTTPRoute, JsonSchema, ParameterInfo, ParameterLocation, RequestBodyInfo, ResponseInfo, ) from .schemas import ( _combine_schemas_and_map_params, _replace_ref_with_defs, ) logger = get_logger(__name__) # Type variables for generic parser TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30) TSchema = TypeVar("TSchema", Schema, Schema_30) TReference = TypeVar("TReference", Reference, Reference_30) TParameter = TypeVar("TParameter", Parameter, Parameter_30) TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30) TResponse = TypeVar("TResponse", Response, Response_30) TOperation = TypeVar("TOperation", Operation, Operation_30) TPathItem = TypeVar("TPathItem", PathItem, PathItem_30) def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]: """ Parses an OpenAPI schema dictionary into a list of HTTPRoute objects using the openapi-pydantic library. Supports both OpenAPI 3.0.x and 3.1.x versions. """ # Check OpenAPI version to use appropriate model openapi_version = openapi_dict.get("openapi", "") try: if openapi_version.startswith("3.0"): # Use OpenAPI 3.0 models openapi_30 = OpenAPI_30.model_validate(openapi_dict) logger.debug( f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}" ) parser = OpenAPIParser( openapi_30, Reference_30, Schema_30, Parameter_30, RequestBody_30, Response_30, Operation_30, PathItem_30, openapi_version, ) return parser.parse() else: # Default to OpenAPI 3.1 models openapi_31 = OpenAPI.model_validate(openapi_dict) logger.debug( f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}" ) parser = OpenAPIParser( openapi_31, Reference, Schema, Parameter, RequestBody, Response, Operation, PathItem, openapi_version, ) return parser.parse() except ValidationError as e: logger.error(f"OpenAPI schema validation failed: {e}") error_details = e.errors() logger.error(f"Validation errors: {error_details}") raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e class OpenAPIParser( Generic[ TOpenAPI, TReference, TSchema, TParameter, TRequestBody, TResponse, TOperation, TPathItem, ] ): """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.""" def __init__( self, openapi: TOpenAPI, reference_cls: type[TReference], schema_cls: type[TSchema], parameter_cls: type[TParameter], request_body_cls: type[TRequestBody], response_cls: type[TResponse], operation_cls: type[TOperation], path_item_cls: type[TPathItem], openapi_version: str, ): """Initialize the parser with the OpenAPI schema and type classes.""" self.openapi = openapi self.reference_cls = reference_cls self.schema_cls = schema_cls self.parameter_cls = parameter_cls self.request_body_cls = request_body_cls self.response_cls = response_cls self.operation_cls = operation_cls self.path_item_cls = path_item_cls self.openapi_version = openapi_version def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation: """Convert string parameter location to our ParameterLocation type.""" if param_in in ["path", "query", "header", "cookie"]: return cast(ParameterLocation, param_in) logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'") return cast(ParameterLocation, "query") def _resolve_ref(self, item: Any) -> Any: """Resolves a reference to its target definition.""" if isinstance(item, self.reference_cls): ref_str = item.ref # Ensure ref_str is a string before calling startswith() if not isinstance(ref_str, str): return item try: if not ref_str.startswith("#/"): raise ValueError( f"External or non-local reference not supported: {ref_str}" ) parts = ref_str.strip("#/").split("/") target = self.openapi for part in parts: if part.isdigit() and isinstance(target, list): target = target[int(part)] elif isinstance(target, BaseModel): # Check class fields first, then model_extra if part in target.__class__.model_fields: target = getattr(target, part, None) elif target.model_extra and part in target.model_extra: target = target.model_extra[part] else: # Special handling for components if part == "components" and hasattr(target, "components"): target = target.components elif hasattr(target, part): # Fallback check target = getattr(target, part, None) else: target = None # Part not found elif isinstance(target, dict): target = target.get(part) else: raise ValueError( f"Cannot traverse part '{part}' in reference '{ref_str}'" ) if target is None: raise ValueError( f"Reference part '{part}' not found in path '{ref_str}'" ) # Handle nested references if isinstance(target, self.reference_cls): return self._resolve_ref(target) return target except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e: raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e return item def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema: """Resolves a schema and returns it as a dictionary.""" try: resolved_schema = self._resolve_ref(schema_obj) if isinstance(resolved_schema, self.schema_cls): # Convert schema to dictionary result = resolved_schema.model_dump( mode="json", by_alias=True, exclude_none=True ) elif isinstance(resolved_schema, dict): result = resolved_schema else: logger.warning( f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict." ) result = {} # Convert refs from OpenAPI format to JSON Schema format using recursive approach result = _replace_ref_with_defs(result) return result except ValueError as e: # Re-raise ValueError for external reference errors and other validation issues if "External or non-local reference not supported" in str(e): raise logger.error(f"Failed to extract schema as dict: {e}", exc_info=False) return {} except Exception as e: logger.error(f"Failed to extract schema as dict: {e}", exc_info=False) return {} def _extract_parameters( self, operation_params: list[Any] | None = None, path_item_params: list[Any] | None = None, ) -> list[ParameterInfo]: """Extract and resolve parameters from operation and path item.""" extracted_params: list[ParameterInfo] = [] seen_params: dict[ tuple[str, str], bool ] = {} # Use tuple of (name, location) as key all_params = (operation_params or []) + (path_item_params or []) for param_or_ref in all_params: try: parameter = self._resolve_ref(param_or_ref) if not isinstance(parameter, self.parameter_cls): logger.warning( f"Expected Parameter after resolving, got {type(parameter)}. Skipping." ) continue # Extract parameter info - handle both 3.0 and 3.1 parameter models param_in = parameter.param_in # Both use param_in # Handle enum or string parameter locations from enum import Enum param_in_str = ( param_in.value if isinstance(param_in, Enum) else param_in ) param_location = self._convert_to_parameter_location(param_in_str) param_schema_obj = parameter.param_schema # Both use param_schema # Skip duplicate parameters (same name and location) param_key = (parameter.name, param_in_str) if param_key in seen_params: continue seen_params[param_key] = True # Extract schema param_schema_dict = {} if param_schema_obj: # Process schema object param_schema_dict = self._extract_schema_as_dict(param_schema_obj) # Handle default value resolved_schema = self._resolve_ref(param_schema_obj) if ( not isinstance(resolved_schema, self.reference_cls) and hasattr(resolved_schema, "default") and resolved_schema.default is not None ): param_schema_dict["default"] = resolved_schema.default elif hasattr(parameter, "content") and parameter.content: # Handle content-based parameters first_media_type = next(iter(parameter.content.values()), None) if ( first_media_type and hasattr(first_media_type, "media_type_schema") and first_media_type.media_type_schema ): media_schema = first_media_type.media_type_schema param_schema_dict = self._extract_schema_as_dict(media_schema) # Handle default value in content schema resolved_media_schema = self._resolve_ref(media_schema) if ( not isinstance(resolved_media_schema, self.reference_cls) and hasattr(resolved_media_schema, "default") and resolved_media_schema.default is not None ): param_schema_dict["default"] = resolved_media_schema.default # Extract explode and style properties if present explode = getattr(parameter, "explode", None) style = getattr(parameter, "style", None) # Create parameter info object param_info = ParameterInfo( name=parameter.name, location=param_location, required=parameter.required, schema=param_schema_dict, description=parameter.description, explode=explode, style=style, ) extracted_params.append(param_info) except Exception as e: param_name = getattr( param_or_ref, "name", getattr(param_or_ref, "ref", "unknown") ) logger.error( f"Failed to extract parameter '{param_name}': {e}", exc_info=False ) return extracted_params def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None: """Extract and resolve request body information.""" if not request_body_or_ref: return None try: request_body = self._resolve_ref(request_body_or_ref) if not isinstance(request_body, self.request_body_cls): logger.warning( f"Expected RequestBody after resolving, got {type(request_body)}. Returning None." ) return None # Create request body info request_body_info = RequestBodyInfo( required=request_body.required, description=request_body.description, ) # Extract content schemas if hasattr(request_body, "content") and request_body.content: for media_type_str, media_type_obj in request_body.content.items(): if ( media_type_obj and hasattr(media_type_obj, "media_type_schema") and media_type_obj.media_type_schema ): try: schema_dict = self._extract_schema_as_dict( media_type_obj.media_type_schema ) request_body_info.content_schema[media_type_str] = ( schema_dict ) except ValueError as e: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str( e ): raise logger.error( f"Failed to extract schema for media type '{media_type_str}': {e}" ) except Exception as e: logger.error( f"Failed to extract schema for media type '{media_type_str}': {e}" ) return request_body_info except ValueError as e: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str(e): raise ref_name = getattr(request_body_or_ref, "ref", "unknown") logger.error( f"Failed to extract request body '{ref_name}': {e}", exc_info=False ) return None except Exception as e: ref_name = getattr(request_body_or_ref, "ref", "unknown") logger.error( f"Failed to extract request body '{ref_name}': {e}", exc_info=False ) return None def _is_success_status_code(self, status_code: str) -> bool: """Check if a status code represents a successful response (2xx).""" try: code_int = int(status_code) return 200 <= code_int < 300 except (ValueError, TypeError): # Handle special cases like 'default' or other non-numeric codes return status_code.lower() in ["default", "2xx"] def _get_primary_success_response( self, operation_responses: dict[str, Any] ) -> tuple[str, Any] | None: """Get the primary success response for an MCP tool. We only need one success response.""" if not operation_responses: return None # Priority order: 200, 201, 202, 204, 207, then any other 2xx priority_codes = ["200", "201", "202", "204", "207"] # First check priority codes for code in priority_codes: if code in operation_responses: return (code, operation_responses[code]) # Then check any other 2xx codes for status_code, resp_or_ref in operation_responses.items(): if self._is_success_status_code(status_code): return (status_code, resp_or_ref) # If no success codes found, return None (tool will have no output schema) return None def _extract_responses( self, operation_responses: dict[str, Any] | None ) -> dict[str, ResponseInfo]: """Extract and resolve response information. Only includes the primary success response for MCP tools.""" extracted_responses: dict[str, ResponseInfo] = {} if not operation_responses: return extracted_responses # For MCP tools, we only need the primary success response primary_response = self._get_primary_success_response(operation_responses) if not primary_response: logger.debug("No success responses found, tool will have no output schema") return extracted_responses status_code, resp_or_ref = primary_response logger.debug(f"Using primary success response: {status_code}") try: response = self._resolve_ref(resp_or_ref) if not isinstance(response, self.response_cls): logger.warning( f"Expected Response after resolving for status code {status_code}, " f"got {type(response)}. Returning empty responses." ) return extracted_responses # Create response info resp_info = ResponseInfo(description=response.description) # Extract content schemas if hasattr(response, "content") and response.content: for media_type_str, media_type_obj in response.content.items(): if ( media_type_obj and hasattr(media_type_obj, "media_type_schema") and media_type_obj.media_type_schema ): try: # Track if this is a top-level $ref before resolution top_level_schema_name = None media_schema = media_type_obj.media_type_schema if isinstance(media_schema, self.reference_cls): ref_str = media_schema.ref if isinstance(ref_str, str) and ref_str.startswith( "#/components/schemas/" ): top_level_schema_name = ref_str.split("/")[-1] schema_dict = self._extract_schema_as_dict(media_schema) # Add marker for top-level schema if it was a ref if top_level_schema_name: schema_dict["x-fastmcp-top-level-schema"] = ( top_level_schema_name ) resp_info.content_schema[media_type_str] = schema_dict except ValueError as e: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str( e ): raise logger.error( f"Failed to extract schema for media type '{media_type_str}' " f"in response {status_code}: {e}" ) except Exception as e: logger.error( f"Failed to extract schema for media type '{media_type_str}' " f"in response {status_code}: {e}" ) else: # Record the media type even without a schema so MIME # type inference can still use the declared content type. resp_info.content_schema.setdefault(media_type_str, {}) extracted_responses[str(status_code)] = resp_info except ValueError as e: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str(e): raise ref_name = getattr(resp_or_ref, "ref", "unknown") logger.error( f"Failed to extract response for status code {status_code} " f"from reference '{ref_name}': {e}", exc_info=False, ) except Exception as e: ref_name = getattr(resp_or_ref, "ref", "unknown") logger.error( f"Failed to extract response for status code {status_code} " f"from reference '{ref_name}': {e}", exc_info=False, ) return extracted_responses def _extract_schema_dependencies( self, schema: dict, all_schemas: dict[str, Any], collected: set[str] | None = None, ) -> set[str]: """ Extract all schema names referenced by a schema (including transitive dependencies). Args: schema: The schema to analyze all_schemas: All available schema definitions collected: Set of already collected schema names (for recursion) Returns: Set of schema names that are referenced """ if collected is None: collected = set() def find_refs(obj): """Recursively find all $ref references.""" if isinstance(obj, dict): if "$ref" in obj and isinstance(obj["$ref"], str): ref = obj["$ref"] # Handle both converted and unconverted refs if ref.startswith(("#/$defs/", "#/components/schemas/")): schema_name = ref.split("/")[-1] else: return # Add this schema and recursively find its dependencies if ( collected is not None and schema_name not in collected and schema_name in all_schemas ): collected.add(schema_name) # Recursively find dependencies of this schema find_refs(all_schemas[schema_name]) # Continue searching in all values for value in obj.values(): find_refs(value) elif isinstance(obj, list): for item in obj: find_refs(item) find_refs(schema) return collected def _extract_input_schema_dependencies( self, parameters: list[ParameterInfo], request_body: RequestBodyInfo | None, all_schemas: dict[str, Any], ) -> dict[str, Any]: """ Extract only the schema definitions needed for input (parameters and request body). Args: parameters: Route parameters request_body: Route request body all_schemas: All available schema definitions Returns: Dictionary containing only the schemas needed for input """ needed_schemas = set() # Check parameters for schema references for param in parameters: if param.schema_: deps = self._extract_schema_dependencies(param.schema_, all_schemas) needed_schemas.update(deps) # Check request body for schema references if request_body and request_body.content_schema: for content_schema in request_body.content_schema.values(): deps = self._extract_schema_dependencies(content_schema, all_schemas) needed_schemas.update(deps) # Return only the needed input schemas return { name: all_schemas[name] for name in needed_schemas if name in all_schemas } def _extract_output_schema_dependencies( self, responses: dict[str, ResponseInfo], all_schemas: dict[str, Any], ) -> dict[str, Any]: """ Extract only the schema definitions needed for outputs (responses). Args: responses: Route responses all_schemas: All available schema definitions Returns: Dictionary containing only the schemas needed for outputs """ if not responses or not all_schemas: return {} needed_schemas: set[str] = set() for response in responses.values(): if not response.content_schema: continue for content_schema in response.content_schema.values(): deps = self._extract_schema_dependencies(content_schema, all_schemas) needed_schemas.update(deps) schema_name = content_schema.get("x-fastmcp-top-level-schema") if isinstance(schema_name, str) and schema_name in all_schemas: needed_schemas.add(schema_name) self._extract_schema_dependencies( all_schemas[schema_name], all_schemas, collected=needed_schemas, ) return { name: all_schemas[name] for name in needed_schemas if name in all_schemas } def parse(self) -> list[HTTPRoute]: """Parse the OpenAPI schema into HTTP routes.""" routes: list[HTTPRoute] = [] if not hasattr(self.openapi, "paths") or not self.openapi.paths: logger.warning("OpenAPI schema has no paths defined.") return [] # Extract component schemas schema_definitions = {} if hasattr(self.openapi, "components") and self.openapi.components: components = self.openapi.components if hasattr(components, "schemas") and components.schemas: for name, schema in components.schemas.items(): try: if isinstance(schema, self.reference_cls): resolved_schema = self._resolve_ref(schema) schema_definitions[name] = self._extract_schema_as_dict( resolved_schema ) else: schema_definitions[name] = self._extract_schema_as_dict( schema ) except Exception as e: logger.warning( f"Failed to extract schema definition '{name}': {e}" ) # Convert schema definitions refs from OpenAPI to JSON Schema format (once) if schema_definitions: # Convert each schema definition recursively for name, schema in schema_definitions.items(): if isinstance(schema, dict): schema_definitions[name] = _replace_ref_with_defs(schema) # Process paths and operations for path_str, path_item_obj in self.openapi.paths.items(): if not isinstance(path_item_obj, self.path_item_cls): logger.warning( f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})" ) continue path_level_params = ( path_item_obj.parameters if hasattr(path_item_obj, "parameters") else None ) # Get HTTP methods from the path item class fields http_methods = [ "get", "put", "post", "delete", "options", "head", "patch", "trace", ] for method_lower in http_methods: operation = getattr(path_item_obj, method_lower, None) if operation and isinstance(operation, self.operation_cls): # Cast method to HttpMethod - safe since we only use valid HTTP methods method_upper = method_lower.upper() try: parameters = self._extract_parameters( getattr(operation, "parameters", None), path_level_params ) request_body_info = self._extract_request_body( getattr(operation, "requestBody", None) ) responses = self._extract_responses( getattr(operation, "responses", None) ) extensions = {} if hasattr(operation, "model_extra") and operation.model_extra: extensions = { k: v for k, v in operation.model_extra.items() if k.startswith("x-") } # Extract schemas separately for input and output input_schemas = self._extract_input_schema_dependencies( parameters, request_body_info, schema_definitions, ) output_schemas = self._extract_output_schema_dependencies( responses, schema_definitions, ) # Create initial route without pre-calculated fields route = HTTPRoute( path=path_str, method=method_upper, # type: ignore[arg-type] # Known valid HTTP method operation_id=getattr(operation, "operationId", None), summary=getattr(operation, "summary", None), description=getattr(operation, "description", None), tags=getattr(operation, "tags", []) or [], parameters=parameters, request_body=request_body_info, responses=responses, request_schemas=input_schemas, response_schemas=output_schemas, extensions=extensions, openapi_version=self.openapi_version, ) # Pre-calculate schema and parameter mapping for performance try: flat_schema, param_map = _combine_schemas_and_map_params( route, convert_refs=False, # Parser already converted refs ) route.flat_param_schema = flat_schema route.parameter_map = param_map except Exception as schema_error: logger.warning( f"Failed to pre-calculate schema for route {method_upper} {path_str}: {schema_error}" ) # Continue with empty pre-calculated fields route.flat_param_schema = { "type": "object", "properties": {}, } route.parameter_map = {} routes.append(route) except ValueError as op_error: # Re-raise ValueError for external reference errors if "External or non-local reference not supported" in str( op_error ): raise op_id = getattr(operation, "operationId", "unknown") logger.error( f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", exc_info=True, ) except Exception as op_error: op_id = getattr(operation, "operationId", "unknown") logger.error( f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", exc_info=True, ) logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.") return routes # Export public symbols __all__ = [ "OpenAPIParser", "parse_openapi_to_http_routes", ] ================================================ FILE: src/fastmcp/utilities/openapi/schemas.py ================================================ """Schema manipulation utilities for OpenAPI operations.""" from typing import Any from fastmcp.utilities.logging import get_logger from .models import HTTPRoute, JsonSchema, ResponseInfo logger = get_logger(__name__) def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: """ Clean up a schema dictionary for display by removing internal/complex fields. """ if not schema or not isinstance(schema, dict): return schema # Make a copy to avoid modifying the input schema cleaned = schema.copy() # Fields commonly removed for simpler display to LLMs or users fields_to_remove = [ "allOf", "anyOf", "oneOf", "not", # Composition keywords "nullable", # Handled by type unions usually "discriminator", "readOnly", "writeOnly", "deprecated", "xml", "externalDocs", # Can be verbose, maybe remove based on flag? # "pattern", "minLength", "maxLength", # "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", # "multipleOf", "minItems", "maxItems", "uniqueItems", # "minProperties", "maxProperties" ] for field in fields_to_remove: if field in cleaned: cleaned.pop(field) # Recursively clean properties and items if "properties" in cleaned: cleaned["properties"] = { k: clean_schema_for_display(v) for k, v in cleaned["properties"].items() } # Remove properties section if empty after cleaning if not cleaned["properties"]: cleaned.pop("properties") if "items" in cleaned: cleaned["items"] = clean_schema_for_display(cleaned["items"]) # Remove items section if empty after cleaning if not cleaned["items"]: cleaned.pop("items") if "additionalProperties" in cleaned: # Often verbose, can be simplified if isinstance(cleaned["additionalProperties"], dict): cleaned["additionalProperties"] = clean_schema_for_display( cleaned["additionalProperties"] ) elif cleaned["additionalProperties"] is True: # Maybe keep 'true' or represent as 'Allows additional properties' text? pass # Keep simple boolean for now return cleaned def _replace_ref_with_defs( info: dict[str, Any], description: str | None = None ) -> dict[str, Any]: """ Replace openapi $ref with jsonschema $defs recursively. Examples: - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}} - {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/..."}, "properties": {...}} - {"$ref": "#/components/schemas/..."} - {"items": {"$ref": "#/components/schemas/..."}} - {"anyOf": [{"$ref": "#/components/schemas/..."}]} - {"allOf": [{"$ref": "#/components/schemas/..."}]} - {"oneOf": [{"$ref": "#/components/schemas/..."}]} Args: info: dict[str, Any] description: str | None Returns: dict[str, Any] """ schema = info.copy() if ref_path := schema.get("$ref"): if isinstance(ref_path, str): if ref_path.startswith("#/components/schemas/"): schema_name = ref_path.split("/")[-1] schema["$ref"] = f"#/$defs/{schema_name}" elif not ref_path.startswith("#/"): raise ValueError( f"External or non-local reference not supported: {ref_path}. " f"FastMCP only supports local schema references starting with '#/'. " f"Please include all schema definitions within the OpenAPI document." ) elif properties := schema.get("properties"): if "$ref" in properties: schema["properties"] = _replace_ref_with_defs(properties) else: schema["properties"] = { prop_name: _replace_ref_with_defs(prop_schema) for prop_name, prop_schema in properties.items() } elif item_schema := schema.get("items"): schema["items"] = _replace_ref_with_defs(item_schema) for section in ["anyOf", "allOf", "oneOf"]: if section in schema: schema[section] = [_replace_ref_with_defs(item) for item in schema[section]] if additionalProperties := schema.get("additionalProperties"): if not isinstance(additionalProperties, bool): schema["additionalProperties"] = _replace_ref_with_defs( additionalProperties ) # Handle propertyNames if property_names := schema.get("propertyNames"): if isinstance(property_names, dict): schema["propertyNames"] = _replace_ref_with_defs(property_names) # Handle patternProperties if pattern_properties := schema.get("patternProperties"): if isinstance(pattern_properties, dict): schema["patternProperties"] = { pattern: _replace_ref_with_defs(subschema) if isinstance(subschema, dict) else subschema for pattern, subschema in pattern_properties.items() } if info.get("description", description) and not schema.get("description"): schema["description"] = description return schema def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: """ Make an optional parameter schema nullable to allow None values. For optional parameters, we need to allow null values in addition to the specified type to handle cases where None is passed for optional parameters. """ # If schema already has multiple types or is already nullable, don't modify if "anyOf" in schema or "oneOf" in schema or "allOf" in schema: return schema # If it's already nullable (type includes null), don't modify if isinstance(schema.get("type"), list) and "null" in schema["type"]: return schema # Create a new schema that allows null in addition to the original type if "type" in schema: original_type = schema["type"] if isinstance(original_type, str): # Handle different types appropriately if original_type in ("array", "object"): # For complex types (array/object), preserve the full structure # and allow null as an alternative if original_type == "array" and "items" in schema: # Array with items - preserve items in anyOf branch array_schema = schema.copy() top_level_fields = ["default", "description", "title", "example"] nullable_schema = {} # Move top-level fields to the root for field in top_level_fields: if field in array_schema: nullable_schema[field] = array_schema.pop(field) nullable_schema["anyOf"] = [array_schema, {"type": "null"}] return nullable_schema elif original_type == "object" and "properties" in schema: # Object with properties - preserve properties in anyOf branch object_schema = schema.copy() top_level_fields = ["default", "description", "title", "example"] nullable_schema = {} # Move top-level fields to the root for field in top_level_fields: if field in object_schema: nullable_schema[field] = object_schema.pop(field) nullable_schema["anyOf"] = [object_schema, {"type": "null"}] return nullable_schema else: # Simple object/array without items/properties nullable_schema = {} original_schema = schema.copy() top_level_fields = ["default", "description", "title", "example"] for field in top_level_fields: if field in original_schema: nullable_schema[field] = original_schema.pop(field) nullable_schema["anyOf"] = [original_schema, {"type": "null"}] return nullable_schema else: # Simple types (string, integer, number, boolean) top_level_fields = ["default", "description", "title", "example"] nullable_schema = {} original_schema = schema.copy() for field in top_level_fields: if field in original_schema: nullable_schema[field] = original_schema.pop(field) nullable_schema["anyOf"] = [original_schema, {"type": "null"}] return nullable_schema return schema def _combine_schemas_and_map_params( route: HTTPRoute, convert_refs: bool = True, ) -> tuple[dict[str, Any], dict[str, dict[str, str]]]: """ Combines parameter and request body schemas into a single schema. Handles parameter name collisions by adding location suffixes. Also returns parameter mapping for request director. Args: route: HTTPRoute object Returns: Tuple of (combined schema dictionary, parameter mapping) Parameter mapping format: {'flat_arg_name': {'location': 'path', 'openapi_name': 'id'}} """ properties = {} required = [] parameter_map = {} # Track mapping from flat arg names to OpenAPI locations # First pass: collect parameter names by location and body properties param_names_by_location = { "path": set(), "query": set(), "header": set(), "cookie": set(), } body_props = {} for param in route.parameters: param_names_by_location[param.location].add(param.name) if route.request_body and route.request_body.content_schema: content_type = next(iter(route.request_body.content_schema)) # Convert refs if needed if convert_refs: body_schema = _replace_ref_with_defs( route.request_body.content_schema[content_type] ) else: body_schema = route.request_body.content_schema[content_type] if route.request_body.description and not body_schema.get("description"): body_schema["description"] = route.request_body.description # Handle allOf at the top level by merging all schemas if "allOf" in body_schema and isinstance(body_schema["allOf"], list): merged_props = {} merged_required = [] for sub_schema in body_schema["allOf"]: if isinstance(sub_schema, dict): # Merge properties if "properties" in sub_schema: merged_props.update(sub_schema["properties"]) # Merge required fields if "required" in sub_schema: merged_required.extend(sub_schema["required"]) # Update body_schema with merged properties body_schema["properties"] = merged_props if merged_required: # Remove duplicates while preserving order seen = set() body_schema["required"] = [ x for x in merged_required if not (x in seen or seen.add(x)) ] # Remove the allOf since we've merged it body_schema.pop("allOf", None) body_props = body_schema.get("properties", {}) # Detect collisions: parameters that exist in both body and path/query/header all_non_body_params = set() for location_params in param_names_by_location.values(): all_non_body_params.update(location_params) body_param_names = set(body_props.keys()) colliding_params = all_non_body_params & body_param_names # Add parameters with suffixes for collisions for param in route.parameters: if param.name in colliding_params: # Add suffix for non-body parameters when collision detected suffixed_name = f"{param.name}__{param.location}" if param.required: required.append(suffixed_name) # Track parameter mapping parameter_map[suffixed_name] = { "location": param.location, "openapi_name": param.name, } # Convert refs if needed if convert_refs: param_schema = _replace_ref_with_defs(param.schema_, param.description) else: param_schema = param.schema_.copy() if param.description and not param_schema.get("description"): param_schema["description"] = param.description original_desc = param_schema.get("description", "") location_desc = f"({param.location.capitalize()} parameter)" if original_desc: param_schema["description"] = f"{original_desc} {location_desc}" else: param_schema["description"] = location_desc # Don't make optional parameters nullable - they can simply be omitted # The OpenAPI specification doesn't require optional parameters to accept null values properties[suffixed_name] = param_schema else: # No collision, use original name if param.required: required.append(param.name) # Track parameter mapping parameter_map[param.name] = { "location": param.location, "openapi_name": param.name, } # Convert refs if needed if convert_refs: param_schema = _replace_ref_with_defs(param.schema_, param.description) else: param_schema = param.schema_.copy() if param.description and not param_schema.get("description"): param_schema["description"] = param.description # Don't make optional parameters nullable - they can simply be omitted # The OpenAPI specification doesn't require optional parameters to accept null values properties[param.name] = param_schema # Add request body properties (no suffixes for body parameters) if route.request_body and route.request_body.content_schema: # If body is just a $ref, we need to handle it differently if "$ref" in body_schema and not body_props: # The entire body is a reference to a schema # We need to expand this inline or keep the ref # For simplicity, we'll keep it as a single property properties["body"] = body_schema if route.request_body.required: required.append("body") parameter_map["body"] = {"location": "body", "openapi_name": "body"} elif body_props: # Normal case: body has properties for prop_name, prop_schema in body_props.items(): properties[prop_name] = prop_schema # Track parameter mapping for body properties parameter_map[prop_name] = { "location": "body", "openapi_name": prop_name, } if route.request_body.required: required.extend(body_schema.get("required", [])) else: # Handle direct array/primitive schemas (like list[str] parameters from FastAPI) # Use the schema title as parameter name, fall back to generic name param_name = body_schema.get("title", "body").lower() # Clean the parameter name to be valid import re param_name = re.sub(r"[^a-zA-Z0-9_]", "_", param_name) if not param_name or param_name[0].isdigit(): param_name = "body_data" properties[param_name] = body_schema if route.request_body.required: required.append(param_name) parameter_map[param_name] = {"location": "body", "openapi_name": param_name} result = { "type": "object", "properties": properties, "required": required, } # Add schema definitions if available schema_defs = route.request_schemas if schema_defs: if convert_refs: # Need to convert refs and prune all_defs = schema_defs.copy() # Convert each schema definition recursively for name, schema in all_defs.items(): if isinstance(schema, dict): all_defs[name] = _replace_ref_with_defs(schema) # Prune to only needed schemas used_refs = set() def find_refs_in_value(value): """Recursively find all $ref references.""" if isinstance(value, dict): if "$ref" in value and isinstance(value["$ref"], str): ref = value["$ref"] if ref.startswith("#/$defs/"): used_refs.add(ref.split("/")[-1]) for v in value.values(): find_refs_in_value(v) elif isinstance(value, list): for item in value: find_refs_in_value(item) # Find refs in properties find_refs_in_value(properties) # Collect transitive dependencies if used_refs: collected_all = False while not collected_all: initial_count = len(used_refs) for name in list(used_refs): if name in all_defs: find_refs_in_value(all_defs[name]) collected_all = len(used_refs) == initial_count result["$defs"] = { name: def_schema for name, def_schema in all_defs.items() if name in used_refs } else: # From parser - already converted and pruned result["$defs"] = schema_defs return result, parameter_map def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: """ Combines parameter and request body schemas into a single schema. Handles parameter name collisions by adding location suffixes. This is a backward compatibility wrapper around _combine_schemas_and_map_params. Args: route: HTTPRoute object Returns: Combined schema dictionary """ schema, _ = _combine_schemas_and_map_params(route) return schema def extract_output_schema_from_responses( responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None, openapi_version: str | None = None, ) -> dict[str, Any] | None: """ Extract output schema from OpenAPI responses for use as MCP tool output schema. This function finds the first successful response (200, 201, 202, 204) with a JSON-compatible content type and extracts its schema. If the schema is not an object type, it wraps it to comply with MCP requirements. Args: responses: Dictionary of ResponseInfo objects keyed by status code schema_definitions: Optional schema definitions to include in the output schema openapi_version: OpenAPI version string, used to optimize nullable field handling Returns: dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found """ if not responses: return None # Priority order for success status codes success_codes = ["200", "201", "202", "204"] # Find the first successful response response_info = None for status_code in success_codes: if status_code in responses: response_info = responses[status_code] break # If no explicit success codes, try any 2xx response if response_info is None: for status_code, resp_info in responses.items(): if status_code.startswith("2"): response_info = resp_info break if response_info is None or not response_info.content_schema: return None # Prefer application/json, then fall back to other JSON-compatible types json_compatible_types = [ "application/json", "application/vnd.api+json", "application/hal+json", "application/ld+json", "text/json", ] schema = None for content_type in json_compatible_types: if content_type in response_info.content_schema: schema = response_info.content_schema[content_type] break # If no JSON-compatible type found, try the first available content type if schema is None and response_info.content_schema: first_content_type = next(iter(response_info.content_schema)) schema = response_info.content_schema[first_content_type] logger.debug( f"Using non-JSON content type for output schema: {first_content_type}" ) if not schema or not isinstance(schema, dict): return None # Convert refs if needed output_schema = _replace_ref_with_defs(schema) # If schema has a $ref, resolve it first before processing nullable fields if "$ref" in output_schema and schema_definitions: ref_path = output_schema["$ref"] if ref_path.startswith("#/$defs/"): schema_name = ref_path.split("/")[-1] if schema_name in schema_definitions: # Replace $ref with the actual schema definition output_schema = _replace_ref_with_defs(schema_definitions[schema_name]) if openapi_version and openapi_version.startswith("3"): # Convert OpenAPI 3.x schema to JSON Schema format for proper handling # of constructs like oneOf, anyOf, and nullable fields from .json_schema_converter import convert_openapi_schema_to_json_schema output_schema = convert_openapi_schema_to_json_schema( output_schema, openapi_version ) # MCP requires output schemas to be objects. If this schema is not an object, # we need to wrap it similar to how ParsedFunction.from_function() does it if output_schema.get("type") != "object": # Create a wrapped schema that contains the original schema under a "result" key wrapped_schema = { "type": "object", "properties": {"result": output_schema}, "required": ["result"], "x-fastmcp-wrap-result": True, } output_schema = wrapped_schema # Add schema definitions if available if schema_definitions: # Convert refs if needed processed_defs = schema_definitions.copy() # Convert each schema definition recursively for name, schema in processed_defs.items(): if isinstance(schema, dict): processed_defs[name] = _replace_ref_with_defs(schema) # Convert OpenAPI schema definitions to JSON Schema format if needed if openapi_version and openapi_version.startswith("3"): from .json_schema_converter import convert_openapi_schema_to_json_schema for def_name in list(processed_defs.keys()): processed_defs[def_name] = convert_openapi_schema_to_json_schema( processed_defs[def_name], openapi_version ) output_schema["$defs"] = processed_defs return output_schema # Export public symbols __all__ = [ "_combine_schemas", "_combine_schemas_and_map_params", "_make_optional_parameter_nullable", "clean_schema_for_display", "extract_output_schema_from_responses", ] ================================================ FILE: src/fastmcp/utilities/pagination.py ================================================ """Pagination utilities for MCP list operations.""" from __future__ import annotations import base64 import binascii import json from collections.abc import Sequence from dataclasses import dataclass from typing import TypeVar T = TypeVar("T") @dataclass class CursorState: """Internal representation of pagination cursor state. The cursor encodes the offset into the result set. This is opaque to clients per the MCP spec - they should not parse or modify cursors. """ offset: int def encode(self) -> str: """Encode cursor state to an opaque string.""" data = json.dumps({"o": self.offset}) return base64.urlsafe_b64encode(data.encode()).decode() @classmethod def decode(cls, cursor: str) -> CursorState: """Decode cursor from an opaque string. Raises: ValueError: If the cursor is invalid or malformed. """ try: data = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode()) return cls(offset=data["o"]) except ( json.JSONDecodeError, KeyError, ValueError, TypeError, binascii.Error, ) as e: raise ValueError(f"Invalid cursor: {cursor}") from e def paginate_sequence( items: Sequence[T], cursor: str | None, page_size: int, ) -> tuple[list[T], str | None]: """Paginate a sequence of items. Args: items: The full sequence to paginate. cursor: Optional cursor from a previous request. None for first page. page_size: Maximum number of items per page. Returns: Tuple of (page_items, next_cursor). next_cursor is None if no more pages. Raises: ValueError: If the cursor is invalid. """ offset = 0 if cursor: state = CursorState.decode(cursor) offset = state.offset end = offset + page_size page = list(items[offset:end]) next_cursor = None if end < len(items): next_cursor = CursorState(offset=end).encode() return page, next_cursor ================================================ FILE: src/fastmcp/utilities/skills.py ================================================ """Client utilities for discovering and downloading skills from MCP servers.""" from __future__ import annotations import base64 import json from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING import mcp.types if TYPE_CHECKING: from fastmcp.client import Client @dataclass class SkillSummary: """Summary information about a skill available on a server.""" name: str description: str uri: str @dataclass class SkillFile: """Information about a file within a skill.""" path: str size: int hash: str @dataclass class SkillManifest: """Full manifest of a skill including all files.""" name: str files: list[SkillFile] async def list_skills(client: Client) -> list[SkillSummary]: """List all available skills from an MCP server. Discovers skills by finding resources with URIs matching the `skill://{name}/SKILL.md` pattern. Args: client: Connected FastMCP client Returns: List of SkillSummary objects with name, description, and URI Example: ```python from fastmcp import Client from fastmcp.utilities.skills import list_skills async with Client("http://skills-server/mcp") as client: skills = await list_skills(client) for skill in skills: print(f"{skill.name}: {skill.description}") ``` """ resources = await client.list_resources() skills = [] for resource in resources: uri = str(resource.uri) # Match skill://{name}/SKILL.md pattern if uri.startswith("skill://") and uri.endswith("/SKILL.md"): # Extract skill name from URI path_part = uri[len("skill://") :] name = path_part.rsplit("/", 1)[0] skills.append( SkillSummary( name=name, description=resource.description or "", uri=uri, ) ) return skills async def get_skill_manifest(client: Client, skill_name: str) -> SkillManifest: """Get the manifest for a specific skill. Args: client: Connected FastMCP client skill_name: Name of the skill Returns: SkillManifest with file listing Raises: ValueError: If manifest cannot be read or parsed """ manifest_uri = f"skill://{skill_name}/_manifest" result = await client.read_resource(manifest_uri) if not result: raise ValueError(f"Could not read manifest for skill: {skill_name}") content = result[0] if isinstance(content, mcp.types.TextResourceContents): try: manifest_data = json.loads(content.text) except json.JSONDecodeError as e: raise ValueError(f"Invalid manifest JSON for skill: {skill_name}") from e else: raise ValueError(f"Unexpected manifest format for skill: {skill_name}") try: return SkillManifest( name=manifest_data["skill"], files=[ SkillFile(path=f["path"], size=f["size"], hash=f["hash"]) for f in manifest_data["files"] ], ) except (KeyError, TypeError) as e: raise ValueError(f"Invalid manifest format for skill: {skill_name}") from e async def download_skill( client: Client, skill_name: str, target_dir: str | Path, *, overwrite: bool = False, ) -> Path: """Download a skill and all its files to a local directory. Creates a subdirectory named after the skill containing all files. Args: client: Connected FastMCP client skill_name: Name of the skill to download target_dir: Directory where skill folder will be created overwrite: If True, overwrite existing skill directory. If False (default), raise FileExistsError if directory exists. Returns: Path to the downloaded skill directory Raises: ValueError: If skill cannot be found or downloaded FileExistsError: If skill directory exists and overwrite=False Example: ```python from fastmcp import Client from fastmcp.utilities.skills import download_skill async with Client("http://skills-server/mcp") as client: skill_path = await download_skill( client, "pdf-processing", "~/.claude/skills" ) print(f"Downloaded to: {skill_path}") ``` """ target_dir = Path(target_dir).expanduser().resolve() skill_dir = (target_dir / skill_name).resolve() # Security: ensure skill_dir stays within target_dir if not skill_dir.is_relative_to(target_dir): raise ValueError(f"Skill name {skill_name!r} would escape the target directory") # Check if directory exists if skill_dir.exists() and not overwrite: raise FileExistsError( f"Skill directory already exists: {skill_dir}. " "Use overwrite=True to replace." ) # Get manifest to know what files to download manifest = await get_skill_manifest(client, skill_name) # Create skill directory skill_dir.mkdir(parents=True, exist_ok=True) # Download each file for file_info in manifest.files: # Security: reject absolute paths and paths that escape skill_dir if Path(file_info.path).is_absolute(): continue file_path = (skill_dir / file_info.path).resolve() if not file_path.is_relative_to(skill_dir): continue file_uri = f"skill://{skill_name}/{file_info.path}" result = await client.read_resource(file_uri) if not result: continue content = result[0] # Create parent directories if needed file_path.parent.mkdir(parents=True, exist_ok=True) # Write content if isinstance(content, mcp.types.TextResourceContents): file_path.write_text(content.text) elif isinstance(content, mcp.types.BlobResourceContents): file_path.write_bytes(base64.b64decode(content.blob)) else: # Skip unknown content types continue return skill_dir async def sync_skills( client: Client, target_dir: str | Path, *, overwrite: bool = False, ) -> list[Path]: """Download all available skills from a server. Args: client: Connected FastMCP client target_dir: Directory where skill folders will be created overwrite: If True, overwrite existing files Returns: List of paths to downloaded skill directories Example: ```python from fastmcp import Client from fastmcp.utilities.skills import sync_skills async with Client("http://skills-server/mcp") as client: paths = await sync_skills(client, "~/.claude/skills") print(f"Downloaded {len(paths)} skills") ``` """ skills = await list_skills(client) downloaded = [] for skill in skills: try: path = await download_skill( client, skill.name, target_dir, overwrite=overwrite ) downloaded.append(path) except FileExistsError: # Skip existing skills when not overwriting continue return downloaded ================================================ FILE: src/fastmcp/utilities/tests.py ================================================ from __future__ import annotations import copy import multiprocessing import socket import time from collections.abc import AsyncGenerator, Callable, Generator from contextlib import asynccontextmanager, contextmanager, suppress from typing import TYPE_CHECKING, Any, Literal from urllib.parse import parse_qs, urlparse import httpx import uvicorn from fastmcp import settings from fastmcp.client.auth.oauth import OAuth from fastmcp.utilities.http import find_available_port if TYPE_CHECKING: from fastmcp.server.server import FastMCP @contextmanager def temporary_settings(**kwargs: Any): """ Temporarily override FastMCP setting values. Args: **kwargs: The settings to override, including nested settings. Example: Temporarily override a setting: ```python import fastmcp from fastmcp.utilities.tests import temporary_settings with temporary_settings(log_level='DEBUG'): assert fastmcp.settings.log_level == 'DEBUG' assert fastmcp.settings.log_level == 'INFO' ``` """ old_settings = copy.deepcopy(settings) try: # apply the new settings for attr, value in kwargs.items(): settings.set_setting(attr, value) yield finally: # restore the old settings for attr in kwargs: settings.set_setting(attr, old_settings.get_setting(attr)) def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> None: # Some Starlette apps are not pickleable, so we need to create them here based on the indicated transport if transport == "sse": app = mcp_server.http_app(transport="sse") else: raise ValueError(f"Invalid transport: {transport}") uvicorn_server = uvicorn.Server( config=uvicorn.Config( app=app, host="127.0.0.1", port=port, log_level="error", ws="websockets-sansio", ) ) uvicorn_server.run() @contextmanager def run_server_in_process( server_fn: Callable[..., None], *args: Any, provide_host_and_port: bool = True, host: str = "127.0.0.1", port: int | None = None, **kwargs: Any, ) -> Generator[str, None, None]: """ Context manager that runs a FastMCP server in a separate process and returns the server URL. When the context manager is exited, the server process is killed. Args: server_fn: The function that runs a FastMCP server. FastMCP servers are not pickleable, so we need a function that creates and runs one. *args: Arguments to pass to the server function. provide_host_and_port: Whether to provide the host and port to the server function as kwargs. host: Host to bind the server to (default: "127.0.0.1"). port: Port to bind the server to (default: find available port). **kwargs: Keyword arguments to pass to the server function. Returns: The server URL. """ # Use provided port or find an available one if port is None: port = find_available_port() if provide_host_and_port: kwargs |= {"host": host, "port": port} proc = multiprocessing.Process( target=server_fn, args=args, kwargs=kwargs, daemon=True ) proc.start() # Wait for server to be running max_attempts = 30 attempt = 0 while attempt < max_attempts and proc.is_alive(): try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.connect((host, port)) break except ConnectionRefusedError: if attempt < 5: time.sleep(0.05) elif attempt < 15: time.sleep(0.1) else: time.sleep(0.2) attempt += 1 else: raise RuntimeError(f"Server failed to start after {max_attempts} attempts") yield f"http://{host}:{port}" proc.terminate() proc.join(timeout=5) if proc.is_alive(): # If it's still alive, then force kill it proc.kill() proc.join(timeout=2) if proc.is_alive(): raise RuntimeError("Server process failed to terminate even after kill") @asynccontextmanager async def run_server_async( server: FastMCP, port: int | None = None, transport: Literal["http", "streamable-http", "sse"] = "http", path: str = "/mcp", host: str = "127.0.0.1", ) -> AsyncGenerator[str, None]: """ Start a FastMCP server as an asyncio task for in-process async testing. This is the recommended way to test FastMCP servers. It runs the server as an async task in the same process, eliminating subprocess coordination, sleeps, and cleanup issues. Args: server: FastMCP server instance port: Port to bind to (default: find available port) transport: Transport type ("http", "streamable-http", or "sse") path: URL path for the server (default: "/mcp") host: Host to bind to (default: "127.0.0.1") Yields: Server URL string Example: ```python import pytest from fastmcp import FastMCP, Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.utilities.tests import run_server_async @pytest.fixture async def server(): mcp = FastMCP("test") @mcp.tool() def greet(name: str) -> str: return f"Hello, {name}!" async with run_server_async(mcp) as url: yield url async def test_greet(server: str): async with Client(StreamableHttpTransport(server)) as client: result = await client.call_tool("greet", {"name": "World"}) assert result.content[0].text == "Hello, World!" ``` """ import asyncio if port is None: port = find_available_port() # Wait a tiny bit for the port to be released if it was just used await asyncio.sleep(0.01) # Start server as a background task server_task = asyncio.create_task( server.run_http_async( host=host, port=port, transport=transport, path=path, show_banner=False, ) ) # Wait for server lifespan to be ready await server._started.wait() # Give uvicorn a moment to bind the port after lifespan is ready await asyncio.sleep(0.1) try: yield f"http://{host}:{port}{path}" finally: # Cleanup: cancel the task with timeout to avoid hanging on Windows server_task.cancel() with suppress(asyncio.CancelledError, asyncio.TimeoutError): await asyncio.wait_for(server_task, timeout=2.0) class HeadlessOAuth(OAuth): """ OAuth provider that bypasses browser interaction for testing. This simulates the complete OAuth flow programmatically by making HTTP requests instead of opening a browser and running a callback server. Useful for automated testing. """ def __init__(self, mcp_url: str, **kwargs): """Initialize HeadlessOAuth with stored response tracking.""" self._stored_response = None super().__init__(mcp_url, **kwargs) async def redirect_handler(self, authorization_url: str) -> None: """Make HTTP request to authorization URL and store response for callback handler.""" async with httpx.AsyncClient() as client: response = await client.get(authorization_url, follow_redirects=False) self._stored_response = response async def callback_handler(self) -> tuple[str, str | None]: """Parse stored response and return (auth_code, state).""" if not self._stored_response: raise RuntimeError( "No authorization response stored. redirect_handler must be called first." ) response = self._stored_response # Extract auth code from redirect location if response.status_code == 302: redirect_url = response.headers["location"] parsed = urlparse(redirect_url) query_params = parse_qs(parsed.query) if "error" in query_params: error = query_params["error"][0] error_desc = query_params.get("error_description", ["Unknown error"])[0] raise RuntimeError( f"OAuth authorization failed: {error} - {error_desc}" ) auth_code = query_params["code"][0] state = query_params.get("state", [None])[0] return auth_code, state else: raise RuntimeError(f"Authorization failed: {response.status_code}") ================================================ FILE: src/fastmcp/utilities/timeout.py ================================================ """Timeout normalization utilities.""" from __future__ import annotations import datetime def normalize_timeout_to_timedelta( value: int | float | datetime.timedelta | None, ) -> datetime.timedelta | None: """Normalize a timeout value to a timedelta. Args: value: Timeout value as int/float (seconds), timedelta, or None Returns: timedelta if value provided, None otherwise """ if value is None: return None if isinstance(value, datetime.timedelta): return value if isinstance(value, int | float): return datetime.timedelta(seconds=float(value)) raise TypeError(f"Invalid timeout type: {type(value)}") def normalize_timeout_to_seconds( value: int | float | datetime.timedelta | None, ) -> float | None: """Normalize a timeout value to seconds (float). Args: value: Timeout value as int/float (seconds), timedelta, or None. Zero values are treated as "disabled" and return None. Returns: float seconds if value provided and non-zero, None otherwise """ if value is None: return None if isinstance(value, datetime.timedelta): seconds = value.total_seconds() return None if seconds == 0 else seconds if isinstance(value, int | float): return None if value == 0 else float(value) raise TypeError(f"Invalid timeout type: {type(value)}") ================================================ FILE: src/fastmcp/utilities/token_cache.py ================================================ """In-memory cache for token verification results. Provides a generic TTL-based cache for ``AccessToken`` objects, designed to reduce repeated network calls during opaque-token verification. Only *successful* verifications should be cached; errors and failures must be retried on every request. Example: ```python from fastmcp.utilities.token_cache import TokenCache cache = TokenCache(ttl_seconds=300, max_size=10000) # On cache miss, call the upstream verifier and store the result. hit, token = cache.get(raw_token) if not hit: token = await _call_upstream(raw_token) if token is not None: cache.set(raw_token, token) ``` """ from __future__ import annotations import hashlib import time from dataclasses import dataclass from fastmcp.server.auth.auth import AccessToken from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) DEFAULT_MAX_CACHE_SIZE = 10_000 _CLEANUP_INTERVAL = 60 # seconds between periodic sweeps @dataclass class _CacheEntry: """A cached token result with its absolute expiration timestamp.""" result: AccessToken expires_at: float class TokenCache: """TTL-based in-memory cache for ``AccessToken`` objects. Features: - SHA-256 hashed cache keys (fixed size, regardless of token length). - Per-entry TTL that respects both the configured ``ttl_seconds`` and the token's own ``expires_at`` claim (whichever is sooner). - Bounded size with FIFO eviction when the cache is full. - Periodic cleanup of expired entries to prevent unbounded growth. - Defensive deep copies on both store and retrieve to prevent callers from mutating cached values. Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or when ``max_size`` is ``0``. Negative values raise ``ValueError``. """ def __init__( self, *, ttl_seconds: int | None = None, max_size: int | None = None, ) -> None: """Initialise the cache. Args: ttl_seconds: How long cached entries remain valid, in seconds. ``None`` or ``0`` disables caching entirely. max_size: Upper bound on the number of entries. When the limit is reached, expired entries are swept first; if still full the oldest entry is evicted. Defaults to 10 000. """ if ttl_seconds is not None and ttl_seconds < 0: raise ValueError( f"cache_ttl_seconds must be non-negative, got {ttl_seconds}" ) if max_size is not None and max_size < 0: raise ValueError(f"max_cache_size must be non-negative, got {max_size}") self._ttl = ttl_seconds or 0 self._max_size = max_size if max_size is not None else DEFAULT_MAX_CACHE_SIZE self._entries: dict[str, _CacheEntry] = {} self._last_cleanup = time.monotonic() @property def enabled(self) -> bool: """Return whether caching is active.""" return self._ttl > 0 and self._max_size > 0 # -- public API ---------------------------------------------------------- def get(self, token: str) -> tuple[bool, AccessToken | None]: """Look up a cached verification result. Returns: ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss or when caching is disabled. The returned ``AccessToken`` is a deep copy that is safe to mutate. """ if not self.enabled: return (False, None) cache_key = self._hash_token(token) entry = self._entries.get(cache_key) if entry is None: return (False, None) if entry.expires_at < time.time(): del self._entries[cache_key] return (False, None) return (True, entry.result.model_copy(deep=True)) def set(self, token: str, result: AccessToken) -> None: """Store a *successful* verification result. Only successful verifications should be cached. Failures (inactive tokens, missing scopes, HTTP errors, timeouts) must **not** be cached so that transient problems do not produce sticky false negatives. """ if not self.enabled: return cache_key = self._hash_token(token) self._maybe_cleanup() if cache_key not in self._entries: self._enforce_size_limit() expires_at = time.time() + self._ttl if result.expires_at: expires_at = min(expires_at, float(result.expires_at)) self._entries[cache_key] = _CacheEntry( result=result.model_copy(deep=True), expires_at=expires_at, ) # -- internals ----------------------------------------------------------- @staticmethod def _hash_token(token: str) -> str: """Return the SHA-256 hex digest of *token*.""" return hashlib.sha256(token.encode("utf-8")).hexdigest() def _cleanup_expired(self) -> None: """Remove all entries whose TTL has elapsed.""" now = time.time() expired = [k for k, v in self._entries.items() if v.expires_at < now] for key in expired: del self._entries[key] if expired: logger.debug("Cleaned up %d expired cache entries", len(expired)) def _maybe_cleanup(self) -> None: """Run ``_cleanup_expired`` at most once per cleanup interval.""" now = time.monotonic() if now - self._last_cleanup > _CLEANUP_INTERVAL: self._cleanup_expired() self._last_cleanup = now def _enforce_size_limit(self) -> None: """Ensure there is room for at least one new entry.""" if len(self._entries) < self._max_size: return self._cleanup_expired() if len(self._entries) >= self._max_size: oldest_key = next(iter(self._entries)) del self._entries[oldest_key] ================================================ FILE: src/fastmcp/utilities/types.py ================================================ """Common types used across FastMCP.""" import base64 import inspect import mimetypes import os from collections.abc import Callable from functools import lru_cache from pathlib import Path from types import EllipsisType, UnionType from typing import ( Annotated, Any, Protocol, TypeAlias, Union, get_args, get_origin, get_type_hints, ) import mcp.types from mcp.types import Annotations, ContentBlock, ModelPreferences, SamplingMessage from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints from typing_extensions import TypeVar T = TypeVar("T", default=Any) # sentinel values for optional arguments NotSet = ... NotSetT: TypeAlias = EllipsisType def get_fn_name(fn: Callable[..., Any]) -> str: return fn.__name__ # ty: ignore[unresolved-attribute] class FastMCPBaseModel(BaseModel): """Base model for FastMCP models.""" model_config = ConfigDict(extra="forbid") @lru_cache(maxsize=5000) def get_cached_typeadapter(cls: T) -> TypeAdapter[T]: """ TypeAdapters are heavy objects, and in an application context we'd typically create them once in a global scope and reuse them as often as possible. 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. """ # For functions, process annotations to handle forward references and convert # Annotated[Type, "string"] to Annotated[Type, Field(description="string")] if inspect.isfunction(cls) or inspect.ismethod(cls): if hasattr(cls, "__annotations__") and cls.__annotations__: try: # Resolve forward references first resolved_hints = get_type_hints(cls, include_extras=True) except Exception: # If forward reference resolution fails, use original annotations resolved_hints = cls.__annotations__ # Process annotations to convert string descriptions to Fields processed_hints = {} for name, annotation in resolved_hints.items(): # Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")] if ( get_origin(annotation) is Annotated and len(get_args(annotation)) == 2 and isinstance(get_args(annotation)[1], str) ): base_type, description = get_args(annotation) processed_hints[name] = Annotated[ base_type, Field(description=description) ] else: processed_hints[name] = annotation # Create new function if annotations changed if processed_hints != cls.__annotations__: import types # Handle both functions and methods if inspect.ismethod(cls): actual_func = cls.__func__ code = actual_func.__code__ # ty: ignore[unresolved-attribute] globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] name = actual_func.__name__ # ty: ignore[unresolved-attribute] defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] kwdefaults = actual_func.__kwdefaults__ # ty: ignore[unresolved-attribute] closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] else: code = cls.__code__ globals_dict = cls.__globals__ name = cls.__name__ defaults = cls.__defaults__ kwdefaults = cls.__kwdefaults__ closure = cls.__closure__ new_func = types.FunctionType( code, globals_dict, name, defaults, closure, ) new_func.__dict__.update(cls.__dict__) new_func.__module__ = cls.__module__ new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__) new_func.__annotations__ = processed_hints new_func.__kwdefaults__ = kwdefaults if inspect.ismethod(cls): new_method = types.MethodType(new_func, cls.__self__) return TypeAdapter(new_method) else: return TypeAdapter(new_func) return TypeAdapter(cls) def issubclass_safe(cls: type, base: type) -> bool: """Check if cls is a subclass of base, even if cls is a type variable.""" try: if origin := get_origin(cls): return issubclass_safe(origin, base) return issubclass(cls, base) except TypeError: return False 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. 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]). """ origin = get_origin(cls) # Handle both types of unions: UnionType (from types module, used with | syntax) # and typing.Union (used with Union[] syntax) if origin is UnionType or origin == Union: return any(is_class_member_of_type(arg, base) for arg in get_args(cls)) elif origin is Annotated: # For Annotated[T, ...], check if T is a member of base args = get_args(cls) if args: return is_class_member_of_type(args[0], base) return False else: return issubclass_safe(cls, base) def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None: """ 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. """ if inspect.ismethod(fn) and hasattr(fn, "__func__"): fn = fn.__func__ # Try to get resolved type hints try: # Use include_extras=True to preserve Annotated metadata type_hints = get_type_hints(fn, include_extras=True) except Exception: # If resolution fails, use raw annotations if they exist type_hints = getattr(fn, "__annotations__", {}) sig = inspect.signature(fn) for name, param in sig.parameters.items(): # Use resolved hint if available, otherwise raw annotation annotation = type_hints.get(name, param.annotation) if is_class_member_of_type(annotation, kwarg_type): return name return None def create_function_without_params( fn: Callable[..., Any], exclude_params: list[str] ) -> Callable[..., Any]: """ Create a new function with the same code but without the specified parameters in annotations. This is used to exclude parameters from type adapter processing when they can't be serialized. The excluded parameters are removed from the function's __annotations__ dictionary. """ import types if inspect.ismethod(fn): actual_func = fn.__func__ code = actual_func.__code__ # ty: ignore[unresolved-attribute] globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] name = actual_func.__name__ # ty: ignore[unresolved-attribute] defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] else: code = fn.__code__ # ty: ignore[unresolved-attribute] globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute] name = fn.__name__ # ty: ignore[unresolved-attribute] defaults = fn.__defaults__ # ty: ignore[unresolved-attribute] closure = fn.__closure__ # ty: ignore[unresolved-attribute] # Create a copy of annotations without the excluded parameters original_annotations = getattr(fn, "__annotations__", {}) new_annotations = { k: v for k, v in original_annotations.items() if k not in exclude_params } # Create new signature without the excluded parameters sig = inspect.signature(fn) new_params = [ param for name, param in sig.parameters.items() if name not in exclude_params ] new_sig = inspect.Signature(new_params, return_annotation=sig.return_annotation) new_func = types.FunctionType( code, globals_dict, name, defaults, closure, ) new_func.__dict__.update(fn.__dict__) new_func.__module__ = fn.__module__ new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute] new_func.__annotations__ = new_annotations new_func.__signature__ = new_sig # type: ignore[attr-defined] if inspect.ismethod(fn): return types.MethodType(new_func, fn.__self__) else: return new_func class Image: """Helper class for returning images from tools.""" def __init__( self, path: str | Path | None = None, data: bytes | None = None, format: str | None = None, annotations: Annotations | None = None, ): if path is None and data is None: raise ValueError("Either path or data must be provided") if path is not None and data is not None: raise ValueError("Only one of path or data can be provided") self.path = self._get_expanded_path(path) self.data = data self._format = format self._mime_type = self._get_mime_type() self.annotations = annotations @staticmethod def _get_expanded_path(path: str | Path | None) -> Path | None: """Expand environment variables and user home in path.""" return Path(os.path.expandvars(str(path))).expanduser() if path else None def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" if self._format: return f"image/{self._format.lower()}" if self.path: # Workaround for WEBP in Py3.10 mimetypes.add_type("image/webp", ".webp") resp = mimetypes.guess_type(self.path, strict=False) if resp and resp[0] is not None: return resp[0] return "application/octet-stream" return "image/png" # default for raw binary data def _get_data(self) -> str: """Get raw image data as base64-encoded string.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() elif self.data is not None: data = base64.b64encode(self.data).decode() else: raise ValueError("No image data available") return data def to_image_content( self, mime_type: str | None = None, annotations: Annotations | None = None, ) -> mcp.types.ImageContent: """Convert to MCP ImageContent.""" data = self._get_data() return mcp.types.ImageContent( type="image", data=data, mimeType=mime_type or self._mime_type, annotations=annotations or self.annotations, ) def to_data_uri(self, mime_type: str | None = None) -> str: """Get image as a data URI.""" data = self._get_data() return f"data:{mime_type or self._mime_type};base64,{data}" class Audio: """Helper class for returning audio from tools.""" def __init__( self, path: str | Path | None = None, data: bytes | None = None, format: str | None = None, annotations: Annotations | None = None, ): if path is None and data is None: raise ValueError("Either path or data must be provided") if path is not None and data is not None: raise ValueError("Only one of path or data can be provided") self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None self.data = data self._format = format self._mime_type = self._get_mime_type() self.annotations = annotations def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" if self._format: return f"audio/{self._format.lower()}" if self.path: suffix = self.path.suffix.lower() return { ".wav": "audio/wav", ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac", }.get(suffix, "application/octet-stream") return "audio/wav" # default for raw binary data def to_audio_content( self, mime_type: str | None = None, annotations: Annotations | None = None, ) -> mcp.types.AudioContent: if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() elif self.data is not None: data = base64.b64encode(self.data).decode() else: raise ValueError("No audio data available") return mcp.types.AudioContent( type="audio", data=data, mimeType=mime_type or self._mime_type, annotations=annotations or self.annotations, ) class File: """Helper class for returning file data from tools.""" def __init__( self, path: str | Path | None = None, data: bytes | None = None, format: str | None = None, name: str | None = None, annotations: Annotations | None = None, ): if path is None and data is None: raise ValueError("Either path or data must be provided") if path is not None and data is not None: raise ValueError("Only one of path or data can be provided") self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None self.data = data self._format = format self._mime_type = self._get_mime_type() self._name = name self.annotations = annotations def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" if self._format: fmt = self._format.lower() # Map common text formats to text/plain if fmt in {"plain", "txt", "text"}: return "text/plain" return f"application/{fmt}" if self.path: mime_type, _ = mimetypes.guess_type(self.path) if mime_type: return mime_type return "application/octet-stream" def to_resource_content( self, mime_type: str | None = None, annotations: Annotations | None = None, ) -> mcp.types.EmbeddedResource: if self.path: with open(self.path, "rb") as f: raw_data = f.read() uri_str = self.path.resolve().as_uri() elif self.data is not None: raw_data = self.data if self._name: uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}" else: uri_str = f"file:///resource.{self._mime_type.split('/')[1]}" else: raise ValueError("No resource data available") mime = mime_type or self._mime_type UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)] uri = TypeAdapter(UriType).validate_python(uri_str) if mime.startswith("text/"): try: text = raw_data.decode("utf-8") except UnicodeDecodeError: text = raw_data.decode("latin-1") resource = mcp.types.TextResourceContents( text=text, mimeType=mime, uri=uri, ) else: data = base64.b64encode(raw_data).decode() resource = mcp.types.BlobResourceContents( blob=data, mimeType=mime, uri=uri, ) return mcp.types.EmbeddedResource( type="resource", resource=resource, annotations=annotations or self.annotations, ) def replace_type(type_, type_map: dict[type, type]): """ Given a (possibly generic, nested, or otherwise complex) type, replaces all instances of old_type with new_type. This is useful for transforming types when creating tools. Args: type_: The type to replace instances of old_type with new_type. old_type: The type to replace. new_type: The type to replace old_type with. Examples: ```python >>> replace_type(list[int | bool], {int: str}) list[str | bool] >>> replace_type(list[list[int]], {int: str}) list[list[str]] ``` """ if type_ in type_map: return type_map[type_] origin = get_origin(type_) if not origin: return type_ args = get_args(type_) new_args = tuple(replace_type(arg, type_map) for arg in args) if origin is UnionType: return Union[new_args] # noqa: UP007 else: return origin[new_args] class ContextSamplingFallbackProtocol(Protocol): async def __call__( self, messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None, ) -> ContentBlock: ... ================================================ FILE: src/fastmcp/utilities/ui.py ================================================ """ Shared UI utilities for FastMCP HTML pages. This module provides reusable HTML/CSS components for OAuth callbacks, consent pages, and other user-facing interfaces. """ from __future__ import annotations import html from starlette.responses import HTMLResponse # FastMCP branding FASTMCP_LOGO_URL = "https://gofastmcp.com/assets/brand/blue-logo.png" # Base CSS styles shared across all FastMCP pages BASE_STYLES = """ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; margin: 0; padding: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f9fafb; color: #0a0a0a; } .container { background: #ffffff; border: 1px solid #e5e7eb; padding: 3rem 2.5rem; border-radius: 1rem; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); text-align: center; max-width: 36rem; margin: 1rem; width: 100%; } @media (max-width: 640px) { .container { padding: 2rem 1.5rem; margin: 0.5rem; } } .logo { width: 64px; height: auto; margin-bottom: 1.5rem; display: block; margin-left: auto; margin-right: auto; } h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1.5rem; color: #111827; } """ # Button styles BUTTON_STYLES = """ .button-group { display: flex; gap: 0.75rem; margin-top: 1.5rem; justify-content: center; } button { padding: 0.75rem 2rem; font-size: 0.9375rem; font-weight: 500; border-radius: 0.5rem; border: none; cursor: pointer; transition: all 0.15s; font-family: inherit; } button:hover { transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } .btn-approve, .btn-primary { background: #10b981; color: #ffffff; min-width: 120px; } .btn-deny, .btn-secondary { background: #6b7280; color: #ffffff; min-width: 120px; } """ # Info box / message box styles INFO_BOX_STYLES = """ .info-box { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; text-align: left; font-size: 0.9375rem; line-height: 1.5; color: #374151; } .info-box p { margin-bottom: 0.5rem; } .info-box p:last-child { margin-bottom: 0; } .info-box.centered { text-align: center; } .info-box.error { background: #fef2f2; border-color: #fecaca; color: #991b1b; } .info-box strong { color: #0ea5e9; font-weight: 600; } .info-box .server-name-link { color: #0ea5e9; text-decoration: underline; font-weight: 600; cursor: pointer; transition: opacity 0.15s; } .info-box .server-name-link:hover { opacity: 0.8; } /* Monospace info box - gray styling with code font */ .info-box-mono { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.875rem; margin: 1.25rem 0; font-size: 0.875rem; color: #6b7280; font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; text-align: left; } .info-box-mono.centered { text-align: center; } .info-box-mono.error { background: #fef2f2; border-color: #fecaca; color: #991b1b; } .info-box-mono strong { color: #111827; font-weight: 600; } .warning-box { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; text-align: center; } .warning-box p { margin-bottom: 0.5rem; line-height: 1.5; color: #6b7280; font-size: 0.9375rem; } .warning-box p:last-child { margin-bottom: 0; } .warning-box strong { color: #0ea5e9; font-weight: 600; } .warning-box a { color: #0ea5e9; text-decoration: underline; font-weight: 600; } .warning-box a:hover { color: #0284c7; text-decoration: underline; } """ # Status message styles (for success/error indicators) STATUS_MESSAGE_STYLES = """ .status-message { display: flex; align-items: center; justify-content: center; gap: 0.75rem; margin-bottom: 1.5rem; } .status-icon { font-size: 1.5rem; line-height: 1; display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 0.5rem; flex-shrink: 0; } .status-icon.success { background: #10b98120; } .status-icon.error { background: #ef444420; } .message { font-size: 1.125rem; line-height: 1.75; color: #111827; font-weight: 600; text-align: left; } """ # Detail box styles (for key-value pairs) DETAIL_BOX_STYLES = """ .detail-box { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; text-align: left; } .detail-row { display: flex; padding: 0.5rem 0; border-bottom: 1px solid #e5e7eb; } .detail-row:last-child { border-bottom: none; } .detail-label { font-weight: 600; min-width: 160px; color: #6b7280; font-size: 0.875rem; flex-shrink: 0; padding-right: 1rem; } .detail-value { flex: 1; font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.75rem; color: #111827; word-break: break-all; overflow-wrap: break-word; } """ # Redirect section styles (for OAuth redirect URI box) REDIRECT_SECTION_STYLES = """ .redirect-section { background: #fffbeb; border: 1px solid #fcd34d; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1.5rem; text-align: left; } .redirect-section .label { font-size: 0.875rem; color: #6b7280; font-weight: 600; margin-bottom: 0.5rem; display: block; } .redirect-section .value { font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; font-size: 0.875rem; color: #111827; word-break: break-all; margin-top: 0.25rem; } """ # Collapsible details styles DETAILS_STYLES = """ details { margin-bottom: 1.5rem; text-align: left; } summary { cursor: pointer; font-size: 0.875rem; color: #6b7280; font-weight: 600; list-style: none; padding: 0.5rem; border-radius: 0.25rem; } summary:hover { background: #f9fafb; } summary::marker { display: none; } summary::before { content: "▶"; display: inline-block; margin-right: 0.5rem; transition: transform 0.2s; font-size: 0.75rem; } details[open] summary::before { transform: rotate(90deg); } """ # Helper text styles HELPER_TEXT_STYLES = """ .close-instruction, .help-text { font-size: 0.875rem; color: #6b7280; margin-top: 1.5rem; } """ # Tooltip styles for hover help TOOLTIP_STYLES = """ .help-link-container { position: fixed; bottom: 1.5rem; right: 1.5rem; font-size: 0.875rem; } .help-link { color: #6b7280; text-decoration: none; cursor: help; position: relative; display: inline-block; border-bottom: 1px dotted #9ca3af; } @media (max-width: 640px) { .help-link { background: #ffffff; padding: 0.25rem 0.5rem; border-radius: 0.25rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } } .help-link:hover { color: #111827; border-bottom-color: #111827; } .help-link:hover .tooltip { opacity: 1; visibility: visible; } .tooltip { position: absolute; bottom: 100%; right: 0; left: auto; margin-bottom: 0.5rem; background: #1f2937; color: #ffffff; padding: 0.75rem 1rem; border-radius: 0.5rem; font-size: 0.8125rem; line-height: 1.5; width: 280px; max-width: calc(100vw - 3rem); opacity: 0; visibility: hidden; transition: opacity 0.2s, visibility 0.2s; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); text-align: left; } .tooltip::after { content: ''; position: absolute; top: 100%; right: 1rem; border: 6px solid transparent; border-top-color: #1f2937; } .tooltip-link { color: #60a5fa; text-decoration: underline; } """ def create_page( content: str, title: str = "FastMCP", additional_styles: str = "", csp_policy: str = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'", ) -> str: """ Create a complete HTML page with FastMCP styling. Args: content: HTML content to place inside the page title: Page title additional_styles: Extra CSS to include csp_policy: Content Security Policy header value. If empty string "", the CSP meta tag is omitted entirely. Returns: Complete HTML page as string """ title = html.escape(title) # Only include CSP meta tag if policy is non-empty csp_meta = ( f'' if csp_policy else "" ) return f""" {title} {csp_meta} {content} """ def create_logo(icon_url: str | None = None, alt_text: str = "FastMCP") -> str: """Create logo HTML. Args: icon_url: Optional custom icon URL. If not provided, uses the FastMCP logo. alt_text: Alt text for the logo image. Returns: HTML for logo image tag. """ url = icon_url or FASTMCP_LOGO_URL alt = html.escape(alt_text) return f'' def create_status_message(message: str, is_success: bool = True) -> str: """ Create a status message with icon. Args: message: Status message text is_success: True for success (✓), False for error (✕) Returns: HTML for status message """ message = html.escape(message) icon = "✓" if is_success else "✕" icon_class = "success" if is_success else "error" return f"""
{icon}
{message}
""" def create_info_box( content: str, is_error: bool = False, centered: bool = False, monospace: bool = False, ) -> str: """ Create an info box. Args: content: HTML content for the info box is_error: True for error styling, False for normal centered: True to center the text, False for left-aligned monospace: True to use gray monospace font styling instead of blue Returns: HTML for info box """ content = html.escape(content) base_class = "info-box-mono" if monospace else "info-box" classes = [base_class] if is_error: classes.append("error") if centered: classes.append("centered") class_str = " ".join(classes) return f'
{content}
' def create_detail_box(rows: list[tuple[str, str]]) -> str: """ Create a detail box with key-value pairs. Args: rows: List of (label, value) tuples Returns: HTML for detail box """ rows_html = "\n".join( f"""
{html.escape(label)}:
{html.escape(value)}
""" for label, value in rows ) return f'
{rows_html}
' def create_button_group(buttons: list[tuple[str, str, str]]) -> str: """ Create a group of buttons. Args: buttons: List of (text, value, css_class) tuples Returns: HTML for button group """ buttons_html = "\n".join( f'' for text, value, css_class in buttons ) return f'
{buttons_html}
' def create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse: """ Create an HTMLResponse with security headers. Adds X-Frame-Options: DENY to prevent clickjacking attacks per MCP security best practices. Args: html: HTML content to return status_code: HTTP status code Returns: HTMLResponse with security headers """ return HTMLResponse( content=html, status_code=status_code, headers={"X-Frame-Options": "DENY"}, ) ================================================ FILE: src/fastmcp/utilities/version_check.py ================================================ """Version checking utilities for FastMCP.""" from __future__ import annotations import json import time from pathlib import Path import httpx from packaging.version import Version from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) PYPI_URL = "https://pypi.org/pypi/fastmcp/json" CACHE_TTL_SECONDS = 60 * 60 * 12 # 12 hours REQUEST_TIMEOUT_SECONDS = 2.0 def _get_cache_path(include_prereleases: bool = False) -> Path: """Get the path to the version cache file.""" import fastmcp suffix = "_prerelease" if include_prereleases else "" return fastmcp.settings.home / f"version_cache{suffix}.json" def _read_cache(include_prereleases: bool = False) -> tuple[str | None, float]: """Read cached version info. Returns: Tuple of (cached_version, cache_timestamp) or (None, 0) if no cache. """ cache_path = _get_cache_path(include_prereleases) if not cache_path.exists(): return None, 0 try: data = json.loads(cache_path.read_text()) return data.get("latest_version"), data.get("timestamp", 0) except (json.JSONDecodeError, OSError): return None, 0 def _write_cache(latest_version: str, include_prereleases: bool = False) -> None: """Write version info to cache.""" cache_path = _get_cache_path(include_prereleases) try: cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_text( json.dumps({"latest_version": latest_version, "timestamp": time.time()}) ) except OSError: # Silently ignore cache write failures pass def _fetch_latest_version(include_prereleases: bool = False) -> str | None: """Fetch the latest version from PyPI. Args: include_prereleases: If True, include pre-release versions (alpha, beta, rc). Returns: The latest version string, or None if the fetch failed. """ try: response = httpx.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS) response.raise_for_status() data = response.json() releases = data.get("releases", {}) if not releases: return None versions = [] for version_str in releases: try: v = Version(version_str) # Skip prereleases if not requested if not include_prereleases and v.is_prerelease: continue versions.append(v) except ValueError: logger.debug(f"Skipping invalid version string: {version_str}") continue if not versions: return None return str(max(versions)) except (httpx.HTTPError, json.JSONDecodeError, KeyError): return None def get_latest_version(include_prereleases: bool = False) -> str | None: """Get the latest version of FastMCP from PyPI, using cache when available. Args: include_prereleases: If True, include pre-release versions. Returns: The latest version string, or None if unavailable. """ # Check cache first cached_version, cache_timestamp = _read_cache(include_prereleases) if cached_version and (time.time() - cache_timestamp) < CACHE_TTL_SECONDS: return cached_version # Fetch from PyPI latest_version = _fetch_latest_version(include_prereleases) # Update cache if we got a valid version if latest_version: _write_cache(latest_version, include_prereleases) return latest_version # Return stale cache if available return cached_version def check_for_newer_version() -> str | None: """Check if a newer version of FastMCP is available. Returns: The latest version string if newer than current, None otherwise. """ import fastmcp setting = fastmcp.settings.check_for_updates if setting == "off": return None include_prereleases = setting == "prerelease" latest_version = get_latest_version(include_prereleases) if not latest_version: return None try: current = Version(fastmcp.__version__) latest = Version(latest_version) if latest > current: return latest_version except ValueError: logger.debug( f"Could not compare versions: current={fastmcp.__version__!r}, " f"latest={latest_version!r}" ) return None ================================================ FILE: src/fastmcp/utilities/versions.py ================================================ """Version comparison utilities for component versioning. This module provides utilities for comparing component versions. Versions are strings that are first attempted to be parsed as PEP 440 versions (using the `packaging` library), falling back to lexicographic string comparison. Examples: - "1", "2", "10" → parsed as PEP 440, compared semantically (1 < 2 < 10) - "1.0", "2.0" → parsed as PEP 440 - "v1.0" → 'v' prefix stripped, parsed as "1.0" - "2025-01-15" → not valid PEP 440, compared as strings - None → sorts lowest (unversioned components) """ from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import total_ordering from typing import TYPE_CHECKING, Any, TypeVar, cast from packaging.version import InvalidVersion, Version if TYPE_CHECKING: from fastmcp.utilities.components import FastMCPComponent C = TypeVar("C", bound=Any) @dataclass class VersionSpec: """Specification for filtering components by version. Used by transforms and providers to filter components to a specific version or version range. Unversioned components (version=None) always match any spec. Args: gte: If set, only versions >= this value match. lt: If set, only versions < this value match. eq: If set, only this exact version matches (gte/lt ignored). """ gte: str | None = None lt: str | None = None eq: str | None = None def matches(self, version: str | None, *, match_none: bool = True) -> bool: """Check if a version matches this spec. Args: version: The version to check, or None for unversioned. match_none: Whether unversioned (None) components match. Defaults to True for backward compatibility with retrieval operations. Set to False when filtering (e.g., enable/disable) to exclude unversioned components from version-specific rules. Returns: True if the version matches the spec. """ if version is None: return match_none if self.eq is not None: return version == self.eq key = parse_version_key(version) if self.gte is not None: gte_key = parse_version_key(self.gte) if key < gte_key: return False if self.lt is not None: lt_key = parse_version_key(self.lt) if not key < lt_key: return False return True def intersect(self, other: VersionSpec | None) -> VersionSpec: """Return a spec that satisfies both this spec and other. Used by transforms to combine caller constraints with filter constraints. For example, if a VersionFilter has lt="3.0" and caller requests eq="1.0", the intersection validates "1.0" is in range and returns the exact spec. Args: other: Another spec to intersect with, or None. Returns: A VersionSpec that matches only versions satisfying both specs. """ if other is None: return self if self.eq is not None: # This spec wants exact - validate against other's range if other.matches(self.eq): return self return VersionSpec(eq="__impossible__") if other.eq is not None: # Other wants exact - validate against our range if self.matches(other.eq): return other return VersionSpec(eq="__impossible__") # Both are ranges - take tighter bounds return VersionSpec( gte=max_version(self.gte, other.gte), lt=min_version(self.lt, other.lt), ) @total_ordering class VersionKey: """A comparable version key that handles None, PEP 440 versions, and strings. Comparison order: 1. None (unversioned) sorts lowest 2. PEP 440 versions sort by semantic version order 3. Invalid versions (strings) sort lexicographically 4. When comparing PEP 440 vs string, PEP 440 comes first """ __slots__ = ("_is_none", "_is_pep440", "_parsed", "_raw") def __init__(self, version: str | None) -> None: self._raw = version self._is_none = version is None self._is_pep440 = False self._parsed: Version | str | None = None if version is not None: # Strip leading 'v' if present (common convention like "v1.0") normalized = version.lstrip("v") if version.startswith("v") else version try: self._parsed = Version(normalized) self._is_pep440 = True except InvalidVersion: # Fall back to string comparison for non-PEP 440 versions self._parsed = version def __eq__(self, other: object) -> bool: if not isinstance(other, VersionKey): return NotImplemented if self._is_none and other._is_none: return True if self._is_none != other._is_none: return False # Both are not None if self._is_pep440 and other._is_pep440: return self._parsed == other._parsed if not self._is_pep440 and not other._is_pep440: return self._parsed == other._parsed # One is PEP 440, other is string - never equal return False def __lt__(self, other: object) -> bool: if not isinstance(other, VersionKey): return NotImplemented # None sorts lowest if self._is_none and other._is_none: return False # Equal if self._is_none: return True # None < anything if other._is_none: return False # anything > None # Both are not None if self._is_pep440 and other._is_pep440: # Both PEP 440 - compare normally assert isinstance(self._parsed, Version) assert isinstance(other._parsed, Version) return self._parsed < other._parsed if not self._is_pep440 and not other._is_pep440: # Both strings - lexicographic assert isinstance(self._parsed, str) assert isinstance(other._parsed, str) return self._parsed < other._parsed # Mixed: PEP 440 sorts before strings # (arbitrary but consistent choice) return self._is_pep440 def __repr__(self) -> str: return f"VersionKey({self._raw!r})" def parse_version_key(version: str | None) -> VersionKey: """Parse a version string into a sortable key. Args: version: The version string, or None for unversioned. Returns: A VersionKey suitable for sorting. """ return VersionKey(version) def version_sort_key(component: FastMCPComponent) -> VersionKey: """Get a sort key for a component based on its version. Use with sorted() or max() to order components by version. Args: component: The component to get a sort key for. Returns: A sortable VersionKey. Example: ```python tools = [tool_v1, tool_v2, tool_unversioned] highest = max(tools, key=version_sort_key) # Returns tool_v2 ``` """ return parse_version_key(component.version) def compare_versions(a: str | None, b: str | None) -> int: """Compare two version strings. Args: a: First version string (or None). b: Second version string (or None). Returns: -1 if a < b, 0 if a == b, 1 if a > b. Example: ```python compare_versions("1.0", "2.0") # Returns -1 compare_versions("2.0", "1.0") # Returns 1 compare_versions(None, "1.0") # Returns -1 (None < any version) ``` """ key_a = parse_version_key(a) key_b = parse_version_key(b) return (key_a > key_b) - (key_a < key_b) def is_version_greater(a: str | None, b: str | None) -> bool: """Check if version a is greater than version b. Args: a: First version string (or None). b: Second version string (or None). Returns: True if a > b, False otherwise. """ return compare_versions(a, b) > 0 def max_version(a: str | None, b: str | None) -> str | None: """Return the greater of two versions. Args: a: First version string (or None). b: Second version string (or None). Returns: The greater version, or None if both are None. """ if a is None: return b if b is None: return a return a if compare_versions(a, b) >= 0 else b def min_version(a: str | None, b: str | None) -> str | None: """Return the lesser of two versions. Args: a: First version string (or None). b: Second version string (or None). Returns: The lesser version, or None if both are None. """ if a is None: return b if b is None: return a return a if compare_versions(a, b) <= 0 else b def dedupe_with_versions( components: Sequence[C], key_fn: Callable[[C], str], ) -> list[C]: """Deduplicate components by key, keeping highest version. Groups components by key, selects the highest version from each group, and injects available versions into meta if any component is versioned. Args: components: Sequence of components to deduplicate. key_fn: Function to extract the grouping key from a component. Returns: Deduplicated list with versions injected into meta. """ by_key: dict[str, list[C]] = {} for c in components: by_key.setdefault(key_fn(c), []).append(c) result: list[C] = [] for versions in by_key.values(): highest: C = cast(C, max(versions, key=version_sort_key)) if any(c.version is not None for c in versions): all_versions = sorted( [c.version for c in versions if c.version is not None], key=parse_version_key, reverse=True, ) meta = highest.meta or {} highest = highest.model_copy( update={ "meta": { **meta, "fastmcp": { **meta.get("fastmcp", {}), "versions": all_versions, }, } } ) result.append(highest) return result ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/cli/__init__.py ================================================ """CLI test package.""" ================================================ FILE: tests/cli/test_cimd_cli.py ================================================ """Tests for the CIMD CLI commands (create and validate).""" from __future__ import annotations import json from unittest.mock import AsyncMock, patch import pytest from pydantic import AnyHttpUrl from fastmcp.cli.cimd import create_command, validate_command from fastmcp.server.auth.cimd import CIMDDocument, CIMDFetchError, CIMDValidationError class TestCIMDCreateCommand: """Tests for `fastmcp auth cimd create`.""" def test_minimal_output(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], ) doc = json.loads(capsys.readouterr().out) assert doc["client_name"] == "Test App" assert doc["redirect_uris"] == ["http://localhost:*/callback"] assert doc["token_endpoint_auth_method"] == "none" assert doc["grant_types"] == ["authorization_code"] assert doc["response_types"] == ["code"] # Placeholder client_id assert "YOUR-DOMAIN" in doc["client_id"] def test_with_client_id(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], client_id="https://myapp.example.com/client.json", ) doc = json.loads(capsys.readouterr().out) assert doc["client_id"] == "https://myapp.example.com/client.json" def test_with_output_file(self, tmp_path): output_file = tmp_path / "client.json" create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], client_id="https://example.com/client.json", output=str(output_file), ) doc = json.loads(output_file.read_text()) assert doc["client_id"] == "https://example.com/client.json" assert doc["client_name"] == "Test App" def test_relative_path_resolved(self, tmp_path, monkeypatch): """Relative paths should be resolved against cwd.""" monkeypatch.chdir(tmp_path) create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], output="./subdir/client.json", ) resolved = tmp_path / "subdir" / "client.json" assert resolved.exists() doc = json.loads(resolved.read_text()) assert doc["client_name"] == "Test App" def test_with_scope(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], scope="read write", ) doc = json.loads(capsys.readouterr().out) assert doc["scope"] == "read write" def test_with_client_uri(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], client_uri="https://example.com", ) doc = json.loads(capsys.readouterr().out) assert doc["client_uri"] == "https://example.com" def test_with_logo_uri(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], logo_uri="https://example.com/logo.png", ) doc = json.loads(capsys.readouterr().out) assert doc["logo_uri"] == "https://example.com/logo.png" def test_multiple_redirect_uris(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=[ "http://localhost:*/callback", "https://myapp.example.com/callback", ], ) doc = json.loads(capsys.readouterr().out) assert len(doc["redirect_uris"]) == 2 def test_no_pretty(self, capsys: pytest.CaptureFixture[str]): create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], pretty=False, ) output = capsys.readouterr().out.strip() # Compact JSON has no newlines within the object assert "\n" not in output doc = json.loads(output) assert doc["client_name"] == "Test App" def test_placeholder_warning_on_stderr(self, capsys: pytest.CaptureFixture[str]): """When outputting to stdout with no --client-id, warning goes to stderr.""" create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], ) captured = capsys.readouterr() # stdout has valid JSON json.loads(captured.out) # stderr has the warning (Rich Console writes to stderr) assert "placeholder" in captured.err def test_no_warning_with_client_id(self, capsys: pytest.CaptureFixture[str]): """No placeholder warning when --client-id is provided.""" create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], client_id="https://example.com/client.json", ) captured = capsys.readouterr() assert "placeholder" not in captured.err def test_optional_fields_omitted_when_none( self, capsys: pytest.CaptureFixture[str] ): """Optional fields like scope, client_uri, logo_uri are omitted if not given.""" create_command( name="Test App", redirect_uri=["http://localhost:*/callback"], ) doc = json.loads(capsys.readouterr().out) assert "scope" not in doc assert "client_uri" not in doc assert "logo_uri" not in doc class TestCIMDValidateCommand: """Tests for `fastmcp auth cimd validate`.""" def test_invalid_url_format(self, capsys: pytest.CaptureFixture[str]): with pytest.raises(SystemExit, match="1"): validate_command("http://insecure.com/client.json") captured = capsys.readouterr() assert "Invalid CIMD URL" in captured.out def test_root_path_rejected(self, capsys: pytest.CaptureFixture[str]): with pytest.raises(SystemExit, match="1"): validate_command("https://example.com/") captured = capsys.readouterr() assert "Invalid CIMD URL" in captured.out def test_success(self, capsys: pytest.CaptureFixture[str]): mock_doc = CIMDDocument( client_id=AnyHttpUrl("https://myapp.example.com/client.json"), client_name="Test App", redirect_uris=["http://localhost:*/callback"], token_endpoint_auth_method="none", grant_types=["authorization_code"], response_types=["code"], ) with patch.object(CIMDDocument, "__init__", return_value=None): pass mock_fetch = AsyncMock(return_value=mock_doc) with patch( "fastmcp.cli.cimd.CIMDFetcher.fetch", mock_fetch, ): validate_command("https://myapp.example.com/client.json") captured = capsys.readouterr() assert "Valid CIMD document" in captured.out assert "Test App" in captured.out def test_fetch_error(self, capsys: pytest.CaptureFixture[str]): mock_fetch = AsyncMock(side_effect=CIMDFetchError("Connection refused")) with patch( "fastmcp.cli.cimd.CIMDFetcher.fetch", mock_fetch, ): with pytest.raises(SystemExit, match="1"): validate_command("https://myapp.example.com/client.json") captured = capsys.readouterr() assert "Failed to fetch" in captured.out def test_validation_error(self, capsys: pytest.CaptureFixture[str]): mock_fetch = AsyncMock(side_effect=CIMDValidationError("client_id mismatch")) with patch( "fastmcp.cli.cimd.CIMDFetcher.fetch", mock_fetch, ): with pytest.raises(SystemExit, match="1"): validate_command("https://myapp.example.com/client.json") captured = capsys.readouterr() assert "Validation error" in captured.out ================================================ FILE: tests/cli/test_cli.py ================================================ import subprocess from pathlib import Path from unittest.mock import Mock, patch import pytest from fastmcp.cli.cli import _parse_env_var, app class TestMainCLI: """Test the main CLI application.""" def test_app_exists(self): """Test that the main app is properly configured.""" # app.name is a tuple in cyclopts assert "fastmcp" in app.name assert "FastMCP" in app.help # Just check that version exists, not the specific value assert hasattr(app, "version") def test_parse_env_var_valid(self): """Test parsing valid environment variables.""" key, value = _parse_env_var("KEY=value") assert key == "KEY" assert value == "value" key, value = _parse_env_var("COMPLEX_KEY=complex=value=with=equals") assert key == "COMPLEX_KEY" assert value == "complex=value=with=equals" def test_parse_env_var_invalid(self): """Test parsing invalid environment variables exits.""" with pytest.raises(SystemExit) as exc_info: _parse_env_var("INVALID_FORMAT") assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestVersionCommand: """Test the version command.""" @patch("fastmcp.cli.cli.check_for_newer_version", return_value=None) def test_version_command_execution(self, mock_check): """Test that version command executes properly.""" # The version command should execute without raising SystemExit command, bound, _ = app.parse_args(["version"]) command() # Should not raise def test_version_command_parsing(self): """Test that the version command parses arguments correctly.""" command, bound, _ = app.parse_args(["version"]) assert callable(command) assert command.__name__ == "version" # type: ignore[attr-defined] # Default arguments aren't included in bound.arguments assert bound.arguments == {} def test_version_command_with_copy_flag(self): """Test that the version command parses --copy flag correctly.""" command, bound, _ = app.parse_args(["version", "--copy"]) assert callable(command) assert command.__name__ == "version" # type: ignore[attr-defined] assert bound.arguments == {"copy": True} @patch("fastmcp.cli.cli.pyperclip.copy") @patch("fastmcp.cli.cli.console") def test_version_command_copy_functionality( self, mock_console, mock_pyperclip_copy ): """Test that the version command copies to clipboard when --copy is used.""" command, bound, _ = app.parse_args(["version", "--copy"]) command(**bound.arguments) # Verify pyperclip.copy was called with plain text format mock_pyperclip_copy.assert_called_once() copied_text = mock_pyperclip_copy.call_args[0][0] # Verify the copied text contains expected version info keys in plain text assert "FastMCP version:" in copied_text assert "MCP version:" in copied_text assert "Python version:" in copied_text assert "Platform:" in copied_text assert "FastMCP root path:" in copied_text # Verify no ANSI escape codes (terminal control characters) assert "\x1b[" not in copied_text mock_console.print.assert_called_with( "[green]✓[/green] Version information copied to clipboard" ) class TestDevCommand: """Test the dev command.""" def test_dev_inspector_command_parsing(self): """Test that dev inspector command can be parsed with various options.""" # Test basic parsing command, bound, _ = app.parse_args(["dev", "inspector", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Test with options command, bound, _ = app.parse_args( [ "dev", "inspector", "server.py", "--with", "package1", "--inspector-version", "1.0.0", "--ui-port", "3000", ] ) assert bound.arguments["with_packages"] == ["package1"] assert bound.arguments["inspector_version"] == "1.0.0" assert bound.arguments["ui_port"] == 3000 def test_dev_inspector_command_parsing_with_new_options(self): """Test dev inspector command parsing with new uv options.""" command, bound, _ = app.parse_args( [ "dev", "inspector", "server.py", "--python", "3.10", "--project", "/workspace", "--with-requirements", "dev-requirements.txt", "--with", "pytest", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["python"] == "3.10" assert bound.arguments["project"] == Path("/workspace") assert bound.arguments["with_requirements"] == Path("dev-requirements.txt") assert bound.arguments["with_packages"] == ["pytest"] class TestRunCommand: """Test the run command.""" def test_run_command_parsing_basic(self): """Test basic run command parsing.""" command, bound, _ = app.parse_args(["run", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Cyclopts only includes non-default values assert "transport" not in bound.arguments assert "host" not in bound.arguments assert "port" not in bound.arguments assert "path" not in bound.arguments assert "log_level" not in bound.arguments assert "no_banner" not in bound.arguments def test_run_command_parsing_with_options(self): """Test run command parsing with various options.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--transport", "http", "--host", "localhost", "--port", "8080", "--path", "/v1/mcp", "--log-level", "DEBUG", "--no-banner", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["transport"] == "http" assert bound.arguments["host"] == "localhost" assert bound.arguments["port"] == 8080 assert bound.arguments["path"] == "/v1/mcp" assert bound.arguments["log_level"] == "DEBUG" assert bound.arguments["no_banner"] is True def test_run_command_parsing_partial_options(self): """Test run command parsing with only some options.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--transport", "http", "--no-banner", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["transport"] == "http" assert bound.arguments["no_banner"] is True # Other options should not be present assert "host" not in bound.arguments assert "port" not in bound.arguments assert "log_level" not in bound.arguments assert "path" not in bound.arguments def test_run_command_parsing_with_new_options(self): """Test run command parsing with new uv options.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--python", "3.11", "--with", "pandas", "--with", "numpy", "--project", "/path/to/project", "--with-requirements", "requirements.txt", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["python"] == "3.11" assert bound.arguments["with_packages"] == ["pandas", "numpy"] assert bound.arguments["project"] == Path("/path/to/project") assert bound.arguments["with_requirements"] == Path("requirements.txt") def test_run_command_transport_aliases(self): """Test that both 'http' and 'streamable-http' are accepted as valid transport options.""" # Test with 'http' transport command, bound, _ = app.parse_args( [ "run", "server.py", "--transport", "http", ] ) assert command is not None assert bound.arguments["transport"] == "http" # Test with 'streamable-http' transport command, bound, _ = app.parse_args( [ "run", "server.py", "--transport", "streamable-http", ] ) assert command is not None assert bound.arguments["transport"] == "streamable-http" def test_run_command_parsing_with_server_args(self): """Test run command parsing with server arguments after --.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--", "--config", "test.json", "--debug", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Server args after -- are captured as positional arguments in bound.args assert bound.args == ("server.py", "--config", "test.json", "--debug") def test_run_command_parsing_with_mixed_args(self): """Test run command parsing with both FastMCP options and server args.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--transport", "http", "--port", "8080", "--", "--server-port", "9090", "--debug", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["transport"] == "http" assert bound.arguments["port"] == 8080 # Server args after -- are captured separately from FastMCP options assert bound.args == ("server.py", "--server-port", "9090", "--debug") def test_run_command_parsing_with_positional_server_args(self): """Test run command parsing with positional server arguments.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--", "arg1", "arg2", "--flag", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Positional args and flags after -- are all captured assert bound.args == ("server.py", "arg1", "arg2", "--flag") def test_run_command_parsing_server_args_require_delimiter(self): """Test that server args without -- delimiter are rejected.""" # Should fail because --config is not a recognized FastMCP option with pytest.raises(SystemExit): app.parse_args( [ "run", "server.py", "--config", "test.json", ] ) def test_run_command_parsing_project_flag(self): """Test run command parsing with --project flag.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--project", "./test-env", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["project"] == Path("./test-env") 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_project_and_skip_source(self): """Test run command parsing with --project and --skip-source flags.""" command, bound, _ = app.parse_args( [ "run", "server.py", "--project", "./test-env", "--skip-source", ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["project"] == Path("./test-env") assert bound.arguments["skip_source"] is True def test_show_server_banner_setting(self): """Test that show_server_banner setting works with environment variable.""" import os from unittest import mock from fastmcp.settings import Settings # Test default (banner shown) settings = Settings() assert settings.show_server_banner is True # Test with env var set to false (banner hidden) with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "false"}): settings = Settings() assert settings.show_server_banner is False # Test CLI precedence logic (simulated) with mock.patch.dict(os.environ, {"FASTMCP_SHOW_SERVER_BANNER": "true"}): settings = Settings() # CLI --no-banner flag would override cli_no_banner = True final = cli_no_banner if cli_no_banner else not settings.show_server_banner assert final is True # Banner suppressed by CLI flag class TestWindowsSpecific: """Test Windows-specific functionality.""" @patch("subprocess.run") def test_get_npx_command_windows_cmd(self, mock_run): """Test npx command detection on Windows with npx.cmd.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "win32"): # First call succeeds with npx.cmd mock_run.return_value = Mock(returncode=0) result = _get_npx_command() assert result == "npx.cmd" mock_run.assert_called_once_with( ["npx.cmd", "--version"], check=True, capture_output=True, ) @patch("subprocess.run") def test_get_npx_command_windows_exe(self, mock_run): """Test npx command detection on Windows with npx.exe.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "win32"): # First call fails, second succeeds mock_run.side_effect = [ subprocess.CalledProcessError(1, "npx.cmd"), Mock(returncode=0), ] result = _get_npx_command() assert result == "npx.exe" assert mock_run.call_count == 2 @patch("subprocess.run") def test_get_npx_command_windows_cmd_missing(self, mock_run): """Test npx command detection continues when npx.cmd is missing.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "win32"): # Missing npx.cmd should not abort detection mock_run.side_effect = [ FileNotFoundError("npx.cmd not found"), Mock(returncode=0), ] result = _get_npx_command() assert result == "npx.exe" assert mock_run.call_count == 2 @patch("subprocess.run") def test_get_npx_command_windows_fallback(self, mock_run): """Test npx command detection on Windows with plain npx.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "win32"): # First two calls fail, third succeeds mock_run.side_effect = [ subprocess.CalledProcessError(1, "npx.cmd"), subprocess.CalledProcessError(1, "npx.exe"), Mock(returncode=0), ] result = _get_npx_command() assert result == "npx" assert mock_run.call_count == 3 @patch("subprocess.run") def test_get_npx_command_windows_not_found(self, mock_run): """Test npx command detection on Windows when npx is not found.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "win32"): # All calls fail mock_run.side_effect = subprocess.CalledProcessError(1, "npx") result = _get_npx_command() assert result is None assert mock_run.call_count == 3 @patch("subprocess.run") def test_get_npx_command_unix(self, mock_run): """Test npx command detection on Unix systems.""" from fastmcp.cli.cli import _get_npx_command with patch("sys.platform", "darwin"): result = _get_npx_command() assert result == "npx" mock_run.assert_not_called() def test_windows_path_parsing_with_colon(self, tmp_path): """Test parsing Windows paths with drive letters and colons.""" from pathlib import Path from fastmcp.utilities.mcp_server_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) source = FileSystemSource(path=str(test_file)) assert source.entrypoint is None assert Path(source.path).resolve() == test_file.resolve() # Test file:object parsing source = FileSystemSource(path=f"{test_file}:myapp") assert source.entrypoint == "myapp" # Test that the file portion resolves correctly when object is specified assert Path(source.path).resolve() == test_file.resolve() class TestInspectCommand: """Test the inspect command.""" def test_inspect_command_parsing_basic(self): """Test basic inspect command parsing.""" command, bound, _ = app.parse_args(["inspect", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Only explicitly set parameters are in bound.arguments assert "output" not in bound.arguments def test_inspect_command_parsing_with_output(self, tmp_path): """Test inspect command parsing with output file.""" output_file = tmp_path / "output.json" command, bound, _ = app.parse_args( [ "inspect", "server.py", "--output", str(output_file), ] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" # Output is parsed as a Path object assert bound.arguments["output"] == output_file async def test_inspect_command_text_summary(self, tmp_path, capsys): """Test inspect command with no format shows text summary.""" # Create a real server file server_file = tmp_path / "test_server.py" server_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("InspectTestServer", instructions="Test instructions", version="1.0.0") @mcp.tool def test_tool(x: int) -> int: return x * 2 """) # Parse and execute the command without format or output command, bound, _ = app.parse_args( [ "inspect", str(server_file), ] ) await command(**bound.arguments) # Check the console output captured = capsys.readouterr() # Check for the table format output assert "InspectTestServer" in captured.out assert "Test instructions" in captured.out assert "1.0.0" in captured.out assert "Tools" in captured.out assert "1" in captured.out # number of tools assert "FastMCP" in captured.out assert "MCP" in captured.out assert "Use --format [fastmcp|mcp] for complete JSON output" in captured.out async def test_inspect_command_with_real_server(self, tmp_path): """Test inspect command with a real server file.""" # Create a real server file server_file = tmp_path / "test_server.py" server_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("InspectTestServer") @mcp.tool def test_tool(x: int) -> int: return x * 2 @mcp.prompt def test_prompt(name: str) -> str: return f"Hello, {name}!" """) output_file = tmp_path / "inspect_output.json" # Parse and execute the command with format and output file command, bound, _ = app.parse_args( [ "inspect", str(server_file), "--format", "fastmcp", "--output", str(output_file), ] ) await command(**bound.arguments) # Verify the output file was created and contains expected content assert output_file.exists() content = output_file.read_text() # Basic checks that the fastmcp format worked import json data = json.loads(content) assert data["server"]["name"] == "InspectTestServer" assert len(data["tools"]) == 1 assert len(data["prompts"]) == 1 ================================================ FILE: tests/cli/test_client_commands.py ================================================ """Tests for fastmcp list and fastmcp call CLI commands.""" import json from pathlib import Path from typing import Any from unittest.mock import patch import mcp.types import pytest from fastmcp import FastMCP from fastmcp.cli import client as client_module from fastmcp.cli.client import ( Client, _build_client, _build_stdio_from_command, _format_call_result_text, _is_http_target, _sanitize_untrusted_text, call_command, coerce_value, format_tool_signature, list_command, parse_tool_arguments, resolve_server_spec, ) from fastmcp.client.client import CallToolResult from fastmcp.client.transports.stdio import StdioTransport # --------------------------------------------------------------------------- # coerce_value # --------------------------------------------------------------------------- class TestCoerceValue: def test_integer(self): assert coerce_value("42", {"type": "integer"}) == 42 def test_integer_negative(self): assert coerce_value("-7", {"type": "integer"}) == -7 def test_integer_invalid(self): with pytest.raises(ValueError, match="Expected integer"): coerce_value("abc", {"type": "integer"}) def test_number(self): assert coerce_value("3.14", {"type": "number"}) == 3.14 def test_number_integer_value(self): assert coerce_value("5", {"type": "number"}) == 5.0 def test_number_invalid(self): with pytest.raises(ValueError, match="Expected number"): coerce_value("xyz", {"type": "number"}) def test_boolean_true_variants(self): for val in ("true", "True", "TRUE", "1", "yes"): assert coerce_value(val, {"type": "boolean"}) is True def test_boolean_false_variants(self): for val in ("false", "False", "FALSE", "0", "no"): assert coerce_value(val, {"type": "boolean"}) is False def test_boolean_invalid(self): with pytest.raises(ValueError, match="Expected boolean"): coerce_value("maybe", {"type": "boolean"}) def test_array(self): assert coerce_value("[1, 2, 3]", {"type": "array"}) == [1, 2, 3] def test_array_invalid(self): with pytest.raises(ValueError, match="Expected JSON array"): coerce_value("not-json", {"type": "array"}) def test_object(self): assert coerce_value('{"a": 1}', {"type": "object"}) == {"a": 1} def test_string(self): assert coerce_value("hello", {"type": "string"}) == "hello" def test_string_default(self): """Unknown or missing type treats value as string.""" assert coerce_value("hello", {}) == "hello" def test_string_preserves_numeric_looking_values(self): assert coerce_value("42", {"type": "string"}) == "42" # --------------------------------------------------------------------------- # parse_tool_arguments # --------------------------------------------------------------------------- class TestParseToolArguments: SCHEMA: dict[str, Any] = { "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "integer"}, "verbose": {"type": "boolean"}, }, "required": ["query"], } def test_basic_key_value(self): result = parse_tool_arguments(("query=hello", "limit=10"), None, self.SCHEMA) assert result == {"query": "hello", "limit": 10} def test_input_json_only(self): result = parse_tool_arguments((), '{"query": "hello", "limit": 5}', self.SCHEMA) assert result == {"query": "hello", "limit": 5} def test_key_value_overrides_input_json(self): result = parse_tool_arguments( ("limit=20",), '{"query": "hello", "limit": 5}', self.SCHEMA ) assert result == {"query": "hello", "limit": 20} def test_value_containing_equals(self): result = parse_tool_arguments(("query=a=b=c",), None, self.SCHEMA) assert result == {"query": "a=b=c"} def test_invalid_arg_format_exits(self): with pytest.raises(SystemExit): parse_tool_arguments(("noequalssign",), None, self.SCHEMA) def test_invalid_input_json_exits(self): with pytest.raises(SystemExit): parse_tool_arguments((), "not-valid-json", self.SCHEMA) def test_input_json_non_object_exits(self): with pytest.raises(SystemExit): parse_tool_arguments((), "[1,2,3]", self.SCHEMA) def test_single_json_object_as_positional(self): result = parse_tool_arguments( ('{"query": "hello", "limit": 5}',), None, self.SCHEMA ) assert result == {"query": "hello", "limit": 5} def test_json_positional_ignored_when_input_json_set(self): """When --input-json is already provided, a JSON positional arg is not special.""" with pytest.raises(SystemExit): parse_tool_arguments(('{"limit": 99}',), '{"query": "hello"}', self.SCHEMA) def test_coercion_error_exits(self): with pytest.raises(SystemExit): parse_tool_arguments(("limit=abc",), None, self.SCHEMA) # --------------------------------------------------------------------------- # format_tool_signature # --------------------------------------------------------------------------- class TestFormatToolSignature: def _make_tool( self, name: str = "my_tool", properties: dict[str, Any] | None = None, required: list[str] | None = None, output_schema: dict[str, Any] | None = None, description: str | None = None, ) -> mcp.types.Tool: input_schema: dict[str, Any] = {"type": "object"} if properties is not None: input_schema["properties"] = properties if required is not None: input_schema["required"] = required return mcp.types.Tool( name=name, description=description, inputSchema=input_schema, outputSchema=output_schema, ) def test_no_params(self): tool = self._make_tool() assert format_tool_signature(tool) == "my_tool()" def test_required_param(self): tool = self._make_tool( properties={"query": {"type": "string"}}, required=["query"], ) assert format_tool_signature(tool) == "my_tool(query: str)" def test_optional_param_with_default(self): tool = self._make_tool( properties={"limit": {"type": "integer", "default": 10}}, ) assert format_tool_signature(tool) == "my_tool(limit: int = 10)" def test_optional_param_without_default(self): tool = self._make_tool( properties={"limit": {"type": "integer"}}, ) assert format_tool_signature(tool) == "my_tool(limit: int = ...)" def test_mixed_required_and_optional(self): tool = self._make_tool( properties={ "query": {"type": "string"}, "limit": {"type": "integer", "default": 10}, }, required=["query"], ) sig = format_tool_signature(tool) assert sig == "my_tool(query: str, limit: int = 10)" def test_with_output_schema(self): tool = self._make_tool( properties={"q": {"type": "string"}}, required=["q"], output_schema={"type": "object"}, ) assert format_tool_signature(tool) == "my_tool(q: str) -> dict" def test_anyof_type(self): tool = self._make_tool( properties={"value": {"anyOf": [{"type": "string"}, {"type": "integer"}]}}, required=["value"], ) assert format_tool_signature(tool) == "my_tool(value: str | int)" # --------------------------------------------------------------------------- # resolve_server_spec # --------------------------------------------------------------------------- class TestResolveServerSpec: def test_http_url(self): assert ( resolve_server_spec("http://localhost:8000/mcp") == "http://localhost:8000/mcp" ) def test_https_url(self): assert ( resolve_server_spec("https://example.com/mcp") == "https://example.com/mcp" ) def test_python_file_existing(self, tmp_path: Path): py_file = tmp_path / "server.py" py_file.write_text("# empty") result = resolve_server_spec(str(py_file)) assert isinstance(result, StdioTransport) assert result.command == "fastmcp" assert result.args == ["run", str(py_file.resolve()), "--no-banner"] def test_json_mcp_config(self, tmp_path: Path): config_file = tmp_path / "mcp.json" config = {"mcpServers": {"test": {"url": "http://localhost:8000"}}} config_file.write_text(json.dumps(config)) result = resolve_server_spec(str(config_file)) assert isinstance(result, dict) assert "mcpServers" in result def test_json_fastmcp_config_exits(self, tmp_path: Path): config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps({"source": {"type": "file"}})) with pytest.raises(SystemExit): resolve_server_spec(str(config_file)) def test_json_not_found_exits(self, tmp_path: Path): with pytest.raises(SystemExit): resolve_server_spec(str(tmp_path / "nonexistent.json")) def test_directory_exits(self, tmp_path: Path): """Directories should not be treated as file paths.""" with pytest.raises(SystemExit): resolve_server_spec(str(tmp_path)) def test_unrecognised_exits(self): with pytest.raises(SystemExit): resolve_server_spec("some_random_thing") def test_command_returns_stdio_transport(self): result = resolve_server_spec(None, command="npx -y @mcp/server") assert isinstance(result, StdioTransport) assert result.command == "npx" assert result.args == ["-y", "@mcp/server"] def test_command_single_word(self): result = resolve_server_spec(None, command="myserver") assert isinstance(result, StdioTransport) assert result.command == "myserver" assert result.args == [] def test_server_spec_and_command_exits(self): with pytest.raises(SystemExit): resolve_server_spec("http://localhost:8000", command="npx server") def test_neither_server_spec_nor_command_exits(self): with pytest.raises(SystemExit): resolve_server_spec(None) def test_transport_sse_rewrites_url(self): result = resolve_server_spec("http://localhost:8000/mcp", transport="sse") assert result == "http://localhost:8000/mcp/sse" def test_transport_sse_no_duplicate_suffix(self): result = resolve_server_spec("http://localhost:8000/sse", transport="sse") assert result == "http://localhost:8000/sse" def test_transport_sse_trailing_slash(self): result = resolve_server_spec("http://localhost:8000/mcp/", transport="sse") assert result == "http://localhost:8000/mcp/sse" def test_transport_http_leaves_url_unchanged(self): result = resolve_server_spec("http://localhost:8000/mcp", transport="http") assert result == "http://localhost:8000/mcp" # --------------------------------------------------------------------------- # _build_stdio_from_command # --------------------------------------------------------------------------- class TestBuildStdioFromCommand: def test_simple_command(self): transport = _build_stdio_from_command("uvx my-server") assert transport.command == "uvx" assert transport.args == ["my-server"] def test_quoted_args(self): transport = _build_stdio_from_command("npx -y '@scope/server'") assert transport.command == "npx" assert transport.args == ["-y", "@scope/server"] def test_empty_command_exits(self): with pytest.raises(SystemExit): _build_stdio_from_command("") def test_invalid_shell_syntax_exits(self): with pytest.raises(SystemExit): _build_stdio_from_command("npx 'unterminated") # --------------------------------------------------------------------------- # _is_http_target # --------------------------------------------------------------------------- class TestIsHttpTarget: def test_http_url(self): assert _is_http_target("http://localhost:8000") is True def test_https_url(self): assert _is_http_target("https://example.com/mcp") is True def test_file_path(self): assert _is_http_target("/path/to/server.py") is False def test_stdio_transport(self): assert _is_http_target(StdioTransport(command="npx", args=[])) is False def test_mcp_config_dict(self): """MCPConfig dicts are not HTTP targets — auth is per-server internally.""" assert _is_http_target({"mcpServers": {}}) is False # --------------------------------------------------------------------------- # _build_client # --------------------------------------------------------------------------- class TestBuildClient: def test_http_target_gets_oauth_by_default(self): client = _build_client("http://localhost:8000/mcp") # OAuth is applied during Client init via _set_auth assert client.transport.auth is not None def test_stdio_target_no_auth(self): transport = StdioTransport(command="npx", args=["-y", "@mcp/server"]) client = _build_client(transport) # Stdio transports don't support auth — no auth should be set assert not hasattr(client.transport, "auth") or client.transport.auth is None def test_explicit_auth_none_disables_oauth(self): client = _build_client("http://localhost:8000/mcp", auth="none") # "none" explicitly disables auth, even for HTTP targets assert client.transport.auth is None def test_mcp_config_no_auth(self): """MCPConfig dicts handle auth per-server; no top-level auth applied.""" client = _build_client({"mcpServers": {"test": {"url": "http://localhost"}}}) # MCPConfigTransport doesn't support _set_auth — no crash means success assert client.transport is not None # --------------------------------------------------------------------------- # Integration tests — invoke actual CLI commands via monkeypatched _build_client # --------------------------------------------------------------------------- def _build_test_server() -> FastMCP: """Create a minimal FastMCP server for integration tests.""" server = FastMCP("TestServer") @server.tool def greet(name: str) -> str: """Say hello to someone.""" return f"Hello, {name}!" @server.tool def add(a: int, b: int) -> int: """Add two numbers.""" return a + b @server.resource("test://greeting") def greeting_resource() -> str: """A static greeting resource.""" return "Hello from resource!" @server.prompt def ask(topic: str) -> str: """Ask about a topic.""" return f"Tell me about {topic}" return server @pytest.fixture() def _patch_client(): """Patch resolve_server_spec and _build_client so CLI commands use the in-process test server without needing a real transport.""" server = _build_test_server() def fake_resolve(server_spec: Any, **kwargs: Any) -> str: return "fake" def fake_build_client(resolved: Any, **kwargs: Any) -> Client: return Client(server) with ( patch.object(client_module, "resolve_server_spec", side_effect=fake_resolve), patch.object(client_module, "_build_client", side_effect=fake_build_client), ): yield class TestListCommandCLI: @pytest.mark.usefixtures("_patch_client") async def test_list_tools(self, capsys: pytest.CaptureFixture[str]): await list_command("fake://server") captured = capsys.readouterr() assert "greet" in captured.out assert "add" in captured.out @pytest.mark.usefixtures("_patch_client") async def test_list_json(self, capsys: pytest.CaptureFixture[str]): await list_command("fake://server", json_output=True) captured = capsys.readouterr() data = json.loads(captured.out) names = {t["name"] for t in data["tools"]} assert "greet" in names assert "add" in names @pytest.mark.usefixtures("_patch_client") async def test_list_resources(self, capsys: pytest.CaptureFixture[str]): await list_command("fake://server", resources=True) captured = capsys.readouterr() assert "test://greeting" in captured.out @pytest.mark.usefixtures("_patch_client") async def test_list_prompts(self, capsys: pytest.CaptureFixture[str]): await list_command("fake://server", prompts=True) captured = capsys.readouterr() assert "ask" in captured.out class TestCallCommandCLI: @pytest.mark.usefixtures("_patch_client") async def test_call_tool(self, capsys: pytest.CaptureFixture[str]): await call_command("fake://server", "greet", "name=World") captured = capsys.readouterr() assert "Hello, World!" in captured.out @pytest.mark.usefixtures("_patch_client") async def test_call_tool_json(self, capsys: pytest.CaptureFixture[str]): await call_command("fake://server", "greet", "name=World", json_output=True) captured = capsys.readouterr() data = json.loads(captured.out) assert data["is_error"] is False @pytest.mark.usefixtures("_patch_client") async def test_call_tool_not_found(self): with pytest.raises(SystemExit): await call_command("fake://server", "nonexistent") @pytest.mark.usefixtures("_patch_client") async def test_call_tool_missing_args(self): with pytest.raises(SystemExit): await call_command("fake://server", "greet") @pytest.mark.usefixtures("_patch_client") async def test_call_resource_by_uri(self, capsys: pytest.CaptureFixture[str]): await call_command("fake://server", "test://greeting") captured = capsys.readouterr() assert "Hello from resource!" in captured.out @pytest.mark.usefixtures("_patch_client") async def test_call_resource_json(self, capsys: pytest.CaptureFixture[str]): await call_command("fake://server", "test://greeting", json_output=True) captured = capsys.readouterr() data = json.loads(captured.out) assert isinstance(data, list) assert data[0]["text"] == "Hello from resource!" @pytest.mark.usefixtures("_patch_client") async def test_call_prompt(self, capsys: pytest.CaptureFixture[str]): await call_command("fake://server", "ask", "topic=Python", prompt=True) captured = capsys.readouterr() assert "Python" in captured.out @pytest.mark.usefixtures("_patch_client") async def test_call_prompt_json(self, capsys: pytest.CaptureFixture[str]): await call_command( "fake://server", "ask", "topic=Python", prompt=True, json_output=True ) captured = capsys.readouterr() data = json.loads(captured.out) assert "messages" in data @pytest.mark.usefixtures("_patch_client") async def test_call_prompt_not_found(self): with pytest.raises(SystemExit): await call_command("fake://server", "nonexistent", prompt=True) async def test_call_missing_target(self): with pytest.raises(SystemExit): await call_command("fake://server", "") # --------------------------------------------------------------------------- # Structured content serialization # --------------------------------------------------------------------------- class TestFormatCallResult: def test_structured_content_uses_dict_not_data( self, capsys: pytest.CaptureFixture[str] ): """structured_content (raw dict) is used for display, not data (which may be a non-serializable dataclass).""" result = CallToolResult( content=[mcp.types.TextContent(type="text", text="ok")], structured_content={"key": "value"}, meta=None, data=object(), # non-serializable on purpose is_error=False, ) # Should not raise — uses structured_content, not data _format_call_result_text(result) captured = capsys.readouterr() assert "value" in captured.out def test_escapes_rich_markup_and_control_chars( self, capsys: pytest.CaptureFixture[str] ): result = CallToolResult( content=[mcp.types.TextContent(type="text", text="[red]x[/red]\x1b[2J")], structured_content=None, meta=None, data=None, is_error=False, ) _format_call_result_text(result) captured = capsys.readouterr() assert "[red]x[/red]" in captured.out assert "\\x1b" in captured.out assert "\x1b" not in captured.out class TestSanitizeUntrustedText: def test_sanitize_untrusted_text(self): value = "[bold]hello[/bold]\x07" sanitized = _sanitize_untrusted_text(value) assert sanitized == "\\[bold]hello\\[/bold]\\x07" ================================================ FILE: tests/cli/test_config.py ================================================ """Tests for FastMCP configuration file support with nested structure.""" import json import os from pathlib import Path import pytest from pydantic import ValidationError from fastmcp.utilities.mcp_server_config import ( Deployment, MCPServerConfig, ) from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestFileSystemSource: """Test FileSystemSource class.""" def test_dict_source_minimal(self): """Test that dict source is converted to FileSystemSource.""" config = MCPServerConfig(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" def test_dict_source_with_entrypoint(self): """Test dict source with entrypoint field.""" config = MCPServerConfig(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_filesystem_source_entrypoint(self): """Test FileSystemSource entrypoint format.""" config = MCPServerConfig( source=FileSystemSource(path="src/server.py", entrypoint="mcp") ) assert isinstance(config.source, FileSystemSource) assert config.source.path == "src/server.py" assert config.source.entrypoint == "mcp" assert config.source.type == "filesystem" class TestEnvironment: """Test Environment class.""" def test_environment_config_fields(self): """Test all Environment fields.""" config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy>=2.0"], "requirements": "requirements.txt", "project": ".", "editable": ["../my-package"], }, ) env = config.environment assert env.python == "3.12" assert env.dependencies == ["requests", "numpy>=2.0"] # Paths are stored as Path objects assert env.requirements == Path("requirements.txt") assert env.project == Path(".") assert env.editable == [Path("../my-package")] def test_needs_uv(self): """Test needs_uv() method.""" # No environment config - doesn't need UV config = MCPServerConfig(source={"path": "server.py"}) assert not config.environment._must_run_with_uv() # Empty environment - doesn't need UV config = MCPServerConfig(source={"path": "server.py"}, environment={}) assert not config.environment._must_run_with_uv() # With dependencies - needs UV config = MCPServerConfig( source={"path": "server.py"}, environment={"dependencies": ["requests"]} ) assert config.environment._must_run_with_uv() # With Python version - needs UV config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"} ) assert config.environment._must_run_with_uv() def test_build_uv_run_command(self): """Test build_uv_run_command() method.""" config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["requests", "numpy"], "requirements": "requirements.txt", "project": ".", }, ) cmd = config.environment.build_command(["fastmcp", "run", "server.py"]) assert cmd[0] == "uv" assert cmd[1] == "run" # Python version not added when project is specified (project defines its own Python) assert "--python" not in cmd assert "3.12" not in cmd assert "--project" in cmd # Project path should be resolved to absolute path project_idx = cmd.index("--project") assert Path(cmd[project_idx + 1]).is_absolute() assert "--with" in cmd assert "requests" in cmd assert "numpy" in cmd assert "--with-requirements" in cmd # Requirements path should be resolved to absolute path req_idx = cmd.index("--with-requirements") assert Path(cmd[req_idx + 1]).is_absolute() # Command args should be at the end assert "fastmcp" in cmd[-3:] assert "run" in cmd[-2:] assert "server.py" in cmd[-1:] class TestDeployment: """Test Deployment class.""" def test_deployment_config_fields(self): """Test all Deployment fields.""" config = MCPServerConfig( source={"path": "server.py"}, deployment={ "transport": "http", "host": "0.0.0.0", "port": 8000, "path": "/api/", "log_level": "DEBUG", "env": {"API_KEY": "secret"}, "cwd": "./work", "args": ["--debug"], }, ) deploy = config.deployment assert deploy.transport == "http" assert deploy.host == "0.0.0.0" assert deploy.port == 8000 assert deploy.path == "/api/" assert deploy.log_level == "DEBUG" assert deploy.env == {"API_KEY": "secret"} assert deploy.cwd == "./work" assert deploy.args == ["--debug"] def test_apply_runtime_settings(self, tmp_path): """Test apply_runtime_settings() method.""" import os # Create config with env vars and cwd work_dir = tmp_path / "work" work_dir.mkdir() config = MCPServerConfig( source={"path": "server.py"}, deployment={ "env": {"TEST_VAR": "test_value"}, "cwd": "work", }, ) original_cwd = os.getcwd() original_env = os.environ.get("TEST_VAR") try: config.deployment.apply_runtime_settings(tmp_path / "fastmcp.json") # Check environment variable was set assert os.environ["TEST_VAR"] == "test_value" # Check working directory was changed assert Path.cwd() == work_dir.resolve() finally: # Restore original state os.chdir(original_cwd) if original_env is None: os.environ.pop("TEST_VAR", None) else: os.environ["TEST_VAR"] = original_env def test_env_var_interpolation(self, tmp_path): """Test environment variable interpolation in deployment env.""" import os # Set up test environment variables os.environ["BASE_URL"] = "example.com" os.environ["ENV_NAME"] = "production" config = MCPServerConfig( source={"path": "server.py"}, deployment={ "env": { "API_URL": "https://api.${BASE_URL}/v1", "DATABASE": "postgres://${ENV_NAME}.db", "PREFIXED": "MY_${ENV_NAME}_SERVER", "MISSING": "value_${NONEXISTENT}_here", "STATIC": "no_interpolation", } }, ) original_values = { key: os.environ.get(key) for key in ["API_URL", "DATABASE", "PREFIXED", "MISSING", "STATIC"] } try: config.deployment.apply_runtime_settings() # Check interpolated values assert os.environ["API_URL"] == "https://api.example.com/v1" assert os.environ["DATABASE"] == "postgres://production.db" assert os.environ["PREFIXED"] == "MY_production_SERVER" # Missing variables should keep the placeholder assert os.environ["MISSING"] == "value_${NONEXISTENT}_here" # Static values should remain unchanged assert os.environ["STATIC"] == "no_interpolation" finally: # Clean up os.environ.pop("BASE_URL", None) os.environ.pop("ENV_NAME", None) for key, value in original_values.items(): if value is None: os.environ.pop(key, None) else: os.environ[key] = value class TestMCPServerConfig: """Test MCPServerConfig root configuration.""" def test_minimal_config(self): """Test creating a config with only required fields.""" config = MCPServerConfig(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, UVEnvironment) assert isinstance(config.deployment, Deployment) # Check they have no values set assert not config.environment._must_run_with_uv() assert all( getattr(config.deployment, field, None) is None for field in Deployment.model_fields ) def test_nested_structure(self): """Test the nested configuration structure.""" config = MCPServerConfig( source={"path": "server.py"}, environment={ "python": "3.12", "dependencies": ["fastmcp"], }, deployment={ "transport": "stdio", "log_level": "INFO", }, ) assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" assert config.source.entrypoint is None assert isinstance(config.environment, UVEnvironment) 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/public/schemas/fastmcp.json/v1.json", "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["requests"]}, "deployment": {"transport": "http", "port": 8000}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) config = MCPServerConfig.from_file(config_file) # 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 dict source format.""" config_data = { "source": {"path": "server.py", "entrypoint": "mcp"}, "environment": {"dependencies": ["fastmcp"]}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) config = MCPServerConfig.from_file(config_file) # String entrypoint with : should be converted to EntrypointConfig assert isinstance(config.source, FileSystemSource) assert config.source.path == "server.py" assert config.source.entrypoint == "mcp" def test_string_entrypoint_with_entrypoint_and_environment(self, tmp_path): """Test that file.py:entrypoint syntax works with environment config.""" config_data = { "source": {"path": "src/server.py", "entrypoint": "app"}, "environment": {"python": "3.12", "dependencies": ["fastmcp", "requests"]}, "deployment": {"transport": "http", "port": 8000}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) config = MCPServerConfig.from_file(config_file) # Should be parsed into EntrypointConfig 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" assert config.environment.dependencies == ["fastmcp", "requests"] # Deployment config should still work assert config.deployment.transport == "http" assert config.deployment.port == 8000 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({"source": {"path": "server.py"}})) original_cwd = os.getcwd() try: os.chdir(tmp_path) found = MCPServerConfig.find_config() assert found == config_file finally: os.chdir(original_cwd) 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({"source": {"path": "server.py"}})) subdir = tmp_path / "subdir" subdir.mkdir() # Should NOT find config in parent directory found = MCPServerConfig.find_config(subdir) assert found is None 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({"source": {"path": "server.py"}})) # Should find config when looking in the directory that contains it found = MCPServerConfig.find_config(tmp_path) assert found == config_file def test_find_config_not_found(self, tmp_path): """Test when config is not found.""" found = MCPServerConfig.find_config(tmp_path) assert found is None def test_invalid_transport(self, tmp_path): """Test loading config with invalid transport value.""" config_data = { "source": {"path": "server.py"}, "deployment": {"transport": "invalid_transport"}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) with pytest.raises(ValidationError): MCPServerConfig.from_file(config_file) def test_optional_sections(self): """Test that all config sections are optional except source.""" # Only source is required config = MCPServerConfig(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, UVEnvironment) assert isinstance(config.deployment, Deployment) # Only environment with values config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"} ) assert config.environment.python == "3.12" assert isinstance(config.deployment, Deployment) assert all( getattr(config.deployment, field, None) is None for field in Deployment.model_fields ) # Only deployment with values config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "http"} ) assert isinstance(config.environment, UVEnvironment) # Check all fields except 'type' which has a default value assert all( getattr(config.environment, field, None) is None for field in UVEnvironment.model_fields if field != "type" ) assert config.deployment.transport == "http" class TestMCPServerConfigRoundtrip: """Test that MCPServerConfig survives model_dump() -> reconstruct pattern. This is used by the CLI to apply overrides immutably. """ def test_roundtrip_preserves_schema(self): """Ensure schema_ field survives dump/reconstruct cycle.""" config = MCPServerConfig(source=FileSystemSource(path="server.py")) config_dict = config.model_dump() reconstructed = MCPServerConfig(**config_dict) assert reconstructed.schema_ == config.schema_ def test_roundtrip_with_all_fields(self): """Full config survives dump/reconstruct.""" config = MCPServerConfig( source=FileSystemSource(path="server.py", entrypoint="app"), environment=UVEnvironment(python="3.11"), deployment=Deployment(transport="http", port=8080), ) config_dict = config.model_dump() reconstructed = MCPServerConfig(**config_dict) assert reconstructed.source.path == "server.py" assert reconstructed.environment.python == "3.11" assert reconstructed.deployment.port == 8080 ================================================ FILE: tests/cli/test_cursor.py ================================================ import base64 import json from pathlib import Path from unittest.mock import Mock, patch import pytest from fastmcp.cli.install.cursor import ( cursor_command, generate_cursor_deeplink, install_cursor, install_cursor_workspace, open_deeplink, ) from fastmcp.mcp_config import StdioMCPServer class TestCursorDeeplinkGeneration: """Test cursor deeplink generation functionality.""" def test_generate_deeplink_basic(self): """Test basic deeplink generation.""" server_config = StdioMCPServer( command="uv", args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], ) deeplink = generate_cursor_deeplink("test-server", server_config) assert deeplink.startswith("cursor://anysphere.cursor-deeplink/mcp/install?") assert "name=test-server" in deeplink assert "config=" in deeplink # Verify base64 encoding config_part = deeplink.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) assert config_data["command"] == "uv" assert config_data["args"] == [ "run", "--with", "fastmcp", "fastmcp", "run", "server.py", ] def test_generate_deeplink_with_env_vars(self): """Test deeplink generation with environment variables.""" server_config = StdioMCPServer( command="uv", args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], env={"API_KEY": "secret123", "DEBUG": "true"}, ) deeplink = generate_cursor_deeplink("my-server", server_config) # Decode and verify config_part = deeplink.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) assert config_data["env"] == {"API_KEY": "secret123", "DEBUG": "true"} def test_generate_deeplink_special_characters(self): """Test deeplink generation with special characters in server name.""" server_config = StdioMCPServer( command="uv", args=["run", "--with", "fastmcp", "fastmcp", "run", "server.py"], ) # Test with spaces and special chars in name - should be URL encoded deeplink = generate_cursor_deeplink("my server (test)", server_config) # Spaces and parentheses must be URL-encoded assert "name=my%20server%20%28test%29" in deeplink # Ensure no unencoded version appears assert "name=my server (test)" not in deeplink def test_generate_deeplink_empty_config(self): """Test deeplink generation with minimal config.""" server_config = StdioMCPServer(command="python", args=["server.py"]) deeplink = generate_cursor_deeplink("minimal", server_config) config_part = deeplink.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) assert config_data["command"] == "python" assert config_data["args"] == ["server.py"] assert config_data["env"] == {} # Empty env dict is included def test_generate_deeplink_complex_args(self): """Test deeplink generation with complex arguments.""" server_config = StdioMCPServer( command="uv", args=[ "run", "--with", "fastmcp", "--with", "numpy>=1.20", "--with-editable", "/path/to/local/package", "fastmcp", "run", "server.py:CustomServer", ], ) deeplink = generate_cursor_deeplink("complex-server", server_config) config_part = deeplink.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) assert "--with-editable" in config_data["args"] assert "server.py:CustomServer" in config_data["args"] def test_generate_deeplink_url_injection_protection(self): """Test that special characters in server name are properly URL-encoded to prevent injection.""" server_config = StdioMCPServer( command="python", args=["server.py"], ) # Test the PoC case from the security advisory deeplink = generate_cursor_deeplink("test&calc", server_config) # The & should be encoded as %26, preventing it from being interpreted as a query parameter separator assert "name=test%26calc" in deeplink assert "name=test&calc" not in deeplink # Verify the URL structure is intact assert deeplink.startswith("cursor://anysphere.cursor-deeplink/mcp/install?") assert deeplink.count("&") == 1 # Only one & between name and config parameters # Test other potentially dangerous characters dangerous_names = [ ("test|calc", "test%7Ccalc"), ("test;calc", "test%3Bcalc"), ("testcalc", "test%3Ecalc"), ("test`calc", "test%60calc"), ("test$calc", "test%24calc"), ("test'calc", "test%27calc"), ('test"calc', "test%22calc"), ("test calc", "test%20calc"), ("test#anchor", "test%23anchor"), ("test?query=val", "test%3Fquery%3Dval"), ] for dangerous_name, expected_encoded in dangerous_names: deeplink = generate_cursor_deeplink(dangerous_name, server_config) assert f"name={expected_encoded}" in deeplink, ( f"Failed to encode {dangerous_name}" ) # Ensure no unencoded special chars that could break URL structure name_part = deeplink.split("name=")[1].split("&")[0] assert name_part == expected_encoded class TestOpenDeeplink: """Test deeplink opening functionality.""" @patch("subprocess.run") def test_open_deeplink_macos(self, mock_run): """Test opening deeplink on macOS.""" with patch("sys.platform", "darwin"): mock_run.return_value = Mock(returncode=0) result = open_deeplink("cursor://test") assert result is True mock_run.assert_called_once_with( ["open", "cursor://test"], check=True, capture_output=True ) def test_open_deeplink_windows(self): """Test opening deeplink on Windows.""" with patch("sys.platform", "win32"): with patch( "fastmcp.cli.install.shared.os.startfile", create=True ) as mock_startfile: result = open_deeplink("cursor://test") assert result is True mock_startfile.assert_called_once_with("cursor://test") @patch("subprocess.run") def test_open_deeplink_linux(self, mock_run): """Test opening deeplink on Linux.""" with patch("sys.platform", "linux"): mock_run.return_value = Mock(returncode=0) result = open_deeplink("cursor://test") assert result is True mock_run.assert_called_once_with( ["xdg-open", "cursor://test"], check=True, capture_output=True ) @patch("subprocess.run") def test_open_deeplink_failure(self, mock_run): """Test handling of deeplink opening failure.""" import subprocess with patch("sys.platform", "darwin"): mock_run.side_effect = subprocess.CalledProcessError(1, ["open"]) result = open_deeplink("cursor://test") assert result is False @patch("subprocess.run") def test_open_deeplink_command_not_found(self, mock_run): """Test handling when open command is not found.""" with patch("sys.platform", "darwin"): mock_run.side_effect = FileNotFoundError() result = open_deeplink("cursor://test") assert result is False def test_open_deeplink_invalid_scheme(self): """Test that non-cursor:// URLs are rejected.""" result = open_deeplink("http://malicious.com") assert result is False result = open_deeplink("https://example.com") assert result is False result = open_deeplink("file:///etc/passwd") assert result is False def test_open_deeplink_valid_cursor_scheme(self): """Test that cursor:// URLs are accepted.""" with patch("sys.platform", "darwin"): with patch("subprocess.run") as mock_run: mock_run.return_value = Mock(returncode=0) result = open_deeplink("cursor://anysphere.cursor-deeplink/mcp/install") assert result is True def test_open_deeplink_empty_url(self): """Test handling of empty URL.""" result = open_deeplink("") assert result is False def test_open_deeplink_windows_oserror(self): """Test handling of OSError on Windows.""" with patch("sys.platform", "win32"): with patch( "fastmcp.cli.install.shared.os.startfile", create=True ) as mock_startfile: mock_startfile.side_effect = OSError("File not found") result = open_deeplink("cursor://test") assert result is False class TestInstallCursor: """Test cursor installation functionality.""" @patch("fastmcp.cli.install.cursor.open_deeplink") @patch("fastmcp.cli.install.cursor.print") def test_install_cursor_success(self, mock_print, mock_open_deeplink): """Test successful cursor installation.""" mock_open_deeplink.return_value = True result = install_cursor( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) assert result is True mock_open_deeplink.assert_called_once() # Verify the deeplink was generated correctly call_args = mock_open_deeplink.call_args[0][0] assert call_args.startswith("cursor://anysphere.cursor-deeplink/mcp/install?") assert "name=test-server" in call_args @patch("fastmcp.cli.install.cursor.open_deeplink") @patch("fastmcp.cli.install.cursor.print") def test_install_cursor_with_packages(self, mock_print, mock_open_deeplink): """Test cursor installation with additional packages.""" mock_open_deeplink.return_value = True result = install_cursor( file=Path("/path/to/server.py"), server_object="app", name="test-server", with_packages=["numpy", "pandas"], env_vars={"API_KEY": "test"}, ) assert result is True call_args = mock_open_deeplink.call_args[0][0] # Decode the config to verify packages config_part = call_args.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) # Check that all packages are included assert "--with" in config_data["args"] assert "numpy" in config_data["args"] assert "pandas" in config_data["args"] assert "fastmcp" in config_data["args"] assert config_data["env"] == {"API_KEY": "test"} @patch("fastmcp.cli.install.cursor.open_deeplink") @patch("fastmcp.cli.install.cursor.print") def test_install_cursor_with_editable(self, mock_print, mock_open_deeplink): """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=[editable_path], ) assert result is True call_args = mock_open_deeplink.call_args[0][0] # Decode and verify editable path config_part = call_args.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) assert "--with-editable" 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") @patch("fastmcp.cli.install.cursor.print") def test_install_cursor_failure(self, mock_print, mock_open_deeplink): """Test cursor installation when deeplink fails to open.""" mock_open_deeplink.return_value = False result = install_cursor( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) assert result is False # Verify failure message was printed mock_print.assert_called() def test_install_cursor_workspace_path_is_file(self, tmp_path): """Test that passing a file as workspace_path returns False.""" file_path = tmp_path / "somefile.txt" file_path.write_text("hello") result = install_cursor_workspace( file=Path("/path/to/server.py"), server_object=None, name="test-server", workspace_path=file_path, ) assert result is False def test_install_cursor_deduplicate_packages(self): """Test that duplicate packages are deduplicated.""" with patch("fastmcp.cli.install.cursor.open_deeplink") as mock_open: mock_open.return_value = True install_cursor( file=Path("/path/to/server.py"), server_object=None, name="test-server", with_packages=["numpy", "fastmcp", "numpy", "pandas", "fastmcp"], ) call_args = mock_open.call_args[0][0] config_part = call_args.split("config=")[1] decoded = base64.urlsafe_b64decode(config_part).decode() config_data = json.loads(decoded) # Count occurrences of each package args_str = " ".join(config_data["args"]) assert args_str.count("--with numpy") == 1 assert args_str.count("--with pandas") == 1 assert args_str.count("--with fastmcp") == 1 class TestCursorCommand: """Test the cursor CLI command.""" @patch("fastmcp.cli.install.cursor.install_cursor") @patch("fastmcp.cli.install.cursor.process_common_args") async def test_cursor_command_basic(self, mock_process_args, mock_install): """Test basic cursor command execution.""" mock_process_args.return_value = ( Path("server.py"), None, "test-server", [], {}, ) mock_install.return_value = True with patch("sys.exit") as mock_exit: await cursor_command("server.py") mock_install.assert_called_once_with( file=Path("server.py"), server_object=None, name="test-server", with_editable=[], with_packages=[], env_vars={}, python_version=None, with_requirements=None, project=None, workspace=None, ) mock_exit.assert_not_called() @patch("fastmcp.cli.install.cursor.install_cursor") @patch("fastmcp.cli.install.cursor.process_common_args") async def test_cursor_command_failure(self, mock_process_args, mock_install): """Test cursor command when installation fails.""" mock_process_args.return_value = ( Path("server.py"), None, "test-server", [], {}, ) mock_install.return_value = False with pytest.raises(SystemExit) as exc_info: await cursor_command("server.py") assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 ================================================ FILE: tests/cli/test_discovery.py ================================================ """Tests for MCP server discovery and name-based resolution.""" import json from pathlib import Path from typing import Any import pytest import yaml from fastmcp.cli.client import _is_http_target, resolve_server_spec from fastmcp.cli.discovery import ( DiscoveredServer, _normalize_server_entry, _parse_mcp_config, _scan_claude_code, _scan_claude_desktop, _scan_cursor_workspace, _scan_gemini, _scan_goose, _scan_project_mcp_json, discover_servers, resolve_name, ) from fastmcp.client.transports.http import StreamableHttpTransport from fastmcp.client.transports.sse import SSETransport from fastmcp.client.transports.stdio import StdioTransport from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _STDIO_CONFIG: dict[str, Any] = { "mcpServers": { "weather": { "command": "npx", "args": ["-y", "@mcp/weather"], }, "github": { "command": "npx", "args": ["-y", "@mcp/github"], "env": {"GITHUB_TOKEN": "xxx"}, }, } } _REMOTE_CONFIG: dict[str, Any] = { "mcpServers": { "api": { "url": "http://localhost:8000/mcp", }, } } def _write_config(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data)) # --------------------------------------------------------------------------- # DiscoveredServer properties # --------------------------------------------------------------------------- class TestDiscoveredServer: def test_qualified_name(self): server = DiscoveredServer( name="weather", source="claude-desktop", config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]), config_path=Path("/fake/config.json"), ) assert server.qualified_name == "claude-desktop:weather" def test_transport_summary_stdio(self): server = DiscoveredServer( name="weather", source="cursor", config=StdioMCPServer(command="npx", args=["-y", "@mcp/weather"]), config_path=Path("/fake/config.json"), ) assert server.transport_summary == "stdio: npx -y @mcp/weather" def test_transport_summary_remote(self): server = DiscoveredServer( name="api", source="project", config=RemoteMCPServer(url="http://localhost:8000/mcp"), config_path=Path("/fake/config.json"), ) assert server.transport_summary == "http: http://localhost:8000/mcp" def test_transport_summary_remote_sse(self): server = DiscoveredServer( name="api", source="project", config=RemoteMCPServer(url="http://localhost:8000/sse", transport="sse"), config_path=Path("/fake/config.json"), ) assert server.transport_summary == "sse: http://localhost:8000/sse" # --------------------------------------------------------------------------- # _parse_mcp_config # --------------------------------------------------------------------------- class TestParseMcpConfig: def test_valid_config(self, tmp_path: Path): path = tmp_path / "config.json" _write_config(path, _STDIO_CONFIG) servers = _parse_mcp_config(path, "test-source") assert len(servers) == 2 names = {s.name for s in servers} assert names == {"weather", "github"} assert all(s.source == "test-source" for s in servers) assert all(s.config_path == path for s in servers) def test_missing_file(self, tmp_path: Path): path = tmp_path / "nonexistent.json" servers = _parse_mcp_config(path, "test") assert servers == [] def test_invalid_json(self, tmp_path: Path): path = tmp_path / "bad.json" path.write_text("{not json") servers = _parse_mcp_config(path, "test") assert servers == [] def test_no_mcp_servers_key(self, tmp_path: Path): path = tmp_path / "config.json" _write_config(path, {"something": "else"}) servers = _parse_mcp_config(path, "test") assert servers == [] def test_empty_mcp_servers(self, tmp_path: Path): path = tmp_path / "config.json" _write_config(path, {"mcpServers": {}}) servers = _parse_mcp_config(path, "test") assert servers == [] def test_remote_server(self, tmp_path: Path): path = tmp_path / "config.json" _write_config(path, _REMOTE_CONFIG) servers = _parse_mcp_config(path, "test") assert len(servers) == 1 assert isinstance(servers[0].config, RemoteMCPServer) assert servers[0].config.url == "http://localhost:8000/mcp" # --------------------------------------------------------------------------- # Scanner: Claude Desktop # --------------------------------------------------------------------------- class TestScanClaudeDesktop: def test_finds_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): config_dir = tmp_path / "Claude" config_path = config_dir / "claude_desktop_config.json" _write_config(config_path, _STDIO_CONFIG) monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) # Force darwin for deterministic path monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin") # We need to override the path construction. On macOS it's # ~/Library/Application Support/Claude — create that. mac_dir = tmp_path / "Library" / "Application Support" / "Claude" mac_path = mac_dir / "claude_desktop_config.json" _write_config(mac_path, _STDIO_CONFIG) servers = _scan_claude_desktop() assert len(servers) == 2 assert all(s.source == "claude-desktop" for s in servers) def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "darwin") servers = _scan_claude_desktop() assert servers == [] # --------------------------------------------------------------------------- # Normalize server entry # --------------------------------------------------------------------------- class TestNormalizeServerEntry: def test_remote_type_becomes_transport(self): entry = {"url": "http://localhost:8000/sse", "type": "sse"} result = _normalize_server_entry(entry) assert result["transport"] == "sse" assert "type" not in result def test_remote_with_transport_unchanged(self): entry = {"url": "http://localhost:8000/mcp", "transport": "http"} result = _normalize_server_entry(entry) assert result["transport"] == "http" def test_stdio_type_unchanged(self): """Stdio entries have ``type`` as a proper field — leave it alone.""" entry = {"command": "npx", "args": [], "type": "stdio"} result = _normalize_server_entry(entry) assert result["type"] == "stdio" def test_gemini_http_url_becomes_url(self): entry = {"httpUrl": "https://api.example.com/mcp/"} result = _normalize_server_entry(entry) assert result["url"] == "https://api.example.com/mcp/" assert "httpUrl" not in result def test_gemini_http_url_does_not_override_url(self): entry = {"url": "http://real.com", "httpUrl": "http://other.com"} result = _normalize_server_entry(entry) assert result["url"] == "http://real.com" # --------------------------------------------------------------------------- # Scanner: Claude Code # --------------------------------------------------------------------------- def _claude_code_config( *, global_servers: dict[str, Any] | None = None, project_path: str | None = None, project_servers: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build a minimal ~/.claude.json structure.""" data: dict[str, Any] = {} if global_servers is not None: data["mcpServers"] = global_servers if project_path and project_servers is not None: data["projects"] = {project_path: {"mcpServers": project_servers}} return data class TestScanClaudeCode: def test_global_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) config_path = tmp_path / ".claude.json" _write_config( config_path, _claude_code_config(global_servers=_STDIO_CONFIG["mcpServers"]), ) servers = _scan_claude_code(tmp_path) assert len(servers) == 2 assert all(s.source == "claude-code" for s in servers) def test_project_servers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) project_dir = tmp_path / "my-project" project_dir.mkdir() config_path = tmp_path / ".claude.json" _write_config( config_path, _claude_code_config( project_path=str(project_dir), project_servers={"api": {"url": "http://localhost:8000/mcp"}}, ), ) servers = _scan_claude_code(project_dir) assert len(servers) == 1 assert servers[0].name == "api" def test_global_and_project_combined( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) project_dir = tmp_path / "proj" project_dir.mkdir() config_path = tmp_path / ".claude.json" _write_config( config_path, _claude_code_config( global_servers={"global-tool": {"command": "echo", "args": ["hi"]}}, project_path=str(project_dir), project_servers={"local-tool": {"command": "cat", "args": []}}, ), ) servers = _scan_claude_code(project_dir) names = {s.name for s in servers} assert names == {"global-tool", "local-tool"} def test_type_normalized_to_transport( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): """Claude Code uses ``type: sse`` — verify it becomes ``transport``.""" monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) config_path = tmp_path / ".claude.json" _write_config( config_path, _claude_code_config( global_servers={ "sse-server": { "type": "sse", "url": "http://localhost:8000/sse", } } ), ) servers = _scan_claude_code(tmp_path) assert len(servers) == 1 assert isinstance(servers[0].config, RemoteMCPServer) assert servers[0].config.transport == "sse" def test_missing_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) servers = _scan_claude_code(tmp_path) assert servers == [] def test_no_matching_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) config_path = tmp_path / ".claude.json" _write_config( config_path, _claude_code_config( project_path="/some/other/project", project_servers={"tool": {"command": "echo", "args": []}}, ), ) servers = _scan_claude_code(tmp_path) assert servers == [] # --------------------------------------------------------------------------- # Scanner: Cursor workspace # --------------------------------------------------------------------------- class TestScanCursorWorkspace: def test_finds_config_in_cwd(self, tmp_path: Path): cursor_path = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_path, _STDIO_CONFIG) servers = _scan_cursor_workspace(tmp_path) assert len(servers) == 2 assert all(s.source == "cursor" for s in servers) def test_finds_config_in_parent(self, tmp_path: Path): cursor_path = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_path, _STDIO_CONFIG) child = tmp_path / "src" / "deep" child.mkdir(parents=True) servers = _scan_cursor_workspace(child) assert len(servers) == 2 def test_stops_at_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Place config above home — should not be found monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) above_home = tmp_path.parent / ".cursor" / "mcp.json" _write_config(above_home, _STDIO_CONFIG) child = tmp_path / "project" child.mkdir() servers = _scan_cursor_workspace(child) assert servers == [] def test_no_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Confine walk to tmp_path so it doesn't find sibling test dirs monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) servers = _scan_cursor_workspace(tmp_path) assert servers == [] # --------------------------------------------------------------------------- # Scanner: project mcp.json # --------------------------------------------------------------------------- class TestScanProjectMcpJson: def test_finds_config(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) servers = _scan_project_mcp_json(tmp_path) assert len(servers) == 2 assert all(s.source == "project" for s in servers) def test_no_config(self, tmp_path: Path): servers = _scan_project_mcp_json(tmp_path) assert servers == [] # --------------------------------------------------------------------------- # Scanner: Gemini CLI # --------------------------------------------------------------------------- class TestScanGemini: def test_user_level_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) config_path = tmp_path / ".gemini" / "settings.json" _write_config(config_path, _STDIO_CONFIG) servers = _scan_gemini(tmp_path) assert len(servers) == 2 assert all(s.source == "gemini" for s in servers) def test_project_level_config( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) project_dir = tmp_path / "my-project" project_dir.mkdir() config_path = project_dir / ".gemini" / "settings.json" _write_config(config_path, _STDIO_CONFIG) servers = _scan_gemini(project_dir) assert len(servers) == 2 def test_http_url_normalized(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Gemini uses ``httpUrl`` — verify it becomes ``url``.""" monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) config_path = tmp_path / ".gemini" / "settings.json" _write_config( config_path, { "mcpServers": { "api": {"httpUrl": "https://api.example.com/mcp/"}, } }, ) servers = _scan_gemini(tmp_path) assert len(servers) == 1 assert isinstance(servers[0].config, RemoteMCPServer) assert servers[0].config.url == "https://api.example.com/mcp/" def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) servers = _scan_gemini(tmp_path) assert servers == [] # --------------------------------------------------------------------------- # Scanner: Goose # --------------------------------------------------------------------------- _GOOSE_CONFIG = { "extensions": { "developer": { "enabled": True, "name": "developer", "type": "builtin", }, "tavily": { "cmd": "npx", "args": ["-y", "mcp-tavily-search"], "enabled": True, "envs": {"TAVILY_API_KEY": "xxx"}, "type": "stdio", }, "disabled-tool": { "cmd": "echo", "args": ["hi"], "enabled": False, "type": "stdio", }, } } class TestScanGoose: def test_finds_stdio_extensions( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) config_dir = tmp_path / ".config" / "goose" config_path = config_dir / "config.yaml" config_path.parent.mkdir(parents=True) config_path.write_text(yaml.dump(_GOOSE_CONFIG)) # Force non-windows platform for path logic monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") servers = _scan_goose() assert len(servers) == 1 assert servers[0].name == "tavily" assert servers[0].source == "goose" assert isinstance(servers[0].config, StdioMCPServer) assert servers[0].config.command == "npx" def test_skips_builtin_and_disabled( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) config_dir = tmp_path / ".config" / "goose" config_path = config_dir / "config.yaml" config_path.parent.mkdir(parents=True) config_path.write_text(yaml.dump(_GOOSE_CONFIG)) monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") servers = _scan_goose() names = {s.name for s in servers} assert "developer" not in names assert "disabled-tool" not in names def test_missing_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) monkeypatch.setattr("fastmcp.cli.discovery.sys.platform", "linux") servers = _scan_goose() assert servers == [] # --------------------------------------------------------------------------- # discover_servers # --------------------------------------------------------------------------- def _suppress_user_scanners(monkeypatch: pytest.MonkeyPatch) -> None: """Suppress all scanners that read real user config files.""" monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_desktop", lambda: []) monkeypatch.setattr("fastmcp.cli.discovery._scan_claude_code", lambda start_dir: []) monkeypatch.setattr("fastmcp.cli.discovery._scan_gemini", lambda start_dir: []) monkeypatch.setattr("fastmcp.cli.discovery._scan_goose", lambda: []) class TestDiscoverServers: def test_combines_sources(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # Set up project mcp.json project_config = tmp_path / "mcp.json" _write_config(project_config, _STDIO_CONFIG) # Set up cursor config cursor_config = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_config, _REMOTE_CONFIG) _suppress_user_scanners(monkeypatch) servers = discover_servers(start_dir=tmp_path) sources = {s.source for s in servers} assert "project" in sources assert "cursor" in sources assert len(servers) == 3 # 2 from project + 1 from cursor def test_preserves_duplicates( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): """Same server name in multiple sources should appear multiple times.""" project_config = tmp_path / "mcp.json" _write_config(project_config, _STDIO_CONFIG) cursor_config = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_config, _STDIO_CONFIG) _suppress_user_scanners(monkeypatch) servers = discover_servers(start_dir=tmp_path) weather_servers = [s for s in servers if s.name == "weather"] assert len(weather_servers) == 2 assert {s.source for s in weather_servers} == {"cursor", "project"} # --------------------------------------------------------------------------- # resolve_name # --------------------------------------------------------------------------- class TestResolveName: @pytest.fixture(autouse=True) def _isolate_scanners(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): """Suppress scanners that read real user configs and confine walks to tmp_path.""" _suppress_user_scanners(monkeypatch) monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) def test_unique_match(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) transport = resolve_name("weather", start_dir=tmp_path) assert isinstance(transport, StdioTransport) def test_qualified_match(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) transport = resolve_name("project:weather", start_dir=tmp_path) assert isinstance(transport, StdioTransport) def test_not_found_with_servers(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) with pytest.raises(ValueError, match="No server named 'nope'.*Available"): resolve_name("nope", start_dir=tmp_path) def test_not_found_no_servers(self, tmp_path: Path): with pytest.raises(ValueError, match="No server named 'nope'.*Searched"): resolve_name("nope", start_dir=tmp_path) def test_ambiguous_name(self, tmp_path: Path): project_config = tmp_path / "mcp.json" _write_config(project_config, _STDIO_CONFIG) cursor_config = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_config, _STDIO_CONFIG) with pytest.raises(ValueError, match="Ambiguous server name 'weather'"): resolve_name("weather", start_dir=tmp_path) def test_ambiguous_resolved_by_qualified(self, tmp_path: Path): project_config = tmp_path / "mcp.json" _write_config(project_config, _STDIO_CONFIG) cursor_config = tmp_path / ".cursor" / "mcp.json" _write_config(cursor_config, _STDIO_CONFIG) transport = resolve_name("cursor:weather", start_dir=tmp_path) assert isinstance(transport, StdioTransport) def test_qualified_not_found(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) with pytest.raises( ValueError, match="No server named 'nope' found in source 'project'" ): resolve_name("project:nope", start_dir=tmp_path) def test_remote_server_resolves_to_http_transport(self, tmp_path: Path): config_path = tmp_path / "mcp.json" _write_config(config_path, _REMOTE_CONFIG) transport = resolve_name("api", start_dir=tmp_path) assert isinstance(transport, StreamableHttpTransport) # --------------------------------------------------------------------------- # Integration: resolve_server_spec falls through to name resolution # --------------------------------------------------------------------------- class TestResolveServerSpecNameFallback: def test_bare_name_resolves(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): config_path = tmp_path / "mcp.json" _write_config(config_path, _STDIO_CONFIG) _suppress_user_scanners(monkeypatch) monkeypatch.setattr("fastmcp.cli.discovery.Path.home", lambda: tmp_path) # Monkeypatch resolve_name in client module to use our tmp_path original_resolve = resolve_name def patched_resolve(name: str, start_dir: Path | None = None) -> Any: return original_resolve(name, start_dir=tmp_path) monkeypatch.setattr("fastmcp.cli.client.resolve_name", patched_resolve) result = resolve_server_spec("weather") assert isinstance(result, StdioTransport) def test_url_takes_priority_over_name(self): """URLs should be resolved before name lookup.""" result = resolve_server_spec("http://localhost:8000/mcp") assert result == "http://localhost:8000/mcp" # --------------------------------------------------------------------------- # Integration: _is_http_target detects transport objects # --------------------------------------------------------------------------- class TestIsHttpTargetTransports: def test_streamable_http_transport(self): transport = StreamableHttpTransport("http://localhost:8000/mcp") assert _is_http_target(transport) is True def test_sse_transport(self): transport = SSETransport("http://localhost:8000/sse") assert _is_http_target(transport) is True def test_stdio_transport(self): transport = StdioTransport(command="echo", args=["hello"]) assert _is_http_target(transport) is False def test_string_url(self): assert _is_http_target("http://localhost:8000") is True def test_string_non_url(self): assert _is_http_target("server.py") is False def test_dict_config(self): assert _is_http_target({"mcpServers": {}}) is False ================================================ FILE: tests/cli/test_generate_cli.py ================================================ """Tests for fastmcp generate-cli command.""" import sys from pathlib import Path from typing import Any from unittest.mock import patch import mcp.types import pytest from fastmcp import FastMCP from fastmcp.cli import generate as generate_module from fastmcp.cli.client import Client from fastmcp.cli.generate import ( _derive_server_name, _param_to_cli_flag, _schema_to_python_type, _schema_type_label, _to_python_identifier, _tool_function_source, generate_cli_command, generate_cli_script, generate_skill_content, serialize_transport, ) from fastmcp.client.transports.stdio import StdioTransport # --------------------------------------------------------------------------- # _schema_to_python_type # --------------------------------------------------------------------------- class TestSchemaToPythonType: def test_simple_string(self): py_type, needs_json = _schema_to_python_type({"type": "string"}) assert py_type == "str" assert needs_json is False def test_simple_integer(self): py_type, needs_json = _schema_to_python_type({"type": "integer"}) assert py_type == "int" assert needs_json is False def test_simple_number(self): py_type, needs_json = _schema_to_python_type({"type": "number"}) assert py_type == "float" assert needs_json is False def test_simple_boolean(self): py_type, needs_json = _schema_to_python_type({"type": "boolean"}) assert py_type == "bool" assert needs_json is False def test_array_of_strings(self): py_type, needs_json = _schema_to_python_type( {"type": "array", "items": {"type": "string"}} ) assert py_type == "list[str]" assert needs_json is False def test_array_of_integers(self): py_type, needs_json = _schema_to_python_type( {"type": "array", "items": {"type": "integer"}} ) assert py_type == "list[int]" assert needs_json is False def test_complex_object(self): py_type, needs_json = _schema_to_python_type({"type": "object"}) assert py_type == "str" assert needs_json is True def test_complex_nested_array(self): py_type, needs_json = _schema_to_python_type( {"type": "array", "items": {"type": "object"}} ) assert py_type == "str" assert needs_json is True def test_union_of_simple_types(self): py_type, needs_json = _schema_to_python_type({"type": ["string", "null"]}) assert py_type == "str | None" assert needs_json is False # --------------------------------------------------------------------------- # _to_python_identifier # --------------------------------------------------------------------------- class TestToPythonIdentifier: def test_plain_name(self): assert _to_python_identifier("hello") == "hello" def test_hyphens(self): assert _to_python_identifier("get-forecast") == "get_forecast" def test_dots_and_slashes(self): assert _to_python_identifier("a.b/c") == "a_b_c" def test_leading_digit(self): assert _to_python_identifier("3d_render") == "_3d_render" def test_spaces(self): assert _to_python_identifier("my tool") == "my_tool" def test_empty_string(self): assert _to_python_identifier("") == "_unnamed" # --------------------------------------------------------------------------- # serialize_transport # --------------------------------------------------------------------------- class TestSerializeTransport: def test_url_string(self): code, imports = serialize_transport("http://localhost:8000/mcp") assert code == "'http://localhost:8000/mcp'" assert imports == set() def test_stdio_transport_basic(self): transport = StdioTransport(command="fastmcp", args=["run", "server.py"]) code, imports = serialize_transport(transport) assert "StdioTransport" in code assert "command='fastmcp'" in code assert "args=['run', 'server.py']" in code assert "from fastmcp.client.transports import StdioTransport" in imports def test_stdio_transport_with_env(self): transport = StdioTransport( command="python", args=["-m", "myserver"], env={"KEY": "val"} ) code, imports = serialize_transport(transport) assert "env={'KEY': 'val'}" in code def test_dict_passthrough(self): d: dict[str, Any] = {"mcpServers": {"test": {"url": "http://localhost"}}} code, imports = serialize_transport(d) assert "mcpServers" in code assert imports == set() # --------------------------------------------------------------------------- # _tool_function_source # --------------------------------------------------------------------------- class TestToolFunctionSource: def test_required_param(self): tool = mcp.types.Tool( name="greet", inputSchema={ "properties": {"name": {"type": "string", "description": "Who"}}, "required": ["name"], }, ) source = _tool_function_source(tool) assert "async def greet(" in source assert "name: Annotated[str" in source assert "= None" not in source assert "_call_tool('greet', {'name': name})" in source def test_optional_param(self): tool = mcp.types.Tool( name="search", inputSchema={ "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "integer", "description": "Max results"}, }, "required": ["query"], }, ) source = _tool_function_source(tool) assert "query: Annotated[str" in source assert "limit: Annotated[int | None" in source assert "= None" in source def test_param_with_default(self): tool = mcp.types.Tool( name="fetch", inputSchema={ "properties": { "url": {"type": "string", "description": "URL"}, "timeout": { "type": "integer", "description": "Timeout", "default": 30, }, }, "required": ["url"], }, ) source = _tool_function_source(tool) assert "timeout: Annotated[int" in source assert "= 30" in source def test_no_params(self): tool = mcp.types.Tool( name="ping", inputSchema={"properties": {}}, ) source = _tool_function_source(tool) assert "async def ping(" in source assert "_call_tool('ping', {})" in source def test_preserves_underscores(self): tool = mcp.types.Tool( name="get_forecast", inputSchema={ "properties": {"city": {"type": "string"}}, "required": ["city"], }, ) source = _tool_function_source(tool) assert "async def get_forecast(" in source def test_sanitizes_tool_name(self): tool = mcp.types.Tool( name="my.tool/v2", inputSchema={"properties": {}}, ) source = _tool_function_source(tool) assert "async def my_tool_v2(" in source assert "name='my.tool/v2'" in source def test_sanitizes_param_name(self): tool = mcp.types.Tool( name="fetch", inputSchema={ "properties": {"content-type": {"type": "string", "description": "CT"}}, "required": ["content-type"], }, ) source = _tool_function_source(tool) assert "content_type: Annotated[str" in source assert "'content-type': content_type" in source def test_description_in_docstring(self): tool = mcp.types.Tool( name="greet", description="Say hello to someone.", inputSchema={ "properties": {"name": {"type": "string"}}, "required": ["name"], }, ) source = _tool_function_source(tool) assert "'''Say hello to someone.'''" in source def test_description_with_quotes(self): tool = mcp.types.Tool( name="fetch", description="Fetch data from 'source' API.", inputSchema={ "properties": {"url": {"type": "string"}}, "required": ["url"], }, ) source = _tool_function_source(tool) # Should escape single quotes in the description assert r"Fetch data from \'source\' API." in source # Generated code should compile compile(source, "", "exec") def test_array_of_strings_parameter(self): tool = mcp.types.Tool( name="tag_items", description="Tag multiple items.", inputSchema={ "properties": { "item_id": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, }, "required": ["item_id"], }, ) source = _tool_function_source(tool) # Should use list[str] type with help metadata assert "tags: Annotated[list[str]" in source assert "= []" in source # Should not have JSON parsing for simple arrays assert "json.loads" not in source compile(source, "", "exec") def test_complex_object_parameter(self): tool = mcp.types.Tool( name="create_user", description="Create a user.", inputSchema={ "properties": { "name": {"type": "string"}, "metadata": { "type": "object", "properties": { "role": {"type": "string"}, "dept": {"type": "string"}, }, }, }, "required": ["name"], }, ) source = _tool_function_source(tool) # Should use str type for complex object assert "metadata: Annotated[str | None" in source # Should include JSON schema in help (with escaped quotes) assert "JSON Schema:" in source assert '\\"type\\": \\"object\\"' in source # Should have JSON parsing with isinstance check assert ( "metadata_parsed = json.loads(metadata) if isinstance(metadata, str) else metadata" in source ) # Should use parsed version in call assert "'metadata': metadata_parsed" in source compile(source, "", "exec") def test_nested_array_parameter(self): tool = mcp.types.Tool( name="batch_process", description="Process batches.", inputSchema={ "properties": { "batches": { "type": "array", "items": { "type": "object", "properties": {"id": {"type": "string"}}, }, }, }, "required": ["batches"], }, ) source = _tool_function_source(tool) # Nested arrays need JSON parsing assert "batches: Annotated[str" in source assert "JSON Schema:" in source assert ( "batches_parsed = json.loads(batches) if isinstance(batches, str) else batches" in source ) compile(source, "", "exec") def test_complex_type_with_default(self): """Test that complex types with defaults are JSON-serialized.""" tool = mcp.types.Tool( name="configure", inputSchema={ "properties": { "options": { "type": "object", "default": {"timeout": 30, "retry": True}, }, }, }, ) source = _tool_function_source(tool) # Default should be JSON string, not Python dict # pydantic_core.to_json produces compact JSON assert '= \'{"timeout":30,"retry":true}\'' in source # Should parse safely even with default assert "isinstance(options, str)" in source compile(source, "", "exec") def test_name_collision_detection(self): """Test that parameter name collisions are detected.""" tool = mcp.types.Tool( name="test", inputSchema={ "properties": { "content-type": {"type": "string"}, "content_type": {"type": "string"}, }, }, ) # Should raise ValueError for collision with pytest.raises(ValueError, match="both sanitize to 'content_type'"): _tool_function_source(tool) # --------------------------------------------------------------------------- # _derive_server_name # --------------------------------------------------------------------------- class TestDeriveServerName: def test_bare_name(self): assert _derive_server_name("weather") == "weather" def test_qualified_name(self): assert _derive_server_name("cursor:weather") == "weather" def test_python_file(self): assert _derive_server_name("server.py") == "server" def test_url(self): assert _derive_server_name("http://localhost:8000/mcp") == "localhost" def test_trailing_colon(self): assert _derive_server_name("source:") == "source" # --------------------------------------------------------------------------- # generate_cli_script — produces compilable Python # --------------------------------------------------------------------------- class TestGenerateCliScript: def _make_tools(self) -> list[mcp.types.Tool]: return [ mcp.types.Tool( name="greet", description="Say hello", inputSchema={ "properties": { "name": {"type": "string", "description": "Who to greet"}, }, "required": ["name"], }, ), mcp.types.Tool( name="add_numbers", description="Add two numbers", inputSchema={ "properties": { "a": {"type": "integer", "description": "First number"}, "b": {"type": "integer", "description": "Second number"}, }, "required": ["a", "b"], }, ), ] def test_compiles(self): script = generate_cli_script( server_name="test", server_spec="test", transport_code='"http://localhost:8000/mcp"', extra_imports=set(), tools=self._make_tools(), ) compile(script, "", "exec") def test_contains_tool_functions(self): script = generate_cli_script( server_name="test", server_spec="test", transport_code='"http://localhost:8000/mcp"', extra_imports=set(), tools=self._make_tools(), ) assert "async def greet(" in script assert "async def add_numbers(" in script def test_contains_generic_commands(self): script = generate_cli_script( server_name="test", server_spec="test", transport_code='"http://localhost:8000/mcp"', extra_imports=set(), tools=[], ) assert "async def list_tools(" in script assert "async def list_resources(" in script assert "async def list_prompts(" in script assert "async def read_resource(" in script assert "async def get_prompt(" in script def test_embeds_transport(self): script = generate_cli_script( server_name="test", server_spec="test", transport_code="StdioTransport(command='fastmcp', args=['run', 'x.py'])", extra_imports={"from fastmcp.client.transports import StdioTransport"}, tools=[], ) assert "StdioTransport(command='fastmcp'" in script assert "from fastmcp.client.transports import StdioTransport" in script def test_no_tools_still_valid(self): script = generate_cli_script( server_name="empty", server_spec="empty", transport_code='"http://localhost"', extra_imports=set(), tools=[], ) compile(script, "", "exec") assert "call_tool_app" in script def test_server_name_with_quotes(self): """Test that server names with quotes are properly escaped.""" script = generate_cli_script( server_name='Test "Server" Name', server_spec="test", transport_code='"http://localhost"', extra_imports=set(), tools=[], ) # Should compile without syntax errors compile(script, "", "exec") # App name should have escaped quotes assert r'app = cyclopts.App(name="test-\"server\"-name"' in script def test_compiles_with_unusual_names(self): tools = [ mcp.types.Tool( name="my.tool/v2", description="A tool with dots and slashes", inputSchema={ "properties": { "content-type": {"type": "string", "description": "CT"}, }, "required": ["content-type"], }, ), ] script = generate_cli_script( server_name="test", server_spec="test", transport_code='"http://localhost:8000/mcp"', extra_imports=set(), tools=tools, ) compile(script, "", "exec") def test_compiles_with_stdio_transport(self): transport = StdioTransport(command="fastmcp", args=["run", "server.py"]) transport_code, extra_imports = serialize_transport(transport) script = generate_cli_script( server_name="test", server_spec="server.py", transport_code=transport_code, extra_imports=extra_imports, tools=self._make_tools(), ) compile(script, "", "exec") # --------------------------------------------------------------------------- # generate_cli_command — integration tests # --------------------------------------------------------------------------- def _build_test_server() -> FastMCP: """Create a minimal FastMCP server for integration tests.""" server = FastMCP("TestServer") @server.tool def greet(name: str) -> str: """Say hello to someone.""" return f"Hello, {name}!" @server.tool def add(a: int, b: int) -> int: """Add two numbers.""" return a + b @server.resource("test://greeting") def greeting_resource() -> str: """A static greeting resource.""" return "Hello from resource!" @server.prompt def ask(topic: str) -> str: """Ask about a topic.""" return f"Tell me about {topic}" return server @pytest.fixture() def _patch_client(): """Patch resolve_server_spec and _build_client to use an in-process server.""" server = _build_test_server() def fake_resolve(server_spec: Any, **kwargs: Any) -> str: return "fake://server" def fake_build_client(resolved: Any, **kwargs: Any) -> Client: return Client(server) with ( patch.object(generate_module, "resolve_server_spec", side_effect=fake_resolve), patch.object(generate_module, "_build_client", side_effect=fake_build_client), ): yield class TestGenerateCliCommand: @pytest.mark.usefixtures("_patch_client") async def test_writes_file(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.exists() content = output.read_text() compile(content, str(output), "exec") @pytest.mark.usefixtures("_patch_client") async def test_contains_tools(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) content = output.read_text() assert "async def greet(" in content assert "async def add(" in content @pytest.mark.usefixtures("_patch_client") async def test_default_output_path( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.chdir(tmp_path) await generate_cli_command("test-server") assert (tmp_path / "cli.py").exists() @pytest.mark.usefixtures("_patch_client") async def test_error_if_exists(self, tmp_path: Path): output = tmp_path / "cli.py" output.write_text("existing") with pytest.raises(SystemExit): await generate_cli_command("test-server", str(output)) @pytest.mark.usefixtures("_patch_client") async def test_force_overwrites(self, tmp_path: Path): output = tmp_path / "cli.py" output.write_text("existing") await generate_cli_command("test-server", str(output), force=True) content = output.read_text() assert content != "existing" assert "async def greet(" in content @pytest.mark.skipif( sys.platform == "win32", reason="Unix executable bits N/A on Windows" ) @pytest.mark.usefixtures("_patch_client") async def test_file_is_executable(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) assert output.stat().st_mode & 0o111 @pytest.mark.usefixtures("_patch_client") async def test_writes_skill_file(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) skill_path = tmp_path / "SKILL.md" assert skill_path.exists() content = skill_path.read_text() assert "---" in content assert "name:" in content @pytest.mark.usefixtures("_patch_client") async def test_skill_contains_tools(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output)) content = (tmp_path / "SKILL.md").read_text() assert "### greet" in content assert "### add" in content assert "--name" in content assert "call-tool greet" in content @pytest.mark.usefixtures("_patch_client") async def test_no_skill_flag(self, tmp_path: Path): output = tmp_path / "cli.py" await generate_cli_command("test-server", str(output), no_skill=True) assert not (tmp_path / "SKILL.md").exists() @pytest.mark.usefixtures("_patch_client") async def test_error_if_skill_exists(self, tmp_path: Path): output = tmp_path / "cli.py" (tmp_path / "SKILL.md").write_text("existing") with pytest.raises(SystemExit): await generate_cli_command("test-server", str(output)) @pytest.mark.usefixtures("_patch_client") async def test_force_overwrites_skill(self, tmp_path: Path): output = tmp_path / "cli.py" (tmp_path / "SKILL.md").write_text("existing") await generate_cli_command("test-server", str(output), force=True) content = (tmp_path / "SKILL.md").read_text() assert content != "existing" assert "### greet" in content @pytest.mark.usefixtures("_patch_client") async def test_skill_references_cli_filename(self, tmp_path: Path): output = tmp_path / "my_weather.py" await generate_cli_command("test-server", str(output)) content = (tmp_path / "SKILL.md").read_text() assert "uv run --with fastmcp python my_weather.py" in content # --------------------------------------------------------------------------- # _param_to_cli_flag # --------------------------------------------------------------------------- class TestParamToCliFlag: def test_simple_name(self): assert _param_to_cli_flag("city") == "--city" def test_underscore_name(self): assert _param_to_cli_flag("max_days") == "--max-days" def test_hyphenated_name(self): # content-type → _to_python_identifier → content_type → --content-type assert _param_to_cli_flag("content-type") == "--content-type" def test_digit_prefix(self): # 3d_mode → _3d_mode → --3d-mode (leading underscore stripped) assert _param_to_cli_flag("3d_mode") == "--3d-mode" def test_trailing_underscore(self): # from → from_ after identifier sanitization; Cyclopts strips trailing "-" assert _param_to_cli_flag("from") == "--from" def test_camel_case(self): # camelCase → camel-case (cyclopts default_name_transform) assert _param_to_cli_flag("myParam") == "--my-param" def test_pascal_case(self): assert _param_to_cli_flag("MyParam") == "--my-param" # --------------------------------------------------------------------------- # _schema_type_label # --------------------------------------------------------------------------- class TestSchemaTypeLabel: def test_simple_string(self): assert _schema_type_label({"type": "string"}) == "string" def test_integer(self): assert _schema_type_label({"type": "integer"}) == "integer" def test_array_of_strings(self): assert ( _schema_type_label({"type": "array", "items": {"type": "string"}}) == "array[string]" ) def test_union_types(self): result = _schema_type_label({"type": ["string", "null"]}) assert "string" in result assert "null" in result def test_object(self): assert _schema_type_label({"type": "object"}) == "object" def test_missing_type(self): assert _schema_type_label({}) == "string" # --------------------------------------------------------------------------- # generate_skill_content # --------------------------------------------------------------------------- class TestGenerateSkillContent: def test_frontmatter(self): content = generate_skill_content("weather", "cli.py", []) assert content.startswith("---\n") assert 'name: "weather-cli"' in content assert "description:" in content def test_no_tools(self): content = generate_skill_content("weather", "cli.py", []) assert "## Utility Commands" in content assert "## Tool Commands" not in content def test_tool_sections(self): tools = [ mcp.types.Tool( name="greet", description="Say hello", inputSchema={ "type": "object", "properties": { "name": {"type": "string", "description": "Who to greet"} }, "required": ["name"], }, ), ] content = generate_skill_content("test", "cli.py", tools) assert "## Tool Commands" in content assert "### greet" in content assert "Say hello" in content assert "call-tool greet" in content assert "`--name`" in content assert "| string |" in content assert "| yes |" in content def test_frontmatter_with_tools_starts_at_column_zero(self): tools = [ mcp.types.Tool( name="greet", inputSchema={"type": "object", "properties": {}}, ), ] content = generate_skill_content("weather", "cli.py", tools) assert content.splitlines()[0] == "---" def test_optional_param(self): tools = [ mcp.types.Tool( name="search", description="Search things", inputSchema={ "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "integer"}, }, "required": ["query"], }, ), ] content = generate_skill_content("test", "cli.py", tools) # query is required, limit is not assert "| `--query` | string | yes |" in content assert "| `--limit` | integer | no |" in content def test_complex_json_param(self): tools = [ mcp.types.Tool( name="create", description="Create item", inputSchema={ "type": "object", "properties": { "data": { "type": "object", "properties": {"x": {"type": "integer"}}, }, }, "required": ["data"], }, ), ] content = generate_skill_content("test", "cli.py", tools) assert "JSON string" in content def test_no_params_tool(self): tools = [ mcp.types.Tool( name="ping", description="Ping the server", inputSchema={"type": "object", "properties": {}}, ), ] content = generate_skill_content("test", "cli.py", tools) assert "### ping" in content assert "call-tool ping" in content # No parameter table assert "| Flag |" not in content def test_cli_filename_in_utility_commands(self): content = generate_skill_content("test", "my_cli.py", []) assert "uv run --with fastmcp python my_cli.py list-tools" in content assert "uv run --with fastmcp python my_cli.py list-resources" in content def test_pipe_in_description_escaped(self): tools = [ mcp.types.Tool( name="test", description="Test", inputSchema={ "type": "object", "properties": { "mode": {"type": "string", "description": "a|b|c"}, }, }, ), ] content = generate_skill_content("test", "cli.py", tools) assert "a\\|b\\|c" in content def test_union_type_pipes_escaped(self): tools = [ mcp.types.Tool( name="test", description="Test", inputSchema={ "type": "object", "properties": { "val": {"type": ["string", "null"]}, }, }, ), ] content = generate_skill_content("test", "cli.py", tools) # Pipes in type label must be escaped so markdown table renders correctly assert "string \\| null" in content def test_boolean_param_no_value_placeholder(self): tools = [ mcp.types.Tool( name="run", description="Run something", inputSchema={ "type": "object", "properties": { "verbose": {"type": "boolean", "description": "Verbose output"}, "name": {"type": "string"}, }, }, ), ] content = generate_skill_content("test", "cli.py", tools) assert "--verbose " not in content assert "--name " in content def test_server_name_in_header(self): content = generate_skill_content("My Weather API", "cli.py", []) assert "# My Weather API CLI" in content assert 'name: "my-weather-api-cli"' in content ================================================ FILE: tests/cli/test_goose.py ================================================ from pathlib import Path from unittest.mock import patch from urllib.parse import parse_qs, unquote, urlparse import pytest from fastmcp.cli.install.goose import ( _build_uvx_command, _slugify, generate_goose_deeplink, goose_command, install_goose, ) class TestSlugify: def test_simple_name(self): assert _slugify("My Server") == "my-server" def test_special_characters(self): assert _slugify("my_server (v2.0)") == "my-server-v2-0" def test_already_slugified(self): assert _slugify("my-server") == "my-server" def test_empty_string(self): assert _slugify("") == "fastmcp-server" def test_only_special_chars(self): assert _slugify("!!!") == "fastmcp-server" def test_consecutive_hyphens_collapsed(self): assert _slugify("a---b") == "a-b" def test_leading_trailing_stripped(self): assert _slugify("--hello--") == "hello" class TestBuildUvxCommand: def test_basic(self): cmd = _build_uvx_command("server.py") assert cmd == ["uvx", "fastmcp", "run", "server.py"] def test_with_python_version(self): cmd = _build_uvx_command("server.py", python_version="3.11") assert cmd == [ "uvx", "--python", "3.11", "fastmcp", "run", "server.py", ] def test_with_packages(self): cmd = _build_uvx_command("server.py", with_packages=["numpy", "pandas"]) assert "--with" in cmd assert "numpy" in cmd assert "pandas" in cmd def test_fastmcp_not_in_with(self): cmd = _build_uvx_command("server.py", with_packages=["fastmcp", "numpy"]) # fastmcp is the command itself, so it shouldn't appear in --with with_indices = [i for i, v in enumerate(cmd) if v == "--with"] with_values = [cmd[i + 1] for i in with_indices] assert "fastmcp" not in with_values def test_packages_sorted_and_deduplicated(self): cmd = _build_uvx_command( "server.py", with_packages=["pandas", "numpy", "pandas"] ) with_indices = [i for i, v in enumerate(cmd) if v == "--with"] with_values = [cmd[i + 1] for i in with_indices] assert with_values == ["numpy", "pandas"] def test_server_spec_with_object(self): cmd = _build_uvx_command("server.py:app") assert cmd[-1] == "server.py:app" class TestGooseDeeplinkGeneration: def test_basic_deeplink(self): deeplink = generate_goose_deeplink( name="test-server", command="uvx", args=["fastmcp", "run", "server.py"], ) assert deeplink.startswith("goose://extension?") parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert params["cmd"] == ["uvx"] assert params["name"] == ["test-server"] assert params["id"] == ["test-server"] def test_special_characters_in_name(self): deeplink = generate_goose_deeplink( name="my server (test)", command="uvx", args=["fastmcp", "run", "server.py"], ) assert "name=my%20server%20%28test%29" in deeplink parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert params["id"] == ["my-server-test"] def test_url_injection_protection(self): deeplink = generate_goose_deeplink( name="test&evil=true", command="uvx", args=["fastmcp", "run", "server.py"], ) assert "name=test%26evil%3Dtrue" in deeplink parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert params["name"] == ["test&evil=true"] def test_dangerous_characters_encoded(self): dangerous_names = [ ("test|calc", "test%7Ccalc"), ("test;calc", "test%3Bcalc"), ("testcalc", "test%3Ecalc"), ("test`calc", "test%60calc"), ("test$calc", "test%24calc"), ("test'calc", "test%27calc"), ('test"calc', "test%22calc"), ("test calc", "test%20calc"), ("test#anchor", "test%23anchor"), ("test?query=val", "test%3Fquery%3Dval"), ] for dangerous_name, expected_encoded in dangerous_names: deeplink = generate_goose_deeplink( name=dangerous_name, command="uvx", args=["fastmcp", "run", "server.py"] ) assert f"name={expected_encoded}" in deeplink, ( f"Failed to encode {dangerous_name}" ) def test_custom_description(self): deeplink = generate_goose_deeplink( name="my-server", command="uvx", args=["fastmcp", "run", "server.py"], description="My custom MCP server", ) parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert params["description"] == ["My custom MCP server"] def test_args_with_special_characters(self): deeplink = generate_goose_deeplink( name="test", command="uvx", args=[ "--with", "numpy>=1.20", "fastmcp", "run", "server.py:MyApp", ], ) parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert "numpy>=1.20" in params["arg"] assert "server.py:MyApp" in params["arg"] def test_empty_args(self): deeplink = generate_goose_deeplink(name="simple", command="python", args=[]) parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert "arg" not in params assert params["cmd"] == ["python"] def test_command_with_path(self): deeplink = generate_goose_deeplink( name="test", command="/usr/local/bin/uvx", args=["fastmcp", "run", "server.py"], ) parsed = urlparse(deeplink) params = parse_qs(parsed.query) assert params["cmd"] == ["/usr/local/bin/uvx"] class TestInstallGoose: @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_success(self, mock_print, mock_open): mock_open.return_value = True result = install_goose( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) assert result is True mock_open.assert_called_once() call_url = mock_open.call_args[0][0] assert call_url.startswith("goose://extension?") assert mock_open.call_args[1] == {"expected_scheme": "goose"} @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_success_uses_uvx(self, mock_print, mock_open): mock_open.return_value = True install_goose( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) call_url = mock_open.call_args[0][0] parsed = urlparse(call_url) params = parse_qs(parsed.query) assert params["cmd"] == ["uvx"] assert "fastmcp" in params["arg"] @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_failure(self, mock_print, mock_open): mock_open.return_value = False result = install_goose( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) assert result is False @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_with_server_object(self, mock_print, mock_open): mock_open.return_value = True install_goose( file=Path("/path/to/server.py"), server_object="app", name="test-server", ) call_url = mock_open.call_args[0][0] parsed = urlparse(call_url) params = parse_qs(parsed.query) args = params["arg"] assert any("server.py:app" in unquote(a) for a in args) @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_with_packages(self, mock_print, mock_open): mock_open.return_value = True install_goose( file=Path("/path/to/server.py"), server_object=None, name="test-server", with_packages=["numpy", "pandas"], ) call_url = mock_open.call_args[0][0] parsed = urlparse(call_url) params = parse_qs(parsed.query) args = params["arg"] assert "numpy" in args assert "pandas" in args @patch("fastmcp.cli.install.goose.open_deeplink") @patch("fastmcp.cli.install.goose.print") def test_fallback_message_on_failure(self, mock_print, mock_open): mock_open.return_value = False install_goose( file=Path("/path/to/server.py"), server_object=None, name="test-server", ) fallback_calls = [ call for call in mock_print.call_args_list if "copy this link" in str(call).lower() or "goose://" in str(call) ] assert len(fallback_calls) > 0 class TestGooseCommand: @patch("fastmcp.cli.install.goose.install_goose") @patch("fastmcp.cli.install.goose.process_common_args") async def test_basic(self, mock_process, mock_install): mock_process.return_value = (Path("server.py"), None, "test-server", [], {}) mock_install.return_value = True await goose_command("server.py") mock_install.assert_called_once_with( file=Path("server.py"), server_object=None, name="test-server", with_packages=[], python_version=None, ) @patch("fastmcp.cli.install.goose.install_goose") @patch("fastmcp.cli.install.goose.process_common_args") async def test_failure_exits(self, mock_process, mock_install): mock_process.return_value = (Path("server.py"), None, "test-server", [], {}) mock_install.return_value = False with pytest.raises(SystemExit) as exc_info: await goose_command("server.py") assert exc_info.value.code == 1 ================================================ FILE: tests/cli/test_install.py ================================================ from pathlib import Path import pytest from fastmcp.cli.install import install_app from fastmcp.cli.install.shared import validate_server_name from fastmcp.cli.install.stdio import install_stdio class TestInstallApp: """Test the install subapp.""" def test_install_app_exists(self): """Test that the install app is properly configured.""" # install_app.name is a tuple in cyclopts assert "install" in install_app.name assert "Install MCP servers" in install_app.help def test_install_commands_registered(self): """Test that all install commands are registered.""" # Check that the app has the expected help text and structure # This is a simpler check that doesn't rely on internal methods assert hasattr(install_app, "help") assert "Install MCP servers" in install_app.help # We can test that the commands parse without errors try: install_app.parse_args(["claude-code", "--help"]) install_app.parse_args(["claude-desktop", "--help"]) install_app.parse_args(["cursor", "--help"]) install_app.parse_args(["gemini-cli", "--help"]) install_app.parse_args(["goose", "--help"]) install_app.parse_args(["mcp-json", "--help"]) install_app.parse_args(["stdio", "--help"]) except SystemExit: # Help commands exit with 0, that's expected pass class TestClaudeCodeInstall: """Test claude-code install command.""" def test_claude_code_basic(self): """Test basic claude-code install command parsing.""" # Parse command with correct parameter names command, bound, _ = install_app.parse_args( ["claude-code", "server.py", "--name", "test-server"] ) # Verify parsing was successful assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_claude_code_with_options(self): """Test claude-code install with various options.""" command, bound, _ = install_app.parse_args( [ "claude-code", "server.py", "--name", "test-server", "--with", "package1", "--with", "package2", "--env", "VAR1=value1", ] ) assert bound.arguments["with_packages"] == ["package1", "package2"] assert bound.arguments["env_vars"] == ["VAR1=value1"] def test_claude_code_with_new_options(self): """Test claude-code install with new uv options.""" from pathlib import Path command, bound, _ = install_app.parse_args( [ "claude-code", "server.py", "--python", "3.11", "--project", "/workspace", "--with-requirements", "requirements.txt", ] ) assert bound.arguments["python"] == "3.11" assert bound.arguments["project"] == Path("/workspace") assert bound.arguments["with_requirements"] == Path("requirements.txt") class TestClaudeDesktopInstall: """Test claude-desktop install command.""" def test_claude_desktop_basic(self): """Test basic claude-desktop install command parsing.""" command, bound, _ = install_app.parse_args( ["claude-desktop", "server.py", "--name", "test-server"] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_claude_desktop_with_env_vars(self): """Test claude-desktop install with environment variables.""" command, bound, _ = install_app.parse_args( [ "claude-desktop", "server.py", "--name", "test-server", "--env", "VAR1=value1", "--env", "VAR2=value2", ] ) assert bound.arguments["env_vars"] == ["VAR1=value1", "VAR2=value2"] def test_claude_desktop_with_new_options(self): """Test claude-desktop install with new uv options.""" from pathlib import Path command, bound, _ = install_app.parse_args( [ "claude-desktop", "server.py", "--python", "3.10", "--project", "/my/project", "--with-requirements", "reqs.txt", ] ) assert bound.arguments["python"] == "3.10" assert bound.arguments["project"] == Path("/my/project") assert bound.arguments["with_requirements"] == Path("reqs.txt") def test_claude_desktop_with_config_path(self): """Test claude-desktop install with custom config path.""" command, bound, _ = install_app.parse_args( ["claude-desktop", "server.py", "--config-path", "/custom/path/Claude"] ) assert bound.arguments["config_path"] == Path("/custom/path/Claude") def test_claude_desktop_without_config_path(self): """Test claude-desktop install without config path defaults to None.""" command, bound, _ = install_app.parse_args(["claude-desktop", "server.py"]) assert bound.arguments.get("config_path") is None class TestCursorInstall: """Test cursor install command.""" def test_cursor_basic(self): """Test basic cursor install command parsing.""" command, bound, _ = install_app.parse_args( ["cursor", "server.py", "--name", "test-server"] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_cursor_with_options(self): """Test cursor install with options.""" command, bound, _ = install_app.parse_args( ["cursor", "server.py", "--name", "test-server"] ) assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" class TestGooseInstall: """Test goose install command.""" def test_goose_basic(self): """Test basic goose install command parsing.""" command, bound, _ = install_app.parse_args( ["goose", "server.py", "--name", "test-server"] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_goose_with_options(self): """Test goose install with various options.""" command, bound, _ = install_app.parse_args( [ "goose", "server.py", "--name", "test-server", "--with", "package1", "--with", "package2", "--env", "VAR1=value1", ] ) assert bound.arguments["with_packages"] == ["package1", "package2"] assert bound.arguments["env_vars"] == ["VAR1=value1"] def test_goose_with_python(self): """Test goose install with --python option.""" command, bound, _ = install_app.parse_args( [ "goose", "server.py", "--python", "3.11", ] ) assert bound.arguments["python"] == "3.11" class TestMcpJsonInstall: """Test mcp-json install command.""" def test_mcp_json_basic(self): """Test basic mcp-json install command parsing.""" command, bound, _ = install_app.parse_args( ["mcp-json", "server.py", "--name", "test-server"] ) assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_mcp_json_with_copy(self): """Test mcp-json install with copy to clipboard option.""" command, bound, _ = install_app.parse_args( ["mcp-json", "server.py", "--name", "test-server", "--copy"] ) assert bound.arguments["copy"] is True class TestStdioInstall: """Test stdio install command.""" def test_stdio_basic(self): """Test basic stdio install command parsing.""" command, bound, _ = install_app.parse_args(["stdio", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" def test_stdio_with_copy(self): """Test stdio install with copy to clipboard option.""" command, bound, _ = install_app.parse_args(["stdio", "server.py", "--copy"]) assert bound.arguments["copy"] is True def test_stdio_with_packages(self): """Test stdio install with additional packages.""" command, bound, _ = install_app.parse_args( ["stdio", "server.py", "--with", "requests", "--with", "httpx"] ) assert bound.arguments["with_packages"] == ["requests", "httpx"] def test_install_stdio_generates_command(self, tmp_path: Path): """Test that install_stdio produces a shell command containing fastmcp run.""" server_file = tmp_path / "server.py" server_file.write_text("# placeholder") # Capture stdout import io import sys captured = io.StringIO() old_stdout = sys.stdout sys.stdout = captured try: result = install_stdio(file=server_file, server_object=None) finally: sys.stdout = old_stdout assert result is True output = captured.getvalue() assert "fastmcp" in output assert "run" in output assert str(server_file.resolve()) in output def test_install_stdio_with_object(self, tmp_path: Path): """Test that install_stdio includes the :object suffix.""" server_file = tmp_path / "server.py" server_file.write_text("# placeholder") import io import sys captured = io.StringIO() old_stdout = sys.stdout sys.stdout = captured try: result = install_stdio(file=server_file, server_object="app") finally: sys.stdout = old_stdout assert result is True output = captured.getvalue() assert f"{server_file.resolve()}:app" in output class TestGeminiCliInstall: """Test gemini-cli install command.""" def test_gemini_cli_basic(self): """Test basic gemini-cli install command parsing.""" # Parse command with correct parameter names command, bound, _ = install_app.parse_args( ["gemini-cli", "server.py", "--name", "test-server"] ) # Verify parsing was successful assert command is not None assert bound.arguments["server_spec"] == "server.py" assert bound.arguments["server_name"] == "test-server" def test_gemini_cli_with_options(self): """Test gemini-cli install with various options.""" command, bound, _ = install_app.parse_args( [ "gemini-cli", "server.py", "--name", "test-server", "--with", "package1", "--with", "package2", "--env", "VAR1=value1", ] ) assert bound.arguments["with_packages"] == ["package1", "package2"] assert bound.arguments["env_vars"] == ["VAR1=value1"] def test_gemini_cli_with_new_options(self): """Test gemini-cli install with new uv options.""" from pathlib import Path command, bound, _ = install_app.parse_args( [ "gemini-cli", "server.py", "--python", "3.11", "--project", "/workspace", "--with-requirements", "requirements.txt", ] ) assert bound.arguments["python"] == "3.11" assert bound.arguments["project"] == Path("/workspace") assert bound.arguments["with_requirements"] == Path("requirements.txt") class TestInstallCommandParsing: """Test command parsing and error handling.""" def test_install_minimal_args(self): """Test install commands with minimal required arguments.""" # Each command should work with just a server spec commands_to_test = [ ["claude-code", "server.py"], ["claude-desktop", "server.py"], ["cursor", "server.py"], ["gemini-cli", "server.py"], ["goose", "server.py"], ["stdio", "server.py"], ] for cmd_args in commands_to_test: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert bound.arguments["server_spec"] == "server.py" def test_mcp_json_minimal(self): """Test that mcp-json works with minimal arguments.""" # Should work with just server spec command, bound, _ = install_app.parse_args(["mcp-json", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" def test_stdio_minimal(self): """Test that stdio works with minimal arguments.""" command, bound, _ = install_app.parse_args(["stdio", "server.py"]) assert command is not None assert bound.arguments["server_spec"] == "server.py" def test_python_option(self): """Test --python option for all install commands.""" commands_to_test = [ ["claude-code", "server.py", "--python", "3.11"], ["claude-desktop", "server.py", "--python", "3.11"], ["cursor", "server.py", "--python", "3.11"], ["gemini-cli", "server.py", "--python", "3.11"], ["goose", "server.py", "--python", "3.11"], ["mcp-json", "server.py", "--python", "3.11"], ["stdio", "server.py", "--python", "3.11"], ] for cmd_args in commands_to_test: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert bound.arguments["python"] == "3.11" def test_with_requirements_option(self): """Test --with-requirements option for all install commands.""" commands_to_test = [ ["claude-code", "server.py", "--with-requirements", "requirements.txt"], ["claude-desktop", "server.py", "--with-requirements", "requirements.txt"], ["cursor", "server.py", "--with-requirements", "requirements.txt"], ["gemini-cli", "server.py", "--with-requirements", "requirements.txt"], ["mcp-json", "server.py", "--with-requirements", "requirements.txt"], ["stdio", "server.py", "--with-requirements", "requirements.txt"], ] for cmd_args in commands_to_test: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert str(bound.arguments["with_requirements"]) == "requirements.txt" def test_project_option(self): """Test --project option for all install commands.""" commands_to_test = [ ["claude-code", "server.py", "--project", "/path/to/project"], ["claude-desktop", "server.py", "--project", "/path/to/project"], ["cursor", "server.py", "--project", "/path/to/project"], ["gemini-cli", "server.py", "--project", "/path/to/project"], ["mcp-json", "server.py", "--project", "/path/to/project"], ["stdio", "server.py", "--project", "/path/to/project"], ] for cmd_args in commands_to_test: command, bound, _ = install_app.parse_args(cmd_args) assert command is not None assert str(bound.arguments["project"]) == str(Path("/path/to/project")) class TestServerNameValidation: """Test server name validation rejects shell metacharacters.""" @pytest.mark.parametrize( "name", [ "my-server", "my_server", "My Server", "server.v2", "test123", ], ) def test_valid_names(self, name: str): assert validate_server_name(name) == name @pytest.mark.parametrize( "name", [ "test&calc", "test|whoami", "test;ls", "test$(id)", "test`id`", 'test"quoted', "test>file", "test str: '''Say hello to someone''' return f"Hello, {name}!" @mcp.resource("resource://greeting") def get_greeting() -> str: '''Get a greeting message''' return "Welcome to FastMCP!" if __name__ == "__main__": import asyncio asyncio.run(mcp.run_async()) """) # Create config file config_data = { "$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"], }, "deployment": {"transport": "stdio", "log_level": "INFO"}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data, indent=2)) return tmp_path class TestConfigFileDetection: """Test configuration file detection patterns.""" 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({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name assert config_file.name.endswith("fastmcp.json") 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({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name 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({"source": {"path": "server.py"}})) # Should be detected as fastmcp config assert "fastmcp.json" in config_file.name class TestConfigWithClient: """Test fastmcp.json configuration with client connections.""" async def test_config_server_with_client(self, server_with_config): """Test that a server loaded from config works with a client.""" # Load the config config_file = server_with_config / "fastmcp.json" config = MCPServerConfig.from_file(config_file) # Import the server using the source import importlib.util import sys # 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 {source_path}") module = importlib.util.module_from_spec(spec) sys.modules["test_server"] = module spec.loader.exec_module(module) assert hasattr(module, "mcp") server = module.mcp # Connect client to server async with Client(server) as client: # Test tool result = await client.call_tool("hello", {"name": "FastMCP"}) assert result.data == "Hello, FastMCP!" # Use .data for string result # Test resource results = await client.read_resource("resource://greeting") assert len(results) == 1 # Resource results should have text content assert hasattr(results[0], "text") or hasattr(results[0], "contents") # Get the text content from the resource text = getattr(results[0], "text", None) or getattr( results[0], "contents", "" ) assert "Welcome to FastMCP!" in str(text) class TestEnvironmentExecution: """Test environment configuration execution paths.""" def test_needs_uv_with_dependencies(self): """Test that environment with dependencies needs UV.""" config = MCPServerConfig( source={"path": "server.py"}, environment={"dependencies": ["requests", "numpy"]}, ) assert config.environment is not None assert config.environment._must_run_with_uv() def test_needs_uv_with_python_version(self): """Test that environment with Python version needs UV.""" config = MCPServerConfig( source={"path": "server.py"}, environment={"python": "3.12"}, ) assert config.environment is not None assert config.environment._must_run_with_uv() def test_no_uv_needed_without_environment(self): """Test that no UV is needed without environment config.""" config = MCPServerConfig(source={"path": "server.py"}) # Environment is now always present but may be empty assert config.environment is not None assert not config.environment._must_run_with_uv() def test_no_uv_needed_with_empty_environment(self): """Test that no UV is needed with empty environment config.""" config = MCPServerConfig( source={"path": "server.py"}, environment={}, ) assert config.environment is not None assert not config.environment._must_run_with_uv() class TestPathResolution: """Test path resolution in configurations.""" 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() src_dir = tmp_path / "src" src_dir.mkdir() # Server is in src, config is in config server_file = src_dir / "server.py" server_file.write_text("# Server") config = MCPServerConfig(source={"path": "../src/server.py"}) # 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.""" import os # Create directory structure work_dir = tmp_path / "work" work_dir.mkdir() config = MCPServerConfig( source={"path": "server.py"}, deployment={"cwd": "work"}, ) original_cwd = os.getcwd() try: # Apply runtime settings relative to config location assert config.deployment is not None config.deployment.apply_runtime_settings(tmp_path / "fastmcp.json") # Should change to work directory assert Path.cwd() == work_dir.resolve() finally: os.chdir(original_cwd) def test_requirements_path_resolution(self, tmp_path): """Test that requirements path is resolved correctly.""" # Create requirements file reqs_file = tmp_path / "requirements.txt" reqs_file.write_text("fastmcp>=2.0") config = MCPServerConfig( source={"path": "server.py"}, environment={"requirements": "requirements.txt"}, ) # Build UV command assert config.environment is not None uv_cmd = config.environment.build_command(["fastmcp", "run"]) # Should include requirements file with absolute path assert "--with-requirements" in uv_cmd req_idx = uv_cmd.index("--with-requirements") + 1 assert Path(uv_cmd[req_idx]).is_absolute() assert Path(uv_cmd[req_idx]).name == "requirements.txt" class TestConfigValidation: """Test configuration validation.""" def test_invalid_transport_rejected(self): """Test that invalid transport values are rejected.""" with pytest.raises(ValueError): MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "invalid_transport"}, ) def test_streamable_http_transport_accepted(self): """Test that streamable-http transport is accepted as a valid value.""" config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": "streamable-http"}, ) assert config.deployment.transport == "streamable-http" def test_invalid_log_level_rejected(self): """Test that invalid log level values are rejected.""" with pytest.raises(ValueError): MCPServerConfig( source={"path": "server.py"}, deployment={"log_level": "INVALID"}, ) def test_missing_source_rejected(self): """Test that config without source is rejected.""" with pytest.raises(ValueError): MCPServerConfig() # type: ignore[call-arg] def test_valid_transport_values(self): """Test that all valid transport values are accepted.""" for transport in ["stdio", "http", "sse"]: config = MCPServerConfig( source={"path": "server.py"}, deployment={"transport": transport}, ) assert config.deployment is not None assert config.deployment.transport == transport def test_valid_log_levels(self): """Test that all valid log levels are accepted.""" for level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: config = MCPServerConfig( source={"path": "server.py"}, deployment={"log_level": level}, ) assert config.deployment is not None assert config.deployment.log_level == level ================================================ FILE: tests/cli/test_mcp_server_config_schema.py ================================================ """Test that the generated JSON schema has the correct structure.""" import pytest from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( Deployment, generate_schema, ) 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/public/schemas/fastmcp.json/v1.json" ) def test_schema_has_required_fields(): """Test that the schema specifies the required fields correctly.""" generated_schema = generate_schema() assert generated_schema is not None # Check that source is required assert "required" in generated_schema assert "source" in generated_schema["required"] # Check that source is in properties assert "properties" in generated_schema 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 assert "environment" in properties env_schema = properties["environment"] # Environment can be in anyOf or direct properties if "anyOf" in env_schema: # Find the UVEnvironment in anyOf for option in env_schema["anyOf"]: if option.get("type") == "object" and "properties" in option: env_props = option["properties"] assert "type" in env_props # New type field assert "python" in env_props assert "dependencies" in env_props assert "requirements" in env_props assert "project" in env_props assert "editable" in env_props break elif "properties" in env_schema: env_props = env_schema["properties"] assert "type" in env_props # New type field assert "python" in env_props assert "dependencies" in env_props assert "requirements" in env_props assert "project" in env_props assert "editable" in env_props # Check deployment section assert "deployment" in properties deploy_schema = properties["deployment"] if "properties" in deploy_schema: deploy_props = deploy_schema["properties"] assert "transport" in deploy_props assert "host" in deploy_props assert "port" in deploy_props assert "log_level" in deploy_props assert "env" in deploy_props assert "cwd" in deploy_props assert "args" in deploy_props 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"] # Handle both direct properties and anyOf cases if "anyOf" in deploy_schema: # Find the object type in anyOf for option in deploy_schema["anyOf"]: if option.get("type") == "object" and "properties" in option: transport_schema = option["properties"].get("transport", {}) if "anyOf" in transport_schema: # Look for enum in anyOf options for trans_option in transport_schema["anyOf"]: if "enum" in trans_option: valid_transports = trans_option["enum"] assert "stdio" in valid_transports assert "http" in valid_transports assert "sse" in valid_transports assert "streamable-http" in valid_transports break elif "properties" in deploy_schema: transport_schema = deploy_schema["properties"].get("transport", {}) if "anyOf" in transport_schema: for option in transport_schema["anyOf"]: if "enum" in option: valid_transports = option["enum"] assert "stdio" in valid_transports assert "http" in valid_transports assert "sse" in valid_transports assert "streamable-http" in valid_transports break 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"] # Handle both direct properties and anyOf cases if "anyOf" in deploy_schema: # Find the object type in anyOf for option in deploy_schema["anyOf"]: if option.get("type") == "object" and "properties" in option: log_level_schema = option["properties"].get("log_level", {}) if "anyOf" in log_level_schema: # Look for enum in anyOf options for level_option in log_level_schema["anyOf"]: if "enum" in level_option: valid_levels = level_option["enum"] assert "DEBUG" in valid_levels assert "INFO" in valid_levels assert "WARNING" in valid_levels assert "ERROR" in valid_levels assert "CRITICAL" in valid_levels break elif "properties" in deploy_schema: log_level_schema = deploy_schema["properties"].get("log_level", {}) if "anyOf" in log_level_schema: for option in log_level_schema["anyOf"]: if "enum" in option: valid_levels = option["enum"] assert "DEBUG" in valid_levels assert "INFO" in valid_levels assert "WARNING" in valid_levels assert "ERROR" in valid_levels assert "CRITICAL" in valid_levels break @pytest.mark.parametrize( "transport", [ "streamable-http", "http", "stdio", "sse", None, ], ) def test_transport_values_accepted(transport): """Test that all valid transport values are accepted.""" deployment = Deployment(transport=transport) assert deployment.transport == transport ================================================ FILE: tests/cli/test_project_prepare.py ================================================ """Tests for the fastmcp project prepare command.""" import subprocess from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestMCPServerConfigPrepare: """Test the MCPServerConfig.prepare() method.""" @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_calls_both_methods(self, mock_env, mock_src): """Test that prepare() calls both prepare_environment and prepare_source.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=UVEnvironment(python="3.10"), ) await config.prepare() mock_env.assert_called_once() mock_src.assert_called_once() @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_with_output_dir(self, mock_env, mock_src): """Test that prepare() with output_dir calls prepare_environment with it.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=UVEnvironment(python="3.10"), ) output_path = Path("/tmp/test-env") await config.prepare(skip_source=False, output_dir=output_path) mock_env.assert_called_once_with(output_dir=output_path) mock_src.assert_called_once() @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_environment", new_callable=AsyncMock, ) async def test_prepare_skip_source(self, mock_env, mock_src): """Test that prepare() skips source when skip_source=True.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), environment=UVEnvironment(python="3.10"), ) await config.prepare(skip_source=True) mock_env.assert_called_once_with(output_dir=None) mock_src.assert_not_called() @patch( "fastmcp.utilities.mcp_server_config.v1.mcp_server_config.MCPServerConfig.prepare_source", new_callable=AsyncMock, ) @patch( "fastmcp.utilities.mcp_server_config.v1.environments.uv.UVEnvironment.prepare", new_callable=AsyncMock, ) async def test_prepare_no_environment_settings(self, mock_env_prepare, mock_src): """Test that prepare() works with default empty environment config.""" config = MCPServerConfig( source=FileSystemSource(path="server.py"), # environment defaults to empty Environment() ) await config.prepare(skip_source=False) # Environment prepare should be called even with empty config mock_env_prepare.assert_called_once_with(output_dir=None) mock_src.assert_called_once() class TestEnvironmentPrepare: """Test the Environment.prepare() method.""" @patch("shutil.which") async def test_prepare_no_uv_installed(self, mock_which, tmp_path): """Test that prepare() raises error when uv is not installed.""" mock_which.return_value = None env = UVEnvironment(python="3.10") with pytest.raises(RuntimeError, match="uv is not installed"): await env.prepare(tmp_path / "test-env") @patch("subprocess.run") @patch("shutil.which") async def test_prepare_no_settings(self, mock_which, mock_run, tmp_path): """Test that prepare() does nothing when no settings are configured.""" mock_which.return_value = "/usr/bin/uv" env = UVEnvironment() # No settings await env.prepare(tmp_path / "test-env") # Should not run any commands mock_run.assert_not_called() @patch("subprocess.run") @patch("shutil.which") async def test_prepare_with_python(self, mock_which, mock_run, tmp_path): """Test that prepare() runs uv with python version.""" mock_which.return_value = "/usr/bin/uv" mock_run.return_value = MagicMock( returncode=0, stdout="Environment cached", stderr="" ) env = UVEnvironment(python="3.10") await env.prepare(tmp_path / "test-env") # Should run multiple uv commands for initializing the project assert mock_run.call_count > 0 # Check the first call should be uv init first_call_args = mock_run.call_args_list[0][0][0] assert first_call_args[0] == "uv" assert "init" in first_call_args @patch("subprocess.run") @patch("shutil.which") async def test_prepare_with_dependencies(self, mock_which, mock_run, tmp_path): """Test that prepare() includes dependencies.""" mock_which.return_value = "/usr/bin/uv" mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") env = UVEnvironment(dependencies=["numpy", "pandas"]) await env.prepare(tmp_path / "test-env") # Should run multiple uv commands, one of which should be uv add assert mock_run.call_count > 0 # Find the add command call add_call = None for call_args, _ in mock_run.call_args_list: args = call_args[0] if "add" in args: add_call = args break assert add_call is not None, "Should have called uv add" assert "numpy" in add_call assert "pandas" in add_call assert "fastmcp" in add_call # Always added @patch("subprocess.run") @patch("shutil.which") async def test_prepare_command_fails(self, mock_which, mock_run, tmp_path): """Test that prepare() raises error when uv command fails.""" mock_which.return_value = "/usr/bin/uv" mock_run.side_effect = subprocess.CalledProcessError( 1, ["uv"], stderr="Package not found" ) env = UVEnvironment(python="3.10") with pytest.raises(RuntimeError, match="Failed to initialize project"): await env.prepare(tmp_path / "test-env") class TestProjectPrepareCommand: """Test the CLI project prepare command.""" @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config") async def test_project_prepare_auto_detect(self, mock_find, mock_from_file): """Test project prepare with auto-detected config.""" from fastmcp.cli.cli import prepare # Setup mocks mock_find.return_value = Path("fastmcp.json") mock_config = AsyncMock() mock_from_file.return_value = mock_config # Run command with output_dir with patch("sys.exit"): with patch("fastmcp.cli.cli.console.print") as mock_print: await prepare(config_path=None, output_dir="./test-env") # Should find and load config mock_find.assert_called_once() mock_from_file.assert_called_once_with(Path("fastmcp.json")) # Should call prepare with output_dir mock_config.prepare.assert_called_once_with( skip_source=False, output_dir=Path("./test-env"), ) # Should print success message mock_print.assert_called() success_call = mock_print.call_args_list[-1][0][0] assert "Project prepared successfully" in success_call @patch("pathlib.Path.exists") @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") async def test_project_prepare_explicit_path(self, mock_from_file, mock_exists): """Test project prepare with explicit config path.""" from fastmcp.cli.cli import prepare # Setup mocks mock_exists.return_value = True mock_config = AsyncMock() mock_from_file.return_value = mock_config # Run command with explicit path with patch("fastmcp.cli.cli.console.print"): await prepare(config_path="myconfig.json", output_dir="./test-env") # Should load specified config mock_from_file.assert_called_once_with(Path("myconfig.json")) # Should call prepare mock_config.prepare.assert_called_once_with( skip_source=False, output_dir=Path("./test-env"), ) @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.find_config") async def test_project_prepare_no_config_found(self, mock_find): """Test project prepare when no config is found.""" from fastmcp.cli.cli import prepare # Setup mocks mock_find.return_value = None # Run command without output_dir - should exit with error for missing output_dir with pytest.raises(SystemExit) as exc_info: with patch("fastmcp.cli.cli.logger.error") as mock_error: await prepare(config_path=None, output_dir=None) assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 mock_error.assert_called() error_msg = mock_error.call_args[0][0] assert "--output-dir parameter is required" in error_msg @patch("pathlib.Path.exists") async def test_project_prepare_config_not_exists(self, mock_exists): """Test project prepare when specified config doesn't exist.""" from fastmcp.cli.cli import prepare # Setup mocks mock_exists.return_value = False # Run command without output_dir - should exit with error for missing output_dir with pytest.raises(SystemExit) as exc_info: with patch("fastmcp.cli.cli.logger.error") as mock_error: await prepare(config_path="missing.json", output_dir=None) assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 mock_error.assert_called() error_msg = mock_error.call_args[0][0] assert "--output-dir parameter is required" in error_msg @patch("pathlib.Path.exists") @patch("fastmcp.utilities.mcp_server_config.MCPServerConfig.from_file") async def test_project_prepare_failure(self, mock_from_file, mock_exists): """Test project prepare when prepare() fails.""" from fastmcp.cli.cli import prepare # Setup mocks mock_exists.return_value = True mock_config = AsyncMock() mock_config.prepare.side_effect = RuntimeError("Preparation failed") mock_from_file.return_value = mock_config # Run command - should exit with error with pytest.raises(SystemExit) as exc_info: with patch("fastmcp.cli.cli.console.print") as mock_print: await prepare(config_path="config.json", output_dir="./test-env") assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 # Should print error message error_call = mock_print.call_args_list[-1][0][0] assert "Failed to prepare project" in error_call ================================================ FILE: tests/cli/test_run.py ================================================ import inspect import json import subprocess import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from pydantic import ValidationError from fastmcp.cli.cli import inspector, run from fastmcp.cli.run import ( create_mcp_config_server, is_url, run_module_command, ) 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.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestUrlDetection: """Test URL detection functionality.""" def test_is_url_valid_http(self): """Test detection of valid HTTP URLs.""" assert is_url("http://example.com") assert is_url("http://localhost:8080") assert is_url("http://127.0.0.1:3000/path") def test_is_url_valid_https(self): """Test detection of valid HTTPS URLs.""" assert is_url("https://example.com") assert is_url("https://api.example.com/mcp") assert is_url("https://localhost:8443") def test_is_url_invalid(self): """Test detection of non-URLs.""" assert not is_url("server.py") assert not is_url("/path/to/server.py") assert not is_url("server.py:app") assert not is_url("ftp://example.com") # Not http/https assert not is_url("file:///path/to/file") class TestFileSystemSource: """Test FileSystemSource path parsing functionality.""" 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") source = FileSystemSource(path=str(test_file)) assert Path(source.path).resolve() == test_file.resolve() assert source.entrypoint is None 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") source = FileSystemSource(path=f"{test_file}:app") assert Path(source.path).resolve() == test_file.resolve() assert source.entrypoint == "app" 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 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") source = FileSystemSource(path=f"{complex_file}:app") assert Path(source.path).resolve() == complex_file.resolve() assert source.entrypoint == "app" 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: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 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: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestMCPConfig: """Test MCPConfig functionality.""" async def test_run_mcp_config(self, tmp_path: Path): """Test creating a server from an MCPConfig file.""" server_script = inspect.cleandoc(""" from fastmcp import FastMCP mcp = FastMCP() @mcp.tool def add(a: int, b: int) -> int: return a + b if __name__ == '__main__': mcp.run() """) script_path: Path = tmp_path / "test.py" script_path.write_text(server_script) mcp_config_path = tmp_path / "mcp_config.json" mcp_config = MCPConfig( mcpServers={ "test_server": StdioMCPServer(command="python", args=[str(script_path)]) } ) mcp_config.write_to_file(mcp_config_path) server: FastMCP[None] = create_mcp_config_server(mcp_config_path) client = Client[FastMCPTransport](server) async with client: tools = await client.list_tools() assert len(tools) == 1 async def test_validate_mcp_config(self, tmp_path: Path): """Test creating a server from an MCPConfig file.""" mcp_config_path = tmp_path / "mcp_config.json" mcp_config = {"mcpServers": {"test_server": dict(x=1, y=2)}} with mcp_config_path.open("w") as f: json.dump(mcp_config, f) with pytest.raises(ValidationError, match="validation errors for MCPConfig"): create_mcp_config_server(mcp_config_path) class TestServerImport: """Test server import functionality using real files.""" async def test_import_server_basic_mcp(self, tmp_path): """Test importing server with basic FastMCP server.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" """) source = FileSystemSource(path=str(test_file)) server = await source.load_server() assert server.name == "TestServer" tools = await server.list_tools() assert any(t.name == "greet" for t in tools) async def test_import_server_with_main_block(self, tmp_path): """Test importing server with if __name__ == '__main__' block.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp app = fastmcp.FastMCP("MainServer") @app.tool def calculate(x: int, y: int) -> int: return x + y if __name__ == "__main__": app.run() """) source = FileSystemSource(path=str(test_file)) server = await source.load_server() assert server.name == "MainServer" tools = await server.list_tools() assert any(t.name == "calculate" for t in tools) async def test_import_server_standard_names(self, tmp_path): """Test automatic detection of standard names (mcp, server, app).""" # Test with 'mcp' name mcp_file = tmp_path / "mcp_server.py" mcp_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("MCPServer") """) source = FileSystemSource(path=str(mcp_file)) server = await source.load_server() assert server.name == "MCPServer" # Test with 'server' name server_file = tmp_path / "server_server.py" server_file.write_text(""" import fastmcp server = fastmcp.FastMCP("ServerServer") """) source = FileSystemSource(path=str(server_file)) server = await source.load_server() assert server.name == "ServerServer" # Test with 'app' name app_file = tmp_path / "app_server.py" app_file.write_text(""" import fastmcp app = fastmcp.FastMCP("AppServer") """) 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): """Test importing server with non-standard object name.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp my_custom_server = fastmcp.FastMCP("CustomServer") @my_custom_server.tool def custom_tool() -> str: return "custom" """) source = FileSystemSource(path=f"{test_file}:my_custom_server") server = await source.load_server() assert server.name == "CustomServer" tools = await server.list_tools() assert any(t.name == "custom_tool" for t in tools) async def test_import_server_no_standard_names_fails(self, tmp_path): """Test importing server when no standard names exist fails.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp other_name = fastmcp.FastMCP("OtherServer") """) source = FileSystemSource(path=str(test_file)) with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 async def test_import_server_nonexistent_object_fails(self, tmp_path): """Test importing nonexistent server object fails.""" test_file = tmp_path / "server.py" test_file.write_text(""" import fastmcp mcp = fastmcp.FastMCP("TestServer") """) source = FileSystemSource(path=f"{test_file}:nonexistent") with pytest.raises(SystemExit) as exc_info: await source.load_server() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestV1ServerAsync: """Test FastMCP 1.x server async support.""" async def test_run_v1_server_stdio(self, tmp_path): """Test that v1 server uses async stdio method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_stdio_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="stdio") run_mock.assert_called_once() async def test_run_v1_server_http(self, tmp_path): """Test that v1 server uses async http method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="http") run_mock.assert_called_once() async def test_run_v1_server_streamable_http(self, tmp_path): """Test that v1 server uses async streamable-http method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="streamable-http") run_mock.assert_called_once() async def test_run_v1_server_sse(self, tmp_path): """Test that v1 server uses async sse method.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_sse_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file), transport="sse") run_mock.assert_called_once() async def test_run_v1_server_default_transport(self, tmp_path): """Test that v1 server uses streamable-http by default.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command(str(test_file)) run_mock.assert_called_once() async def test_run_v1_server_with_host_port(self, tmp_path): """Test that v1 server receives host/port settings.""" from unittest.mock import AsyncMock, patch from mcp.server.fastmcp import FastMCP as FastMCP1x from fastmcp.cli.run import run_command # Create a v1 FastMCP server file with both sync and async tools test_file = tmp_path / "v1_server.py" test_file.write_text(""" from mcp.server.fastmcp import FastMCP mcp = FastMCP("V1Server") @mcp.tool() def sync_echo(text: str) -> str: '''Sync tool for testing''' return f"sync: {text}" @mcp.tool() async def async_echo(text: str) -> str: '''Async tool for testing''' return f"async: {text}" """) # Mock the async run method with patch.object( FastMCP1x, "run_streamable_http_async", new_callable=AsyncMock ) as run_mock: await run_command( str(test_file), transport="http", host="0.0.0.0", port=9000 ) run_mock.assert_called_once() 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.mcp_server_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.mcp_server_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() class TestReloadFunctionality: """Test reload functionality.""" def test_watch_filter_accepts_watched_extensions(self): """Test that watch filter accepts common source file extensions.""" from watchfiles import Change from fastmcp.cli.run import _watch_filter # Python assert _watch_filter(Change.modified, "/path/to/file.py") is True assert _watch_filter(Change.added, "server.py") is True # JavaScript/TypeScript assert _watch_filter(Change.modified, "/path/to/file.js") is True assert _watch_filter(Change.modified, "/path/to/file.ts") is True assert _watch_filter(Change.modified, "/path/to/file.jsx") is True assert _watch_filter(Change.modified, "/path/to/file.tsx") is True # Markup/Content assert _watch_filter(Change.modified, "/path/to/file.html") is True assert _watch_filter(Change.modified, "/path/to/file.md") is True assert _watch_filter(Change.modified, "/path/to/file.txt") is True # Styles assert _watch_filter(Change.modified, "/path/to/file.css") is True assert _watch_filter(Change.modified, "/path/to/file.scss") is True # Data/Config assert _watch_filter(Change.modified, "/path/to/file.json") is True assert _watch_filter(Change.modified, "/path/to/file.yaml") is True # Images assert _watch_filter(Change.modified, "/path/to/file.png") is True assert _watch_filter(Change.modified, "/path/to/file.svg") is True def test_watch_filter_rejects_unwatched_extensions(self): """Test that watch filter rejects files not in the watched set.""" from watchfiles import Change from fastmcp.cli.run import _watch_filter assert _watch_filter(Change.modified, "/path/to/file.pyc") is False assert _watch_filter(Change.modified, "/path/to/file.pyo") is False assert _watch_filter(Change.modified, "Dockerfile") is False assert _watch_filter(Change.modified, "/path/to/file.lock") is False assert _watch_filter(Change.modified, "/path/to/.gitignore") is False def test_all_watched_extensions_are_accepted(self): """Test that every extension in WATCHED_EXTENSIONS is accepted.""" from watchfiles import Change from fastmcp.cli.run import WATCHED_EXTENSIONS, _watch_filter for ext in WATCHED_EXTENSIONS: path = f"/path/to/file{ext}" assert _watch_filter(Change.modified, path) is True, ( f"Expected {ext} to be watched" ) def test_watched_extensions_includes_frontend_types(self): """Verify WATCHED_EXTENSIONS contains the expected frontend file types.""" from fastmcp.cli.run import WATCHED_EXTENSIONS # Core frontend extensions that must be present expected = { # Python ".py", # JavaScript/TypeScript ".js", ".ts", ".jsx", ".tsx", # Markup ".html", ".md", ".mdx", ".xml", # Styles ".css", ".scss", ".sass", ".less", # Data/Config ".json", ".yaml", ".yml", ".toml", # Images ".png", ".jpg", ".svg", # Media ".mp4", ".mp3", } for ext in expected: assert ext in WATCHED_EXTENSIONS, f"Expected {ext} in WATCHED_EXTENSIONS" class TestRunModuleCommand: """Test run_module_command functionality.""" def test_runs_python_m_module(self): """Test that run_module_command invokes python -m .""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit) as exc_info, ): run_module_command("my_package") assert exc_info.value.code == 0 call_args = mock_run.call_args cmd = call_args[0][0] assert "-m" in cmd assert "my_package" in cmd def test_forwards_extra_args(self): """Test that extra arguments are forwarded after the module name.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_package", extra_args=["--host", "0.0.0.0"]) cmd = mock_run.call_args[0][0] assert "--host" in cmd assert "0.0.0.0" in cmd def test_uses_env_command_builder(self): """Test that env_command_builder wraps the command.""" mock_result = MagicMock() mock_result.returncode = 0 def fake_builder(cmd: list[str]) -> list[str]: return ["uv", "run", *cmd] with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_package", env_command_builder=fake_builder) cmd = mock_run.call_args[0][0] assert cmd[0] == "uv" assert cmd[1] == "run" # Should use bare "python" (not sys.executable) so uv resolves the interpreter assert cmd[2] == "python" assert "-m" in cmd assert "my_package" in cmd def test_exits_with_subprocess_error_code(self): """Test that non-zero exit codes from the module are propagated.""" with ( patch( "fastmcp.cli.run.subprocess.run", side_effect=subprocess.CalledProcessError(42, ["python", "-m", "bad"]), ), pytest.raises(SystemExit) as exc_info, ): run_module_command("bad") assert exc_info.value.code == 42 def test_no_env_builder_runs_plain_python(self): """Test that without env_command_builder, plain python is used.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_run, pytest.raises(SystemExit), ): run_module_command("my_module", env_command_builder=None) cmd = mock_run.call_args[0][0] assert cmd[0] == sys.executable assert cmd[1] == "-m" assert cmd[2] == "my_module" class TestRunModuleMode: """Test the run command's module-mode branch.""" async def test_run_module_mode_requires_server_spec(self): """Test that module mode exits with error when server_spec is None.""" with pytest.raises(SystemExit) as exc_info: await run(None, module=True) assert exc_info.value.code == 1 async def test_run_module_mode_warns_ignored_options(self, caplog): """Test that ignored options produce a warning in module mode.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch("fastmcp.cli.run.subprocess.run", return_value=mock_result), pytest.raises(SystemExit), caplog.at_level("WARNING"), ): await run( "my_module", module=True, transport="sse", host="0.0.0.0", port=8080, ) assert any("ignored in module mode" in r.message for r in caplog.records) async def test_run_module_mode_delegates_to_run_module_command(self): """Test that module mode calls run_module_command with correct args.""" mock_result = MagicMock() mock_result.returncode = 0 with ( patch( "fastmcp.cli.run.subprocess.run", return_value=mock_result ) as mock_subprocess, pytest.raises(SystemExit), ): await run("my_module", module=True) cmd = mock_subprocess.call_args[0][0] assert "-m" in cmd assert "my_module" in cmd async def test_run_module_mode_with_reload(self): """Test that --reload in module mode delegates to run_with_reload.""" with patch( "fastmcp.cli.run.run_with_reload", new_callable=AsyncMock ) as mock_reload: await run("my_module", module=True, reload=True, skip_env=True) mock_reload.assert_called_once() cmd = mock_reload.call_args[0][0] assert "fastmcp" in cmd assert "--module" in cmd assert "--no-reload" in cmd assert "my_module" in cmd class TestInspectorModuleMode: """Test the inspector command's module-mode handling.""" async def test_inspector_module_mode_skips_load_server(self): """Test that inspector with module=True skips load_server() and forwards --module.""" mock_config = MagicMock() mock_config.deployment.port = 8080 mock_config.environment.build_command = lambda cmd: cmd mock_config.source.load_server = AsyncMock() mock_process = MagicMock() mock_process.returncode = 0 with ( patch( "fastmcp.cli.cli.load_and_merge_config", return_value=(mock_config, "my_module"), ), patch("fastmcp.cli.cli._get_npx_command", return_value="npx"), patch( "fastmcp.cli.cli.subprocess.run", return_value=mock_process ) as mock_subprocess, pytest.raises(SystemExit), ): await inspector("my_module", module=True) # load_server should NOT have been called in module mode mock_config.source.load_server.assert_not_called() # --module should be in the subprocess command cmd = mock_subprocess.call_args[0][0] assert "--module" in cmd ================================================ FILE: tests/cli/test_run_config.py ================================================ """Integration tests for FastMCP configuration with run command.""" import json import os from pathlib import Path import pytest from fastmcp.cli.run import load_mcp_server_config from fastmcp.utilities.mcp_server_config import ( Deployment, MCPServerConfig, ) from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment from fastmcp.utilities.mcp_server_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/public/schemas/fastmcp.json/v1.json", "source": {"path": "server.py"}, "environment": {"python": "3.11", "dependencies": ["requests"]}, "deployment": {"transport": "stdio", "env": {"TEST_VAR": "test_value"}}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data, indent=2)) # Create a simple server file server_file = tmp_path / "server.py" server_file.write_text(""" from fastmcp import FastMCP mcp = FastMCP("Test Server") @mcp.tool def test_tool(message: str) -> str: return f"Echo: {message}" """) return config_file def test_load_mcp_server_config(sample_config, monkeypatch): """Test loading configuration and returning config subsets.""" # Capture environment changes original_env = dict(os.environ) try: config = load_mcp_server_config(sample_config) # Check that we got the right types assert isinstance(config, MCPServerConfig) assert isinstance(config.source, FileSystemSource) assert isinstance(config.deployment, Deployment) assert isinstance(config.environment, UVEnvironment) # 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 config.environment.python == "3.11" assert config.environment.dependencies == ["requests"] # Check deployment config 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" finally: # Restore original environment os.environ.clear() os.environ.update(original_env) def test_load_config_with_entrypoint_source(tmp_path): """Test loading config with entrypoint-format source.""" config_data = { "source": {"path": "src/server.py", "entrypoint": "app"}, "deployment": {"transport": "http", "port": 8000}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create the server file in subdirectory src_dir = tmp_path / "src" src_dir.mkdir() server_file = src_dir / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) # 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 config.deployment.transport == "http" assert config.deployment.port == 8000 def test_load_config_with_cwd(tmp_path): """Test that Deployment applies working directory change.""" # Create a subdirectory subdir = tmp_path / "subdir" subdir.mkdir() config_data = {"source": {"path": "server.py"}, "deployment": {"cwd": "subdir"}} config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file in subdirectory server_file = subdir / "server.py" server_file.write_text("# Test server") original_cwd = os.getcwd() try: config = load_mcp_server_config(config_file) # noqa: F841 # Check that working directory was changed assert Path.cwd() == subdir.resolve() finally: # Restore original working directory os.chdir(original_cwd) def test_load_config_with_relative_cwd(tmp_path): """Test configuration with relative working directory.""" # Create nested subdirectories subdir1 = tmp_path / "dir1" subdir2 = subdir1 / "dir2" subdir2.mkdir(parents=True) config_data = { "source": {"path": "server.py"}, "deployment": { "cwd": "../" # Relative to config file location }, } config_file = subdir2 / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file in parent directory server_file = subdir1 / "server.py" server_file.write_text("# Server") original_cwd = os.getcwd() try: config = load_mcp_server_config(config_file) # noqa: F841 # Should change to parent directory of config file assert Path.cwd() == subdir1.resolve() finally: os.chdir(original_cwd) def test_load_minimal_config(tmp_path): """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)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) # 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 = { "source": {"path": "server.py"}, "deployment": {"args": ["--debug", "--config", "custom.json"]}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) assert config.deployment.args == ["--debug", "--config", "custom.json"] def test_load_config_with_log_level(tmp_path): """Test configuration with log_level setting.""" config_data = { "source": {"path": "server.py"}, "deployment": {"log_level": "DEBUG"}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) assert config.deployment.log_level == "DEBUG" def test_load_config_with_various_log_levels(tmp_path): """Test that all valid log levels are accepted.""" valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] for level in valid_levels: config_data = { "source": {"path": "server.py"}, "deployment": {"log_level": level}, } config_file = tmp_path / f"fastmcp_{level}.json" config_file.write_text(json.dumps(config_data)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) assert config.deployment.log_level == level def test_config_subset_independence(tmp_path): """Test that config subsets can be used independently.""" config_data = { "source": {"path": "server.py"}, "environment": {"python": "3.12", "dependencies": ["pandas"]}, "deployment": {"transport": "http", "host": "0.0.0.0", "port": 3000}, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) # Each subset should be independently usable # Path is not resolved yet, only during load_server assert config.source.path == "server.py" assert config.source.entrypoint is None assert config.environment.python == "3.12" assert config.environment.dependencies == ["pandas"] assert config.environment._must_run_with_uv() # Has dependencies 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): """Test that paths in environment config are resolved correctly.""" # Create requirements file reqs_file = tmp_path / "requirements.txt" reqs_file.write_text("fastmcp>=2.0") config_data = { "source": {"path": "server.py"}, "environment": { "requirements": "requirements.txt", "project": ".", "editable": ["../other-project"], }, } config_file = tmp_path / "fastmcp.json" config_file.write_text(json.dumps(config_data)) # Create server file server_file = tmp_path / "server.py" server_file.write_text("# Server") config = load_mcp_server_config(config_file) # Check that UV command is built with resolved paths uv_cmd = config.environment.build_command(["fastmcp", "run", "server.py"]) assert "--with-requirements" in uv_cmd assert "--project" in uv_cmd # Path should be resolved relative to config file req_idx = uv_cmd.index("--with-requirements") + 1 assert Path(uv_cmd[req_idx]).is_absolute() or uv_cmd[req_idx] == "requirements.txt" ================================================ FILE: tests/cli/test_server_args.py ================================================ """Test server argument passing functionality.""" from pathlib import Path import pytest from fastmcp.utilities.mcp_server_config import MCPServerConfig from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource class TestServerArguments: """Test passing arguments to servers.""" 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 = MCPServerConfig(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.list_tools() assert any(t.name == "get_config" for t in tools) 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 = MCPServerConfig(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" 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 = MCPServerConfig(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" 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 = MCPServerConfig(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.list_tools() assert any(t.name == "get_status" for t in tools) assert any(t.name == "echo_message" for t in tools) ================================================ FILE: tests/cli/test_shared.py ================================================ from fastmcp.cli.cli import _parse_env_var class TestEnvVarParsing: """Test environment variable parsing functionality.""" def test_parse_env_var_simple(self): """Test parsing simple environment variable.""" key, value = _parse_env_var("API_KEY=secret123") assert key == "API_KEY" assert value == "secret123" def test_parse_env_var_with_equals_in_value(self): """Test parsing env var with equals signs in the value.""" key, value = _parse_env_var("DATABASE_URL=postgresql://user:pass@host:5432/db") assert key == "DATABASE_URL" assert value == "postgresql://user:pass@host:5432/db" def test_parse_env_var_with_spaces(self): """Test parsing env var with spaces (should be stripped).""" key, value = _parse_env_var(" API_KEY = secret with spaces ") assert key == "API_KEY" assert value == "secret with spaces" def test_parse_env_var_empty_value(self): """Test parsing env var with empty value.""" key, value = _parse_env_var("EMPTY_VAR=") assert key == "EMPTY_VAR" assert value == "" ================================================ FILE: tests/cli/test_tasks.py ================================================ """Tests for the fastmcp tasks CLI.""" import pytest from fastmcp.cli.tasks import check_distributed_backend, tasks_app from fastmcp.utilities.tests import temporary_settings class TestCheckDistributedBackend: """Test the distributed backend checker function.""" def test_succeeds_with_redis_url(self): """Test that it succeeds with Redis URL.""" with temporary_settings(docket__url="redis://localhost:6379/0"): check_distributed_backend() def test_exits_with_helpful_error_for_memory_url(self): """Test that it exits with helpful error for memory:// URLs.""" with temporary_settings(docket__url="memory://test-123"): with pytest.raises(SystemExit) as exc_info: check_distributed_backend() assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 class TestWorkerCommand: """Test the worker command.""" def test_worker_command_parsing(self): """Test that worker command parses arguments correctly.""" command, bound, _ = tasks_app.parse_args(["worker", "server.py"]) assert callable(command) assert command.__name__ == "worker" # type: ignore[attr-defined] assert bound.arguments["server_spec"] == "server.py" class TestTasksAppIntegration: """Test the tasks app integration.""" def test_tasks_app_exists(self): """Test that the tasks app is properly configured.""" assert "tasks" in tasks_app.name assert "Docket" in tasks_app.help def test_tasks_app_has_commands(self): """Test that all expected commands are registered.""" # Just verify the app exists and has the right metadata # Detailed command testing is done in individual test classes assert "tasks" in tasks_app.name assert tasks_app.help ================================================ FILE: tests/cli/test_with_argv.py ================================================ """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 ================================================ FILE: tests/client/__init__.py ================================================ ================================================ FILE: tests/client/auth/__init__.py ================================================ ================================================ FILE: tests/client/auth/test_oauth_cimd.py ================================================ """Tests for CIMD (Client ID Metadata Document) support in the OAuth client.""" from __future__ import annotations import warnings import httpx import pytest from fastmcp.client.auth import OAuth from fastmcp.client.transports import StreamableHttpTransport from fastmcp.client.transports.sse import SSETransport VALID_CIMD_URL = "https://myapp.example.com/oauth/client.json" MCP_SERVER_URL = "https://mcp-server.example.com/mcp" class TestOAuthClientMetadataURL: """Tests for the client_metadata_url parameter on OAuth.""" def test_stored_on_instance(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert oauth._client_metadata_url == VALID_CIMD_URL def test_none_by_default(self): oauth = OAuth() assert oauth._client_metadata_url is None def test_passed_to_parent_on_bind(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth = OAuth(client_metadata_url=VALID_CIMD_URL) oauth._bind(MCP_SERVER_URL) assert oauth.context.client_metadata_url == VALID_CIMD_URL def test_none_metadata_url_on_parent(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth = OAuth(mcp_url=MCP_SERVER_URL) assert oauth.context.client_metadata_url is None def test_unbound_when_no_mcp_url(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert oauth._bound is False def test_bound_when_mcp_url_provided(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth = OAuth( mcp_url=MCP_SERVER_URL, client_metadata_url=VALID_CIMD_URL, ) assert oauth._bound is True def test_invalid_cimd_url_rejected(self): """CIMD URLs must be HTTPS with a non-root path.""" with pytest.raises(ValueError, match="valid HTTPS URL"): OAuth( mcp_url=MCP_SERVER_URL, client_metadata_url="http://insecure.com/client.json", ) def test_root_path_cimd_url_rejected(self): with pytest.raises(ValueError, match="valid HTTPS URL"): OAuth( mcp_url=MCP_SERVER_URL, client_metadata_url="https://example.com/", ) class TestOAuthBind: """Tests for the _bind() deferred initialization.""" def test_bind_sets_bound_true(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert oauth._bound is False with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth._bind(MCP_SERVER_URL) assert oauth._bound is True def test_bind_idempotent(self): """Second call to _bind is a no-op.""" oauth = OAuth(client_metadata_url=VALID_CIMD_URL) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth._bind(MCP_SERVER_URL) oauth._bind("https://other-server.example.com/mcp") # First binding wins assert oauth.mcp_url == MCP_SERVER_URL def test_bind_sets_mcp_url(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth._bind(MCP_SERVER_URL + "/") # Trailing slash stripped assert oauth.mcp_url == MCP_SERVER_URL def test_bind_creates_token_storage(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert not hasattr(oauth, "token_storage_adapter") with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth._bind(MCP_SERVER_URL) assert hasattr(oauth, "token_storage_adapter") async def test_unbound_raises_runtime_error(self): """async_auth_flow should fail clearly when OAuth is not bound.""" oauth = OAuth(client_metadata_url=VALID_CIMD_URL) request = httpx.Request("GET", MCP_SERVER_URL) with pytest.raises(RuntimeError, match="no server URL"): async for _ in oauth.async_auth_flow(request): pass def test_scopes_forwarded_as_list(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth = OAuth( client_metadata_url=VALID_CIMD_URL, scopes=["read", "write"], ) oauth._bind(MCP_SERVER_URL) assert oauth.context.client_metadata.scope == "read write" def test_scopes_forwarded_as_string(self): with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) oauth = OAuth( client_metadata_url=VALID_CIMD_URL, scopes="read write", ) oauth._bind(MCP_SERVER_URL) assert oauth.context.client_metadata.scope == "read write" class TestOAuthBindFromTransport: """Tests that transports call _bind() on OAuth instances.""" def test_http_transport_binds_oauth(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert oauth._bound is False with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) StreamableHttpTransport(MCP_SERVER_URL, auth=oauth) assert oauth._bound is True assert oauth.mcp_url == MCP_SERVER_URL def test_sse_transport_binds_oauth(self): oauth = OAuth(client_metadata_url=VALID_CIMD_URL) assert oauth._bound is False with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) SSETransport(MCP_SERVER_URL, auth=oauth) assert oauth._bound is True assert oauth.mcp_url == MCP_SERVER_URL def test_http_transport_oauth_string_still_works(self): """auth="oauth" should still create a new OAuth instance.""" with warnings.catch_warnings(): warnings.simplefilter("ignore", UserWarning) transport = StreamableHttpTransport(MCP_SERVER_URL, auth="oauth") assert isinstance(transport.auth, OAuth) assert transport.auth._bound is True ================================================ FILE: tests/client/auth/test_oauth_client.py ================================================ from unittest.mock import patch from urllib.parse import urlparse import httpx import pytest from mcp.types import TextResourceContents from fastmcp.client import Client from fastmcp.client.auth import OAuth from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider from fastmcp.server.server import FastMCP from fastmcp.utilities.http import find_available_port from fastmcp.utilities.tests import HeadlessOAuth, run_server_async def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", auth=InMemoryOAuthProvider( base_url=issuer_url, client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=["read", "write"] ), ), ) @server.tool def add(a: int, b: int) -> int: """Add two numbers together.""" return a + b @server.resource("resource://test") def get_test_resource() -> str: """Get a test resource.""" return "Hello from authenticated resource!" return server @pytest.fixture async def streamable_http_server(): """Start OAuth-enabled server.""" port = find_available_port() server = fastmcp_server(f"http://127.0.0.1:{port}") async with run_server_async(server, port=port, transport="http") as url: yield url @pytest.fixture def client_unauthorized(streamable_http_server: str) -> Client: return Client(transport=StreamableHttpTransport(streamable_http_server)) @pytest.fixture def client_with_headless_oauth(streamable_http_server: str) -> Client: """Client with headless OAuth that bypasses browser interaction.""" return Client( transport=StreamableHttpTransport(streamable_http_server), auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]), ) async def test_unauthorized(client_unauthorized: Client): """Test that unauthenticated requests are rejected.""" with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"): async with client_unauthorized: pass async def test_ping(client_with_headless_oauth: Client): """Test that we can ping the server.""" async with client_with_headless_oauth: assert await client_with_headless_oauth.ping() async def test_list_tools(client_with_headless_oauth: Client): """Test that we can list tools.""" async with client_with_headless_oauth: tools = await client_with_headless_oauth.list_tools() tool_names = [tool.name for tool in tools] assert "add" in tool_names async def test_call_tool(client_with_headless_oauth: Client): """Test that we can call a tool.""" async with client_with_headless_oauth: result = await client_with_headless_oauth.call_tool("add", {"a": 5, "b": 3}) # The add tool returns int which gets wrapped as structured output # Client unwraps it and puts the actual int in the data field assert result.data == 8 async def test_list_resources(client_with_headless_oauth: Client): """Test that we can list resources.""" async with client_with_headless_oauth: resources = await client_with_headless_oauth.list_resources() resource_uris = [str(resource.uri) for resource in resources] assert "resource://test" in resource_uris async def test_read_resource(client_with_headless_oauth: Client): """Test that we can read a resource.""" async with client_with_headless_oauth: resource = await client_with_headless_oauth.read_resource("resource://test") assert isinstance(resource[0], TextResourceContents) assert resource[0].text == "Hello from authenticated resource!" async def test_oauth_server_metadata_discovery(streamable_http_server: str): """Test that we can discover OAuth metadata from the running server.""" parsed_url = urlparse(streamable_http_server) server_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" async with httpx.AsyncClient() as client: # Test OAuth discovery endpoint metadata_url = f"{server_base_url}/.well-known/oauth-authorization-server" response = await client.get(metadata_url) assert response.status_code == 200 metadata = response.json() assert "authorization_endpoint" in metadata assert "token_endpoint" in metadata assert "registration_endpoint" in metadata # The endpoints should be properly formed URLs assert metadata["authorization_endpoint"].startswith(server_base_url) assert metadata["token_endpoint"].startswith(server_base_url) class TestOAuthClientUrlHandling: """Tests for OAuth client URL handling (issue #2573).""" def test_oauth_preserves_full_url_with_path(self): """OAuth client should preserve the full MCP URL including path components. This is critical for servers hosted under path-based endpoints like mcp.example.com/server1/v1.0/mcp where OAuth metadata discovery needs the full path to find the correct .well-known endpoints. """ mcp_url = "https://mcp.example.com/server1/v1.0/mcp" oauth = OAuth(mcp_url=mcp_url) # The full URL should be preserved for OAuth discovery assert oauth.context.server_url == mcp_url # The stored mcp_url should match assert oauth.mcp_url == mcp_url def test_oauth_preserves_root_url(self): """OAuth client should work correctly with root-level URLs.""" mcp_url = "https://mcp.example.com" oauth = OAuth(mcp_url=mcp_url) assert oauth.context.server_url == mcp_url assert oauth.mcp_url == mcp_url def test_oauth_normalizes_trailing_slash(self): """OAuth client should normalize trailing slashes for consistency.""" mcp_url_with_slash = "https://mcp.example.com/api/mcp/" oauth = OAuth(mcp_url=mcp_url_with_slash) # Trailing slash should be stripped expected = "https://mcp.example.com/api/mcp" assert oauth.context.server_url == expected assert oauth.mcp_url == expected def test_oauth_token_storage_uses_full_url(self): """Token storage should use the full URL to separate tokens per endpoint.""" mcp_url = "https://mcp.example.com/server1/v1.0/mcp" oauth = OAuth(mcp_url=mcp_url) # Token storage should key by the full URL, not just the host assert oauth.token_storage_adapter._server_url == mcp_url class TestOAuthGeneratorCleanup: """Tests for OAuth async generator cleanup (issue #2643). The MCP SDK's OAuthClientProvider.async_auth_flow() holds a lock via `async with self.context.lock`. If the generator is not explicitly closed, GC may clean it up from a different task, causing: RuntimeError: The current task is not holding this lock """ async def test_generator_closed_on_successful_flow(self): """Verify aclose() is called on the parent generator after successful flow.""" oauth = OAuth(mcp_url="https://example.com") # Track generator lifecycle using a wrapper class class TrackedGenerator: def __init__(self): self.aclose_called = False self._exhausted = False def __aiter__(self): return self async def __anext__(self): if self._exhausted: raise StopAsyncIteration self._exhausted = True return httpx.Request("GET", "https://example.com") async def asend(self, value): if self._exhausted: raise StopAsyncIteration self._exhausted = True return httpx.Request("GET", "https://example.com") async def athrow(self, exc_type, exc_val=None, exc_tb=None): raise StopAsyncIteration async def aclose(self): self.aclose_called = True tracked_gen = TrackedGenerator() # Patch the parent class to return our tracked generator with patch.object( OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen ): # Drive the OAuth flow flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) try: # First asend(None) starts the generator per async generator protocol await flow.asend(None) # ty: ignore[invalid-argument-type] try: await flow.asend(httpx.Response(200)) except StopAsyncIteration: pass except StopAsyncIteration: pass assert tracked_gen.aclose_called, ( "Generator aclose() was not called after flow completion" ) async def test_generator_closed_on_exception(self): """Verify aclose() is called even when an exception occurs mid-flow.""" oauth = OAuth(mcp_url="https://example.com") class FailingGenerator: def __init__(self): self.aclose_called = False self._first_call = True def __aiter__(self): return self async def __anext__(self): return await self.asend(None) async def asend(self, value): if self._first_call: self._first_call = False return httpx.Request("GET", "https://example.com") raise ValueError("Simulated failure") async def athrow(self, exc_type, exc_val=None, exc_tb=None): raise StopAsyncIteration async def aclose(self): self.aclose_called = True tracked_gen = FailingGenerator() with patch.object( OAuth.__bases__[0], "async_auth_flow", return_value=tracked_gen ): flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) with pytest.raises(ValueError, match="Simulated failure"): await flow.asend(None) # ty: ignore[invalid-argument-type] await flow.asend(httpx.Response(200)) assert tracked_gen.aclose_called, ( "Generator aclose() was not called after exception" ) class TestTokenStorageTTL: """Tests for client token storage TTL behavior (issue #2670). The token storage TTL should NOT be based on access token expiry, because the refresh token may be valid much longer. Using access token expiry would cause both tokens to be deleted when the access token expires, preventing refresh. """ async def test_token_storage_uses_long_ttl(self): """Token storage should use a long TTL, not access token expiry. This is the ianw case: IdP returns expires_in=300 (5 min access token) but the refresh token is valid for much longer. The entire token entry should NOT be deleted after 5 minutes. """ from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthToken from fastmcp.client.auth.oauth import TokenStorageAdapter # Create storage adapter storage = MemoryStore() adapter = TokenStorageAdapter( async_key_value=storage, server_url="https://test" ) # Create a token with short access expiry (5 minutes) token = OAuthToken( access_token="test-access-token", token_type="Bearer", expires_in=300, # 5 minutes - but we should NOT use this as storage TTL! refresh_token="test-refresh-token", scope="read write", ) # Store the token await adapter.set_tokens(token) # Verify token is stored stored = await adapter.get_tokens() assert stored is not None assert stored.access_token == "test-access-token" assert stored.refresh_token == "test-refresh-token" # The key assertion: the TTL should be 1 year (365 days), not 300 seconds # We verify this by checking the raw storage entry raw = await storage.get(collection="mcp-oauth-token", key="https://test/tokens") assert raw is not None async def test_token_storage_preserves_refresh_token(self): """Refresh token should not be lost when access token would expire.""" from key_value.aio.stores.memory import MemoryStore from mcp.shared.auth import OAuthToken from fastmcp.client.auth.oauth import TokenStorageAdapter storage = MemoryStore() adapter = TokenStorageAdapter( async_key_value=storage, server_url="https://test" ) # Store token with short access expiry token = OAuthToken( access_token="access", token_type="Bearer", expires_in=300, refresh_token="refresh-token-should-survive", scope="read", ) await adapter.set_tokens(token) # Retrieve and verify refresh token is present stored = await adapter.get_tokens() assert stored is not None assert stored.refresh_token == "refresh-token-should-survive" ================================================ FILE: tests/client/auth/test_oauth_static_client.py ================================================ """Tests for OAuth static client registration (pre-registered client_id/client_secret).""" from unittest.mock import patch import httpx import pytest from mcp.shared.auth import OAuthClientInformationFull from pydantic import AnyUrl from fastmcp.client import Client from fastmcp.client.auth import OAuth from fastmcp.client.auth.oauth import ClientNotFoundError from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider from fastmcp.server.server import FastMCP from fastmcp.utilities.http import find_available_port from fastmcp.utilities.tests import HeadlessOAuth, run_server_async class TestStaticClientInfoConstruction: """Static client info should include full metadata from client_metadata.""" def test_static_client_info_includes_metadata(self): """Static client info should include redirect_uris, grant_types, etc.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="my-client-id", client_secret="my-secret", scopes=["read", "write"], ) info = oauth._static_client_info assert info is not None assert info.client_id == "my-client-id" assert info.client_secret == "my-secret" # Metadata fields should be populated from client_metadata assert info.redirect_uris is not None assert len(info.redirect_uris) == 1 assert info.grant_types is not None assert "authorization_code" in info.grant_types assert "refresh_token" in info.grant_types assert info.response_types is not None assert "code" in info.response_types assert info.scope == "read write" assert info.token_endpoint_auth_method == "client_secret_post" def test_static_client_info_without_secret(self): """Public clients can provide client_id without client_secret.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="public-client", ) info = oauth._static_client_info assert info is not None assert info.client_id == "public-client" assert info.client_secret is None assert info.token_endpoint_auth_method == "none" # Metadata should still be present assert info.redirect_uris is not None assert info.grant_types is not None def test_no_static_client_info_without_client_id(self): """When no client_id is provided, _static_client_info should be None.""" oauth = OAuth(mcp_url="https://example.com/mcp") assert oauth._static_client_info is None def test_static_client_info_includes_additional_metadata(self): """Additional client metadata should be included in static client info.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="my-client", additional_client_metadata={ "token_endpoint_auth_method": "client_secret_post" }, ) info = oauth._static_client_info assert info is not None assert info.token_endpoint_auth_method == "client_secret_post" class TestStaticClientInitialize: """_initialize should set context.client_info and persist to storage.""" async def test_initialize_sets_context_client_info(self): """_initialize should inject static client info into the auth context.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="my-client", client_secret="my-secret", ) # Mock the parent _initialize since it needs a real server with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): await oauth._initialize() assert oauth.context.client_info is not None assert oauth.context.client_info.client_id == "my-client" assert oauth.context.client_info.client_secret == "my-secret" async def test_initialize_persists_static_client_to_storage(self): """Static client info should be persisted to token storage.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="my-client", client_secret="my-secret", ) with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): await oauth._initialize() # Verify it was persisted to storage stored = await oauth.token_storage_adapter.get_client_info() assert stored is not None assert stored.client_id == "my-client" async def test_initialize_without_static_creds_works(self): """_initialize should not error when no static credentials are provided.""" oauth = OAuth(mcp_url="https://example.com/mcp") with patch.object(OAuth.__bases__[0], "_initialize", return_value=None): # This should not raise AttributeError await oauth._initialize() # context.client_info should be whatever the parent set (None by default) class TestStaticClientRetryBehavior: """Retry-on-stale-credentials should short-circuit for static creds.""" async def test_retry_skipped_with_static_creds(self): """When static creds are rejected, should raise immediately, not retry.""" oauth = OAuth( mcp_url="https://example.com/mcp", client_id="bad-client-id", client_secret="bad-secret", ) # Make the parent auth flow raise ClientNotFoundError async def failing_auth_flow(request): raise ClientNotFoundError("client not found") yield # make it a generator # noqa: E275 with patch.object( OAuth.__bases__[0], "async_auth_flow", side_effect=failing_auth_flow ): flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) with pytest.raises(ClientNotFoundError, match="static client credentials"): await flow.__anext__() async def test_retry_still_works_without_static_creds(self): """Without static creds, the retry behavior should be preserved.""" oauth = OAuth(mcp_url="https://example.com/mcp") call_count = 0 async def auth_flow_with_retry(request): nonlocal call_count call_count += 1 if call_count == 1: raise ClientNotFoundError("client not found") # Second attempt succeeds yield httpx.Request("GET", "https://example.com") with patch.object( OAuth.__bases__[0], "async_auth_flow", side_effect=auth_flow_with_retry ): flow = oauth.async_auth_flow(httpx.Request("GET", "https://example.com")) request = await flow.__anext__() assert request is not None assert call_count == 2 class TestStaticClientE2E: """End-to-end tests with a real OAuth server using pre-registered clients.""" async def test_static_client_with_dcr_disabled(self): """Static client_id should work when the server has DCR disabled.""" port = find_available_port() callback_port = find_available_port() issuer_url = f"http://127.0.0.1:{port}" provider = InMemoryOAuthProvider( base_url=issuer_url, client_registration_options=ClientRegistrationOptions( enabled=False, # DCR disabled valid_scopes=["read", "write"], ), ) server = FastMCP("TestServer", auth=provider) @server.tool def greet(name: str) -> str: return f"Hello, {name}!" # Pre-register a client directly in the provider. # The redirect_uri must match what the OAuth client will use. pre_registered = OAuthClientInformationFull( client_id="pre-registered-client", client_secret="pre-registered-secret", redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], grant_types=["authorization_code", "refresh_token"], response_types=["code"], token_endpoint_auth_method="client_secret_post", scope="read write", ) await provider.register_client(pre_registered) async with run_server_async(server, port=port, transport="http") as url: oauth = HeadlessOAuth( mcp_url=url, client_id="pre-registered-client", client_secret="pre-registered-secret", scopes=["read", "write"], callback_port=callback_port, ) async with Client( transport=StreamableHttpTransport(url), auth=oauth, ) as client: assert await client.ping() tools = await client.list_tools() assert any(t.name == "greet" for t in tools) async def test_static_client_with_dcr_enabled(self): """Static client_id should also work when DCR is enabled (skips DCR).""" port = find_available_port() callback_port = find_available_port() issuer_url = f"http://127.0.0.1:{port}" provider = InMemoryOAuthProvider( base_url=issuer_url, client_registration_options=ClientRegistrationOptions( enabled=True, valid_scopes=["read"], ), ) server = FastMCP("TestServer", auth=provider) @server.tool def add(a: int, b: int) -> int: return a + b pre_registered = OAuthClientInformationFull( client_id="my-app", client_secret="my-secret", redirect_uris=[AnyUrl(f"http://localhost:{callback_port}/callback")], grant_types=["authorization_code", "refresh_token"], response_types=["code"], token_endpoint_auth_method="client_secret_post", scope="read", ) await provider.register_client(pre_registered) async with run_server_async(server, port=port, transport="http") as url: oauth = HeadlessOAuth( mcp_url=url, client_id="my-app", client_secret="my-secret", scopes=["read"], callback_port=callback_port, ) async with Client( transport=StreamableHttpTransport(url), auth=oauth, ) as client: result = await client.call_tool("add", {"a": 3, "b": 4}) assert result.data == 7 ================================================ FILE: tests/client/client/__init__.py ================================================ ================================================ FILE: tests/client/client/test_auth.py ================================================ """Client authentication tests.""" import pytest from mcp.client.auth import OAuthClientProvider from fastmcp.client import Client from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.transports import ( SSETransport, StdioTransport, StreamableHttpTransport, ) class TestAuth: def test_default_auth_is_none(self): client = Client(transport=StreamableHttpTransport("http://localhost:8000")) assert client.transport.auth is None def test_stdio_doesnt_support_auth(self): with pytest.raises(ValueError, match="This transport does not support auth"): Client(transport=StdioTransport("echo", ["hello"]), auth="oauth") def test_oauth_literal_sets_up_oauth_shttp(self): client = Client( transport=StreamableHttpTransport("http://localhost:8000"), auth="oauth" ) assert isinstance(client.transport, StreamableHttpTransport) assert isinstance(client.transport.auth, OAuthClientProvider) def test_oauth_literal_pass_direct_to_transport(self): client = Client( transport=StreamableHttpTransport("http://localhost:8000", auth="oauth"), ) assert isinstance(client.transport, StreamableHttpTransport) assert isinstance(client.transport.auth, OAuthClientProvider) def test_oauth_literal_sets_up_oauth_sse(self): client = Client(transport=SSETransport("http://localhost:8000"), auth="oauth") assert isinstance(client.transport, SSETransport) assert isinstance(client.transport.auth, OAuthClientProvider) def test_oauth_literal_pass_direct_to_transport_sse(self): client = Client(transport=SSETransport("http://localhost:8000", auth="oauth")) assert isinstance(client.transport, SSETransport) assert isinstance(client.transport.auth, OAuthClientProvider) def test_auth_string_sets_up_bearer_auth_shttp(self): client = Client( transport=StreamableHttpTransport("http://localhost:8000"), auth="test_token", ) assert isinstance(client.transport, StreamableHttpTransport) assert isinstance(client.transport.auth, BearerAuth) assert client.transport.auth.token.get_secret_value() == "test_token" def test_auth_string_pass_direct_to_transport_shttp(self): client = Client( transport=StreamableHttpTransport( "http://localhost:8000", auth="test_token" ), ) assert isinstance(client.transport, StreamableHttpTransport) assert isinstance(client.transport.auth, BearerAuth) assert client.transport.auth.token.get_secret_value() == "test_token" def test_auth_string_sets_up_bearer_auth_sse(self): client = Client( transport=SSETransport("http://localhost:8000"), auth="test_token", ) assert isinstance(client.transport, SSETransport) assert isinstance(client.transport.auth, BearerAuth) assert client.transport.auth.token.get_secret_value() == "test_token" def test_auth_string_pass_direct_to_transport_sse(self): client = Client( transport=SSETransport("http://localhost:8000", auth="test_token"), ) assert isinstance(client.transport, SSETransport) assert isinstance(client.transport.auth, BearerAuth) assert client.transport.auth.token.get_secret_value() == "test_token" ================================================ FILE: tests/client/client/test_client.py ================================================ """Core client functionality: tools, resources, prompts.""" import asyncio import contextlib from collections.abc import AsyncIterator from typing import Any, cast import anyio import pytest from mcp import ClientSession, McpError from mcp.types import TextContent from pydantic import AnyUrl import fastmcp from fastmcp.client import Client from fastmcp.client.transports import ( ClientTransport, FastMCPTransport, ) from fastmcp.server.server import FastMCP async def test_list_tools(fastmcp_server): """Test listing tools with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_tools() # Check that our tools are available assert len(result) == 3 assert set(tool.name for tool in result) == {"greet", "add", "sleep"} async def test_list_tools_mcp(fastmcp_server): """Test the list_tools_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_tools_mcp() # Check that we got the raw MCP ListToolsResult object assert hasattr(result, "tools") assert len(result.tools) == 3 assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"} async def test_call_tool(fastmcp_server): """Test calling a tool with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.call_tool("greet", {"name": "World"}) assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Hello, World!" assert result.structured_content == {"result": "Hello, World!"} assert result.data == "Hello, World!" assert result.is_error is False async def test_call_tool_mcp(fastmcp_server): """Test the call_tool_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.call_tool_mcp("greet", {"name": "World"}) # Check that we got the raw MCP CallToolResult object assert hasattr(result, "content") assert hasattr(result, "isError") assert result.isError is False # The content is a list, so we'll check the first element # by properly accessing it content = result.content assert len(content) > 0 first_content = content[0] content_str = str(first_content) assert "Hello, World!" in content_str async def test_call_tool_with_meta(): """Test that meta parameter is properly passed from client to server.""" server = FastMCP("MetaTestServer") # Create a tool that accesses the meta from the request context @server.tool def check_meta() -> dict[str, Any]: """A tool that returns the meta from the request context.""" from fastmcp.server.dependencies import get_context context = get_context() assert context.request_context is not None meta = context.request_context.meta # Return the metadata as a dict if meta is not None: return { "has_meta": True, "user_id": getattr(meta, "user_id", None), "trace_id": getattr(meta, "trace_id", None), } return {"has_meta": False} client = Client(transport=FastMCPTransport(server)) async with client: # Test with meta parameter - verify the server receives it test_meta = {"user_id": "test-123", "trace_id": "abc-def"} result = await client.call_tool("check_meta", {}, meta=test_meta) assert result.data["has_meta"] is True assert result.data["user_id"] == "test-123" assert result.data["trace_id"] == "abc-def" # Test without meta parameter - verify fields are not present result_no_meta = await client.call_tool("check_meta", {}) # When meta is not provided, custom fields should not be present assert result_no_meta.data.get("user_id") is None assert result_no_meta.data.get("trace_id") is None async def test_list_resources(fastmcp_server): """Test listing resources with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_resources() # Check that our resource is available assert len(result) == 1 assert str(result[0].uri) == "data://users" async def test_list_resources_mcp(fastmcp_server): """Test the list_resources_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_resources_mcp() # Check that we got the raw MCP ListResourcesResult object assert hasattr(result, "resources") assert len(result.resources) == 1 assert str(result.resources[0].uri) == "data://users" async def test_list_prompts(fastmcp_server): """Test listing prompts with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_prompts() # Check that our prompt is available assert len(result) == 1 assert result[0].name == "welcome" async def test_list_prompts_mcp(fastmcp_server): """Test the list_prompts_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_prompts_mcp() # Check that we got the raw MCP ListPromptsResult object assert hasattr(result, "prompts") assert len(result.prompts) == 1 assert result.prompts[0].name == "welcome" async def test_get_prompt(fastmcp_server): """Test getting a prompt with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.get_prompt("welcome", {"name": "Developer"}) # The result should contain our welcome message assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" assert result.description == "Example greeting prompt." async def test_get_prompt_mcp(fastmcp_server): """Test the get_prompt_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.get_prompt_mcp("welcome", {"name": "Developer"}) # The result should contain our welcome message assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" assert result.description == "Example greeting prompt." async def test_client_serializes_all_non_string_arguments(): """Test that client always serializes non-string arguments to JSON, regardless of server types.""" server = FastMCP("TestServer") @server.prompt def echo_args(arg1: str, arg2: str, arg3: str) -> str: """Server accepts all string args but client sends mixed types.""" return f"arg1: {arg1}, arg2: {arg2}, arg3: {arg3}" client = Client(transport=FastMCPTransport(server)) async with client: result = await client.get_prompt( "echo_args", { "arg1": "hello", # string - should pass through "arg2": [1, 2, 3], # list - should be JSON serialized "arg3": {"key": "value"}, # dict - should be JSON serialized }, ) assert isinstance(result.messages[0].content, TextContent) content = result.messages[0].content.text assert "arg1: hello" in content assert "arg2: [1,2,3]" in content # JSON serialized list assert 'arg3: {"key":"value"}' in content # JSON serialized dict async def test_client_server_type_conversion_integration(): """Test that client serialization works with server-side type conversion.""" server = FastMCP("TestServer") @server.prompt def typed_prompt(numbers: list[int], config: dict[str, str]) -> str: """Server expects typed args - will convert from JSON strings.""" return f"Got {len(numbers)} numbers and {len(config)} config items" client = Client(transport=FastMCPTransport(server)) async with client: result = await client.get_prompt( "typed_prompt", {"numbers": [1, 2, 3, 4], "config": {"theme": "dark", "lang": "en"}}, ) assert isinstance(result.messages[0].content, TextContent) content = result.messages[0].content.text assert "Got 4 numbers and 2 config items" in content async def test_client_serialization_error(): """Test client error when object cannot be serialized.""" import pydantic_core server = FastMCP("TestServer") @server.prompt def any_prompt(data: str) -> str: return f"Got: {data}" # Create an unserializable object class UnserializableClass: def __init__(self): self.func = lambda x: x # functions can't be JSON serialized client = Client(transport=FastMCPTransport(server)) async with client: with pytest.raises( pydantic_core.PydanticSerializationError, match="Unable to serialize" ): await client.get_prompt("any_prompt", {"data": UnserializableClass()}) async def test_server_deserialization_error(): """Test server error when JSON string cannot be converted to expected type.""" server = FastMCP("TestServer") @server.prompt def strict_typed_prompt(numbers: list[int]) -> str: """Expects list of integers but will receive invalid JSON.""" return f"Got {len(numbers)} numbers" client = Client(transport=FastMCPTransport(server)) async with client: with pytest.raises(McpError, match="Error rendering prompt"): await client.get_prompt( "strict_typed_prompt", { "numbers": "not valid json" # This will fail server-side conversion }, ) async def test_read_resource_invalid_uri(fastmcp_server): """Test reading a resource with an invalid URI.""" client = Client(transport=FastMCPTransport(fastmcp_server)) with pytest.raises(ValueError, match="Provided resource URI is invalid"): await client.read_resource("invalid_uri") async def test_read_resource(fastmcp_server): """Test reading a resource with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # Use the URI from the resource we know exists in our server uri = cast( AnyUrl, "data://users" ) # Use cast for type hint only, the URI is valid result = await client.read_resource(uri) # The contents should include our user list contents_str = str(result[0]) assert "Alice" in contents_str assert "Bob" in contents_str assert "Charlie" in contents_str async def test_read_resource_mcp(fastmcp_server): """Test the read_resource_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # Use the URI from the resource we know exists in our server uri = cast( AnyUrl, "data://users" ) # Use cast for type hint only, the URI is valid result = await client.read_resource_mcp(uri) # Check that we got the raw MCP ReadResourceResult object assert hasattr(result, "contents") assert len(result.contents) > 0 contents_str = str(result.contents[0]) assert "Alice" in contents_str assert "Bob" in contents_str assert "Charlie" in contents_str async def test_client_connection(fastmcp_server): """Test that connect is idempotent.""" client = Client(transport=FastMCPTransport(fastmcp_server)) # Connect idempotently async with client: assert client.is_connected() # Make a request to ensure connection is working await client.ping() assert not client.is_connected() async def test_initialize_called_once(fastmcp_server): """Test that initialization is called once and sets initialize_result.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # Verify that initialization succeeded by checking initialize_result assert client.initialize_result is not None assert client.initialize_result.serverInfo is not None async def test_initialize_result_connected(fastmcp_server): """Test that initialize_result returns the correct result when connected.""" client = Client(transport=FastMCPTransport(fastmcp_server)) # Initialize result should be None before connection assert client.initialize_result is None async with client: # Once connected, initialize_result should be available result = client.initialize_result # Verify the initialize result has expected properties assert hasattr(result, "serverInfo") assert result.serverInfo.name == "TestServer" assert result.serverInfo.version is not None async def test_initialize_result_disconnected(fastmcp_server): """Test that initialize_result is None when not connected.""" client = Client(transport=FastMCPTransport(fastmcp_server)) # Initialize result should be None before connection assert client.initialize_result is None # Connect and then disconnect async with client: assert client.is_connected() # After disconnection, initialize_result should be None again assert not client.is_connected() assert client.initialize_result is None async def test_server_info_custom_version(): """Test that custom version is properly set in serverInfo.""" # Test with custom version server_with_version = FastMCP("CustomVersionServer", version="1.2.3") client = Client(transport=FastMCPTransport(server_with_version)) async with client: result = client.initialize_result assert result is not None assert result.serverInfo.name == "CustomVersionServer" assert result.serverInfo.version == "1.2.3" # Test without version (backward compatibility) server_without_version = FastMCP("DefaultVersionServer") client = Client(transport=FastMCPTransport(server_without_version)) async with client: result = client.initialize_result assert result is not None assert result.serverInfo.name == "DefaultVersionServer" # Should fall back to FastMCP version assert result.serverInfo.version == fastmcp.__version__ class _DelayedConnectTransport(ClientTransport): def __init__( self, inner: ClientTransport, connect_started: anyio.Event, allow_connect: anyio.Event, ) -> None: self._inner = inner self._connect_started = connect_started self._allow_connect = allow_connect @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Any ) -> AsyncIterator[ClientSession]: self._connect_started.set() await self._allow_connect.wait() async with self._inner.connect_session(**session_kwargs) as session: yield session async def close(self) -> None: await self._inner.close() async def test_client_nested_context_manager(fastmcp_server): """Test that the client connects and disconnects once in nested context manager.""" client = Client(fastmcp_server) # Before connection assert not client.is_connected() assert client._session_state.session is None # During connection async with client: assert client.is_connected() assert client._session_state.session is not None session = client._session_state.session # Reuse the same session async with client: assert client.is_connected() assert client._session_state.session is session # Reuse the same session async with client: assert client.is_connected() assert client._session_state.session is session # After connection assert not client.is_connected() assert client._session_state.session is None async def test_client_context_entry_cancelled_starter_cleans_up(fastmcp_server): connect_started = anyio.Event() allow_connect = anyio.Event() client = Client( transport=_DelayedConnectTransport( FastMCPTransport(fastmcp_server), connect_started=connect_started, allow_connect=allow_connect, ) ) async def enter_and_never_reach_body() -> None: async with client: pytest.fail( "Context body should not be reached when __aenter__ is cancelled" ) task = asyncio.create_task(enter_and_never_reach_body()) await connect_started.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task # Connection startup was cancelled; session state should be fully reset. assert client._session_state.session_task is None assert client._session_state.session is None assert client._session_state.nesting_counter == 0 # A future connection attempt should work normally. allow_connect.set() async with client: tools = await client.list_tools() assert len(tools) == 3 async def test_cancelled_context_entry_waiter_does_not_close_active_session( fastmcp_server, ): connect_started = anyio.Event() allow_connect = anyio.Event() client = Client( transport=_DelayedConnectTransport( FastMCPTransport(fastmcp_server), connect_started=connect_started, allow_connect=allow_connect, ) ) b_done = asyncio.Event() b_started = asyncio.Event() async def task_a() -> int: async with client: await b_done.wait() tools = await client.list_tools() return len(tools) async def task_b() -> None: b_started.set() async with client: pytest.fail("This context should never be entered due to cancellation") a = asyncio.create_task(task_a()) await connect_started.wait() b = asyncio.create_task(task_b()) await b_started.wait() await asyncio.sleep(0) # let task_b attempt to acquire the client lock b.cancel() allow_connect.set() with pytest.raises(asyncio.CancelledError): await b # task_b is fully cancelled; allow task_a to exercise the connected session. b_done.set() assert await a == 3 async def test_concurrent_client_context_managers(): """ Test that concurrent client usage doesn't cause cross-task cancel scope issues. https://github.com/PrefectHQ/fastmcp/pull/643 """ # Create a simple server server = FastMCP("Test Server") @server.tool def echo(text: str) -> str: """Echo tool""" return text # Create client client = Client(server) # Track results results = {} errors = [] async def use_client(task_id: str, delay: float = 0): """Use the client with a small delay to ensure overlap""" try: async with client: # Add a small delay to ensure contexts overlap await asyncio.sleep(delay) # Make an actual call to exercise the session tools = await client.list_tools() results[task_id] = len(tools) except Exception as e: errors.append((task_id, str(e))) # Run multiple tasks concurrently # The key is having them enter and exit the context at different times await asyncio.gather( use_client("task1", 0.0), use_client("task2", 0.01), # Slight delay to ensure overlap use_client("task3", 0.02), return_exceptions=False, ) assert len(errors) == 0, f"Errors occurred: {errors}" assert len(results) == 3 assert all(count == 1 for count in results.values()) # All should see 1 tool async def test_resource_template(fastmcp_server): """Test using a resource template with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # First, list templates result = await client.list_resource_templates() # Check that our template is available assert len(result) == 1 assert "data://user/{user_id}" in result[0].uriTemplate # Now use the template with a specific user_id uri = cast(AnyUrl, "data://user/123") result = await client.read_resource(uri) # Check the content matches what we expect for the provided user_id content_str = str(result[0]) assert '"id":"123"' in content_str assert '"name":"User 123"' in content_str assert '"active":true' in content_str async def test_list_resource_templates_mcp(fastmcp_server): """Test the list_resource_templates_mcp method that returns raw MCP protocol objects.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: result = await client.list_resource_templates_mcp() # Check that we got the raw MCP ListResourceTemplatesResult object assert hasattr(result, "resourceTemplates") assert len(result.resourceTemplates) == 1 assert "data://user/{user_id}" in result.resourceTemplates[0].uriTemplate async def test_mcp_resource_generation(fastmcp_server): """Test that resources are properly generated in MCP format.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: resources = await client.list_resources() assert len(resources) == 1 resource = resources[0] # Verify resource has correct MCP format assert hasattr(resource, "uri") assert hasattr(resource, "name") assert hasattr(resource, "description") assert str(resource.uri) == "data://users" async def test_mcp_template_generation(fastmcp_server): """Test that templates are properly generated in MCP format.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: templates = await client.list_resource_templates() assert len(templates) == 1 template = templates[0] # Verify template has correct MCP format assert hasattr(template, "uriTemplate") assert hasattr(template, "name") assert hasattr(template, "description") assert "data://user/{user_id}" in template.uriTemplate async def test_template_access_via_client(fastmcp_server): """Test that templates can be accessed through a client.""" client = Client(transport=FastMCPTransport(fastmcp_server)) async with client: # Verify template works correctly when accessed uri = cast(AnyUrl, "data://user/456") result = await client.read_resource(uri) content_str = str(result[0]) assert '"id":"456"' in content_str async def test_tagged_resource_metadata(tagged_resources_server): """Test that resource metadata is preserved in MCP format.""" client = Client(transport=FastMCPTransport(tagged_resources_server)) async with client: resources = await client.list_resources() assert len(resources) == 1 resource = resources[0] # Verify resource metadata is preserved assert str(resource.uri) == "data://tagged" assert resource.description == "A tagged resource" async def test_tagged_template_metadata(tagged_resources_server): """Test that template metadata is preserved in MCP format.""" client = Client(transport=FastMCPTransport(tagged_resources_server)) async with client: templates = await client.list_resource_templates() assert len(templates) == 1 template = templates[0] # Verify template metadata is preserved assert "template://{id}" in template.uriTemplate assert template.description == "A tagged template" async def test_tagged_template_functionality(tagged_resources_server): """Test that tagged templates function correctly when accessed.""" client = Client(transport=FastMCPTransport(tagged_resources_server)) async with client: # Verify template functionality uri = cast(AnyUrl, "template://123") result = await client.read_resource(uri) content_str = str(result[0]) assert '"id":"123"' in content_str assert '"type":"template_data"' in content_str async def test_client_unwraps_result_using_meta(): """Client should unwrap wrapped results using _meta flag.""" server = FastMCP() @server.tool def list_tool() -> list[int]: return [1, 2, 3] client = Client(transport=FastMCPTransport(server)) async with client: result = await client.call_tool("list_tool", {}) assert result.structured_content == {"result": [1, 2, 3]} assert result.data == [1, 2, 3] assert result.meta == {"fastmcp": {"wrap_result": True}} async def test_client_does_not_unwrap_dict_result(): """Client should not unwrap dict results that are not wrapped.""" server = FastMCP() @server.tool def dict_tool() -> dict[str, int]: return {"a": 1} client = Client(transport=FastMCPTransport(server)) async with client: result = await client.call_tool("dict_tool", {}) assert result.structured_content == {"a": 1} assert result.data == {"a": 1} assert result.meta is None ================================================ FILE: tests/client/client/test_error_handling.py ================================================ """Client error handling tests.""" import pytest from mcp.types import TextContent from pydantic import AnyUrl from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.exceptions import ResourceError, ToolError from fastmcp.server.server import FastMCP class TestErrorHandling: async def test_general_tool_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.tool def error_tool(): raise ValueError("This is a test error (abc)") client = Client(transport=FastMCPTransport(mcp)) async with client: result = await client.call_tool_mcp("error_tool", {}) assert result.isError assert isinstance(result.content[0], TextContent) assert "test error" in result.content[0].text assert "abc" in result.content[0].text async def test_general_tool_exceptions_are_masked_when_enabled(self): mcp = FastMCP("TestServer", mask_error_details=True) @mcp.tool def error_tool(): raise ValueError("This is a test error (abc)") client = Client(transport=FastMCPTransport(mcp)) async with client: result = await client.call_tool_mcp("error_tool", {}) assert result.isError assert isinstance(result.content[0], TextContent) assert "test error" not in result.content[0].text assert "abc" not in result.content[0].text async def test_validation_errors_are_not_masked_when_enabled(self): mcp = FastMCP("TestServer", mask_error_details=True) @mcp.tool def validated_tool(x: int) -> int: return x async with Client(transport=FastMCPTransport(mcp)) as client: result = await client.call_tool_mcp("validated_tool", {"x": "abc"}) assert result.isError # Pydantic validation error message should NOT be masked assert isinstance(result.content[0], TextContent) assert "Input should be a valid integer" in result.content[0].text async def test_specific_tool_errors_are_sent_to_client(self): mcp = FastMCP("TestServer") @mcp.tool def custom_error_tool(): raise ToolError("This is a test error (abc)") client = Client(transport=FastMCPTransport(mcp)) async with client: result = await client.call_tool_mcp("custom_error_tool", {}) assert result.isError assert isinstance(result.content[0], TextContent) assert "test error" in result.content[0].text assert "abc" in result.content[0].text async def test_general_resource_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.resource(uri="exception://resource") async def exception_resource(): raise ValueError("This is an internal error (sensitive)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource")) assert "Error reading resource" in str(excinfo.value) assert "sensitive" in str(excinfo.value) assert "internal error" in str(excinfo.value) async def test_general_resource_exceptions_are_masked_when_enabled(self): mcp = FastMCP("TestServer", mask_error_details=True) @mcp.resource(uri="exception://resource") async def exception_resource(): raise ValueError("This is an internal error (sensitive)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource")) assert "Error reading resource" in str(excinfo.value) assert "sensitive" not in str(excinfo.value) assert "internal error" not in str(excinfo.value) async def test_resource_errors_are_sent_to_client(self): mcp = FastMCP("TestServer") @mcp.resource(uri="error://resource") async def error_resource(): raise ResourceError("This is a resource error (xyz)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("error://resource")) assert "This is a resource error (xyz)" in str(excinfo.value) async def test_general_template_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.resource(uri="exception://resource/{id}") async def exception_resource(id: str): raise ValueError("This is an internal error (sensitive)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource/123")) assert "Error reading resource" in str(excinfo.value) assert "sensitive" in str(excinfo.value) assert "internal error" in str(excinfo.value) async def test_general_template_exceptions_are_masked_when_enabled(self): mcp = FastMCP("TestServer", mask_error_details=True) @mcp.resource(uri="exception://resource/{id}") async def exception_resource(id: str): raise ValueError("This is an internal error (sensitive)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource/123")) assert "Error reading resource" in str(excinfo.value) assert "sensitive" not in str(excinfo.value) assert "internal error" not in str(excinfo.value) async def test_template_errors_are_sent_to_client(self): mcp = FastMCP("TestServer") @mcp.resource(uri="error://resource/{id}") async def error_resource(id: str): raise ResourceError("This is a resource error (xyz)") client = Client(transport=FastMCPTransport(mcp)) async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("error://resource/123")) assert "This is a resource error (xyz)" in str(excinfo.value) ================================================ FILE: tests/client/client/test_initialize.py ================================================ """Client initialization tests.""" from fastmcp.client import Client from fastmcp.server.server import FastMCP class TestInitialize: """Tests for client initialization behavior.""" async def test_auto_initialize_default(self, fastmcp_server): """Test that auto_initialize=True is the default and works automatically.""" client = Client(fastmcp_server) async with client: # Should be automatically initialized assert client.initialize_result is not None assert client.initialize_result.serverInfo.name == "TestServer" assert client.initialize_result.instructions is None async def test_auto_initialize_explicit_true(self, fastmcp_server): """Test explicit auto_initialize=True.""" client = Client(fastmcp_server, auto_initialize=True) async with client: assert client.initialize_result is not None assert client.initialize_result.serverInfo.name == "TestServer" async def test_auto_initialize_false(self, fastmcp_server): """Test that auto_initialize=False prevents automatic initialization.""" client = Client(fastmcp_server, auto_initialize=False) async with client: # Should not be automatically initialized assert client.initialize_result is None async def test_manual_initialize(self, fastmcp_server): """Test manual initialization when auto_initialize=False.""" client = Client(fastmcp_server, auto_initialize=False) async with client: # Manually initialize result = await client.initialize() assert result is not None assert result.serverInfo.name == "TestServer" assert client.initialize_result is result async def test_initialize_idempotent(self, fastmcp_server): """Test that calling initialize() multiple times returns cached result.""" client = Client(fastmcp_server, auto_initialize=False) async with client: result1 = await client.initialize() result2 = await client.initialize() result3 = await client.initialize() # All should return the same cached result assert result1 is result2 assert result2 is result3 async def test_initialize_with_instructions(self): """Test that server instructions are available via initialize_result.""" server = FastMCP("InstructionsServer", instructions="Use the greet tool!") @server.tool def greet(name: str) -> str: return f"Hello, {name}!" client = Client(server) async with client: result = client.initialize_result assert result is not None assert result.instructions == "Use the greet tool!" async def test_initialize_timeout_custom(self, fastmcp_server): """Test custom timeout for initialize().""" client = Client(fastmcp_server, auto_initialize=False) async with client: # Should succeed with reasonable timeout result = await client.initialize(timeout=5.0) assert result is not None async def test_initialize_property_after_auto_init(self, fastmcp_server): """Test accessing initialize_result property after auto-initialization.""" client = Client(fastmcp_server, auto_initialize=True) async with client: # Access via property result = client.initialize_result assert result is not None assert result.serverInfo.name == "TestServer" # Call method - should return cached result2 = await client.initialize() assert result is result2 async def test_initialize_property_before_connect(self, fastmcp_server): """Test that initialize_result property is None before connection.""" client = Client(fastmcp_server) # Not yet connected assert client.initialize_result is None async def test_manual_initialize_can_call_tools(self, fastmcp_server): """Test that manually initialized client can call tools.""" client = Client(fastmcp_server, auto_initialize=False) async with client: await client.initialize() # Should be able to call tools after manual initialization result = await client.call_tool("greet", {"name": "World"}) assert "Hello, World!" in str(result.content) ================================================ FILE: tests/client/client/test_session.py ================================================ """Client session and task error propagation tests.""" import asyncio import pytest from fastmcp.client import Client class TestSessionTaskErrorPropagation: """Tests for ensuring session task errors propagate to client calls. Regression tests for https://github.com/PrefectHQ/fastmcp/issues/2595 where the client would hang indefinitely when the session task failed (e.g., due to HTTP 4xx/5xx errors) instead of raising an exception. """ async def test_session_task_error_propagates_to_call(self, fastmcp_server): """Test that errors in session task propagate to pending client calls. When the session task fails (e.g., due to HTTP errors), pending client operations should immediately receive the exception rather than hanging indefinitely. """ client = Client(fastmcp_server) async with client: original_task = client._session_state.session_task assert original_task is not None async def never_complete(): """A coroutine that will never complete normally.""" await asyncio.sleep(1000) async def failing_session(): """Simulates a session task that raises an error.""" raise ValueError("Simulated HTTP error") # Replace session_task with one that will fail client._session_state.session_task = asyncio.create_task(failing_session()) # The monitoring should detect the session task failure with pytest.raises(ValueError, match="Simulated HTTP error"): await client._await_with_session_monitoring(never_complete()) # Restore original task for cleanup client._session_state.session_task = original_task async def test_session_task_already_done_with_error(self, fastmcp_server): """Test that if session task is already done with error, calls fail immediately.""" client = Client(fastmcp_server) async with client: original_task = client._session_state.session_task async def raise_error(): raise ValueError("Session failed") # Replace session_task with one that has already failed failed_task = asyncio.create_task(raise_error()) try: await failed_task except ValueError: pass # Expected client._session_state.session_task = failed_task # New calls should fail immediately with the original error async def simple_coro(): return "should not reach" with pytest.raises(ValueError, match="Session failed"): await client._await_with_session_monitoring(simple_coro()) # Restore original task for cleanup client._session_state.session_task = original_task async def test_session_task_already_done_no_error_raises_runtime_error( self, fastmcp_server ): """Test that if session task completes without error, raises RuntimeError.""" client = Client(fastmcp_server) async with client: original_task = client._session_state.session_task # Create a task that completes normally (unexpected for session task) completed_task = asyncio.create_task(asyncio.sleep(0)) await completed_task client._session_state.session_task = completed_task async def simple_coro(): return "should not reach" with pytest.raises( RuntimeError, match="Session task completed unexpectedly" ): await client._await_with_session_monitoring(simple_coro()) # Restore original task for cleanup client._session_state.session_task = original_task async def test_normal_operation_unaffected(self, fastmcp_server): """Test that normal operation is unaffected by the monitoring.""" client = Client(fastmcp_server) async with client: # These should all work normally tools = await client.list_tools() assert len(tools) > 0 result = await client.call_tool("greet", {"name": "Test"}) assert "Hello, Test!" in str(result.content) resources = await client.list_resources() assert len(resources) > 0 prompts = await client.list_prompts() assert len(prompts) > 0 async def test_no_session_task_falls_back_to_direct_await(self, fastmcp_server): """Test that when no session task exists, it falls back to direct await.""" client = Client(fastmcp_server) async with client: # Temporarily remove session_task to test fallback original_task = client._session_state.session_task client._session_state.session_task = None # Should work via direct await async def simple_coro(): return "success" result = await client._await_with_session_monitoring(simple_coro()) assert result == "success" # Restore for cleanup client._session_state.session_task = original_task ================================================ FILE: tests/client/client/test_timeout.py ================================================ """Client timeout tests.""" import pytest from mcp import McpError from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.server.server import FastMCP class TestTimeout: async def test_timeout(self, fastmcp_server: FastMCP): async with Client( transport=FastMCPTransport(fastmcp_server), timeout=0.05 ) as client: with pytest.raises( McpError, match="Timed out while waiting for response to ClientRequest. Waited 0.05 seconds", ): await client.call_tool("sleep", {"seconds": 0.1}) async def test_timeout_tool_call(self, fastmcp_server: FastMCP): async with Client(transport=FastMCPTransport(fastmcp_server)) as client: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) async def test_timeout_tool_call_overrides_client_timeout( self, fastmcp_server: FastMCP ): async with Client( transport=FastMCPTransport(fastmcp_server), timeout=2, ) as client: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) async def test_timeout_tool_call_overrides_client_timeout_even_if_lower( self, fastmcp_server: FastMCP ): async with Client( transport=FastMCPTransport(fastmcp_server), timeout=0.1, ) as client: await client.call_tool("sleep", {"seconds": 0.5}, timeout=2) ================================================ FILE: tests/client/client/test_transport.py ================================================ """Client transport inference tests.""" import pytest from fastmcp.client.transports import ( FastMCPTransport, MCPConfigTransport, SSETransport, StdioTransport, StreamableHttpTransport, infer_transport, ) class TestInferTransport: """Tests for the infer_transport function.""" @pytest.mark.parametrize( "url", [ "http://example.com/api/sse/stream", "https://localhost:8080/mcp/sse/endpoint", "http://example.com/api/sse", "http://example.com/api/sse/", "https://localhost:8080/mcp/sse/", "http://example.com/api/sse?param=value", "https://localhost:8080/mcp/sse/?param=value", "https://localhost:8000/mcp/sse?x=1&y=2", ], ids=[ "path_with_sse_directory", "path_with_sse_subdirectory", "path_ending_with_sse", "path_ending_with_sse_slash", "path_ending_with_sse_https", "path_with_sse_and_query_params", "path_with_sse_slash_and_query_params", "path_with_sse_and_ampersand_param", ], ) def test_url_returns_sse_transport(self, url): """Test that URLs with /sse/ pattern return SSETransport.""" assert isinstance(infer_transport(url), SSETransport) @pytest.mark.parametrize( "url", [ "http://example.com/api", "https://localhost:8080/mcp/", "http://example.com/asset/image.jpg", "https://localhost:8080/sservice/endpoint", "https://example.com/assets/file", ], ids=[ "regular_http_url", "regular_https_url", "url_with_unrelated_path", "url_with_sservice_in_path", "url_with_assets_in_path", ], ) def test_url_returns_streamable_http_transport(self, url): """Test that URLs without /sse/ pattern return StreamableHttpTransport.""" assert isinstance(infer_transport(url), StreamableHttpTransport) def test_infer_remote_transport_from_config(self): config = { "mcpServers": { "test_server": { "url": "http://localhost:8000/sse/", "headers": {"Authorization": "Bearer 123"}, }, } } transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) assert isinstance(transport.transport, SSETransport) assert transport.transport.url == "http://localhost:8000/sse/" assert transport.transport.headers == {"Authorization": "Bearer 123"} def test_infer_local_transport_from_config(self): config = { "mcpServers": { "test_server": { "command": "echo", "args": ["hello"], }, } } transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) assert isinstance(transport.transport, StdioTransport) assert transport.transport.command == "echo" assert transport.transport.args == ["hello"] def test_config_with_no_servers(self): """Test that an empty MCPConfig raises a ValueError.""" config = {"mcpServers": {}} with pytest.raises(ValueError, match="No MCP servers defined in the config"): infer_transport(config) def test_mcpconfigtransport_with_no_servers(self): """Test that MCPConfigTransport raises a ValueError when initialized with an empty config.""" config = {"mcpServers": {}} with pytest.raises(ValueError, match="No MCP servers defined in the config"): MCPConfigTransport(config=config) def test_infer_composite_client(self): config = { "mcpServers": { "local": { "command": "echo", "args": ["hello"], }, "remote": { "url": "http://localhost:8000/sse/", "headers": {"Authorization": "Bearer 123"}, }, } } transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) # Multi-server configs create composite server at connect time assert len(transport.config.mcpServers) == 2 def test_infer_fastmcp_server(self, fastmcp_server): """FastMCP server instances should infer to FastMCPTransport.""" transport = infer_transport(fastmcp_server) assert isinstance(transport, FastMCPTransport) def test_infer_fastmcp_v1_server(self): """FastMCP 1.0 server instances should infer to FastMCPTransport.""" from mcp.server.fastmcp import FastMCP as FastMCP1 server = FastMCP1() transport = infer_transport(server) assert isinstance(transport, FastMCPTransport) ================================================ FILE: tests/client/sampling/__init__.py ================================================ ================================================ FILE: tests/client/sampling/handlers/__init__.py ================================================ ================================================ FILE: tests/client/sampling/handlers/test_anthropic_handler.py ================================================ from typing import Any from unittest.mock import MagicMock import pytest from anthropic import AsyncAnthropic from anthropic.types import Message, TextBlock, ToolUseBlock, Usage from mcp.types import ( AudioContent, CreateMessageResult, CreateMessageResultWithTools, ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, ToolResultContent, ToolUseContent, ) from fastmcp.client.sampling.handlers.anthropic import ( AnthropicSamplingHandler, _image_content_to_anthropic_block, ) def test_convert_sampling_messages_to_anthropic_messages(): msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=TextContent(type="text", text="hello") ), SamplingMessage( role="assistant", content=TextContent(type="text", text="ok") ), ], ) assert msgs == [ {"role": "user", "content": "hello"}, {"role": "assistant", "content": "ok"}, ] def test_image_content_to_anthropic_block(): block = _image_content_to_anthropic_block( ImageContent(type="image", data="YWJj", mimeType="image/png") ) assert block == { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "YWJj", }, } def test_image_content_unsupported_mime_type_raises(): with pytest.raises(ValueError, match="Unsupported image MIME type"): _image_content_to_anthropic_block( ImageContent(type="image", data="YWJj", mimeType="image/bmp") ) def test_convert_single_image_content_to_anthropic_message(): msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=ImageContent(type="image", data="YWJj", mimeType="image/png"), ) ], ) assert len(msgs) == 1 assert msgs[0] == { "role": "user", "content": [ { "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "YWJj", }, } ], } def test_convert_single_audio_content_raises(): with pytest.raises(ValueError, match="AudioContent is not supported"): AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=AudioContent( type="audio", data="YWJj", mimeType="audio/wav" ), ) ], ) def test_convert_list_content_with_image_and_text(): msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="Describe this image"), ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), ], ) ], ) assert len(msgs) == 1 assert msgs[0] == { "role": "user", "content": [ {"type": "text", "text": "Describe this image"}, { "type": "image", "source": { "type": "base64", "media_type": "image/jpeg", "data": "YWJj", }, }, ], } def test_convert_list_content_with_audio_raises(): with pytest.raises(ValueError, match="AudioContent is not supported"): AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="Listen to this"), AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), ], ) ], ) def test_convert_image_in_assistant_message_raises(): with pytest.raises(ValueError, match="ImageContent is only supported in user"): AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="assistant", content=ImageContent( type="image", data="YWJj", mimeType="image/png" ), ) ], ) def test_convert_list_image_in_assistant_message_raises(): with pytest.raises(ValueError, match="ImageContent is only supported in user"): AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="assistant", content=[ TextContent(type="text", text="Here's the image"), ImageContent(type="image", data="YWJj", mimeType="image/png"), ], ) ], ) @pytest.mark.parametrize( "prefs,expected", [ ("claude-3-5-sonnet-20241022", "claude-3-5-sonnet-20241022"), ( ModelPreferences(hints=[ModelHint(name="claude-3-5-sonnet-20241022")]), "claude-3-5-sonnet-20241022", ), (["claude-3-5-sonnet-20241022", "other"], "claude-3-5-sonnet-20241022"), (None, "fallback-model"), (["unknown-model"], "fallback-model"), ], ) def test_select_model_from_preferences(prefs: Any, expected: str) -> None: mock_client = MagicMock(spec=AsyncAnthropic) handler = AnthropicSamplingHandler( default_model="fallback-model", client=mock_client ) assert handler._select_model_from_preferences(prefs) == expected def test_message_to_create_message_result(): mock_client = MagicMock(spec=AsyncAnthropic) handler = AnthropicSamplingHandler( default_model="fallback-model", client=mock_client ) message = Message( id="msg_123", type="message", role="assistant", content=[TextBlock(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM")], model="claude-3-5-sonnet-20241022", stop_reason="end_turn", stop_sequence=None, usage=Usage(input_tokens=10, output_tokens=20), ) result: CreateMessageResult = handler._message_to_create_message_result(message) assert result == CreateMessageResult( content=TextContent(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM"), role="assistant", model="claude-3-5-sonnet-20241022", ) def test_message_to_result_with_tools(): message = Message( id="msg_123", type="message", role="assistant", content=[ TextBlock(type="text", text="I'll help you with that."), ToolUseBlock( type="tool_use", id="toolu_123", name="get_weather", input={"location": "San Francisco"}, ), ], model="claude-3-5-sonnet-20241022", stop_reason="tool_use", stop_sequence=None, usage=Usage(input_tokens=10, output_tokens=20), ) result: CreateMessageResultWithTools = ( AnthropicSamplingHandler._message_to_result_with_tools(message) ) assert result.role == "assistant" assert result.model == "claude-3-5-sonnet-20241022" assert result.stopReason == "toolUse" content = result.content_as_list assert len(content) == 2 assert content[0] == TextContent(type="text", text="I'll help you with that.") assert content[1] == ToolUseContent( type="tool_use", id="toolu_123", name="get_weather", input={"location": "San Francisco"}, ) def test_convert_tool_choice_auto(): result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="auto") ) assert result is not None assert result["type"] == "auto" def test_convert_tool_choice_required(): result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="required") ) assert result is not None assert result["type"] == "any" def test_convert_tool_choice_none(): result = AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="none") ) # Anthropic doesn't have "none", returns None to signal tools should be omitted assert result is None def test_convert_tool_choice_unknown_raises(): with pytest.raises(ValueError, match="Unsupported tool_choice mode"): AnthropicSamplingHandler._convert_tool_choice_to_anthropic( MagicMock(mode="unknown") ) def test_convert_tools_to_anthropic(): from mcp.types import Tool tools = [ Tool( name="get_weather", description="Get the current weather", inputSchema={ "type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"], }, ) ] result = AnthropicSamplingHandler._convert_tools_to_anthropic(tools) assert len(result) == 1 assert result[0]["name"] == "get_weather" assert result[0]["description"] == "Get the current weather" assert result[0]["input_schema"] == { "type": "object", "properties": {"location": {"type": "string"}}, "required": ["location"], } def test_convert_messages_with_tool_use_content(): """Test converting messages that include tool use content from assistant.""" msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="assistant", content=ToolUseContent( type="tool_use", id="toolu_123", name="get_weather", input={"location": "NYC"}, ), ), ], ) assert len(msgs) == 1 assert msgs[0]["role"] == "assistant" assert msgs[0]["content"] == [ { "type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {"location": "NYC"}, } ] def test_convert_messages_with_tool_result_content(): """Test converting messages that include tool result content from user.""" msgs = AnthropicSamplingHandler._convert_to_anthropic_messages( messages=[ SamplingMessage( role="user", content=ToolResultContent( type="tool_result", toolUseId="toolu_123", content=[TextContent(type="text", text="72F and sunny")], ), ), ], ) assert len(msgs) == 1 assert msgs[0]["role"] == "user" assert msgs[0]["content"] == [ { "type": "tool_result", "tool_use_id": "toolu_123", "content": "72F and sunny", "is_error": False, } ] ================================================ FILE: tests/client/sampling/handlers/test_google_genai_handler.py ================================================ import base64 from unittest.mock import MagicMock import pytest try: from google.genai import Client as GoogleGenaiClient from google.genai.types import ( Candidate, FunctionCall, FunctionCallingConfigMode, GenerateContentResponse, ModelContent, Part, UserContent, ) from mcp.types import ( AudioContent, CreateMessageResult, ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, ToolChoice, ToolResultContent, ToolUseContent, ) from fastmcp.client.sampling.handlers.google_genai import ( GoogleGenaiSamplingHandler, _convert_messages_to_google_genai_content, _convert_tool_choice_to_google_genai, _response_to_create_message_result, _response_to_result_with_tools, _sampling_content_to_google_genai_part, ) GOOGLE_GENAI_AVAILABLE = True except ImportError: GOOGLE_GENAI_AVAILABLE = False pytestmark = pytest.mark.skipif( not GOOGLE_GENAI_AVAILABLE, reason="google-genai not installed" ) def test_convert_sampling_messages_to_google_genai_content(): msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=TextContent(type="text", text="hello") ), SamplingMessage( role="assistant", content=TextContent(type="text", text="ok") ), ], ) assert len(msgs) == 2 assert isinstance(msgs[0], UserContent) assert isinstance(msgs[1], ModelContent) assert msgs[0].parts[0].text == "hello" assert msgs[1].parts[0].text == "ok" def test_convert_single_image_content_to_google_genai(): part = _sampling_content_to_google_genai_part( ImageContent(type="image", data="YWJj", mimeType="image/png") ) assert part.inline_data is not None assert part.inline_data.data == base64.b64decode("YWJj") assert part.inline_data.mime_type == "image/png" def test_convert_single_audio_content_to_google_genai(): part = _sampling_content_to_google_genai_part( AudioContent(type="audio", data="YWJj", mimeType="audio/wav") ) assert part.inline_data is not None assert part.inline_data.data == base64.b64decode("YWJj") assert part.inline_data.mime_type == "audio/wav" def test_convert_image_message_to_google_genai_content(): msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), ) ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert msgs[0].parts[0].inline_data is not None assert msgs[0].parts[0].inline_data.mime_type == "image/jpeg" def test_convert_audio_message_to_google_genai_content(): msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=AudioContent(type="audio", data="YWJj", mimeType="audio/mp3"), ) ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert msgs[0].parts[0].inline_data is not None assert msgs[0].parts[0].inline_data.mime_type == "audio/mp3" def test_convert_list_content_with_image_and_text(): msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="What is in this image?"), ImageContent(type="image", data="YWJj", mimeType="image/png"), ], ) ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert len(msgs[0].parts) == 2 assert msgs[0].parts[0].text == "What is in this image?" assert msgs[0].parts[1].inline_data is not None assert msgs[0].parts[1].inline_data.mime_type == "image/png" def test_convert_list_content_with_audio_and_text(): msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="Transcribe this audio"), AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), ], ) ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert len(msgs[0].parts) == 2 assert msgs[0].parts[0].text == "Transcribe this audio" assert msgs[0].parts[1].inline_data is not None assert msgs[0].parts[1].inline_data.mime_type == "audio/wav" def test_get_model(): mock_client = MagicMock(spec=GoogleGenaiClient) handler = GoogleGenaiSamplingHandler( default_model="fallback-model", client=mock_client ) # Test with Gemini model hint prefs = ModelPreferences(hints=[ModelHint(name="gemini-2.0-flash-exp")]) assert handler._get_model(prefs) == "gemini-2.0-flash-exp" # Test with None assert handler._get_model(None) == "fallback-model" # Test with empty hints prefs_empty = ModelPreferences(hints=[]) assert handler._get_model(prefs_empty) == "fallback-model" # Test with non-Gemini hint falls back to default prefs_other = ModelPreferences(hints=[ModelHint(name="gpt-4o")]) assert handler._get_model(prefs_other) == "fallback-model" # Test with mixed hints selects first Gemini model prefs_mixed = ModelPreferences( hints=[ModelHint(name="claude-3.5-sonnet"), ModelHint(name="gemini-2.0-flash")] ) assert handler._get_model(prefs_mixed) == "gemini-2.0-flash" async def test_response_to_create_message_result(): # Create a mock response mock_response = MagicMock(spec=GenerateContentResponse) mock_response.text = "HELPFUL CONTENT FROM GEMINI" result: CreateMessageResult = _response_to_create_message_result( response=mock_response, model="gemini-2.0-flash-exp" ) assert result == CreateMessageResult( content=TextContent(type="text", text="HELPFUL CONTENT FROM GEMINI"), role="assistant", model="gemini-2.0-flash-exp", ) def test_convert_tool_choice_to_google_genai(): # Test auto mode result = _convert_tool_choice_to_google_genai(ToolChoice(mode="auto")) assert result.function_calling_config is not None assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO # Test required mode result = _convert_tool_choice_to_google_genai(ToolChoice(mode="required")) assert result.function_calling_config is not None assert result.function_calling_config.mode == FunctionCallingConfigMode.ANY # Test none mode result = _convert_tool_choice_to_google_genai(ToolChoice(mode="none")) assert result.function_calling_config is not None assert result.function_calling_config.mode == FunctionCallingConfigMode.NONE # Test None (defaults to auto) result = _convert_tool_choice_to_google_genai(None) assert result.function_calling_config is not None assert result.function_calling_config.mode == FunctionCallingConfigMode.AUTO def test_sampling_content_to_google_genai_part_tool_use(): """Test converting ToolUseContent to Google GenAI Part with FunctionCall.""" content = ToolUseContent( type="tool_use", id="get_weather_abc123", name="get_weather", input={"city": "London"}, ) part = _sampling_content_to_google_genai_part(content) assert part.function_call is not None assert part.function_call.name == "get_weather" assert part.function_call.args == {"city": "London"} def test_sampling_content_to_google_genai_part_tool_result(): """Test converting ToolResultContent to Google GenAI Part with FunctionResponse.""" content = ToolResultContent( type="tool_result", toolUseId="get_weather_abc123", content=[TextContent(type="text", text="Weather is sunny")], ) part = _sampling_content_to_google_genai_part(content) assert part.function_response is not None # Function name is extracted from toolUseId by removing the UUID suffix assert part.function_response.name == "get_weather" assert part.function_response.response == {"result": "Weather is sunny"} def test_sampling_content_to_google_genai_part_tool_result_empty(): """Test converting empty ToolResultContent to Google GenAI Part.""" content = ToolResultContent( type="tool_result", toolUseId="my_tool_xyz789", content=[], ) part = _sampling_content_to_google_genai_part(content) assert part.function_response is not None assert part.function_response.name == "my_tool" assert part.function_response.response == {"result": ""} def test_sampling_content_to_google_genai_part_tool_result_no_underscore(): """Test ToolResultContent when toolUseId has no underscore (fallback).""" content = ToolResultContent( type="tool_result", toolUseId="simplefunction", content=[TextContent(type="text", text="Result")], ) part = _sampling_content_to_google_genai_part(content) # When no underscore, the full ID is used as the name assert part.function_response is not None assert part.function_response.name == "simplefunction" def test_convert_messages_with_tool_use(): """Test converting messages containing ToolUseContent.""" msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=TextContent(type="text", text="What's the weather?"), ), SamplingMessage( role="assistant", content=ToolUseContent( type="tool_use", id="get_weather_123", name="get_weather", input={"city": "NYC"}, ), ), ], ) assert len(msgs) == 2 assert isinstance(msgs[0], UserContent) assert isinstance(msgs[1], ModelContent) assert msgs[1].parts[0].function_call is not None assert msgs[1].parts[0].function_call.name == "get_weather" def test_convert_messages_with_tool_result(): """Test converting messages containing ToolResultContent.""" msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=ToolResultContent( type="tool_result", toolUseId="get_weather_123", content=[TextContent(type="text", text="Sunny, 72F")], ), ), ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert msgs[0].parts[0].function_response is not None assert msgs[0].parts[0].function_response.name == "get_weather" def test_convert_messages_with_multiple_content_blocks(): """Test converting messages with multiple content blocks (list content).""" msgs = _convert_messages_to_google_genai_content( messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="I need weather info."), ToolResultContent( type="tool_result", toolUseId="get_weather_xyz", content=[TextContent(type="text", text="Cloudy")], ), ], ), ], ) assert len(msgs) == 1 assert isinstance(msgs[0], UserContent) assert len(msgs[0].parts) == 2 assert msgs[0].parts[0].text == "I need weather info." assert msgs[0].parts[1].function_response is not None def test_response_to_result_with_tools_text_only(): """Test _response_to_result_with_tools with a text-only response.""" mock_candidate = MagicMock(spec=Candidate) mock_candidate.content = MagicMock() mock_candidate.content.parts = [Part(text="Here's the answer")] mock_candidate.finish_reason = "STOP" mock_response = MagicMock(spec=GenerateContentResponse) mock_response.candidates = [mock_candidate] result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") assert result.role == "assistant" assert result.model == "gemini-2.0-flash" assert result.stopReason == "endTurn" assert isinstance(result.content, list) assert len(result.content) == 1 assert result.content[0].type == "text" assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Here's the answer" def test_response_to_result_with_tools_function_call(): """Test _response_to_result_with_tools with a function call response.""" mock_candidate = MagicMock(spec=Candidate) mock_candidate.content = MagicMock() mock_candidate.content.parts = [ Part(function_call=FunctionCall(name="get_weather", args={"city": "Paris"})) ] mock_candidate.finish_reason = "STOP" mock_response = MagicMock(spec=GenerateContentResponse) mock_response.candidates = [mock_candidate] result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") assert result.stopReason == "toolUse" assert isinstance(result.content, list) assert len(result.content) == 1 tool_use = result.content[0] assert isinstance(tool_use, ToolUseContent) assert tool_use.type == "tool_use" assert tool_use.name == "get_weather" assert tool_use.input == {"city": "Paris"} # ID should be in format "get_weather_{uuid}" assert tool_use.id.startswith("get_weather_") def test_response_to_result_with_tools_mixed_content(): """Test _response_to_result_with_tools with text and function call.""" mock_candidate = MagicMock(spec=Candidate) mock_candidate.content = MagicMock() mock_candidate.content.parts = [ Part(text="Let me check that for you."), Part(function_call=FunctionCall(name="search", args={"query": "test"})), ] mock_candidate.finish_reason = "STOP" mock_response = MagicMock(spec=GenerateContentResponse) mock_response.candidates = [mock_candidate] result = _response_to_result_with_tools(mock_response, model="gemini-2.0-flash") assert result.stopReason == "toolUse" assert isinstance(result.content, list) assert len(result.content) == 2 text_content = result.content[0] assert isinstance(text_content, TextContent) assert text_content.type == "text" assert text_content.text == "Let me check that for you." tool_use = result.content[1] assert isinstance(tool_use, ToolUseContent) assert tool_use.type == "tool_use" assert tool_use.name == "search" ================================================ FILE: tests/client/sampling/handlers/test_openai_handler.py ================================================ from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from mcp.types import ( AudioContent, CreateMessageRequestParams, CreateMessageResult, ImageContent, ModelHint, ModelPreferences, SamplingMessage, TextContent, ToolUseContent, ) from openai import AsyncOpenAI from openai.types.chat import ( ChatCompletion, ChatCompletionAssistantMessageParam, ChatCompletionContentPartImageParam, ChatCompletionContentPartInputAudioParam, ChatCompletionContentPartTextParam, ChatCompletionMessage, ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ) from openai.types.chat.chat_completion import Choice from fastmcp.client.sampling.handlers.openai import ( OpenAISamplingHandler, _audio_content_to_openai_part, _image_content_to_openai_part, ) def test_convert_sampling_messages_to_openai_messages(): msgs = OpenAISamplingHandler._convert_to_openai_messages( system_prompt="sys", messages=[ SamplingMessage( role="user", content=TextContent(type="text", text="hello") ), SamplingMessage( role="assistant", content=TextContent(type="text", text="ok") ), ], ) assert msgs == [ ChatCompletionSystemMessageParam(content="sys", role="system"), ChatCompletionUserMessageParam(content="hello", role="user"), ChatCompletionAssistantMessageParam(content="ok", role="assistant"), ] def test_image_content_to_openai_part(): part = _image_content_to_openai_part( ImageContent(type="image", data="YWJj", mimeType="image/png") ) assert part == ChatCompletionContentPartImageParam( type="image_url", image_url={"url": "data:image/png;base64,YWJj"}, ) def test_audio_content_to_openai_part_wav(): part = _audio_content_to_openai_part( AudioContent(type="audio", data="YWJj", mimeType="audio/wav") ) assert part == ChatCompletionContentPartInputAudioParam( type="input_audio", input_audio={"data": "YWJj", "format": "wav"}, ) def test_audio_content_to_openai_part_mp3(): part = _audio_content_to_openai_part( AudioContent(type="audio", data="YWJj", mimeType="audio/mpeg") ) assert part["input_audio"]["format"] == "mp3" def test_audio_content_to_openai_part_unsupported_raises(): with pytest.raises(ValueError, match="Unsupported audio MIME type"): _audio_content_to_openai_part( AudioContent(type="audio", data="YWJj", mimeType="audio/ogg") ) def test_image_content_to_openai_part_unsupported_raises(): with pytest.raises(ValueError, match="Unsupported image MIME type"): _image_content_to_openai_part( ImageContent(type="image", data="YWJj", mimeType="image/bmp") ) def test_convert_single_image_content_to_openai_message(): msgs = OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="user", content=ImageContent(type="image", data="YWJj", mimeType="image/png"), ) ], ) assert len(msgs) == 1 assert msgs[0] == ChatCompletionUserMessageParam( role="user", content=[ ChatCompletionContentPartImageParam( type="image_url", image_url={"url": "data:image/png;base64,YWJj"}, ) ], ) def test_convert_single_audio_content_to_openai_message(): msgs = OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="user", content=AudioContent(type="audio", data="YWJj", mimeType="audio/wav"), ) ], ) assert len(msgs) == 1 assert msgs[0] == ChatCompletionUserMessageParam( role="user", content=[ ChatCompletionContentPartInputAudioParam( type="input_audio", input_audio={"data": "YWJj", "format": "wav"}, ) ], ) def test_convert_list_content_with_image_and_text(): msgs = OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="user", content=[ TextContent(type="text", text="What is in this image?"), ImageContent(type="image", data="YWJj", mimeType="image/jpeg"), ], ) ], ) assert len(msgs) == 1 assert msgs[0] == ChatCompletionUserMessageParam( role="user", content=[ ChatCompletionContentPartTextParam( type="text", text="What is in this image?" ), ChatCompletionContentPartImageParam( type="image_url", image_url={"url": "data:image/jpeg;base64,YWJj"}, ), ], ) def test_convert_image_in_assistant_message_raises(): with pytest.raises(ValueError, match="ImageContent is only supported in user"): OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="assistant", content=ImageContent( type="image", data="YWJj", mimeType="image/png" ), ) ], ) def test_convert_audio_in_assistant_message_raises(): with pytest.raises(ValueError, match="AudioContent is only supported in user"): OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="assistant", content=AudioContent( type="audio", data="YWJj", mimeType="audio/wav" ), ) ], ) def test_convert_list_image_in_assistant_message_raises(): """Image/audio in an assistant list-content message should raise, not silently drop.""" with pytest.raises(ValueError, match="only supported in user messages"): OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="assistant", content=[ TextContent(type="text", text="Here's the image"), ImageContent(type="image", data="YWJj", mimeType="image/png"), ], ) ], ) def test_convert_list_tool_calls_with_image_raises(): """Image/audio alongside tool_calls in assistant list should raise.""" with pytest.raises(ValueError, match="only supported in user messages"): OpenAISamplingHandler._convert_to_openai_messages( system_prompt=None, messages=[ SamplingMessage( role="assistant", content=[ ToolUseContent( type="tool_use", id="call_1", name="my_tool", input={"arg": "val"}, ), ImageContent(type="image", data="YWJj", mimeType="image/png"), ], ) ], ) @pytest.mark.parametrize( "prefs,expected", [ ("gpt-4o-mini", "gpt-4o-mini"), (ModelPreferences(hints=[ModelHint(name="gpt-4o-mini")]), "gpt-4o-mini"), (["gpt-4o-mini", "other"], "gpt-4o-mini"), (None, "fallback-model"), (["unknown-model"], "fallback-model"), ], ) def test_select_model_from_preferences(prefs: Any, expected: str) -> None: mock_client = MagicMock(spec=AsyncOpenAI) handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] assert handler._select_model_from_preferences(prefs) == expected async def test_handler_passes_max_completion_tokens(): """Verify the handler uses max_completion_tokens (not max_tokens).""" mock_client = MagicMock(spec=AsyncOpenAI) mock_client.chat = MagicMock() mock_client.chat.completions = MagicMock() mock_client.chat.completions.create = AsyncMock( return_value=ChatCompletion( id="123", created=123, model="gpt-4o-mini", object="chat.completion", choices=[ Choice( message=ChatCompletionMessage(content="hi", role="assistant"), finish_reason="stop", index=0, ) ], ) ) handler = OpenAISamplingHandler(default_model="gpt-4o-mini", client=mock_client) messages = [ SamplingMessage(role="user", content=TextContent(type="text", text="hello")) ] params = CreateMessageRequestParams(messages=messages, maxTokens=300) await handler(messages, params, context=None) # type: ignore[arg-type] call_kwargs = mock_client.chat.completions.create.call_args assert "max_completion_tokens" in call_kwargs.kwargs assert call_kwargs.kwargs["max_completion_tokens"] == 300 assert "max_tokens" not in call_kwargs.kwargs async def test_chat_completion_to_create_message_result(): mock_client = MagicMock(spec=AsyncOpenAI) handler = OpenAISamplingHandler(default_model="fallback-model", client=mock_client) # type: ignore[arg-type] mock_client.chat.completions.create.return_value = ChatCompletion( id="123", created=123, model="gpt-4o-mini", object="chat.completion", choices=[ Choice( message=ChatCompletionMessage( content="HELPFUL CONTENT FROM A VERY SMART LLM", role="assistant" ), finish_reason="stop", index=0, ) ], ) result: CreateMessageResult = handler._chat_completion_to_create_message_result( chat_completion=mock_client.chat.completions.create.return_value ) assert result == CreateMessageResult( content=TextContent(type="text", text="HELPFUL CONTENT FROM A VERY SMART LLM"), role="assistant", model="gpt-4o-mini", ) ================================================ FILE: tests/client/tasks/conftest.py ================================================ """Configuration for client task tests.""" ================================================ FILE: tests/client/tasks/test_client_prompt_tasks.py ================================================ """ Tests for client-side prompt task methods. Tests the client's get_prompt_as_task method. """ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.tasks import PromptTask @pytest.fixture async def prompt_server(): """Create a test server with background-enabled prompts.""" mcp = FastMCP("prompt-client-test") @mcp.prompt(task=True) async def analysis_prompt(topic: str, style: str = "formal") -> str: """Generate an analysis prompt.""" return f"Analyze {topic} in a {style} style" @mcp.prompt(task=True) async def creative_prompt(theme: str) -> str: """Generate a creative writing prompt.""" return f"Write a story about {theme}" return mcp async def test_get_prompt_as_task_returns_prompt_task(prompt_server): """get_prompt with task=True returns a PromptTask object.""" async with Client(prompt_server) as client: task = await client.get_prompt("analysis_prompt", {"topic": "AI"}, task=True) assert isinstance(task, PromptTask) assert isinstance(task.task_id, str) async def test_prompt_task_server_generated_id(prompt_server): """get_prompt with task=True gets server-generated task ID.""" async with Client(prompt_server) as client: task = await client.get_prompt( "creative_prompt", {"theme": "future"}, task=True, ) # Server should generate a UUID task ID assert task.task_id is not None assert isinstance(task.task_id, str) # UUIDs have hyphens assert "-" in task.task_id async def test_prompt_task_result_returns_get_prompt_result(prompt_server): """PromptTask.result() returns GetPromptResult.""" async with Client(prompt_server) as client: task = await client.get_prompt( "analysis_prompt", {"topic": "Robotics", "style": "casual"}, task=True ) # Verify background execution assert not task.returned_immediately # Get result result = await task.result() # Result should be GetPromptResult assert hasattr(result, "description") assert hasattr(result, "messages") # Check the rendered message content, not the description assert len(result.messages) > 0 assert "Analyze Robotics" in result.messages[0].content.text async def test_prompt_task_await_syntax(prompt_server): """PromptTask can be awaited directly.""" async with Client(prompt_server) as client: task = await client.get_prompt("creative_prompt", {"theme": "ocean"}, task=True) # Can await task directly result = await task assert "Write a story about ocean" in result.messages[0].content.text async def test_prompt_task_status_and_wait(prompt_server): """PromptTask supports status() and wait() methods.""" async with Client(prompt_server) as client: task = await client.get_prompt("analysis_prompt", {"topic": "Space"}, task=True) # Check status status = await task.status() assert status.status in ["working", "completed"] # Wait for completion await task.wait(timeout=2.0) # Get result result = await task.result() assert "Analyze Space" in result.messages[0].content.text ================================================ FILE: tests/client/tasks/test_client_resource_tasks.py ================================================ """ Tests for client-side resource task methods. Tests the client's read_resource_as_task method. """ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.tasks import ResourceTask @pytest.fixture async def resource_server(): """Create a test server with background-enabled resources.""" mcp = FastMCP("resource-client-test") @mcp.resource("file://document.txt", task=True) async def document() -> str: """A document resource.""" return "Document content here" @mcp.resource("file://data/{id}.json", task=True) async def data_file(id: str) -> str: """A parameterized data resource.""" return f'{{"id": "{id}", "value": 42}}' return mcp async def test_read_resource_as_task_returns_resource_task(resource_server): """read_resource with task=True returns a ResourceTask object.""" async with Client(resource_server) as client: task = await client.read_resource("file://document.txt", task=True) assert isinstance(task, ResourceTask) assert isinstance(task.task_id, str) async def test_resource_task_server_generated_id(resource_server): """read_resource with task=True gets server-generated task ID.""" async with Client(resource_server) as client: task = await client.read_resource("file://document.txt", task=True) # Server should generate a UUID task ID assert task.task_id is not None assert isinstance(task.task_id, str) # UUIDs have hyphens assert "-" in task.task_id async def test_resource_task_result_returns_read_resource_result(resource_server): """ResourceTask.result() returns list of ReadResourceContents.""" async with Client(resource_server) as client: task = await client.read_resource("file://document.txt", task=True) # Verify background execution assert not task.returned_immediately # Get result result = await task.result() # Result should be list of ReadResourceContents assert isinstance(result, list) assert len(result) > 0 assert result[0].text == "Document content here" async def test_resource_task_await_syntax(resource_server): """ResourceTask can be awaited directly.""" async with Client(resource_server) as client: task = await client.read_resource("file://document.txt", task=True) # Can await task directly result = await task assert result[0].text == "Document content here" async def test_resource_template_task(resource_server): """Resource templates work with task support.""" async with Client(resource_server) as client: task = await client.read_resource("file://data/999.json", task=True) # Verify background execution assert not task.returned_immediately # Get result result = await task.result() assert '"id": "999"' in result[0].text async def test_resource_task_status_and_wait(resource_server): """ResourceTask supports status() and wait() methods.""" async with Client(resource_server) as client: task = await client.read_resource("file://document.txt", task=True) # Check status status = await task.status() assert status.status in ["working", "completed"] # Wait for completion await task.wait(timeout=2.0) # Get result result = await task.result() assert "Document content" in result[0].text ================================================ FILE: tests/client/tasks/test_client_task_notifications.py ================================================ """ Tests for client-side handling of notifications/tasks/status (SEP-1686 lines 436-444). Verifies that Task objects receive notifications, update their cache, wake up wait() calls, and invoke user callbacks. """ import asyncio import time import pytest from mcp.types import GetTaskResult from fastmcp import FastMCP from fastmcp.client import Client @pytest.fixture async def task_notification_server(): """Server that sends task status notifications.""" mcp = FastMCP("task-notification-test") @mcp.tool(task=True) async def quick_task(value: int) -> int: """Quick background task.""" await asyncio.sleep(0.05) return value * 2 @mcp.tool(task=True) async def slow_task(duration: float = 0.2) -> str: """Slow background task.""" await asyncio.sleep(duration) return "done" @mcp.tool(task=True) async def failing_task() -> str: """Task that fails.""" raise ValueError("Intentional failure") return mcp async def test_task_receives_status_notification(task_notification_server): """Task object receives and processes status notifications.""" async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 5}, task=True) # Wait for task to complete (notification should arrive) status = await task.wait(timeout=2.0) # Verify task completed assert status.status == "completed" async def test_status_cache_updated_by_notification(task_notification_server): """Cached status is updated when notification arrives.""" async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 10}, task=True) # Wait for completion (notification should update cache) await task.wait(timeout=2.0) # Status should be cached (no server call needed) # Call status() twice - should return same cached object status1 = await task.status() status2 = await task.status() # Should be the exact same object (from cache) assert status1 is status2 assert status1.status == "completed" async def test_callback_invoked_on_notification(task_notification_server): """User callback is invoked when notification arrives.""" callback_invocations = [] def status_callback(status: GetTaskResult): """Sync callback.""" callback_invocations.append(status) async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 7}, task=True) # Register callback task.on_status_change(status_callback) # Wait for completion await task.wait(timeout=2.0) # Give callbacks a moment to fire await asyncio.sleep(0.1) # Callback should have been invoked at least once assert len(callback_invocations) > 0 # Should have received completed status completed_statuses = [s for s in callback_invocations if s.status == "completed"] assert len(completed_statuses) > 0 async def test_async_callback_invoked(task_notification_server): """Async callback is invoked when notification arrives.""" callback_invocations = [] async def async_status_callback(status: GetTaskResult): """Async callback.""" await asyncio.sleep(0.01) # Simulate async work callback_invocations.append(status) async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 3}, task=True) # Register async callback task.on_status_change(async_status_callback) # Wait for completion await task.wait(timeout=2.0) # Give async callbacks time to complete await asyncio.sleep(0.2) # Async callback should have been invoked assert len(callback_invocations) > 0 async def test_multiple_callbacks_all_invoked(task_notification_server): """Multiple callbacks are all invoked.""" callback1_calls = [] callback2_calls = [] def callback1(status: GetTaskResult): callback1_calls.append(status.status) def callback2(status: GetTaskResult): callback2_calls.append(status.status) async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 8}, task=True) task.on_status_change(callback1) task.on_status_change(callback2) await task.wait(timeout=2.0) await asyncio.sleep(0.1) # Both callbacks should have been invoked assert len(callback1_calls) > 0 assert len(callback2_calls) > 0 async def test_callback_error_doesnt_break_notification(task_notification_server): """Callback errors don't prevent other callbacks from running.""" callback1_calls = [] callback2_calls = [] def failing_callback(status: GetTaskResult): callback1_calls.append("called") raise ValueError("Callback intentionally fails") def working_callback(status: GetTaskResult): callback2_calls.append(status.status) async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 12}, task=True) task.on_status_change(failing_callback) task.on_status_change(working_callback) await task.wait(timeout=2.0) await asyncio.sleep(0.1) # Failing callback was called (and errored) assert len(callback1_calls) > 0 # Working callback should still have been invoked assert len(callback2_calls) > 0 async def test_wait_wakes_early_on_notification(task_notification_server): """wait() wakes up immediately when notification arrives, not after poll interval.""" async with Client(task_notification_server) as client: task = await client.call_tool("quick_task", {"value": 15}, task=True) # Record timing start = time.time() status = await task.wait(timeout=5.0) elapsed = time.time() - start # Should complete much faster than the fallback poll interval (500ms) # With notifications, should be < 200ms for quick task # Without notifications, would take 500ms+ due to polling assert elapsed < 1.0 # Very generous bound assert status.status == "completed" async def test_notification_with_failed_task(task_notification_server): """Notifications work for failed tasks too.""" async with Client(task_notification_server) as client: task = await client.call_tool("failing_task", {}, task=True) with pytest.raises(Exception): await task # Should have cached the failed status from notification status = await task.status() assert status.status == "failed" assert ( status.statusMessage is not None ) # Error details in statusMessage per spec ================================================ FILE: tests/client/tasks/test_client_task_protocol.py ================================================ """ Tests for client-side task protocol. Generic protocol tests that use tools as test fixtures. """ import asyncio from fastmcp import FastMCP from fastmcp.client import Client async def test_end_to_end_task_flow(): """Complete end-to-end flow: submit, poll, retrieve.""" start_signal = asyncio.Event() complete_signal = asyncio.Event() mcp = FastMCP("protocol-test") @mcp.tool(task=True) async def controlled_tool(message: str) -> str: """Tool with controlled execution.""" start_signal.set() await complete_signal.wait() return f"Processed: {message}" async with Client(mcp) as client: # Submit task task = await client.call_tool( "controlled_tool", {"message": "integration test"}, task=True ) # Wait for execution to start await asyncio.wait_for(start_signal.wait(), timeout=2.0) # Check status while running status = await task.status() assert status.status in ["working"] # Signal completion complete_signal.set() # Wait for task to finish and retrieve result result = await task.result() assert result.data == "Processed: integration test" async def test_multiple_concurrent_tasks(): """Multiple tasks can run concurrently.""" mcp = FastMCP("concurrent-test") @mcp.tool(task=True) async def multiply(a: int, b: int) -> int: return a * b async with Client(mcp) as client: # Submit multiple tasks tasks = [] for i in range(5): task = await client.call_tool("multiply", {"a": i, "b": 2}, task=True) tasks.append((task, i * 2)) # Wait for all to complete and verify results for task, expected in tasks: result = await task.result() assert result.data == expected async def test_task_id_auto_generation(): """Task IDs are auto-generated if not provided.""" mcp = FastMCP("id-test") @mcp.tool(task=True) async def echo(message: str) -> str: return f"Echo: {message}" async with Client(mcp) as client: # Submit without custom task ID task_1 = await client.call_tool("echo", {"message": "first"}, task=True) task_2 = await client.call_tool("echo", {"message": "second"}, task=True) # Should generate different IDs assert task_1.task_id != task_2.task_id assert len(task_1.task_id) > 0 assert len(task_2.task_id) > 0 ================================================ FILE: tests/client/tasks/test_client_tool_tasks.py ================================================ """ Tests for client-side tool task methods. Tests the client's tool-specific task functionality, parallel to test_client_prompt_tasks.py and test_client_resource_tasks.py. """ import pytest from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.tasks import ToolTask @pytest.fixture async def tool_task_server(): """Create a test server with task-enabled tools.""" mcp = FastMCP("tool-task-test") @mcp.tool(task=True) async def echo(message: str) -> str: """Echo back the message.""" return f"Echo: {message}" @mcp.tool(task=True) async def multiply(a: int, b: int) -> int: """Multiply two numbers.""" return a * b return mcp async def test_call_tool_as_task_returns_tool_task(tool_task_server): """call_tool with task=True returns a ToolTask object.""" async with Client(tool_task_server) as client: task = await client.call_tool("echo", {"message": "hello"}, task=True) assert isinstance(task, ToolTask) assert isinstance(task.task_id, str) assert len(task.task_id) > 0 async def test_tool_task_server_generated_id(tool_task_server): """call_tool with task=True gets server-generated task ID.""" async with Client(tool_task_server) as client: task = await client.call_tool("echo", {"message": "test"}, task=True) # Server should generate a UUID task ID assert task.task_id is not None assert isinstance(task.task_id, str) # UUIDs have hyphens assert "-" in task.task_id async def test_tool_task_result_returns_call_tool_result(tool_task_server): """ToolTask.result() returns CallToolResult with tool data.""" async with Client(tool_task_server) as client: task = await client.call_tool("multiply", {"a": 6, "b": 7}, task=True) assert not task.returned_immediately result = await task.result() assert result.data == 42 async def test_tool_task_await_syntax(tool_task_server): """Tool tasks can be awaited directly to get result.""" async with Client(tool_task_server) as client: task = await client.call_tool("multiply", {"a": 7, "b": 6}, task=True) # Can await task directly (syntactic sugar for task.result()) result = await task assert result.data == 42 async def test_tool_task_status_and_wait(tool_task_server): """ToolTask.status() returns GetTaskResult.""" async with Client(tool_task_server) as client: task = await client.call_tool("echo", {"message": "test"}, task=True) status = await task.status() assert status.taskId == task.task_id assert status.status in ["working", "completed"] # Wait for completion await task.wait(timeout=2.0) final_status = await task.status() assert final_status.status == "completed" ================================================ FILE: tests/client/tasks/test_task_context_validation.py ================================================ """ Tests for Task client context validation. Verifies that Task methods properly validate client context and that cached results remain accessible outside context. """ import pytest from fastmcp import FastMCP from fastmcp.client import Client @pytest.fixture async def task_server(): """Create a test server with background tasks.""" mcp = FastMCP("context-test-server") @mcp.tool(task=True) async def background_tool(value: str) -> str: """Tool that runs in background.""" return f"Result: {value}" @mcp.prompt(task=True) async def background_prompt(topic: str) -> str: """Prompt that runs in background.""" return f"Prompt about {topic}" @mcp.resource("file://background.txt", task=True) async def background_resource() -> str: """Resource that runs in background.""" return "Background resource content" return mcp async def test_task_status_outside_context_raises(task_server): """Calling task.status() outside client context raises error.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Now outside context with pytest.raises(RuntimeError, match="outside client context"): await task.status() async def test_task_result_outside_context_raises(task_server): """Calling task.result() outside context raises error.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Now outside context with pytest.raises(RuntimeError, match="outside client context"): await task.result() async def test_task_wait_outside_context_raises(task_server): """Calling task.wait() outside context raises error.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Now outside context with pytest.raises(RuntimeError, match="outside client context"): await task.wait() async def test_task_cancel_outside_context_raises(task_server): """Calling task.cancel() outside context raises error.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Now outside context with pytest.raises(RuntimeError, match="outside client context"): await task.cancel() async def test_cached_tool_task_accessible_outside_context(task_server): """Tool tasks with cached results work outside context.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Get result once to cache it result1 = await task.result() assert result1.data == "Result: test" # Now outside context # Should work because result is cached result2 = await task.result() assert result2 is result1 # Same object assert result2.data == "Result: test" async def test_cached_prompt_task_accessible_outside_context(task_server): """Prompt tasks with cached results work outside context.""" task = None async with Client(task_server) as client: task = await client.get_prompt( "background_prompt", {"topic": "test"}, task=True ) assert not task.returned_immediately # Get result once to cache it result1 = await task.result() assert result1.description == "Prompt that runs in background." # Now outside context # Should work because result is cached result2 = await task.result() assert result2 is result1 # Same object assert result2.description == "Prompt that runs in background." async def test_cached_resource_task_accessible_outside_context(task_server): """Resource tasks with cached results work outside context.""" task = None async with Client(task_server) as client: task = await client.read_resource("file://background.txt", task=True) assert not task.returned_immediately # Get result once to cache it result1 = await task.result() assert len(result1) > 0 # Now outside context # Should work because result is cached result2 = await task.result() assert result2 is result1 # Same object async def test_uncached_status_outside_context_raises(task_server): """Even after caching result, status() still requires client context.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Cache the result await task.result() # Now outside context # result() works (cached) result = await task.result() assert result.data == "Result: test" # But status() still needs client connection with pytest.raises(RuntimeError, match="outside client context"): await task.status() async def test_task_await_syntax_outside_context_raises(task_server): """Using await task syntax outside context raises error for background tasks.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) assert not task.returned_immediately # Now outside context with pytest.raises(RuntimeError, match="outside client context"): await task # Same as await task.result() async def test_task_await_syntax_works_for_cached_results(task_server): """Using await task syntax works outside context when result is cached.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) result1 = await task # Cache it # Now outside context result2 = await task # Should work (cached) assert result2 is result1 assert result2.data == "Result: test" async def test_multiple_result_calls_return_same_cached_object(task_server): """Multiple result() calls return the same cached object.""" async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) result1 = await task.result() result2 = await task.result() result3 = await task.result() # Should all be the same object (cached) assert result1 is result2 assert result2 is result3 async def test_background_task_properties_accessible_outside_context(task_server): """Background task properties like task_id accessible outside context.""" task = None async with Client(task_server) as client: task = await client.call_tool("background_tool", {"value": "test"}, task=True) task_id_inside = task.task_id assert not task.returned_immediately # Now outside context # Properties should still be accessible (they don't need client connection) assert task.task_id == task_id_inside assert task.returned_immediately is False ================================================ FILE: tests/client/tasks/test_task_result_caching.py ================================================ """ Tests for Task result caching behavior. Verifies that Task.result() and await task cache results properly to avoid redundant server calls and ensure consistent object identity. """ from fastmcp import FastMCP from fastmcp.client import Client async def test_tool_task_result_cached_on_first_call(): """First call caches result, subsequent calls return cached value.""" call_count = 0 mcp = FastMCP("test") @mcp.tool(task=True) async def counting_tool() -> int: nonlocal call_count call_count += 1 return call_count async with Client(mcp) as client: task = await client.call_tool("counting_tool", task=True) result1 = await task.result() result2 = await task.result() result3 = await task.result() # All should return 1 (first execution value) assert result1.data == 1 assert result2.data == 1 assert result3.data == 1 # Verify they're the same object (cached) assert result1 is result2 is result3 async def test_prompt_task_result_cached(): """PromptTask caches results on first call.""" call_count = 0 mcp = FastMCP("test") @mcp.prompt(task=True) async def counting_prompt() -> str: nonlocal call_count call_count += 1 return f"Call number: {call_count}" async with Client(mcp) as client: task = await client.get_prompt("counting_prompt", task=True) result1 = await task.result() result2 = await task.result() result3 = await task.result() # All should return same content assert result1.messages[0].content.text == "Call number: 1" assert result2.messages[0].content.text == "Call number: 1" assert result3.messages[0].content.text == "Call number: 1" # Verify they're the same object (cached) assert result1 is result2 is result3 async def test_resource_task_result_cached(): """ResourceTask caches results on first call.""" call_count = 0 mcp = FastMCP("test") @mcp.resource("file://counter.txt", task=True) async def counting_resource() -> str: nonlocal call_count call_count += 1 return f"Count: {call_count}" async with Client(mcp) as client: task = await client.read_resource("file://counter.txt", task=True) result1 = await task.result() result2 = await task.result() result3 = await task.result() # All should return same content assert result1[0].text == "Count: 1" assert result2[0].text == "Count: 1" assert result3[0].text == "Count: 1" # Verify they're the same object (cached) assert result1 is result2 is result3 async def test_multiple_await_returns_same_object(): """Multiple await task calls return identical object.""" mcp = FastMCP("test") @mcp.tool(task=True) async def sample_tool() -> str: return "result" async with Client(mcp) as client: task = await client.call_tool("sample_tool", task=True) result1 = await task result2 = await task result3 = await task # Should be exact same object in memory assert result1 is result2 is result3 assert id(result1) == id(result2) == id(result3) async def test_result_and_await_share_cache(): """task.result() and await task share the same cache.""" mcp = FastMCP("test") @mcp.tool(task=True) async def sample_tool() -> str: return "cached" async with Client(mcp) as client: task = await client.call_tool("sample_tool", task=True) # Call result() first result_via_method = await task.result() # Then await directly result_via_await = await task # Should be the same cached object assert result_via_method is result_via_await assert id(result_via_method) == id(result_via_await) async def test_forbidden_mode_tool_caches_error_result(): """Tools with task=False (mode=forbidden) cache error results.""" mcp = FastMCP("test") @mcp.tool(task=False) async def non_task_tool() -> int: return 1 async with Client(mcp) as client: # Request as task, but mode="forbidden" will reject with error task = await client.call_tool("non_task_tool", task=True) # Should be immediate (error returned immediately) assert task.returned_immediately result1 = await task.result() result2 = await task.result() result3 = await task.result() # All should return cached error assert result1.is_error assert "does not support task-augmented execution" in str(result1) # Verify they're the same object (cached) assert result1 is result2 is result3 async def test_forbidden_mode_prompt_raises_error(): """Prompts with task=False (mode=forbidden) raise error.""" import pytest from mcp.shared.exceptions import McpError mcp = FastMCP("test") @mcp.prompt(task=False) async def non_task_prompt() -> str: return "Immediate" async with Client(mcp) as client: # Prompts with mode="forbidden" raise McpError when called with task=True with pytest.raises(McpError): await client.get_prompt("non_task_prompt", task=True) async def test_forbidden_mode_resource_raises_error(): """Resources with task=False (mode=forbidden) raise error.""" import pytest from mcp.shared.exceptions import McpError mcp = FastMCP("test") @mcp.resource("file://immediate.txt", task=False) async def non_task_resource() -> str: return "Immediate" async with Client(mcp) as client: # Resources with mode="forbidden" raise McpError when called with task=True with pytest.raises(McpError): await client.read_resource("file://immediate.txt", task=True) async def test_immediate_task_caches_result(): """Immediate tasks (optional mode called without background) cache results.""" call_count = 0 mcp = FastMCP("test", tasks=True) # Tool with task=True (optional mode) - but without docket will execute immediately @mcp.tool(task=True) async def task_tool() -> int: nonlocal call_count call_count += 1 return call_count async with Client(mcp) as client: # Call with task=True task = await client.call_tool("task_tool", task=True) # Get result multiple times result1 = await task.result() result2 = await task.result() result3 = await task.result() # All should return cached value assert result1.data == 1 assert result2.data == 1 assert result3.data == 1 # Verify they're the same object (cached) assert result1 is result2 is result3 async def test_cache_persists_across_mixed_access_patterns(): """Cache works correctly when mixing result() and await.""" mcp = FastMCP("test") @mcp.tool(task=True) async def mixed_tool() -> str: return "mixed" async with Client(mcp) as client: task = await client.call_tool("mixed_tool", task=True) # Access in various orders result1 = await task result2 = await task.result() result3 = await task result4 = await task.result() # All should be the same cached object assert result1 is result2 is result3 is result4 async def test_different_tasks_have_separate_caches(): """Different task instances maintain separate caches.""" mcp = FastMCP("test") @mcp.tool(task=True) async def separate_tool(value: str) -> str: return f"Result: {value}" async with Client(mcp) as client: task1 = await client.call_tool("separate_tool", {"value": "A"}, task=True) task2 = await client.call_tool("separate_tool", {"value": "B"}, task=True) result1 = await task1.result() result2 = await task2.result() # Different results assert result1.data == "Result: A" assert result2.data == "Result: B" # Not the same object assert result1 is not result2 # But each task's cache works independently result1_again = await task1.result() result2_again = await task2.result() assert result1 is result1_again assert result2 is result2_again async def test_cache_survives_status_checks(): """Calling status() doesn't affect result caching.""" mcp = FastMCP("test") @mcp.tool(task=True) async def status_check_tool() -> str: return "status" async with Client(mcp) as client: task = await client.call_tool("status_check_tool", task=True) # Check status multiple times await task.status() await task.status() result1 = await task.result() # Check status again await task.status() result2 = await task.result() # Cache should still work assert result1 is result2 async def test_cache_survives_wait_calls(): """Calling wait() doesn't affect result caching.""" mcp = FastMCP("test") @mcp.tool(task=True) async def wait_test_tool() -> str: return "waited" async with Client(mcp) as client: task = await client.call_tool("wait_test_tool", task=True) # Wait for completion await task.wait() result1 = await task.result() # Wait again (no-op since completed) await task.wait() result2 = await task.result() # Cache should still work assert result1 is result2 ================================================ FILE: tests/client/telemetry/__init__.py ================================================ ================================================ FILE: tests/client/telemetry/test_client_tracing.py ================================================ """Tests for client OpenTelemetry tracing.""" from __future__ import annotations from collections.abc import AsyncGenerator import pytest from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.trace import SpanKind, StatusCode from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError class TestClientToolTracing: """Tests for client tool call tracing.""" async def test_call_tool_creates_span(self, trace_exporter: InMemorySpanExporter): server = FastMCP("test-server") @server.tool() def greet(name: str) -> str: return f"Hello, {name}!" client = Client(server) async with client: result = await client.call_tool("greet", {"name": "World"}) assert "Hello, World!" in str(result) spans = trace_exporter.get_finished_spans() span_names = [s.name for s in spans] # Client should create "tools/call greet" span assert "tools/call greet" in span_names async def test_call_tool_span_attributes( self, trace_exporter: InMemorySpanExporter ): server = FastMCP("test-server") @server.tool() def add(a: int, b: int) -> int: return a + b client = Client(server) async with client: await client.call_tool("add", {"a": 1, "b": 2}) spans = trace_exporter.get_finished_spans() # Find client-side span (doesn't have fastmcp.server.name) client_span = next( ( s for s in spans if s.name == "tools/call add" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) assert client_span is not None assert client_span.attributes is not None # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "tools/call" # Standard RPC semantic conventions assert client_span.attributes["rpc.system"] == "mcp" assert client_span.attributes["rpc.method"] == "tools/call" # FastMCP-specific attributes assert client_span.attributes["fastmcp.component.key"] == "add" class TestClientResourceTracing: """Tests for client resource read tracing.""" async def test_read_resource_creates_span( self, trace_exporter: InMemorySpanExporter ): server = FastMCP("test-server") @server.resource("data://config") def get_config() -> str: return "config data" client = Client(server) async with client: result = await client.read_resource("data://config") assert "config data" in str(result) spans = trace_exporter.get_finished_spans() span_names = [s.name for s in spans] # Client should create "resources/read data://config" span assert "resources/read data://config" in span_names async def test_read_resource_span_attributes( self, trace_exporter: InMemorySpanExporter ): server = FastMCP("test-server") @server.resource("data://config") def get_config() -> str: return "config value" client = Client(server) async with client: await client.read_resource("data://config") spans = trace_exporter.get_finished_spans() # Find client-side resource span (doesn't have fastmcp.server.name) client_span = next( ( s for s in spans if s.name.startswith("resources/read data://") and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) assert client_span is not None assert client_span.attributes is not None # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "resources/read" assert "data://" in str(client_span.attributes["mcp.resource.uri"]) # Standard RPC semantic conventions assert client_span.attributes["rpc.system"] == "mcp" assert client_span.attributes["rpc.method"] == "resources/read" # FastMCP-specific attributes # The URI may be normalized with trailing slash assert "data://" in str(client_span.attributes["fastmcp.component.key"]) class TestClientPromptTracing: """Tests for client prompt get tracing.""" async def test_get_prompt_creates_span(self, trace_exporter: InMemorySpanExporter): server = FastMCP("test-server") @server.prompt() def greeting() -> str: return "Hello from prompt!" client = Client(server) async with client: result = await client.get_prompt("greeting") assert "Hello from prompt!" in str(result) spans = trace_exporter.get_finished_spans() span_names = [s.name for s in spans] # Client should create "prompts/get greeting" span assert "prompts/get greeting" in span_names async def test_get_prompt_span_attributes( self, trace_exporter: InMemorySpanExporter ): server = FastMCP("test-server") @server.prompt() def welcome(name: str) -> str: return f"Welcome, {name}!" client = Client(server) async with client: await client.get_prompt("welcome", {"name": "Test"}) spans = trace_exporter.get_finished_spans() # Find client-side prompt span (doesn't have fastmcp.server.name) client_span = next( ( s for s in spans if s.name == "prompts/get welcome" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) assert client_span is not None assert client_span.attributes is not None # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "prompts/get" # Standard RPC semantic conventions assert client_span.attributes["rpc.system"] == "mcp" assert client_span.attributes["rpc.method"] == "prompts/get" # FastMCP-specific attributes assert client_span.attributes["fastmcp.component.key"] == "welcome" class TestClientServerSpanHierarchy: """Tests for span relationships between client and server.""" async def test_client_and_server_spans_created( self, trace_exporter: InMemorySpanExporter ): """Both client and server should create spans for the same operation.""" server = FastMCP("test-server") @server.tool() def echo(message: str) -> str: return message client = Client(server) async with client: await client.call_tool("echo", {"message": "test"}) spans = trace_exporter.get_finished_spans() # Find client span (no fastmcp.server.name) and server span (has fastmcp.server.name) client_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) server_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) # Both spans should exist assert client_span is not None, "Client should create a span" assert client_span.attributes is not None assert server_span is not None, "Server should create a span" assert server_span.attributes is not None # Verify span kinds are correct assert client_span.kind == SpanKind.CLIENT, "Client span should be CLIENT kind" assert server_span.kind == SpanKind.SERVER, "Server span should be SERVER kind" # Verify the spans have different characteristics assert client_span.attributes["rpc.method"] == "tools/call" assert server_span.attributes["fastmcp.server.name"] == "test-server" async def test_trace_context_propagation( self, trace_exporter: InMemorySpanExporter ): """Server span should be a child of client span via trace context propagation.""" server = FastMCP("test-server") @server.tool() def add(a: int, b: int) -> int: return a + b client = Client(server) async with client: await client.call_tool("add", {"a": 1, "b": 2}) spans = trace_exporter.get_finished_spans() # Find client span and server span client_span = next( ( s for s in spans if s.name == "tools/call add" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) server_span = next( ( s for s in spans if s.name == "tools/call add" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) assert client_span is not None, "Client span should exist" assert server_span is not None, "Server span should exist" # Verify trace context propagation: server span should be child of client span # Both should share the same trace_id assert server_span.context.trace_id == client_span.context.trace_id, ( "Server and client spans should share the same trace_id" ) # Server span's parent should be the client span assert server_span.parent is not None, "Server span should have a parent" assert server_span.parent.span_id == client_span.context.span_id, ( "Server span's parent should be the client span" ) class TestClientErrorTracing: """Tests for client span creation during errors. Note: MCP protocol errors are returned as successful responses with error content, so client spans may not have ERROR status even when the operation fails. This is different from server-side where exceptions happen inside the span. The server-side span WILL have ERROR status because the exception occurs within the server's span context. The client span represents the successful MCP protocol round-trip, while application-level errors are communicated via the response. """ async def test_call_tool_error_creates_spans( self, trace_exporter: InMemorySpanExporter ): """Both client and server spans should be created when tool fails.""" server = FastMCP("test-server") @server.tool() def failing_tool() -> str: raise ValueError("Something went wrong") client = Client(server) async with client: with pytest.raises(ToolError): await client.call_tool("failing_tool", {}) spans = trace_exporter.get_finished_spans() # Find client-side span client_span = next( ( s for s in spans if s.name == "tools/call failing_tool" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) # Find server-side span server_span = next( ( s for s in spans if s.name == "tools/call failing_tool" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) # Both spans should exist assert client_span is not None, "Client should create a span" assert server_span is not None, "Server should create a span" # Server span should have ERROR status (exception inside span) assert server_span.status.status_code == StatusCode.ERROR async def test_read_resource_error_creates_spans( self, trace_exporter: InMemorySpanExporter ): """Both client and server spans should be created when resource read fails.""" server = FastMCP("test-server") @server.resource("data://fail") def failing_resource() -> str: raise ValueError("Resource error") client = Client(server) async with client: with pytest.raises(Exception): await client.read_resource("data://fail") spans = trace_exporter.get_finished_spans() # Find client-side span client_span = next( ( s for s in spans if s.name.startswith("resources/read data://fail") and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) # Find server-side span server_span = next( ( s for s in spans if s.name.startswith("resources/read data://fail") and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) # Both spans should exist assert client_span is not None, "Client should create a span" assert server_span is not None, "Server should create a span" # Server span should have ERROR status assert server_span.status.status_code == StatusCode.ERROR async def test_get_prompt_error_creates_spans( self, trace_exporter: InMemorySpanExporter ): """Both client and server spans should be created when prompt get fails.""" server = FastMCP("test-server") @server.prompt() def failing_prompt() -> str: raise ValueError("Prompt error") client = Client(server) async with client: with pytest.raises(Exception): await client.get_prompt("failing_prompt", {}) spans = trace_exporter.get_finished_spans() # Find client-side span client_span = next( ( s for s in spans if s.name == "prompts/get failing_prompt" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) # Find server-side span server_span = next( ( s for s in spans if s.name == "prompts/get failing_prompt" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) # Both spans should exist assert client_span is not None, "Client should create a span" assert server_span is not None, "Server should create a span" # Server span should have ERROR status assert server_span.status.status_code == StatusCode.ERROR async def test_call_nonexistent_tool_creates_spans( self, trace_exporter: InMemorySpanExporter ): """Both client and server spans should be created for nonexistent tool.""" server = FastMCP("test-server") client = Client(server) async with client: with pytest.raises(Exception): await client.call_tool("nonexistent", {}) spans = trace_exporter.get_finished_spans() # Find client-side span client_span = next( ( s for s in spans if s.name == "tools/call nonexistent" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) # Find server-side span server_span = next( ( s for s in spans if s.name == "tools/call nonexistent" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) # Both spans should exist assert client_span is not None, "Client should create a span" assert server_span is not None, "Server should create a span" # Server span should have ERROR status assert server_span.status.status_code == StatusCode.ERROR class TestSessionIdOnSpans: """Tests for session ID capture on client and server spans. Session IDs are only available with HTTP transport (StreamableHttp). """ @pytest.fixture async def http_server_url(self) -> AsyncGenerator[str, None]: """Start an HTTP server and return its URL.""" from fastmcp.utilities.tests import run_server_async server = FastMCP("session-test-server") @server.tool() def echo(message: str) -> str: return message async with run_server_async(server) as url: yield url async def test_client_span_includes_session_id( self, trace_exporter: InMemorySpanExporter, http_server_url: str, ): """Client span should include session ID when using HTTP transport.""" from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport(http_server_url) client = Client(transport=transport) async with client: await client.call_tool("echo", {"message": "test"}) spans = trace_exporter.get_finished_spans() # Find client-side span client_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) assert client_span is not None, "Client should create a span" assert client_span.attributes is not None assert "mcp.session.id" in client_span.attributes assert client_span.attributes["mcp.session.id"] is not None async def test_server_span_includes_session_id( self, trace_exporter: InMemorySpanExporter, http_server_url: str, ): """Server span should include session ID when called via HTTP.""" from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport(http_server_url) client = Client(transport=transport) async with client: await client.call_tool("echo", {"message": "test"}) spans = trace_exporter.get_finished_spans() # Find server-side span server_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) assert server_span is not None, "Server should create a span" assert server_span.attributes is not None assert "mcp.session.id" in server_span.attributes assert server_span.attributes["mcp.session.id"] is not None async def test_client_and_server_share_same_session_id( self, trace_exporter: InMemorySpanExporter, http_server_url: str, ): """Client and server spans should have the same session ID.""" from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport(http_server_url) client = Client(transport=transport) async with client: await client.call_tool("echo", {"message": "test"}) spans = trace_exporter.get_finished_spans() # Find both spans client_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" not in s.attributes ), None, ) server_span = next( ( s for s in spans if s.name == "tools/call echo" and s.attributes is not None and "fastmcp.server.name" in s.attributes ), None, ) assert client_span is not None assert client_span.attributes is not None assert server_span is not None assert server_span.attributes is not None # Both should have session IDs and they should match client_session = client_span.attributes.get("mcp.session.id") server_session = server_span.attributes.get("mcp.session.id") assert client_session is not None, "Client span should have session ID" assert server_session is not None, "Server span should have session ID" assert client_session == server_session, ( "Client and server should share the same session ID" ) ================================================ FILE: tests/client/test_elicitation.py ================================================ from dataclasses import asdict, dataclass from enum import Enum from typing import Any, Literal, cast import pytest from mcp.types import ElicitRequestFormParams, ElicitRequestParams from pydantic import BaseModel from typing_extensions import TypedDict from fastmcp import Context, FastMCP from fastmcp.client.client import Client from fastmcp.client.elicitation import ElicitResult from fastmcp.exceptions import ToolError from fastmcp.server.elicitation import ( AcceptedElicitation, CancelledElicitation, DeclinedElicitation, validate_elicitation_json_schema, ) from fastmcp.utilities.types import TypeAdapter @pytest.fixture def fastmcp_server(): mcp = FastMCP("TestServer") @dataclass class Person: name: str @mcp.tool async def ask_for_name(context: Context) -> str: result = await context.elicit( message="What is your name?", response_type=Person, ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, Person) return f"Hello, {result.data.name}!" else: return "No name provided." @mcp.tool def simple_test() -> str: return "Hello!" return mcp async def test_elicitation_with_no_handler(fastmcp_server): """Test that elicitation works without a handler.""" async with Client(fastmcp_server) as client: with pytest.raises(ToolError, match="Elicitation not supported"): await client.call_tool("ask_for_name") async def test_elicitation_accept_content(fastmcp_server): """Test basic elicitation functionality.""" async def elicitation_handler(message, response_type, params, ctx): # Mock user providing their name return ElicitResult(action="accept", content=response_type(name="Alice")) async with Client( fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "Hello, Alice!" async def test_elicitation_decline(fastmcp_server): """Test that elicitation handler receives correct parameters.""" async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="decline") async with Client( fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "No name provided." async def test_elicitation_handler_parameters(): """Test that elicitation handler receives correct parameters.""" mcp = FastMCP("TestServer") captured_params = {} @mcp.tool async def test_tool(context: Context) -> str: await context.elicit( message="Test message", response_type=int, ) return "done" async def elicitation_handler(message, response_type, params, ctx): captured_params["message"] = message captured_params["response_type"] = str(response_type) captured_params["params"] = params captured_params["ctx"] = ctx return ElicitResult(action="accept", content={"value": 42}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: await client.call_tool("test_tool", {}) assert captured_params["message"] == "Test message" assert "ScalarElicitationType" in str(captured_params["response_type"]) assert captured_params["params"].requestedSchema == { "properties": {"value": {"title": "Value", "type": "integer"}}, "required": ["value"], "title": "ScalarElicitationType", "type": "object", } assert captured_params["ctx"] is not None async def test_elicitation_cancel_action(): """Test user canceling elicitation request.""" mcp = FastMCP("TestServer") @mcp.tool async def ask_for_optional_info(context: Context) -> str: result = await context.elicit( message="Optional: What's your age?", response_type=int ) if result.action == "cancel": return "Request was canceled" elif result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, int) return f"Age: {result.data}" else: return "No response provided" async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="cancel") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("ask_for_optional_info", {}) assert result.data == "Request was canceled" class TestScalarResponseTypes: async def test_elicitation_no_response(self): """Test elicitation with no response type.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> dict[str, Any]: result = await context.elicit(message="", response_type=None) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, dict) return cast(dict[str, Any], result.data) async def elicitation_handler( message, response_type, params: ElicitRequestParams, ctx ): assert isinstance(params, ElicitRequestFormParams) assert params.requestedSchema == {"type": "object", "properties": {}} assert response_type is None return ElicitResult(action="accept") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data is None async def test_elicitation_empty_response(self): """Test elicitation with empty response type.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> dict[str, Any]: result = await context.elicit(message="", response_type=None) assert result.action == "accept" assert isinstance(result, AcceptedElicitation) accepted = cast(AcceptedElicitation[dict[str, Any]], result) assert isinstance(accepted.data, dict) return accepted.data async def elicitation_handler( message, response_type, params: ElicitRequestParams, ctx ): return ElicitResult(action="accept", content={}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data is None async def test_elicitation_response_when_no_response_requested(self): """Test elicitation with no response type.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> dict[str, Any]: result = await context.elicit(message="", response_type=None) assert result.action == "accept" assert isinstance(result, AcceptedElicitation) accepted = cast(AcceptedElicitation[dict[str, Any]], result) assert isinstance(accepted.data, dict) return accepted.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "hello"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: with pytest.raises( ToolError, match="Elicitation expected an empty response" ): await client.call_tool("my_tool", {}) async def test_elicitation_str_response(self): """Test elicitation with string schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> str: result = await context.elicit(message="", response_type=str) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, str) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "hello"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "hello" async def test_elicitation_int_response(self): """Test elicitation with number schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> int: result = await context.elicit(message="", response_type=int) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, int) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": 42}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == 42 async def test_elicitation_float_response(self): """Test elicitation with number schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> float: result = await context.elicit(message="", response_type=float) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, float) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": 3.14}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == 3.14 async def test_elicitation_bool_response(self): """Test elicitation with boolean schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> bool: result = await context.elicit(message="", response_type=bool) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, bool) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": True}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data is True async def test_elicitation_literal_response(self): """Test elicitation with literal schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> Literal["x", "y"]: # Literal types work at runtime but type checker doesn't recognize them in overloads result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore[arg-type] assert isinstance(result, AcceptedElicitation) accepted = cast(AcceptedElicitation[Literal["x", "y"]], result) assert isinstance(accepted.data, str) return accepted.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" async def test_elicitation_enum_response(self): """Test elicitation with enum schema.""" mcp = FastMCP("TestServer") class ResponseEnum(Enum): X = "x" Y = "y" @mcp.tool async def my_tool(context: Context) -> ResponseEnum: result = await context.elicit(message="", response_type=ResponseEnum) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, ResponseEnum) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" async def test_elicitation_list_of_strings_response(self): """Test elicitation with list schema.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(context: Context) -> str: result = await context.elicit(message="", response_type=["x", "y"]) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, str) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "x"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "x" async def test_elicitation_handler_error(): """Test error handling in elicitation handler.""" mcp = FastMCP("TestServer") @mcp.tool async def failing_elicit(context: Context) -> str: try: result = await context.elicit(message="This will fail", response_type=str) assert isinstance(result, AcceptedElicitation) assert result.action == "accept" return f"Got: {result.data}" except Exception as e: return f"Error: {str(e)}" async def elicitation_handler(message, response_type, params, ctx): raise ValueError("Handler failed!") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("failing_elicit", {}) assert "Error:" in result.data async def test_elicitation_multiple_calls(): """Test multiple elicitation calls in sequence.""" mcp = FastMCP("TestServer") @mcp.tool async def multi_step_form(context: Context) -> str: # First question name_result = await context.elicit( message="What's your name?", response_type=str ) assert isinstance(name_result, AcceptedElicitation) if name_result.action != "accept": return "Form abandoned" # Second question age_result = await context.elicit(message="What's your age?", response_type=int) assert isinstance(age_result, AcceptedElicitation) if age_result.action != "accept": return f"Hello {name_result.data}, form incomplete" return f"Hello {name_result.data}, you are {age_result.data} years old" call_count = 0 async def elicitation_handler(message, response_type, params, ctx): nonlocal call_count call_count += 1 if call_count == 1: return ElicitResult(action="accept", content={"value": "Bob"}) elif call_count == 2: return ElicitResult(action="accept", content={"value": 25}) else: raise ValueError("Unexpected call") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("multi_step_form", {}) assert result.data == "Hello Bob, you are 25 years old" assert call_count == 2 @dataclass class UserInfo: name: str age: int class UserInfoTypedDict(TypedDict): name: str age: int class UserInfoPydantic(BaseModel): name: str age: int @pytest.mark.parametrize( "structured_type", [UserInfo, UserInfoTypedDict, UserInfoPydantic] ) async def test_structured_response_type( structured_type: type[UserInfo | UserInfoTypedDict | UserInfoPydantic], ): """Test elicitation with dataclass response type.""" mcp = FastMCP("TestServer") @mcp.tool async def get_user_info(context: Context) -> str: result = await context.elicit( message="Please provide your information", response_type=structured_type ) assert isinstance(result, AcceptedElicitation) if result.action == "accept": assert isinstance(result, AcceptedElicitation) if isinstance(result.data, dict): data_dict = cast(dict[str, Any], result.data) name = data_dict.get("name") age = data_dict.get("age") assert name is not None assert age is not None return f"User: {name}, age: {age}" else: # result.data is a structured type (UserInfo, UserInfoTypedDict, or UserInfoPydantic) assert hasattr(result.data, "name") assert hasattr(result.data, "age") return f"User: {result.data.name}, age: {result.data.age}" return "No user info provided" async def elicitation_handler(message, response_type, params, ctx): # Verify we get the dataclass type assert ( TypeAdapter(response_type).json_schema() == TypeAdapter(structured_type).json_schema() ) # Verify the schema has the dataclass fields (available in params) schema = params.requestedSchema assert schema["type"] == "object" assert "name" in schema["properties"] assert "age" in schema["properties"] assert schema["properties"]["name"]["type"] == "string" assert schema["properties"]["age"]["type"] == "integer" return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30)) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("get_user_info", {}) assert result.data == "User: Alice, age: 30" async def test_all_primitive_field_types(): class DataEnum(Enum): X = "x" Y = "y" @dataclass class Data: integer: int float_: float number: int | float boolean: bool string: str constant: Literal["x"] union: Literal["x"] | Literal["y"] choice: Literal["x", "y"] enum: DataEnum mcp = FastMCP("TestServer") @mcp.tool async def get_data(context: Context) -> Data: result = await context.elicit(message="Enter data", response_type=Data) assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, Data) return result.data async def elicitation_handler(message, response_type, params, ctx): return ElicitResult( action="accept", content=Data( integer=1, float_=1.0, number=1.0, boolean=True, string="hello", constant="x", union="x", choice="x", enum=DataEnum.X, ), ) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("get_data", {}) # Now all literal/enum fields should be preserved as strings result_data = asdict(result.data) result_data_enum = result_data.pop("enum") assert result_data_enum == "x" # Should be a string now, not an enum assert result_data == { "integer": 1, "float_": 1.0, "number": 1.0, "boolean": True, "string": "hello", "constant": "x", "union": "x", "choice": "x", } class TestValidation: async def test_schema_validation_rejects_non_object(self): """Test that non-object schemas are rejected.""" with pytest.raises(TypeError, match="must be an object schema"): validate_elicitation_json_schema({"type": "string"}) async def test_schema_validation_rejects_nested_objects(self): """Test that nested object schemas are rejected.""" with pytest.raises( TypeError, match="is an object, but nested objects are not allowed" ): validate_elicitation_json_schema( { "type": "object", "properties": { "user": { "type": "object", "properties": {"name": {"type": "string"}}, } }, } ) async def test_schema_validation_rejects_arrays(self): """Test that non-enum array schemas are rejected.""" with pytest.raises(TypeError, match="is an array, but arrays are only allowed"): validate_elicitation_json_schema( { "type": "object", "properties": { "users": {"type": "array", "items": {"type": "string"}} }, } ) class TestPatternMatching: async def test_pattern_matching_accept(self): """Test pattern matching with AcceptedElicitation.""" mcp = FastMCP("TestServer") @mcp.tool async def pattern_match_tool(context: Context) -> str: result = await context.elicit("Enter your name:", response_type=str) match result: case AcceptedElicitation(data=name): return f"Hello {name}!" case DeclinedElicitation(): return "You declined" case CancelledElicitation(): return "Cancelled" case _: return "Unknown result" async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="accept", content={"value": "Alice"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "Hello Alice!" async def test_pattern_matching_decline(self): """Test pattern matching with DeclinedElicitation.""" mcp = FastMCP("TestServer") @mcp.tool async def pattern_match_tool(context: Context) -> str: result = await context.elicit("Enter your name:", response_type=str) match result: case AcceptedElicitation(data=name): return f"Hello {name}!" case DeclinedElicitation(): return "You declined" case CancelledElicitation(): return "Cancelled" case _: return "Unknown result" async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="decline") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "You declined" async def test_pattern_matching_cancel(self): """Test pattern matching with CancelledElicitation.""" mcp = FastMCP("TestServer") @mcp.tool async def pattern_match_tool(context: Context) -> str: result = await context.elicit("Enter your name:", response_type=str) match result: case AcceptedElicitation(data=name): return f"Hello {name}!" case DeclinedElicitation(): return "You declined" case CancelledElicitation(): return "Cancelled" case _: return "Unknown result" async def elicitation_handler(message, response_type, params, ctx): return ElicitResult(action="cancel") async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("pattern_match_tool", {}) assert result.data == "Cancelled" ================================================ FILE: tests/client/test_elicitation_enums.py ================================================ """Tests for enum-based elicitation, multi-select, and default values.""" from dataclasses import dataclass from enum import Enum import pytest from pydantic import BaseModel, Field from fastmcp import Context, FastMCP from fastmcp.client.client import Client from fastmcp.client.elicitation import ElicitResult from fastmcp.exceptions import ToolError from fastmcp.server.elicitation import ( AcceptedElicitation, get_elicitation_schema, validate_elicitation_json_schema, ) @pytest.fixture def fastmcp_server(): mcp = FastMCP("TestServer") @dataclass class Person: name: str @mcp.tool async def ask_for_name(context: Context) -> str: result = await context.elicit( message="What is your name?", response_type=Person, ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, Person) return f"Hello, {result.data.name}!" else: return "No name provided." @mcp.tool def simple_test() -> str: return "Hello!" return mcp async def test_elicitation_implicit_acceptance(fastmcp_server): """Test that elicitation handler can return data directly without ElicitResult wrapper.""" async def elicitation_handler(message, response_type, params, ctx): # Return data directly without wrapping in ElicitResult # This should be treated as implicit acceptance return response_type(name="Bob") async with Client( fastmcp_server, elicitation_handler=elicitation_handler ) as client: result = await client.call_tool("ask_for_name") assert result.data == "Hello, Bob!" async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server): """Test that elicitation handler can return data directly without ElicitResult wrapper.""" async def elicitation_handler(message, response_type, params, ctx): # Return data directly without wrapping in ElicitResult # This should be treated as implicit acceptance return "Bob" async with Client( fastmcp_server, elicitation_handler=elicitation_handler ) as client: with pytest.raises( ToolError, match="Elicitation responses must be serializable as a JSON object", ): await client.call_tool("ask_for_name") def test_enum_elicitation_schema_inline(): """Test that enum schemas are generated inline without $ref/$defs for MCP compatibility.""" class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" @dataclass class TaskRequest: title: str priority: Priority # Generate elicitation schema schema = get_elicitation_schema(TaskRequest) # Verify no $defs section exists (enums should be inlined) assert "$defs" not in schema, ( "Schema should not contain $defs - enums must be inline" ) # Verify no $ref in properties for prop_name, prop_schema in schema.get("properties", {}).items(): assert "$ref" not in prop_schema, ( f"Property {prop_name} contains $ref - should be inline" ) # Verify the priority field has inline enum values priority_schema = schema["properties"]["priority"] assert "enum" in priority_schema, "Priority should have enum values inline" assert priority_schema["enum"] == ["low", "medium", "high"] assert priority_schema.get("type") == "string" # Verify title field is a simple string assert schema["properties"]["title"]["type"] == "string" def test_enum_elicitation_schema_inline_untitled(): """Test that enum schemas generate simple enum pattern (no automatic titles).""" class TaskStatus(Enum): NOT_STARTED = "not_started" IN_PROGRESS = "in_progress" COMPLETED = "completed" ON_HOLD = "on_hold" @dataclass class TaskUpdate: task_id: str status: TaskStatus # Generate elicitation schema schema = get_elicitation_schema(TaskUpdate) # Verify enum is inline assert "$defs" not in schema assert "$ref" not in str(schema) status_schema = schema["properties"]["status"] # Should generate simple enum pattern (no automatic title generation) assert "enum" in status_schema assert "oneOf" not in status_schema assert "enumNames" not in status_schema assert status_schema["enum"] == [ "not_started", "in_progress", "completed", "on_hold", ] async def test_dict_based_titled_single_select(): """Test dict-based titled single-select enum.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(ctx: Context) -> str: result = await ctx.elicit( "Choose priority", response_type={ "low": {"title": "Low Priority"}, "high": {"title": "High Priority"}, }, ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, str) return result.data return "declined" async def elicitation_handler(message, response_type, params, ctx): # Verify schema follows SEP-1330 pattern with type: "string" schema = params.requestedSchema assert schema["type"] == "object" assert "value" in schema["properties"] value_schema = schema["properties"]["value"] assert value_schema["type"] == "string" assert "oneOf" in value_schema one_of = value_schema["oneOf"] assert {"const": "low", "title": "Low Priority"} in one_of assert {"const": "high", "title": "High Priority"} in one_of return ElicitResult(action="accept", content={"value": "low"}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low" async def test_list_list_multi_select_untitled(): """Test list[list[str]] for multi-select untitled shorthand.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(ctx: Context) -> str: result = await ctx.elicit( "Choose tags", response_type=[["bug", "feature", "documentation"]], ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) return ",".join(result.data) # type: ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): # Verify schema has array with enum pattern schema = params.requestedSchema assert schema["type"] == "object" assert "value" in schema["properties"] value_schema = schema["properties"]["value"] assert value_schema["type"] == "array" assert "enum" in value_schema["items"] assert value_schema["items"]["enum"] == ["bug", "feature", "documentation"] return ElicitResult(action="accept", content={"value": ["bug", "feature"]}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "bug,feature" async def test_list_dict_multi_select_titled(): """Test list[dict] for multi-select titled.""" mcp = FastMCP("TestServer") @mcp.tool async def my_tool(ctx: Context) -> str: result = await ctx.elicit( "Choose priorities", response_type=[ { "low": {"title": "Low Priority"}, "high": {"title": "High Priority"}, } ], ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) return ",".join(result.data) # type: ignore[no-matching-overload] return "declined" async def elicitation_handler(message, response_type, params, ctx): # Verify schema has array with SEP-1330 compliant items (anyOf pattern) schema = params.requestedSchema assert schema["type"] == "object" assert "value" in schema["properties"] value_schema = schema["properties"]["value"] assert value_schema["type"] == "array" items_schema = value_schema["items"] assert "anyOf" in items_schema any_of = items_schema["anyOf"] assert {"const": "low", "title": "Low Priority"} in any_of assert {"const": "high", "title": "High Priority"} in any_of return ElicitResult(action="accept", content={"value": ["low", "high"]}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low,high" async def test_list_enum_multi_select(): """Test list[Enum] for multi-select with enum in dataclass field.""" class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" @dataclass class TaskRequest: priorities: list[Priority] schema = get_elicitation_schema(TaskRequest) priorities_schema = schema["properties"]["priorities"] assert priorities_schema["type"] == "array" assert "items" in priorities_schema items_schema = priorities_schema["items"] # Should have enum pattern for untitled enums assert "enum" in items_schema assert items_schema["enum"] == ["low", "medium", "high"] async def test_list_enum_multi_select_direct(): """Test list[Enum] type annotation passed directly to ctx.elicit().""" mcp = FastMCP("TestServer") class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" @mcp.tool async def my_tool(ctx: Context) -> str: result = await ctx.elicit( "Choose priorities", response_type=list[Priority], # Type annotation for multi-select ) if result.action == "accept": assert isinstance(result, AcceptedElicitation) assert isinstance(result.data, list) priorities = result.data return ",".join( [p.value if isinstance(p, Priority) else str(p) for p in priorities] ) return "declined" async def elicitation_handler(message, response_type, params, ctx): # Verify schema has array with enum pattern schema = params.requestedSchema assert schema["type"] == "object" assert "value" in schema["properties"] value_schema = schema["properties"]["value"] assert value_schema["type"] == "array" assert "enum" in value_schema["items"] assert value_schema["items"]["enum"] == ["low", "medium", "high"] return ElicitResult(action="accept", content={"value": ["low", "high"]}) async with Client(mcp, elicitation_handler=elicitation_handler) as client: result = await client.call_tool("my_tool", {}) assert result.data == "low,high" async def test_validation_allows_enum_arrays(): """Test validation accepts arrays with enum items.""" schema = { "type": "object", "properties": { "priorities": { "type": "array", "items": {"enum": ["low", "medium", "high"]}, } }, } validate_elicitation_json_schema(schema) # Should not raise async def test_validation_allows_enum_arrays_with_anyof(): """Test validation accepts arrays with anyOf enum pattern (SEP-1330 compliant).""" schema = { "type": "object", "properties": { "priorities": { "type": "array", "items": { "anyOf": [ {"const": "low", "title": "Low Priority"}, {"const": "high", "title": "High Priority"}, ] }, } }, } validate_elicitation_json_schema(schema) # Should not raise async def test_validation_rejects_non_enum_arrays(): """Test validation still rejects arrays of objects.""" schema = { "type": "object", "properties": { "users": { "type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}}}, } }, } with pytest.raises(TypeError, match="array of objects"): validate_elicitation_json_schema(schema) async def test_validation_rejects_primitive_arrays(): """Test validation rejects arrays of primitives without enum pattern.""" schema = { "type": "object", "properties": { "names": {"type": "array", "items": {"type": "string"}}, }, } with pytest.raises(TypeError, match="arrays are only allowed"): validate_elicitation_json_schema(schema) class TestElicitationDefaults: """Test suite for default values in elicitation schemas.""" def test_string_default_preserved(self): """Test that string defaults are preserved in the schema.""" class Model(BaseModel): email: str = Field(default="[email protected]") schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "email" in props assert "default" in props["email"] assert props["email"]["default"] == "[email protected]" assert props["email"]["type"] == "string" def test_integer_default_preserved(self): """Test that integer defaults are preserved in the schema.""" class Model(BaseModel): count: int = Field(default=50) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "count" in props assert "default" in props["count"] assert props["count"]["default"] == 50 assert props["count"]["type"] == "integer" def test_number_default_preserved(self): """Test that number defaults are preserved in the schema.""" class Model(BaseModel): price: float = Field(default=3.14) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "price" in props assert "default" in props["price"] assert props["price"]["default"] == 3.14 assert props["price"]["type"] == "number" def test_boolean_default_preserved(self): """Test that boolean defaults are preserved in the schema.""" class Model(BaseModel): enabled: bool = Field(default=False) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "enabled" in props assert "default" in props["enabled"] assert props["enabled"]["default"] is False assert props["enabled"]["type"] == "boolean" def test_enum_default_preserved(self): """Test that enum defaults are preserved in the schema.""" class Priority(Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" class Model(BaseModel): choice: Priority = Field(default=Priority.MEDIUM) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "choice" in props assert "default" in props["choice"] assert props["choice"]["default"] == "medium" assert "enum" in props["choice"] assert props["choice"]["type"] == "string" def test_all_defaults_preserved_together(self): """Test that all default types are preserved when used together.""" class Priority(Enum): A = "A" B = "B" class Model(BaseModel): string_field: str = Field(default="[email protected]") integer_field: int = Field(default=50) number_field: float = Field(default=3.14) boolean_field: bool = Field(default=False) enum_field: Priority = Field(default=Priority.A) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert props["string_field"]["default"] == "[email protected]" assert props["integer_field"]["default"] == 50 assert props["number_field"]["default"] == 3.14 assert props["boolean_field"]["default"] is False assert props["enum_field"]["default"] == "A" def test_mixed_defaults_and_required(self): """Test that fields with defaults are not in required list.""" class Model(BaseModel): required_field: str = Field(description="Required field") optional_with_default: int = Field(default=42) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) required = schema.get("required", []) assert "required_field" in required assert "optional_with_default" not in required assert props["optional_with_default"]["default"] == 42 def test_compress_schema_preserves_defaults(self): """Test that compress_schema() doesn't strip default values.""" class Model(BaseModel): string_field: str = Field(default="test") integer_field: int = Field(default=42) schema = get_elicitation_schema(Model) props = schema.get("properties", {}) assert "default" in props["string_field"] assert "default" in props["integer_field"] ================================================ FILE: tests/client/test_logs.py ================================================ import logging import pytest from mcp import LoggingLevel from fastmcp import Client, Context, FastMCP from fastmcp.client.logging import LogMessage class LogHandler: def __init__(self): self.logs: list[LogMessage] = [] self.logger = logging.getLogger(__name__) # Backwards-compatible way to get the log level mapping if hasattr(logging, "getLevelNamesMapping"): # For Python 3.11+ self.LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() # pyright: ignore [reportAttributeAccessIssue] else: # For older Python versions self.LOGGING_LEVEL_MAP = logging._nameToLevel async def handle_log(self, message: LogMessage) -> None: self.logs.append(message) level = self.LOGGING_LEVEL_MAP[message.level.upper()] msg = message.data.get("msg") extra = message.data.get("extra") self.logger.log(level, msg, extra=extra) @pytest.fixture def fastmcp_server(): mcp = FastMCP() @mcp.tool async def log(context: Context) -> None: await context.info(message="hello?") @mcp.tool async def echo_log( message: str, context: Context, level: LoggingLevel | None = None, logger: str | None = None, ) -> None: await context.log(message=message, level=level) return mcp class TestClientLogs: async def test_log(self, fastmcp_server: FastMCP, caplog): caplog.set_level(logging.INFO, logger=__name__) log_handler = LogHandler() async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.call_tool("log", {}) assert len(log_handler.logs) == 1 assert log_handler.logs[0].data["msg"] == "hello?" assert log_handler.logs[0].level == "info" assert len(caplog.records) == 1 assert caplog.records[0].msg == "hello?" assert caplog.records[0].levelname == "INFO" async def test_echo_log(self, fastmcp_server: FastMCP, caplog): caplog.set_level(logging.INFO, logger=__name__) log_handler = LogHandler() async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.call_tool("echo_log", {"message": "this is a log"}) assert len(log_handler.logs) == 1 assert len(caplog.records) == 1 await client.call_tool( "echo_log", {"message": "this is a warning log", "level": "warning"} ) assert len(log_handler.logs) == 2 assert len(caplog.records) == 2 assert log_handler.logs[0].data["msg"] == "this is a log" assert log_handler.logs[0].level == "info" assert log_handler.logs[1].data["msg"] == "this is a warning log" assert log_handler.logs[1].level == "warning" assert caplog.records[0].msg == "this is a log" assert caplog.records[0].levelname == "INFO" assert caplog.records[1].msg == "this is a warning log" assert caplog.records[1].levelname == "WARNING" class TestSetLoggingLevel: async def test_set_logging_level(self, fastmcp_server: FastMCP): """Client can set the minimum log level and lower-level messages are suppressed.""" log_handler = LogHandler() async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.set_logging_level("warning") await client.call_tool( "echo_log", {"message": "debug msg", "level": "debug"} ) await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) await client.call_tool( "echo_log", {"message": "warning msg", "level": "warning"} ) await client.call_tool( "echo_log", {"message": "error msg", "level": "error"} ) assert len(log_handler.logs) == 2 assert log_handler.logs[0].data["msg"] == "warning msg" assert log_handler.logs[1].data["msg"] == "error msg" async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP): """Setting level to debug allows all messages through.""" log_handler = LogHandler() async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.set_logging_level("debug") await client.call_tool( "echo_log", {"message": "debug msg", "level": "debug"} ) await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) assert len(log_handler.logs) == 2 async def test_default_level_allows_all(self, fastmcp_server: FastMCP): """Without calling set_logging_level, all messages are sent.""" log_handler = LogHandler() async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client: await client.call_tool( "echo_log", {"message": "debug msg", "level": "debug"} ) await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) assert len(log_handler.logs) == 2 async def test_server_default_client_log_level(self): """Server-wide client_log_level filters messages for all sessions.""" mcp = FastMCP(client_log_level="error") @mcp.tool async def echo_log( message: str, context: Context, level: LoggingLevel | None = None ) -> None: await context.log(message=message, level=level) log_handler = LogHandler() async with Client(mcp, log_handler=log_handler.handle_log) as client: await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) await client.call_tool( "echo_log", {"message": "warning msg", "level": "warning"} ) await client.call_tool( "echo_log", {"message": "error msg", "level": "error"} ) assert len(log_handler.logs) == 1 assert log_handler.logs[0].data["msg"] == "error msg" async def test_session_level_overrides_server_default(self): """Per-session setLevel overrides the server's client_log_level.""" mcp = FastMCP(client_log_level="error") @mcp.tool async def echo_log( message: str, context: Context, level: LoggingLevel | None = None ) -> None: await context.log(message=message, level=level) log_handler = LogHandler() async with Client(mcp, log_handler=log_handler.handle_log) as client: await client.set_logging_level("warning") await client.call_tool("echo_log", {"message": "info msg", "level": "info"}) await client.call_tool( "echo_log", {"message": "warning msg", "level": "warning"} ) await client.call_tool( "echo_log", {"message": "error msg", "level": "error"} ) assert len(log_handler.logs) == 2 assert log_handler.logs[0].data["msg"] == "warning msg" assert log_handler.logs[1].data["msg"] == "error msg" class TestDefaultLogHandler: """Tests for default_log_handler with data as any JSON-serializable type.""" async def test_default_handler_routes_to_correct_levels(self): """Test that default_log_handler routes server logs to appropriate Python log levels.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams from fastmcp.client.logging import default_log_handler with patch("fastmcp.client.logging.from_server_logger") as mock_logger: # Set up mock methods mock_logger.debug = MagicMock() mock_logger.info = MagicMock() mock_logger.warning = MagicMock() mock_logger.error = MagicMock() mock_logger.critical = MagicMock() # Test each log level test_cases = [ ("debug", mock_logger.debug, "Debug message"), ("info", mock_logger.info, "Info message"), ("notice", mock_logger.info, "Notice message"), # notice -> info ("warning", mock_logger.warning, "Warning message"), ("error", mock_logger.error, "Error message"), ("critical", mock_logger.critical, "Critical message"), ("alert", mock_logger.critical, "Alert message"), # alert -> critical ( "emergency", mock_logger.critical, "Emergency message", ), # emergency -> critical ] for level, expected_method, msg in test_cases: # Reset mocks mock_logger.reset_mock() # Create log message with data as a string log_msg = LoggingMessageNotificationParams( level=level, # type: ignore[arg-type] logger="test.logger", data=msg, ) # Call handler await default_log_handler(log_msg) # Verify correct method was called expected_method.assert_called_once_with( msg=f"Received {level.upper()} from server (test.logger): {msg}" ) async def test_default_handler_without_logger_name(self): """Test that default_log_handler works when logger name is None.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams from fastmcp.client.logging import default_log_handler with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.info = MagicMock() log_msg = LoggingMessageNotificationParams( level="info", logger=None, data="Message without logger", ) await default_log_handler(log_msg) mock_logger.info.assert_called_once_with( msg="Received INFO from server: Message without logger" ) async def test_default_handler_with_dict_data(self): """Test that default_log_handler handles dict data correctly.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams from fastmcp.client.logging import default_log_handler with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.info = MagicMock() log_msg = LoggingMessageNotificationParams( level="info", logger="test.logger", data={"key": "value", "count": 42}, ) await default_log_handler(log_msg) # Should log the entire dict as a string mock_logger.info.assert_called_once() call_args = mock_logger.info.call_args assert "Received INFO from server (test.logger):" in call_args[1]["msg"] assert "key" in call_args[1]["msg"] assert "value" in call_args[1]["msg"] async def test_default_handler_with_list_data(self): """Test that default_log_handler handles list data correctly.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams from fastmcp.client.logging import default_log_handler with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.warning = MagicMock() log_msg = LoggingMessageNotificationParams( level="warning", logger="test.logger", data=["item1", "item2", "item3"], ) await default_log_handler(log_msg) # Should log the entire list as a string mock_logger.warning.assert_called_once() call_args = mock_logger.warning.call_args assert "Received WARNING from server (test.logger):" in call_args[1]["msg"] assert "item1" in call_args[1]["msg"] async def test_default_handler_with_number_data(self): """Test that default_log_handler handles numeric data correctly.""" from unittest.mock import MagicMock, patch from mcp.types import LoggingMessageNotificationParams from fastmcp.client.logging import default_log_handler with patch("fastmcp.client.logging.from_server_logger") as mock_logger: mock_logger.error = MagicMock() log_msg = LoggingMessageNotificationParams( level="error", logger=None, data=404, ) await default_log_handler(log_msg) mock_logger.error.assert_called_once_with( msg="Received ERROR from server: 404" ) ================================================ FILE: tests/client/test_notifications.py ================================================ from dataclasses import dataclass, field from datetime import datetime import mcp.types import pytest from fastmcp import Client, FastMCP from fastmcp.client.messages import MessageHandler from fastmcp.server.context import Context @dataclass class NotificationRecording: """Record of a notification that was received.""" method: str notification: mcp.types.ServerNotification timestamp: datetime = field(default_factory=datetime.now) class RecordingMessageHandler(MessageHandler): """A message handler that records all notifications.""" def __init__(self, name: str | None = None): super().__init__() self.notifications: list[NotificationRecording] = [] self.name = name async def on_notification(self, message: mcp.types.ServerNotification) -> None: """Record all notifications with timestamp.""" self.notifications.append( NotificationRecording(method=message.root.method, notification=message) ) def get_notifications( self, method: str | None = None ) -> list[NotificationRecording]: """Get all recorded notifications, optionally filtered by method.""" if method is None: return self.notifications return [n for n in self.notifications if n.method == method] def assert_notification_sent(self, method: str, times: int = 1) -> bool: """Assert that a notification was sent a specific number of times.""" notifications = self.get_notifications(method) actual_times = len(notifications) assert actual_times == times, ( f"Expected {times} notifications for {method}, " f"but received {actual_times} notifications" ) return True def assert_notification_not_sent(self, method: str) -> bool: """Assert that a notification was not sent.""" notifications = self.get_notifications(method) assert len(notifications) == 0, ( f"Expected no notifications for {method}, but received {len(notifications)}" ) return True def reset(self): """Clear all recorded notifications.""" self.notifications.clear() @pytest.fixture def recording_message_handler(): """Fixture that provides a recording message handler instance.""" handler = RecordingMessageHandler(name="recording_message_handler") yield handler class TestNotificationAPI: """Test the notification API.""" async def test_send_notification_async( self, recording_message_handler: RecordingMessageHandler, ): """Test that send_notification sends immediately in async context.""" server = FastMCP(name="NotificationAPITestServer") @server.tool async def trigger_notification(ctx: Context) -> str: """Send a notification using the async API.""" await ctx.send_notification(mcp.types.ToolListChangedNotification()) return "Notification sent" async with Client(server, message_handler=recording_message_handler) as client: recording_message_handler.reset() await client.call_tool("trigger_notification", {}) recording_message_handler.assert_notification_sent( "notifications/tools/list_changed", times=1 ) async def test_send_multiple_notifications( self, recording_message_handler: RecordingMessageHandler, ): """Test sending multiple different notification types.""" server = FastMCP(name="NotificationAPITestServer") @server.tool async def trigger_all_notifications(ctx: Context) -> str: """Send all notification types.""" await ctx.send_notification(mcp.types.ToolListChangedNotification()) await ctx.send_notification(mcp.types.ResourceListChangedNotification()) await ctx.send_notification(mcp.types.PromptListChangedNotification()) return "All notifications sent" async with Client(server, message_handler=recording_message_handler) as client: recording_message_handler.reset() await client.call_tool("trigger_all_notifications", {}) recording_message_handler.assert_notification_sent( "notifications/tools/list_changed", times=1 ) recording_message_handler.assert_notification_sent( "notifications/resources/list_changed", times=1 ) recording_message_handler.assert_notification_sent( "notifications/prompts/list_changed", times=1 ) ================================================ FILE: tests/client/test_oauth_callback_race.py ================================================ import anyio import httpx from fastmcp.client.oauth_callback import ( OAuthCallbackResult, create_oauth_callback_server, ) from fastmcp.utilities.http import find_available_port async def test_oauth_callback_result_ignores_subsequent_callbacks(): """Only the first callback should be captured in shared OAuth callback state.""" port = find_available_port() result = OAuthCallbackResult() result_ready = anyio.Event() server = create_oauth_callback_server( port=port, result_container=result, result_ready=result_ready, ) async with anyio.create_task_group() as tg: tg.start_soon(server.serve) await anyio.sleep(0.05) async with httpx.AsyncClient() as client: first = await client.get( f"http://127.0.0.1:{port}/callback?code=good&state=s1" ) assert first.status_code == 200 await result_ready.wait() second = await client.get( f"http://127.0.0.1:{port}/callback?code=evil&state=s2" ) assert second.status_code == 200 assert result.error is None assert result.code == "good" assert result.state == "s1" tg.cancel_scope.cancel() ================================================ FILE: tests/client/test_oauth_callback_xss.py ================================================ """Comprehensive XSS protection tests for OAuth callback HTML rendering.""" import pytest from fastmcp.client.oauth_callback import create_callback_html from fastmcp.utilities.ui import ( create_detail_box, create_info_box, create_page, create_status_message, ) def test_ui_create_page_escapes_title(): """Test that page title is properly escaped.""" xss_title = "" html = create_page("content", title=xss_title) assert "<script>alert(1)</script>" in html assert "" not in html def test_ui_create_status_message_escapes(): """Test that status messages are properly escaped.""" xss_message = "" html = create_status_message(xss_message) assert "<img src=x onerror=alert(1)>" in html assert "" not in html def test_ui_create_info_box_escapes(): """Test that info box content is properly escaped.""" xss_content = "" html = create_info_box(xss_content) assert "<iframe" in html assert "