From a57a155c2e6a526d66962e59ba9cd3c7b306908e Mon Sep 17 00:00:00 2001 From: Akshay Parihar Date: Sat, 22 Nov 2025 22:52:13 +0530 Subject: [PATCH] Scalekit provider updates (#2413) * sk-provider updates - aud not enforce, scopes enforce if present * updating env_prefix, adding debug logs * updating docs * ruff formatting * not changing prefix for backward compatiblity * backward compatibility changes * give more preference to base_url than mcp_url if both passed * updating docs * refactor * updating example server * updating readme of example * updating docs * updating tests to reflect what should ideally go in the parameter --- docs/integrations/scalekit.mdx | 45 ++--- ...fastmcp-server-auth-providers-scalekit.mdx | 5 +- examples/auth/scalekit_oauth/README.md | 9 +- examples/auth/scalekit_oauth/server.py | 16 +- src/fastmcp/server/auth/providers/scalekit.py | 93 ++++++++-- tests/server/auth/providers/test_scalekit.py | 160 +++++++++++++++--- 6 files changed, 254 insertions(+), 74 deletions(-) diff --git a/docs/integrations/scalekit.mdx b/docs/integrations/scalekit.mdx index 89de64ef1..802fe16ef 100644 --- a/docs/integrations/scalekit.mdx +++ b/docs/integrations/scalekit.mdx @@ -8,19 +8,16 @@ tag: NEW 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. -## Configuration - ### Prerequisites Before you begin -1. Get a [Scalekit account](https://app.scalekit.com/) and grab API credentials such as **Client ID**, **Client Secret** and **Environment URL** from _Dashboard > Developers > Settings_. -2. Have your FastMCP server's endpoint ready (can be localhost for development, e.g., `http://localhost:8000/mcp`) +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 @@ -36,9 +33,10 @@ In your FastMCP project's `.env`: ```sh SCALEKIT_ENVIRONMENT_URL= -SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46 SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 -MCP_URL=http://localhost:8000/mcp +BASE_URL=http://localhost:8000/ +# Optional: additional scopes tokens must have +# SCALEKIT_REQUIRED_SCOPES=read,write ``` @@ -48,6 +46,8 @@ MCP_URL=http://localhost:8000/mcp 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 @@ -55,9 +55,9 @@ 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 - client_id=SCALEKIT_CLIENT_ID, # OAuth client ID resource_id=SCALEKIT_RESOURCE_ID, # Resource server ID - mcp_url=SERVER_URL, # Is also aud claim + base_url=SERVER_URL, # Public MCP endpoint + required_scopes=["read"], # Optional scope enforcement ) # Create FastMCP server with auth @@ -75,6 +75,10 @@ def auth_status() -> dict: ``` + +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 @@ -104,16 +108,18 @@ These environment variables provide default values for the Scalekit provider, wh Your Scalekit environment URL from the Admin Portal (e.g., `https://your-env.scalekit.com`) - -Your Scalekit OAuth application client ID from the Applications section - - -Your Scalekit resource server ID from the Resources section +Your Scalekit resource server ID from the MCP Servers section - -Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/mcp` for development) + +Public URL of your FastMCP server (e.g., `https://your-server.com` or `http://localhost:8000/` for development) + + +Legacy `FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL` is still recognized for backward compatibility but will be removed soon-rename it to `...BASE_URL`. + + +Comma-, space-, or JSON-separated list of scopes that tokens must include to access your server @@ -125,9 +131,10 @@ FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.scalekit.ScalekitProvider # Scalekit configuration FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL=https://your-env.scalekit.com -FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID=skc_123 FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID=res_456 -FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL=https://your-server.com/mcp +FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_BASE_URL=https://your-server.com/ +# FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_REQUIRED_SCOPES=read,write +# FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL=https://your-server.com/ # Deprecated ``` With environment variables set, your server code simplifies to: diff --git a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx index 7dd465a29..546b07b06 100644 --- a/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx +++ b/docs/python-sdk/fastmcp-server-auth-providers-scalekit.mdx @@ -37,9 +37,9 @@ IMPORTANT SETUP REQUIREMENTS: 2. Environment Configuration: - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - - Set SCALEKIT_CLIENT_ID from your OAuth application - Set SCALEKIT_RESOURCE_ID from your created resource - - Set MCP_URL to your FastMCP server's public URL + - Set BASE_URL to your FastMCP server's public URL + - (Optional) Set SCALEKIT_REQUIRED_SCOPES to enforce token scopes For detailed setup instructions, see: https://docs.scalekit.com/mcp/overview/ @@ -61,4 +61,3 @@ 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. - diff --git a/examples/auth/scalekit_oauth/README.md b/examples/auth/scalekit_oauth/README.md index 63c555244..c241d76f7 100644 --- a/examples/auth/scalekit_oauth/README.md +++ b/examples/auth/scalekit_oauth/README.md @@ -9,8 +9,8 @@ Demonstrates FastMCP server protection with Scalekit OAuth. **Create a Scalekit Account**: - Go to [Scalekit Dashboard](https://app.scalekit.com/) -- Navigate to **Developers** → **Settings** -- Copy your Environment URL, Client ID, and Client Secret +- Copy your Environment URL from **Developers** → **Settings** +- Copy Resource ID (res_xxx) from **Developers** → **MCP Servers** **Register Your MCP Server**: @@ -23,9 +23,10 @@ Create a `.env` file: ```bash # Required Scalekit credentials SCALEKIT_ENVIRONMENT_URL= -SCALEKIT_CLIENT_ID= # skc_7008EXAMPLE46 SCALEKIT_RESOURCE_ID= # res_926EXAMPLE5878 -MCP_URL=http://localhost:8000/mcp +BASE_URL=http://localhost:8000/ +# Optional: additional scopes tokens must include (comma-separated) +# SCALEKIT_REQUIRED_SCOPES=read,write ``` ### 2. Run the Example diff --git a/examples/auth/scalekit_oauth/server.py b/examples/auth/scalekit_oauth/server.py index 40da711e2..68cef23b5 100644 --- a/examples/auth/scalekit_oauth/server.py +++ b/examples/auth/scalekit_oauth/server.py @@ -4,9 +4,12 @@ This example demonstrates how to protect a FastMCP server with Scalekit OAuth. Required environment variables: - SCALEKIT_ENVIRONMENT_URL: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") -- SCALEKIT_CLIENT_ID: Your Scalekit OAuth application client ID - SCALEKIT_RESOURCE_ID: Your Scalekit resource ID +Optional: +- SCALEKIT_REQUIRED_SCOPES: Comma-separated scopes tokens must include +- BASE_URL: Public URL where the FastMCP server is exposed (defaults to `http://localhost:8000/`) + To run: python server.py """ @@ -16,12 +19,19 @@ import os from fastmcp import FastMCP from fastmcp.server.auth.providers.scalekit import ScalekitProvider +required_scopes_env = os.getenv("SCALEKIT_REQUIRED_SCOPES") +required_scopes = ( + [scope.strip() for scope in required_scopes_env.split(",") if scope.strip()] + if required_scopes_env + else None +) + auth = ScalekitProvider( environment_url=os.getenv("SCALEKIT_ENVIRONMENT_URL") or "https://your-env.scalekit.com", - client_id=os.getenv("SCALEKIT_CLIENT_ID") or "", resource_id=os.getenv("SCALEKIT_RESOURCE_ID") or "", - mcp_url=os.getenv("MCP_URL", "http://localhost:8000/mcp"), + base_url=os.getenv("BASE_URL", "http://localhost:8000/"), + required_scopes=required_scopes, ) mcp = FastMCP("Scalekit OAuth Example Server", auth=auth) diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py index 1aefaf052..6f115b867 100644 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ b/src/fastmcp/server/auth/providers/scalekit.py @@ -8,7 +8,7 @@ authentication for seamless MCP client authentication. from __future__ import annotations import httpx -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from starlette.responses import JSONResponse from starlette.routing import Route @@ -16,6 +16,7 @@ from starlette.routing import Route from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier from fastmcp.server.auth.providers.jwt import JWTVerifier from fastmcp.settings import ENV_FILE +from fastmcp.utilities.auth import parse_scopes from fastmcp.utilities.logging import get_logger from fastmcp.utilities.types import NotSet, NotSetT @@ -30,9 +31,25 @@ class ScalekitProviderSettings(BaseSettings): ) environment_url: AnyHttpUrl - client_id: str resource_id: str - mcp_url: AnyHttpUrl + base_url: AnyHttpUrl | None = None + mcp_url: AnyHttpUrl | None = None + required_scopes: list[str] | None = None + + @field_validator("required_scopes", mode="before") + @classmethod + def _parse_scopes(cls, value: object): + return parse_scopes(value) + + @model_validator(mode="after") + def _resolve_base_url(self): + resolved = self.base_url or self.mcp_url + if resolved is None: + msg = "Either base_url or mcp_url must be provided for ScalekitProvider" + raise ValueError(msg) + + object.__setattr__(self, "base_url", resolved) + return self class ScalekitProvider(RemoteAuthProvider): @@ -53,9 +70,8 @@ class ScalekitProvider(RemoteAuthProvider): 2. Environment Configuration: - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - - Set SCALEKIT_CLIENT_ID from your OAuth application - Set SCALEKIT_RESOURCE_ID from your created resource - - Set MCP_URL to your FastMCP server's public URL + - Set BASE_URL to your FastMCP server's public URL For detailed setup instructions, see: https://docs.scalekit.com/mcp/overview/ @@ -67,9 +83,8 @@ class ScalekitProvider(RemoteAuthProvider): # Create Scalekit resource server provider scalekit_auth = ScalekitProvider( environment_url="https://your-env.scalekit.com", - client_id="sk_client_...", resource_id="sk_resource_...", - mcp_url="https://your-fastmcp-server.com", + base_url="https://your-fastmcp-server.com", ) # Use with FastMCP @@ -83,44 +98,77 @@ class ScalekitProvider(RemoteAuthProvider): environment_url: AnyHttpUrl | str | NotSetT = NotSet, client_id: str | NotSetT = NotSet, resource_id: str | NotSetT = NotSet, + base_url: AnyHttpUrl | str | NotSetT = NotSet, mcp_url: AnyHttpUrl | str | NotSetT = NotSet, + required_scopes: list[str] | NotSetT = NotSet, token_verifier: TokenVerifier | None = None, ): """Initialize Scalekit resource server provider. Args: environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") - client_id: Your Scalekit OAuth client ID resource_id: Your Scalekit resource ID - mcp_url: Public URL of this FastMCP server (used as audience) + base_url: Public URL of this FastMCP server + required_scopes: Optional list of scopes that must be present in tokens token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit """ + legacy_client_id = client_id is not NotSet + settings = ScalekitProviderSettings.model_validate( { k: v for k, v in { "environment_url": environment_url, - "client_id": client_id, "resource_id": resource_id, + "base_url": base_url, "mcp_url": mcp_url, + "required_scopes": required_scopes, }.items() if v is not NotSet } ) + if settings.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 legacy_client_id: + 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(settings.environment_url).rstrip("/") - self.client_id = settings.client_id self.resource_id = settings.resource_id - self.mcp_url = str(settings.mcp_url) + self.required_scopes = settings.required_scopes or [] + base_url_value = str(settings.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.mcp_url, + 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__( @@ -128,7 +176,7 @@ class ScalekitProvider(RemoteAuthProvider): authorization_servers=[ AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") ], - base_url=self.mcp_url, + base_url=base_url_value, ) def get_routes( @@ -146,16 +194,27 @@ class ScalekitProvider(RemoteAuthProvider): """ # 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( - f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" - ) + 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}") diff --git a/tests/server/auth/providers/test_scalekit.py b/tests/server/auth/providers/test_scalekit.py index f33ca5952..70470a149 100644 --- a/tests/server/auth/providers/test_scalekit.py +++ b/tests/server/auth/providers/test_scalekit.py @@ -19,15 +19,36 @@ class TestScalekitProvider: """Test ScalekitProvider initialization with explicit parameters.""" provider = ScalekitProvider( environment_url="https://my-env.scalekit.com", - client_id="sk_client_123", resource_id="sk_resource_456", - mcp_url="https://myserver.com/", + base_url="https://myserver.com/", + required_scopes=["read"], ) assert provider.environment_url == "https://my-env.scalekit.com" - assert provider.client_id == "sk_client_123" assert provider.resource_id == "sk_resource_456" - assert str(provider.mcp_url) == "https://myserver.com/" + assert str(provider.base_url) == "https://myserver.com/" + assert provider.required_scopes == ["read"] + + def test_init_with_mcp_url_only(self): + """Allow legacy mcp_url parameter as base_url.""" + provider = ScalekitProvider( + environment_url="https://legacy.scalekit.com", + resource_id="sk_resource_legacy", + mcp_url="https://legacy-app.com/", + ) + + assert str(provider.base_url) == "https://legacy-app.com/" + + def test_init_prefers_base_url_over_mcp_url(self): + """mcp_url should take precedence over base_url when both provided.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + resource_id="sk_resource_456", + base_url="https://preferred-base.com/", + mcp_url="https://unused-base.com/", + ) + + assert str(provider.base_url) == "https://preferred-base.com/" def test_init_with_env_vars(self): """Test ScalekitProvider initialization from environment variables.""" @@ -35,51 +56,72 @@ class TestScalekitProvider: os.environ, { "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL": "https://env-scalekit.com", - "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_CLIENT_ID": "skc_123", "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID": "res_456", - "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL": "https://envserver.com/mcp", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_BASE_URL": "https://envserver.com/mcp", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_REQUIRED_SCOPES": "read,write", }, ): provider = ScalekitProvider() assert provider.environment_url == "https://env-scalekit.com" - assert provider.client_id == "skc_123" assert provider.resource_id == "res_456" - assert str(provider.mcp_url) == "https://envserver.com/mcp" + assert str(provider.base_url) == "https://envserver.com/mcp" + assert provider.required_scopes == ["read", "write"] + + def test_init_with_legacy_env_var(self): + """FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL should still be supported.""" + with patch.dict( + os.environ, + { + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_ENVIRONMENT_URL": "https://env-scalekit.com", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_RESOURCE_ID": "res_456", + "FASTMCP_SERVER_AUTH_SCALEKITPROVIDER_MCP_URL": "https://legacy-env.com/", + }, + ): + provider = ScalekitProvider() + + assert str(provider.base_url) == "https://legacy-env.com/" def test_environment_variable_loading(self): """Test that environment variables are loaded correctly.""" provider = ScalekitProvider( environment_url="https://test-env.scalekit.com", - client_id="sk_client_test_123", resource_id="sk_resource_test_456", - mcp_url="http://test-server.com", + base_url="http://test-server.com", ) assert provider.environment_url == "https://test-env.scalekit.com" - assert provider.client_id == "sk_client_test_123" assert provider.resource_id == "sk_resource_test_456" - assert str(provider.mcp_url) == "http://test-server.com/" + assert str(provider.base_url) == "http://test-server.com/" + + def test_accepts_client_id_argument(self): + """client_id parameter should be accepted but ignored.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + resource_id="sk_resource_456", + base_url="https://myserver.com/", + client_id="client_123", + ) + + assert str(provider.base_url) == "https://myserver.com/" def test_url_trailing_slash_handling(self): """Test that URLs handle trailing slashes correctly.""" provider = ScalekitProvider( environment_url="https://my-env.scalekit.com/", - client_id="sk_client_123", resource_id="sk_resource_456", - mcp_url="https://myserver.com/", + base_url="https://myserver.com/", ) assert provider.environment_url == "https://my-env.scalekit.com" - assert str(provider.mcp_url) == "https://myserver.com/" + assert str(provider.base_url) == "https://myserver.com/" def test_jwt_verifier_configured_correctly(self): """Test that JWT verifier is configured correctly.""" provider = ScalekitProvider( environment_url="https://my-env.scalekit.com", - client_id="sk_client_123", resource_id="sk_resource_456", - mcp_url="https://myserver.com/", + base_url="https://myserver.com/", ) # Check that JWT verifier uses the correct endpoints @@ -90,15 +132,27 @@ class TestScalekitProvider: assert ( provider.token_verifier.issuer == "https://my-env.scalekit.com" # type: ignore[attr-defined] ) - assert provider.token_verifier.audience == "https://myserver.com/" # type: ignore[attr-defined] + assert ( + provider.token_verifier.audience is None # type: ignore[attr-defined] + ) + + def test_required_scopes_hooks_into_verifier(self): + """Token verifier should enforce required scopes when provided.""" + provider = ScalekitProvider( + environment_url="https://my-env.scalekit.com", + resource_id="sk_resource_456", + base_url="https://myserver.com/", + required_scopes=["read"], + ) + + assert provider.token_verifier.required_scopes == ["read"] # type: ignore[attr-defined] def test_authorization_servers_configuration(self): """Test that authorization servers are configured correctly.""" provider = ScalekitProvider( environment_url="https://my-env.scalekit.com", - client_id="sk_client_123", resource_id="sk_resource_456", - mcp_url="https://myserver.com/", + base_url="https://myserver.com/", ) assert len(provider.authorization_servers) == 1 @@ -114,9 +168,8 @@ async def mcp_server_url(): mcp = FastMCP( auth=ScalekitProvider( environment_url="https://test-env.scalekit.com", - client_id="sk_client_test_123", resource_id="sk_resource_test_456", - mcp_url="http://localhost:4321", + base_url="http://localhost:4321", ) ) @@ -147,9 +200,60 @@ class TestScalekitProviderIntegration: assert exc_info.value.response.status_code == 401 assert "tools" not in locals() - # async def test_authorized_access(self, client_with_headless_oauth: Client): - # async with client_with_headless_oauth: - # tools = await client_with_headless_oauth.list_tools() - # assert tools is not None - # assert len(tools) > 0 - # assert "add" in tools + async def test_metadata_route_forwards_scalekit_response( + self, + monkeypatch: pytest.MonkeyPatch, + mcp_server_url: str, + ) -> None: + """Ensure Scalekit metadata route proxies upstream JSON.""" + + metadata_payload = { + "issuer": "https://test-env.scalekit.com", + "token_endpoint": "https://test-env.scalekit.com/token", + "authorization_endpoint": "https://test-env.scalekit.com/authorize", + } + + class DummyResponse: + status_code = 200 + + def __init__(self, data: dict[str, str]): + self._data = data + + def json(self): + return self._data + + def raise_for_status(self): + return None + + class DummyAsyncClient: + last_url: str | None = None + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def get(self, url: str): + DummyAsyncClient.last_url = url + return DummyResponse(metadata_payload) + + real_httpx_client = httpx.AsyncClient + + monkeypatch.setattr( + "fastmcp.server.auth.providers.scalekit.httpx.AsyncClient", + DummyAsyncClient, + ) + + base_url = mcp_server_url.rsplit("/mcp", 1)[0] + async with real_httpx_client() as client: + response = await client.get( + f"{base_url}/.well-known/oauth-authorization-server" + ) + + assert response.status_code == 200 + assert response.json() == metadata_payload + assert ( + DummyAsyncClient.last_url + == "https://test-env.scalekit.com/.well-known/oauth-authorization-server/resources/sk_resource_test_456" + )