diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml index 62a91267b..3a8162129 100644 --- a/.github/workflows/run-upgrade-checks.yml +++ b/.github/workflows/run-upgrade-checks.yml @@ -117,14 +117,17 @@ jobs: **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 - This likely means that upgraded dependencies have introduced new errors. Please review the failed jobs and determine if the code needs to be updated or if dependency constraints need to be adjusted. - - - [ ] Review the failure in the workflow run - - [ ] Identify root cause (dependency changes, new linter/type rules, etc.) - - [ ] Implement fix or adjust dependency constraints - - [ ] Verify fix resolves the issue + 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.* diff --git a/docs/docs.json b/docs/docs.json index c31a2e2aa..69b173496 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -163,7 +163,6 @@ "collapsed": true, "group": "Authentication", "icon": "key", - "tag": "UPDATED", "pages": [ "servers/auth/authentication", "servers/auth/token-verification", @@ -172,7 +171,8 @@ "servers/auth/oidc-proxy", "servers/auth/full-oauth-server", "servers/auth/multi-auth" - ] + ], + "tag": "UPDATED" }, "servers/authorization", { @@ -216,7 +216,6 @@ "collapsed": true, "group": "Handlers", "icon": "hand", - "tag": "UPDATED", "pages": [ "clients/notifications", "clients/sampling", @@ -225,7 +224,8 @@ "clients/progress", "clients/logging", "clients/roots" - ] + ], + "tag": "UPDATED" }, { "collapsed": true, @@ -428,6 +428,7 @@ "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" ] } diff --git a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx index 69d71fe3a..9681c3a4a 100644 --- a/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx +++ b/docs/python-sdk/fastmcp-client-sampling-handlers-google_genai.mdx @@ -10,7 +10,7 @@ Google GenAI sampling handler with tool support for FastMCP 3.0. ## Classes -### `GoogleGenaiSamplingHandler` +### `GoogleGenaiSamplingHandler` Sampling handler that uses the Google GenAI API with tool support. diff --git a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx index 6ef971e8e..0553029eb 100644 --- a/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx +++ b/docs/python-sdk/fastmcp-experimental-transforms-code_mode.mdx @@ -60,9 +60,11 @@ Defaults to BM25 ranking. ``"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` +### `GetSchemas` Discovery tool factory that returns schemas for tools by name. @@ -76,7 +78,7 @@ types, and required markers. ``"full"`` returns the complete JSON schema. -### `GetTags` +### `GetTags` Discovery tool factory that lists tool tags from the catalog. @@ -91,7 +93,20 @@ without tags appear under ``"untagged"``. ``"full"`` lists all tools under each tag. -### `CodeMode` +### `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. @@ -108,13 +123,13 @@ environment with ``call_tool(name, params)`` in scope. **Methods:** -#### `transform_tools` +#### `transform_tools` ```python transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None diff --git a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx index 69d5a2eea..fdbd2eb50 100644 --- a/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx +++ b/docs/python-sdk/fastmcp-server-auth-oauth_proxy-ui.mdx @@ -13,7 +13,7 @@ This module contains HTML generation functions for consent and error pages. ## Functions -### `create_consent_html` +### `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 @@ -29,7 +29,7 @@ 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` +### `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 diff --git a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx index a40342ece..5803d4f62 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-aws.mdx @@ -66,7 +66,7 @@ Features: **Methods:** -#### `get_token_verifier` +#### `get_token_verifier` ```python get_token_verifier(self) -> TokenVerifier diff --git a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx index 3a5ca0868..4d3140c4a 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-azure.mdx @@ -14,7 +14,7 @@ using the OAuth Proxy pattern for non-DCR OAuth flows. ## Functions -### `EntraOBOToken` +### `EntraOBOToken` ```python EntraOBOToken(scopes: list[str]) -> str @@ -78,7 +78,7 @@ Setup: **Methods:** -#### `authorize` +#### `authorize` ```python authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str @@ -98,7 +98,7 @@ scopes to determine the resource/audience instead of a separate parameter. - Authorization URL to redirect the user to Azure AD -#### `get_obo_credential` +#### `get_obo_credential` ```python get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential @@ -120,7 +120,7 @@ calls multiple tools with the same scopes. - `ImportError`: If azure-identity is not installed (requires fastmcp[azure]). -#### `close_obo_credentials` +#### `close_obo_credentials` ```python close_obo_credentials(self) -> None @@ -129,7 +129,7 @@ close_obo_credentials(self) -> None Close all cached OBO credentials. -### `AzureJWTVerifier` +### `AzureJWTVerifier` JWT verifier pre-configured for Azure AD / Microsoft Entra ID. @@ -166,7 +166,7 @@ Example:: **Methods:** -#### `scopes_supported` +#### `scopes_supported` ```python scopes_supported(self) -> list[str] diff --git a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx index c54e93028..cb263d9ec 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-workos.mdx @@ -59,7 +59,7 @@ Setup Requirements: 4. Note your Client ID and Client Secret -### `AuthKitProvider` +### `AuthKitProvider` AuthKit metadata provider for DCR (Dynamic Client Registration). @@ -85,7 +85,7 @@ https://workos.com/docs/authkit/mcp/integrating/token-verification **Methods:** -#### `get_routes` +#### `get_routes` ```python get_routes(self, mcp_path: str | None = None) -> list[Route] diff --git a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx index d255888ea..84d60da3d 100644 --- a/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx +++ b/docs/python-sdk/fastmcp-server-providers-fastmcp_provider.mdx @@ -18,7 +18,7 @@ executed. ## Classes -### `FastMCPProviderTool` +### `FastMCPProviderTool` Tool that delegates execution to a wrapped server's middleware. @@ -30,7 +30,7 @@ chain is executed. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool @@ -39,7 +39,7 @@ wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool Wrap a Tool to delegate execution to the server's middleware. -#### `run` +#### `run` ```python run(self, arguments: dict[str, Any]) -> ToolResult @@ -51,13 +51,13 @@ 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` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResource` +### `FastMCPProviderResource` Resource that delegates reading to a wrapped server's read_resource(). @@ -68,7 +68,7 @@ When `read()` is called, this resource invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource @@ -77,13 +77,13 @@ wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource Wrap a Resource to delegate reading to the server's middleware. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderPrompt` +### `FastMCPProviderPrompt` Prompt that delegates rendering to a wrapped server's render_prompt(). @@ -94,7 +94,7 @@ When `render()` is called, this prompt invokes the wrapped server's **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt @@ -103,7 +103,7 @@ wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt Wrap a Prompt to delegate rendering to the server's middleware. -#### `render` +#### `render` ```python render(self, arguments: dict[str, Any] | None = None) -> PromptResult @@ -115,13 +115,13 @@ This is called when the prompt is used within a transformed context or other contexts where task_meta is not available. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProviderResourceTemplate` +### `FastMCPProviderResourceTemplate` Resource template that creates FastMCPProviderResources. @@ -133,7 +133,7 @@ when read. **Methods:** -#### `wrap` +#### `wrap` ```python wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTemplate @@ -142,7 +142,7 @@ wrap(cls, server: Any, template: ResourceTemplate) -> FastMCPProviderResourceTem Wrap a ResourceTemplate to create FastMCPProviderResources. -#### `create_resource` +#### `create_resource` ```python create_resource(self, uri: str, params: dict[str, Any]) -> Resource @@ -155,7 +155,7 @@ We use `_original_uri_template` with `params` to construct the internal URI that the nested server understands. -#### `read` +#### `read` ```python read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult @@ -167,7 +167,7 @@ Reads the resource via the wrapped server and returns the ResourceResult. This method is called by Docket during background task execution. -#### `register_with_docket` +#### `register_with_docket` ```python register_with_docket(self, docket: Docket) -> None @@ -176,7 +176,7 @@ register_with_docket(self, docket: Docket) -> None No-op: the child's actual template is registered via get_tasks(). -#### `add_to_docket` +#### `add_to_docket` ```python add_to_docket(self, docket: Docket, params: dict[str, Any], **kwargs: Any) -> Execution @@ -188,13 +188,13 @@ The child's FunctionResourceTemplate.fn is registered (via get_tasks), and it expects splatted **kwargs, so we splat params here. -#### `get_span_attributes` +#### `get_span_attributes` ```python get_span_attributes(self) -> dict[str, Any] ``` -### `FastMCPProvider` +### `FastMCPProvider` Provider that wraps a FastMCP server. @@ -210,7 +210,7 @@ This ensures middleware runs when components are executed. **Methods:** -#### `get_tasks` +#### `get_tasks` ```python get_tasks(self) -> Sequence[FastMCPComponent] @@ -224,7 +224,7 @@ server's transforms applied, then applies this provider's transforms for correct registration keys. -#### `lifespan` +#### `lifespan` ```python lifespan(self) -> AsyncIterator[None] diff --git a/docs/python-sdk/fastmcp-server-transforms-version_filter.mdx b/docs/python-sdk/fastmcp-server-transforms-version_filter.mdx index e34636b03..7902c0a0a 100644 --- a/docs/python-sdk/fastmcp-server-transforms-version_filter.mdx +++ b/docs/python-sdk/fastmcp-server-transforms-version_filter.mdx @@ -15,16 +15,18 @@ Version filter transform for filtering components by version range. Filters components by version range. -When applied to a provider or server, only components within the version -range are visible. Within that filtered set, the highest version of each -component is exposed to clients (standard deduplication behavior). +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) + # Versions < 3.0 (v1 and v2) server.add_transform(VersionFilter(version_lt="3.0")) - # Versions >= 2.0 and < 3.0 (only v2.x) + # 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). @@ -32,53 +34,55 @@ 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` +#### `list_tools` ```python list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool] ``` -#### `get_tool` +#### `get_tool` ```python get_tool(self, name: str, call_next: GetToolNext) -> Tool | None ``` -#### `list_resources` +#### `list_resources` ```python list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource] ``` -#### `get_resource` +#### `get_resource` ```python get_resource(self, uri: str, call_next: GetResourceNext) -> Resource | None ``` -#### `list_resource_templates` +#### `list_resource_templates` ```python list_resource_templates(self, templates: Sequence[ResourceTemplate]) -> Sequence[ResourceTemplate] ``` -#### `get_resource_template` +#### `get_resource_template` ```python get_resource_template(self, uri: str, call_next: GetResourceTemplateNext) -> ResourceTemplate | None ``` -#### `list_prompts` +#### `list_prompts` ```python list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt] ``` -#### `get_prompt` +#### `get_prompt` ```python get_prompt(self, name: str, call_next: GetPromptNext) -> Prompt | None diff --git a/docs/unify-intent.js b/docs/unify-intent.js new file mode 100644 index 000000000..25eafe494 --- /dev/null +++ b/docs/unify-intent.js @@ -0,0 +1,68 @@ +// Load Unify intent tag on authentication pages only +(function () { + if (typeof window === "undefined") return; + + function isAuthPage() { + var path = window.location.pathname; + return path.includes("/servers/auth/") || path.includes("/clients/auth/"); + } + + function loadUnify() { + var e = [ + "identify", + "page", + "startAutoPage", + "stopAutoPage", + "startAutoIdentify", + "stopAutoIdentify", + ]; + function t(o) { + return Object.assign( + [], + e.reduce(function (r, n) { + r[n] = function () { + return o.push([n, [].slice.call(arguments)]), o; + }; + return r; + }, {}), + ); + } + if (!window.unify) window.unify = t(window.unify); + if (!window.unifyBrowser) window.unifyBrowser = t(window.unifyBrowser); + + var n = document.createElement("script"); + n.async = true; + n.setAttribute( + "src", + "https://tag.unifyintent.com/v1/Rj9KrQqMhyYcU5qfJtVszE/script.js", + ); + n.setAttribute( + "data-api-key", + "wk_SBvJ4jyD_wRgPAHCNJb89seVmREhcj2NspRpxAywi", + ); + n.setAttribute("id", "unifytag"); + (document.body || document.head).appendChild(n); + } + + function update() { + if (isAuthPage() && !document.getElementById("unifytag")) { + loadUnify(); + } else if (!isAuthPage() && document.getElementById("unifytag")) { + document.getElementById("unifytag").remove(); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", update); + } else { + update(); + } + + var lastUrl = location.href; + new MutationObserver(function () { + if (location.href !== lastUrl) { + lastUrl = location.href; + setTimeout(update, 100); + } + }).observe(document.body, { subtree: true, childList: true }); +})(); diff --git a/pyproject.toml b/pyproject.toml index 3ada527fa..320938b2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", "ruff>=0.12.8", - "ty>=0.0.15", + "ty>=0.0.20", "prek>=0.2.12", "loq>=0.1.0a3", "opentelemetry-exporter-otlp-proto-grpc>=1.39.0", diff --git a/src/fastmcp/client/roots.py b/src/fastmcp/client/roots.py index d9dd7cb67..1a55ac36a 100644 --- a/src/fastmcp/client/roots.py +++ b/src/fastmcp/client/roots.py @@ -1,6 +1,6 @@ import inspect from collections.abc import Awaitable, Callable -from typing import TypeAlias +from typing import TypeAlias, cast import mcp.types import pydantic @@ -66,7 +66,9 @@ def _create_roots_callback_from_fn( roots = fn(context) if inspect.isawaitable(roots): roots = await roots - return mcp.types.ListRootsResult(roots=convert_roots_list(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, diff --git a/src/fastmcp/client/sampling/__init__.py b/src/fastmcp/client/sampling/__init__.py index a0b259b10..2987e9259 100644 --- a/src/fastmcp/client/sampling/__init__.py +++ b/src/fastmcp/client/sampling/__init__.py @@ -53,6 +53,8 @@ def create_sampling_callback( if inspect.isawaitable(result): result = await result + result = cast(SamplingHandlerResult, result) + if isinstance(result, str): result = CreateMessageResult( role="assistant", diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py index 84b4e4ac7..1ebc3da35 100644 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ b/src/fastmcp/experimental/transforms/code_mode.py @@ -541,16 +541,9 @@ class CodeMode(CatalogTransform): ctx: Context = None, # type: ignore[assignment] ) -> Any: """Execute tool calls using Python code.""" - cached_tools: Sequence[Tool] | None = None - - async def _get_cached_tools() -> Sequence[Tool]: - nonlocal cached_tools - if cached_tools is None: - cached_tools = await transform.get_tool_catalog(ctx) - return cached_tools async def call_tool(tool_name: str, params: dict[str, Any]) -> Any: - backend_tools = await _get_cached_tools() + 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}") diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py index 4cbb3ec2c..a8a12efe3 100644 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ b/src/fastmcp/server/auth/oauth_proxy/ui.py @@ -5,8 +5,6 @@ This module contains HTML generation functions for consent and error pages. from __future__ import annotations -from urllib.parse import urlparse - from fastmcp.utilities.ui import ( BUTTON_STYLES, DETAIL_BOX_STYLES, @@ -198,20 +196,13 @@ def create_consent_html( # 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: - # Need to allow form-action for form submission - # Chrome requires explicit scheme declarations in CSP form-action when redirect chains - # end in custom protocol schemes (e.g., cursor://). Parse redirect_uri to include its scheme. - parsed_redirect = urlparse(redirect_uri) - redirect_scheme = parsed_redirect.scheme.lower() - - # Build form-action directive with standard schemes plus custom protocol if present - form_action_schemes = ["https:", "http:"] - if redirect_scheme and redirect_scheme not in ("http", "https"): - # Custom protocol scheme (e.g., cursor:, vscode:, etc.) - form_action_schemes.append(f"{redirect_scheme}:") - - form_action_directive = " ".join(form_action_schemes) - csp_policy = f"default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action {form_action_directive}" + # 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, diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py index 46c4f70c9..3eec61776 100644 --- a/src/fastmcp/server/auth/providers/auth0.py +++ b/src/fastmcp/server/auth/providers/auth0.py @@ -70,6 +70,7 @@ class Auth0Provider(OIDCProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, ) -> None: """Initialize Auth0 OAuth provider. @@ -114,6 +115,7 @@ class Auth0Provider(OIDCProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py index d684ee82f..1e4f913b6 100644 --- a/src/fastmcp/server/auth/providers/aws.py +++ b/src/fastmcp/server/auth/providers/aws.py @@ -109,6 +109,7 @@ class AWSCognitoProvider(OIDCProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, ): """Initialize AWS Cognito OAuth provider. @@ -161,6 +162,7 @@ class AWSCognitoProvider(OIDCProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py index 310d26f8a..009cf9316 100644 --- a/src/fastmcp/server/auth/providers/azure.py +++ b/src/fastmcp/server/auth/providers/azure.py @@ -108,6 +108,7 @@ class AzureProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, base_authority: str = "login.microsoftonline.com", http_client: httpx.AsyncClient | None = None, ) -> None: @@ -233,6 +234,7 @@ class AzureProvider(OAuthProxy): 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, ) diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py index 7af28e026..7ef187202 100644 --- a/src/fastmcp/server/auth/providers/discord.py +++ b/src/fastmcp/server/auth/providers/discord.py @@ -193,6 +193,7 @@ class DiscordProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize Discord OAuth provider. @@ -253,6 +254,7 @@ class DiscordProvider(OAuthProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py index 01331ba25..3d0c31d27 100644 --- a/src/fastmcp/server/auth/providers/github.py +++ b/src/fastmcp/server/auth/providers/github.py @@ -192,6 +192,7 @@ class GitHubProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize GitHub OAuth provider. @@ -247,6 +248,7 @@ class GitHubProvider(OAuthProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py index 0dd509e33..265a9dd48 100644 --- a/src/fastmcp/server/auth/providers/google.py +++ b/src/fastmcp/server/auth/providers/google.py @@ -207,6 +207,7 @@ class GoogleProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, extra_authorize_params: dict[str, str] | None = None, http_client: httpx.AsyncClient | None = None, ): @@ -283,6 +284,7 @@ class GoogleProvider(OAuthProxy): 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, ) diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py index c884b6c2c..29527f07a 100644 --- a/src/fastmcp/server/auth/providers/oci.py +++ b/src/fastmcp/server/auth/providers/oci.py @@ -129,6 +129,7 @@ class OCIProvider(OIDCProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, ) -> None: """Initialize OCI OIDC provider. @@ -161,6 +162,7 @@ class OCIProvider(OIDCProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py index 48ed825e1..3b81ca127 100644 --- a/src/fastmcp/server/auth/providers/workos.py +++ b/src/fastmcp/server/auth/providers/workos.py @@ -157,6 +157,7 @@ class WorkOSProvider(OAuthProxy): client_storage: AsyncKeyValue | None = None, jwt_signing_key: str | bytes | None = None, require_authorization_consent: bool = True, + consent_csp_policy: str | None = None, http_client: httpx.AsyncClient | None = None, ): """Initialize WorkOS OAuth provider. @@ -218,6 +219,7 @@ class WorkOSProvider(OAuthProxy): client_storage=client_storage, jwt_signing_key=jwt_signing_key, require_authorization_consent=require_authorization_consent, + consent_csp_policy=consent_csp_policy, ) logger.debug( diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index f44026280..4536b165b 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -17,6 +17,7 @@ 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 ( @@ -851,14 +852,19 @@ class _CurrentContext(Dependency["Context"]): "Check `context.request_context` for None before accessing." ) - async def __aexit__(self, *args: object) -> None: + 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__(*args) + await self._context.__aexit__(exc_type, exc_value, traceback) self._context = None @@ -883,10 +889,15 @@ class _OptionalCurrentContext(Dependency["Context | None"]): self._inner = inner return context - async def __aexit__(self, *args: object) -> None: + 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__(*args) + await self._inner.__aexit__(exc_type, exc_value, traceback) self._inner = None @@ -934,7 +945,12 @@ class _CurrentDocket(Dependency["Docket"]): ) return docket - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @@ -979,7 +995,12 @@ class _CurrentWorker(Dependency["Worker"]): ) return worker - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @@ -1021,7 +1042,12 @@ class _CurrentFastMCP(Dependency["FastMCP"]): raise RuntimeError("FastMCP server instance is no longer available") return server - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @@ -1056,7 +1082,12 @@ class _CurrentRequest(Dependency[Request]): async def __aenter__(self) -> Request: return get_http_request() - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @@ -1092,7 +1123,12 @@ class _CurrentHeaders(Dependency[dict[str, str]]): async def __aenter__(self) -> dict[str, str]: return get_http_headers(include={"authorization"}) - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @@ -1175,7 +1211,12 @@ class InMemoryProgress: async def __aenter__(self) -> InMemoryProgress: return self - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass @property @@ -1243,7 +1284,12 @@ class Progress(Dependency["Progress"]): self._impl = InMemoryProgress() return self - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: self._impl = None @property @@ -1309,7 +1355,12 @@ class _CurrentAccessToken(Dependency[AccessToken]): ) return token - async def __aexit__(self, *args: object) -> None: + 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 @@ -1363,7 +1414,12 @@ class _TokenClaim(Dependency[str]): ) return str(value) - async def __aexit__(self, *args: object) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: pass diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py index 60537147d..440fba4b3 100644 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ b/src/fastmcp/server/mixins/mcp_operations.py @@ -14,62 +14,16 @@ 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, parse_version_key, version_sort_key +from fastmcp.utilities.versions import VersionSpec, dedupe_with_versions if TYPE_CHECKING: from fastmcp.server.server import FastMCP logger = get_logger(__name__) -C = TypeVar("C", bound=Any) PaginateT = TypeVar("PaginateT") -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 - - def _apply_pagination( items: Sequence[PaginateT], cursor: str | None, @@ -152,7 +106,7 @@ class MCPOperationsMixin: 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) + 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 @@ -172,7 +126,7 @@ class MCPOperationsMixin: server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_resources") - resources = _dedupe_with_versions( + resources = dedupe_with_versions( list(await server.list_resources()), lambda r: str(r.uri) ) sdk_resources = [ @@ -195,7 +149,7 @@ class MCPOperationsMixin: server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_resource_templates") - templates = _dedupe_with_versions( + templates = dedupe_with_versions( list(await server.list_resource_templates()), lambda t: t.uri_template ) sdk_templates = [ @@ -220,7 +174,7 @@ class MCPOperationsMixin: server = cast("FastMCP", self) logger.debug(f"[{server.name}] Handler called: list_prompts") - prompts = _dedupe_with_versions( + 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] diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index cab6bc541..92038c540 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -14,6 +14,7 @@ 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 @@ -38,11 +39,28 @@ if TYPE_CHECKING: def _expand_uri_template(template: str, params: dict[str, Any]) -> str: """Expand a URI template with parameters. - Simple implementation that handles {name} style placeholders. + 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 diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py index 6ece2c30e..4ea31f0b6 100644 --- a/src/fastmcp/server/sampling/run.py +++ b/src/fastmcp/server/sampling/run.py @@ -227,6 +227,8 @@ async def call_sampling_handler( if inspect.isawaitable(result): result = await result + result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result) + # Convert string to CreateMessageResult if isinstance(result, str): return CreateMessageResult( diff --git a/src/fastmcp/server/transforms/catalog.py b/src/fastmcp/server/transforms/catalog.py index fd204fa03..748e12645 100644 --- a/src/fastmcp/server/transforms/catalog.py +++ b/src/fastmcp/server/transforms/catalog.py @@ -50,6 +50,7 @@ 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.prompt import Prompt @@ -172,6 +173,10 @@ class CatalogTransform(Transform): ) -> 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. @@ -180,9 +185,10 @@ class CatalogTransform(Transform): """ token = self._bypass.set(True) try: - return await ctx.fastmcp.list_tools(run_middleware=run_middleware) + 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 diff --git a/src/fastmcp/utilities/versions.py b/src/fastmcp/utilities/versions.py index fabd88111..a16389fe3 100644 --- a/src/fastmcp/utilities/versions.py +++ b/src/fastmcp/utilities/versions.py @@ -14,15 +14,18 @@ Examples: from __future__ import annotations +from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import total_ordering -from typing import TYPE_CHECKING +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: @@ -283,3 +286,48 @@ def min_version(a: str | None, b: str | None) -> str | None: 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 diff --git a/tests/client/transports/test_uv_transport.py b/tests/client/transports/test_uv_transport.py index 2917045b8..8c2a500fd 100644 --- a/tests/client/transports/test_uv_transport.py +++ b/tests/client/transports/test_uv_transport.py @@ -17,7 +17,7 @@ _fastmcp_src_dir = ( ) -@pytest.mark.timeout(10) +@pytest.mark.timeout(60) @pytest.mark.client_process @pytest.mark.skipif( sys.platform == "win32", @@ -54,7 +54,7 @@ async def test_uv_transport(): assert sum == 3 -@pytest.mark.timeout(10) +@pytest.mark.timeout(60) @pytest.mark.client_process @pytest.mark.skipif( sys.platform == "win32", diff --git a/tests/experimental/transforms/test_code_mode.py b/tests/experimental/transforms/test_code_mode.py index a57472fb4..454a8ccfb 100644 --- a/tests/experimental/transforms/test_code_mode.py +++ b/tests/experimental/transforms/test_code_mode.py @@ -5,7 +5,7 @@ from typing import Any import pytest from mcp.types import ImageContent, TextContent -from fastmcp import FastMCP +from fastmcp import Client, FastMCP from fastmcp.exceptions import ToolError from fastmcp.experimental.transforms.code_mode import ( CodeMode, @@ -507,6 +507,32 @@ async def test_code_mode_search_respects_disabled_tool_visibility() -> None: assert "secret" not in text or "No tools" in text +async def test_code_mode_execute_sees_mid_run_visibility_changes() -> None: + """Unlocking a tool mid-execution makes it callable in the same run.""" + mcp = FastMCP("CodeMode Unlock") + + @mcp.tool + async def unlock(ctx: Context) -> str: + await ctx.enable_components(names={"secret"}, components={"tool"}) + return "unlocked" + + @mcp.tool + async def secret() -> str: + return "secret-ok" + + mcp.disable(names={"secret"}, components={"tool"}) + mcp.add_transform(CodeMode(sandbox_provider=_UnsafeTestSandboxProvider())) + + async with Client(mcp) as client: + result = await client.call_tool( + "execute", + { + "code": "await call_tool('unlock', {})\nreturn await call_tool('secret', {})" + }, + ) + assert result.data == {"result": "secret-ok"} + + async def test_code_mode_execute_respects_tool_auth() -> None: mcp = FastMCP("CodeMode Auth") diff --git a/tests/server/auth/test_oauth_consent_page.py b/tests/server/auth/test_oauth_consent_page.py index 5e2d51737..bd394db74 100644 --- a/tests/server/auth/test_oauth_consent_page.py +++ b/tests/server/auth/test_oauth_consent_page.py @@ -289,8 +289,8 @@ class TestConsentPageServerIcon: class TestConsentCSPPolicy: """Tests for Content Security Policy customization on consent page.""" - async def test_default_csp_includes_form_action(self): - """Test that default CSP includes form-action directive.""" + async def test_default_csp_omits_form_action(self): + """Test that default CSP omits form-action to avoid Chrome redirect chain issues.""" verifier = Mock(spec=TokenVerifier) verifier.required_scopes = ["read"] @@ -335,9 +335,9 @@ class TestConsentCSPPolicy: response = client.get(f"/consent?txn_id={txn_id}") assert response.status_code == 200 - # Default CSP should be present with form-action + # Default CSP should be present but WITHOUT form-action assert 'http-equiv="Content-Security-Policy"' in response.text - assert "form-action" in response.text + assert "form-action" not in response.text async def test_empty_csp_disables_csp_meta_tag(self): """Test that empty string CSP disables CSP meta tag entirely.""" diff --git a/tests/server/middleware/test_initialization_middleware.py b/tests/server/middleware/test_initialization_middleware.py index 4716af5f0..7441511eb 100644 --- a/tests/server/middleware/test_initialization_middleware.py +++ b/tests/server/middleware/test_initialization_middleware.py @@ -1,5 +1,6 @@ """Tests for middleware support during initialization.""" +from collections.abc import Sequence from typing import Any import mcp.types as mt @@ -9,6 +10,7 @@ from mcp.types import ErrorData, TextContent from fastmcp import Client, FastMCP from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext +from fastmcp.tools.tool import Tool class InitializationMiddleware(Middleware): @@ -28,8 +30,8 @@ class InitializationMiddleware(Middleware): async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, None], - ) -> None: + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: """Capture initialization details.""" self.initialized = True @@ -66,8 +68,8 @@ class ClientDetectionMiddleware(Middleware): async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, None], - ) -> None: + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: """Detect test client during initialization.""" self.initialization_called = True @@ -80,8 +82,8 @@ class ClientDetectionMiddleware(Middleware): async def on_list_tools( self, context: MiddlewareContext[mt.ListToolsRequest], - call_next: CallNext[mt.ListToolsRequest, list], - ) -> list: + call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], + ) -> Sequence[Tool]: """Modify tools based on client detection.""" tools = await call_next(context) @@ -112,8 +114,8 @@ async def test_simple_initialization_hook(): async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, None], - ) -> None: + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: self.called = True return await call_next(context) @@ -304,8 +306,8 @@ async def test_middleware_mcp_error_during_initialization(): async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, None], - ) -> None: + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: raise McpError( ErrorData( code=mt.INVALID_PARAMS, message="Invalid initialization parameters" @@ -330,8 +332,8 @@ async def test_middleware_mcp_error_before_call_next(): async def on_initialize( self, context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, None], - ) -> None: + call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], + ) -> mt.InitializeResult | None: raise McpError( ErrorData(code=mt.INVALID_REQUEST, message="Request validation failed") ) diff --git a/tests/server/mount/test_resources.py b/tests/server/mount/test_resources.py index d0e18b7af..e6c4fb502 100644 --- a/tests/server/mount/test_resources.py +++ b/tests/server/mount/test_resources.py @@ -134,3 +134,65 @@ class TestResourceUriPrefixing: t for t in templates if t.uri_template == "resource://prefix/user/{user_id}" ) assert template.name == "user_template" + + +class TestMountedResourceTemplateQueryParams: + """Test that resource templates with query params work on mounted servers.""" + + async def test_mounted_template_with_query_param(self): + """Query params in resource templates should work through mount.""" + sub = FastMCP("Sub") + + @sub.resource("resource://greet{?name}") + def greet(name: str = "World") -> str: + return f"Hello, {name}!" + + main = FastMCP("Main") + main.mount(sub, "sub") + + result = await main.read_resource("resource://sub/greet?name=Alice") + assert result.contents[0].content == "Hello, Alice!" + + async def test_mounted_template_with_query_param_default(self): + """Missing query params should use defaults through mount.""" + sub = FastMCP("Sub") + + @sub.resource("resource://greet{?name}") + def greet(name: str = "World") -> str: + return f"Hello, {name}!" + + main = FastMCP("Main") + main.mount(sub, "sub") + + result = await main.read_resource("resource://sub/greet") + assert result.contents[0].content == "Hello, World!" + + async def test_mounted_template_with_multiple_query_params(self): + """Multiple query params should all pass through mount correctly.""" + sub = FastMCP("Sub") + + @sub.resource("resource://data/{id}{?format,verbose}") + def get_data(id: str, format: str = "json", verbose: bool = False) -> str: + return f"id={id} format={format} verbose={verbose}" + + main = FastMCP("Main") + main.mount(sub, "api") + + result = await main.read_resource( + "resource://api/data/42?format=xml&verbose=true" + ) + assert result.contents[0].content == "id=42 format=xml verbose=True" + + async def test_mounted_template_with_partial_query_params(self): + """Providing only some query params should use defaults for the rest.""" + sub = FastMCP("Sub") + + @sub.resource("resource://data/{id}{?format,limit}") + def get_data(id: str, format: str = "json", limit: int = 10) -> str: + return f"id={id} format={format} limit={limit}" + + main = FastMCP("Main") + main.mount(sub, "api") + + result = await main.read_resource("resource://api/data/42?limit=5") + assert result.contents[0].content == "id=42 format=json limit=5" diff --git a/tests/server/transforms/test_catalog.py b/tests/server/transforms/test_catalog.py index 77795c9be..01004a2bf 100644 --- a/tests/server/transforms/test_catalog.py +++ b/tests/server/transforms/test_catalog.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast from collections.abc import Sequence from mcp.types import TextContent @@ -10,9 +11,55 @@ from fastmcp import FastMCP from fastmcp.server.context import Context from fastmcp.server.transforms import GetToolNext from fastmcp.server.transforms.catalog import CatalogTransform +from fastmcp.server.transforms.version_filter import VersionFilter from fastmcp.tools.tool import Tool from fastmcp.utilities.versions import VersionSpec +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_versioned_tool(name: str, version: str) -> Tool: + """Create a tool with a specific version for testing.""" + + async def _fn() -> str: + return f"{name}@{version}" + + return Tool.from_function(fn=_fn, name=name, version=version) + + +class CatalogReader(CatalogTransform): + """Minimal CatalogTransform that exposes get_tool_catalog via a tool. + + After calling the ``read_catalog`` tool, the full catalog is available + on ``self.last_catalog`` for assertions beyond tool names. + """ + + def __init__(self) -> None: + super().__init__() + self.last_catalog: Sequence[Tool] = [] + + async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: + return [self._make_reader_tool()] + + async def get_tool( + self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None + ) -> Tool | None: + if name == "read_catalog": + return self._make_reader_tool() + return await call_next(name, version=version) + + def _make_reader_tool(self) -> Tool: + transform = self + + async def read_catalog(ctx: Context = None) -> list[str]: # type: ignore[assignment] + """Return names of tools visible in the catalog.""" + transform.last_catalog = await transform.get_tool_catalog(ctx) + return [t.name for t in transform.last_catalog] + + return Tool.from_function(fn=read_catalog, name="read_catalog") + class ReplacingTransform(CatalogTransform): """Minimal subclass that replaces tools with a synthetic tool. @@ -80,3 +127,132 @@ class TestCatalogTransformBypass: t2 = ReplacingTransform() assert t1._instance_id != t2._instance_id assert t1._bypass is not t2._bypass + + +class TestCatalogDeduplication: + """get_tool_catalog() deduplicates versioned tools, keeping only the highest.""" + + async def test_returns_highest_version_only(self): + mcp = FastMCP("test") + mcp.add_tool(_make_versioned_tool("greet", "1")) + mcp.add_tool(_make_versioned_tool("greet", "2")) + mcp.add_tool(_make_versioned_tool("greet", "3")) + + reader = CatalogReader() + mcp.add_transform(reader) + + result = await mcp.call_tool("read_catalog", {}) + names = _extract_result(result) + assert names == ["greet"] + + async def test_version_metadata_injected(self): + """Highest-version tool has meta.fastmcp.versions listing all available.""" + mcp = FastMCP("test") + mcp.add_tool(_make_versioned_tool("greet", "1")) + mcp.add_tool(_make_versioned_tool("greet", "3")) + + reader = CatalogReader() + mcp.add_transform(reader) + + await mcp.call_tool("read_catalog", {}) + assert len(reader.last_catalog) == 1 + tool = reader.last_catalog[0] + assert tool.name == "greet" + assert tool.version == "3" + assert tool.meta is not None + versions = tool.meta["fastmcp"]["versions"] + assert versions == ["3", "1"] + + async def test_mixed_versioned_and_unversioned(self): + mcp = FastMCP("test") + + @mcp.tool + def standalone() -> str: + return "hi" + + mcp.add_tool(_make_versioned_tool("greet", "1")) + mcp.add_tool(_make_versioned_tool("greet", "2")) + + reader = CatalogReader() + mcp.add_transform(reader) + + result = await mcp.call_tool("read_catalog", {}) + names = sorted(_extract_result(result)) + assert names == ["greet", "standalone"] + + async def test_version_filter_applied_before_catalog(self): + """A VersionFilter added before the CatalogTransform restricts what the catalog sees.""" + mcp = FastMCP("test") + mcp.add_tool(_make_versioned_tool("greet", "1")) + mcp.add_tool(_make_versioned_tool("greet", "2")) + mcp.add_tool(_make_versioned_tool("greet", "3")) + + # Filter keeps only versions < 3 — so v1 and v2 survive, v3 is excluded. + mcp.add_transform(VersionFilter(version_lt="3")) + + reader = CatalogReader() + mcp.add_transform(reader) + + await mcp.call_tool("read_catalog", {}) + assert len(reader.last_catalog) == 1 + tool = reader.last_catalog[0] + assert tool.name == "greet" + assert tool.version == "2" + + +class TestCatalogVisibility: + """get_tool_catalog() respects visibility (disabled tools are excluded).""" + + async def test_disabled_tool_excluded(self): + mcp = FastMCP("test") + + @mcp.tool + def public() -> str: + return "visible" + + @mcp.tool + def secret() -> str: + return "hidden" + + mcp.disable(names={"secret"}, components={"tool"}) + reader = CatalogReader() + mcp.add_transform(reader) + + result = await mcp.call_tool("read_catalog", {}) + names = _extract_result(result) + assert names == ["public"] + + +class TestCatalogAuth: + """get_tool_catalog() respects tool-level auth filtering.""" + + async def test_auth_rejected_tool_excluded(self): + mcp = FastMCP("test") + + @mcp.tool + def public() -> str: + return "visible" + + @mcp.tool(auth=lambda _ctx: False) + def protected() -> str: + return "hidden" + + reader = CatalogReader() + mcp.add_transform(reader) + + result = await mcp.call_tool("read_catalog", {}) + names = _extract_result(result) + assert names == ["public"] + + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +def _extract_result(result: object) -> list[str]: + """Extract the list of names from a call_tool ToolResult.""" + for c in result.content: # type: ignore[union-attr] + if isinstance(c, TextContent): + return ast.literal_eval(c.text) + raise AssertionError("No text content found") diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index 786d4e4e2..2c7f133a7 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -5,6 +5,7 @@ import logging import os import sys import tempfile +import time from collections.abc import AsyncGenerator from datetime import timedelta from pathlib import Path @@ -339,11 +340,26 @@ async def test_multi_client_parallel_calls(tmp_path: Path): assert all(len(result) == 2 for result in results) # type: ignore[arg-type] +async def _wait_for_process_exit(pid: int, timeout: float = 3.0) -> None: + """Poll until a process has exited, raising if it's still alive after timeout.""" + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + psutil.Process(pid) + except psutil.NoSuchProcess: + return + await asyncio.sleep(0.05) + # Final check — if still alive, let the NoSuchProcess propagation fail the test clearly + psutil.Process(pid) + pytest.fail(f"Process {pid} still alive after {timeout}s") + + @pytest.mark.skipif( running_under_debugger(), reason="Debugger holds a reference to the transport", ) -@pytest.mark.timeout(5) +@pytest.mark.timeout(15) async def test_multi_client_lifespan(tmp_path: Path): pid_1: int | None = None pid_2: int | None = None @@ -393,18 +409,13 @@ async def test_multi_client_lifespan(tmp_path: Path): gc_collect_harder() # This test will fail while debugging because the debugger holds a reference to the underlying transport - - with pytest.raises(psutil.NoSuchProcess): - while True: - psutil.Process(pid_1) - await asyncio.sleep(0.01) - - with pytest.raises(psutil.NoSuchProcess): - while True: - psutil.Process(pid_2) - await asyncio.sleep(0.01) + assert pid_1 is not None + assert pid_2 is not None + await _wait_for_process_exit(pid_1) + await _wait_for_process_exit(pid_2) +@pytest.mark.timeout(15) async def test_multi_client_force_close(tmp_path: Path): server_script = inspect.cleandoc(""" from fastmcp import FastMCP @@ -446,15 +457,8 @@ async def test_multi_client_force_close(tmp_path: Path): gc_collect_harder() - with pytest.raises(psutil.NoSuchProcess): - process = psutil.Process(pid_1) - - assert not process - - with pytest.raises(psutil.NoSuchProcess): - process = psutil.Process(pid_2) - - assert not process + await _wait_for_process_exit(pid_1) + await _wait_for_process_exit(pid_2) async def test_remote_config_default_no_auth(): diff --git a/uv.lock b/uv.lock index 3b297bb91..4bf824746 100644 --- a/uv.lock +++ b/uv.lock @@ -895,7 +895,7 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.8" }, - { name = "ty", specifier = ">=0.0.15" }, + { name = "ty", specifier = ">=0.0.20" }, ] [[package]] @@ -2978,26 +2978,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.17" +version = "0.0.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/95/8de69bb98417227b01f1b1d743c819d6456c9fd140255b6124b05b17dfd6/ty-0.0.20.tar.gz", hash = "sha256:ebba6be7974c14efbb2a9adda6ac59848f880d7259f089dfa72a093039f1dcc6", size = 5262529, upload-time = "2026-03-02T15:51:36.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" }, - { url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" }, - { url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" }, - { url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" }, - { url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" }, - { url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" }, - { url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" }, - { url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" }, - { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2c/718abe48393e521bf852cd6b0f984766869b09c258d6e38a118768a91731/ty-0.0.20-py3-none-linux_armv6l.whl", hash = "sha256:7cc12769c169c9709a829c2248ee2826b7aae82e92caeac813d856f07c021eae", size = 10333656, upload-time = "2026-03-02T15:51:56.461Z" }, + { url = "https://files.pythonhosted.org/packages/41/0e/eb1c4cc4a12862e2327b72657bcebb10b7d9f17046f1bdcd6457a0211615/ty-0.0.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3b777c1bf13bc0a95985ebb8a324b8668a4a9b2e514dde5ccf09e4d55d2ff232", size = 10168505, upload-time = "2026-03-02T15:51:51.895Z" }, + { url = "https://files.pythonhosted.org/packages/89/7f/10230798e673f0dd3094dfd16e43bfd90e9494e7af6e8e7db516fb431ddf/ty-0.0.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b2a4a7db48bf8cba30365001bc2cad7fd13c1a5aacdd704cc4b7925de8ca5eb3", size = 9678510, upload-time = "2026-03-02T15:51:48.451Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/59d9159577494edd1728f7db77b51bb07884bd21384f517963114e3ab5f6/ty-0.0.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6846427b8b353a43483e9c19936dc6a25612573b44c8f7d983dfa317e7f00d4c", size = 10162926, upload-time = "2026-03-02T15:51:40.558Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a8/b7273eec3e802f78eb913fbe0ce0c16ef263723173e06a5776a8359b2c66/ty-0.0.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245ceef5bd88df366869385cf96411cb14696334f8daa75597cf7e41c3012eb8", size = 10171702, upload-time = "2026-03-02T15:51:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/5f1144f2f04a275109db06e3498450c4721554215b80ae73652ef412eeab/ty-0.0.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4d21d1cdf67a444d3c37583c17291ddba9382a9871021f3f5d5735e09e85efe", size = 10682552, upload-time = "2026-03-02T15:51:33.102Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9f1f637310792f12bd6ed37d5fc8ab39ba1a9b0c6c55a33865e9f1cad840/ty-0.0.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd4ffd907d1bd70e46af9e9a2f88622f215e1bf44658ea43b32c2c0b357299e4", size = 11242605, upload-time = "2026-03-02T15:51:34.895Z" }, + { url = "https://files.pythonhosted.org/packages/1a/68/cc9cae2e732fcfd20ccdffc508407905a023fc8493b8771c392d915528dc/ty-0.0.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6594b58d8b0e9d16a22b3045fc1305db4b132c8d70c17784ab8c7a7cc986807", size = 10974655, upload-time = "2026-03-02T15:51:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c1/b9e3e3f28fe63486331e653f6aeb4184af8b1fe80542fcf74d2dda40a93d/ty-0.0.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3662f890518ce6cf4d7568f57d03906912d2afbf948a01089a28e325b1ef198c", size = 10761325, upload-time = "2026-03-02T15:51:26.818Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/67db935bdedf219a00fb69ec5437ba24dab66e0f2e706dd54a4eca234b84/ty-0.0.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e3ffbae58f9f0d17cdc4ac6d175ceae560b7ed7d54f9ddfb1c9f31054bcdc2c", size = 10145793, upload-time = "2026-03-02T15:51:38.562Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/b0eb815d4dc5a819c7e4faddc2a79058611169f7eef07ccc006531ce228c/ty-0.0.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:176e52bc8bb00b0e84efd34583962878a447a3a0e34ecc45fd7097a37554261b", size = 10189640, upload-time = "2026-03-02T15:51:50.202Z" }, + { url = "https://files.pythonhosted.org/packages/b8/71/63734923965cbb70df1da3e93e4b8875434e326b89e9f850611122f279bf/ty-0.0.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b2bc73025418e976ca4143dde71fb9025a90754a08ac03e6aa9b80d4bed1294b", size = 10370568, upload-time = "2026-03-02T15:51:42.295Z" }, + { url = "https://files.pythonhosted.org/packages/32/a0/a532c2048533347dff48e9ca98bd86d2c224356e101688a8edaf8d6973fb/ty-0.0.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52f7c9ec6e363e094b3c389c344d5a140401f14a77f0625e3f28c21918552f5", size = 10853999, upload-time = "2026-03-02T15:51:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/48/88/36c652c658fe96658043e4abc8ea97801de6fb6e63ab50aaa82807bff1d8/ty-0.0.20-py3-none-win32.whl", hash = "sha256:c7d32bfe93f8fcaa52b6eef3f1b930fd7da410c2c94e96f7412c30cfbabf1d17", size = 9744206, upload-time = "2026-03-02T15:51:54.183Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/a4a13bed1d7fd9d97aaa3c5bb5e6d3e9a689e6984806cbca2ab4c9233cac/ty-0.0.20-py3-none-win_amd64.whl", hash = "sha256:a5e10f40fc4a0a1cbcb740a4aad5c7ce35d79f030836ea3183b7a28f43170248", size = 10711999, upload-time = "2026-03-02T15:51:29.212Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7e/6bfd748a9f4ff9267ed3329b86a0f02cdf6ab49f87bc36c8a164852f99fc/ty-0.0.20-py3-none-win_arm64.whl", hash = "sha256:53f7a5c12c960e71f160b734f328eff9a35d578af4b67a36b0bb5990ac5cdc27", size = 10150143, upload-time = "2026-03-02T15:51:31.283Z" }, ] [[package]]