From a98838c15807a5bc09a8cb2f598a48ac77d1901d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 15 Nov 2025 11:58:14 -0500 Subject: [PATCH 1/4] Exclude MCP SDK 1.21.1 and update OAuth client tests - Exclude MCP SDK 1.21.1 due to bug adding metadata URL to scopes - Update OAuth client tests to use valid scopes --- pyproject.toml | 2 +- tests/client/auth/test_oauth_client.py | 6 ++++-- uv.lock | 23 +++++++++++++++++++---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e20f60047..07b8a168f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "python-dotenv>=1.1.0", "exceptiongroup>=1.2.2", "httpx>=0.28.1", - "mcp>=1.19.0,<2.0.0", + "mcp>=1.19.0,<2.0.0,!=1.21.1", "openapi-pydantic>=0.5.1", "platformdirs>=4.0.0", "rich>=13.9.4", diff --git a/tests/client/auth/test_oauth_client.py b/tests/client/auth/test_oauth_client.py index dbf41dd0b..f9e452503 100644 --- a/tests/client/auth/test_oauth_client.py +++ b/tests/client/auth/test_oauth_client.py @@ -18,7 +18,9 @@ def fastmcp_server(issuer_url: str): "TestServer", auth=InMemoryOAuthProvider( base_url=issuer_url, - client_registration_options=ClientRegistrationOptions(enabled=True), + client_registration_options=ClientRegistrationOptions( + enabled=True, valid_scopes=["read", "write"] + ), ), ) @@ -54,7 +56,7 @@ def client_with_headless_oauth(streamable_http_server: str) -> Client: """Client with headless OAuth that bypasses browser interaction.""" return Client( transport=StreamableHttpTransport(streamable_http_server), - auth=HeadlessOAuth(mcp_url=streamable_http_server), + auth=HeadlessOAuth(mcp_url=streamable_http_server, scopes=["read", "write"]), ) diff --git a/uv.lock b/uv.lock index cd0f46d40..6b50678b5 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ requires-dist = [ { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jsonschema-path", specifier = ">=0.3.4" }, - { name = "mcp", specifier = ">=1.19.0,<2.0.0" }, + { name = "mcp", specifier = ">=1.19.0,!=1.21.1,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.102.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, { name = "platformdirs", specifier = ">=4.0.0" }, @@ -1033,7 +1033,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.19.0" +version = "1.21.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1042,15 +1042,16 @@ dependencies = [ { name = "jsonschema" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" }, ] [[package]] @@ -1485,6 +1486,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/e8/691115aa790a2fa4bfad456287061a7439aaf877edfb0befd13486440de9/pyinstrument-5.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c6711d53e600cfadb16bff68ba29c9e4f13e61196f185e32e8e29c8baa1dd606", size = 126064, upload-time = "2025-08-10T11:17:37.013Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyperclip" version = "1.9.0" From 1895f863238bca7bf067c2dc461b807721140ce1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:02:28 -0500 Subject: [PATCH 2/4] Add scope validation to InMemoryOAuthProvider and remove debug breakpoint - Add scope validation to InMemoryOAuthProvider.register_client() to match MCP SDK behavior - Ensures unit tests catch scope-related bugs like the MCP SDK 1.21.1 issue - Remove debug breakpoint from OAuth client redirect_handler --- src/fastmcp/server/auth/providers/in_memory.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py index 9a1bd0c7d..24d8acb60 100644 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -66,6 +66,20 @@ class InMemoryOAuthProvider(OAuthProvider): return self.clients.get(client_id) async def register_client(self, client_info: OAuthClientInformationFull) -> None: + # Validate scopes against valid_scopes if configured (matches MCP SDK behavior) + if ( + client_info.scope is not None + and self.client_registration_options is not None + and self.client_registration_options.valid_scopes is not None + ): + requested_scopes = set(client_info.scope.split()) + valid_scopes = set(self.client_registration_options.valid_scopes) + invalid_scopes = requested_scopes - valid_scopes + if invalid_scopes: + raise ValueError( + f"Requested scopes are not valid: {', '.join(invalid_scopes)}" + ) + if client_info.client_id in self.clients: # As per RFC 7591, if client_id is already known, it's an update. # For this simple provider, we'll treat it as re-registration. From 6df54c07292aee0e43e903bff22b7cecc465a66c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:04:51 -0500 Subject: [PATCH 3/4] Fix typos and formatting --- .../auth/test_github_provider_integration.py | 1 + tests/server/auth/test_oidc_proxy.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests/auth/test_github_provider_integration.py b/tests/integration_tests/auth/test_github_provider_integration.py index 7623a7690..8ad84dc65 100644 --- a/tests/integration_tests/auth/test_github_provider_integration.py +++ b/tests/integration_tests/auth/test_github_provider_integration.py @@ -360,6 +360,7 @@ async def test_github_oauth_unauthorized_access(github_server: str): async def test_github_oauth_with_mock(github_client_with_mock: Client): """Test complete GitHub OAuth flow with mocked callback.""" + async with github_client_with_mock: # Test that we can ping the server (requires successful OAuth) assert await github_client_with_mock.ping() diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index 319751bc9..2dd9cac24 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -307,7 +307,7 @@ class TestOIDCConfiguration: with pytest.raises(ValueError, match="Invalid URL for configuration metadata"): OIDCConfiguration.model_validate(valid_oidc_configuration_dict) - def test_explict_strict_with_bad_url_raises_error( + def test_explicit_strict_with_bad_url_raises_error( self, valid_oidc_configuration_dict ): """Test default configuration with explicit True strict setting and bad URL setting.""" @@ -359,7 +359,7 @@ class TestOIDCConfiguration: def validate_get_oidc_configuration(oidc_configuration, strict, timeout_seconds): - """Validate get_oidc_configuation call.""" + """Validate get_oidc_configuration call.""" with patch("httpx.get") as mock_get: mock_response = MagicMock(spec=Response) mock_response.json.return_value = oidc_configuration From d40f22b3e6661a7b3dbb80a503d9103f0efeab45 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 15 Nov 2025 12:06:33 -0500 Subject: [PATCH 4/4] Fix type errors in oauth_proxy and test_auth_integration - Add null checks for client_id before using in OAuthTransaction, AuthorizationCode, AccessToken, RefreshToken - Add null check for redirect_uris before len() call - Import AuthorizeError from mcp.server.auth.provider --- src/fastmcp/server/auth/oauth_proxy.py | 19 +++++++++++++++++-- .../server/auth/providers/in_memory.py | 12 +++++++++++- tests/server/test_auth_integration.py | 6 ++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py index a1452378b..a77b731a4 100644 --- a/src/fastmcp/server/auth/oauth_proxy.py +++ b/src/fastmcp/server/auth/oauth_proxy.py @@ -44,6 +44,7 @@ from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, + AuthorizeError, RefreshToken, TokenError, ) @@ -941,6 +942,8 @@ class OAuthProxy(OAuthProvider): """ # Create a ProxyDCRClient with configured redirect URI validation + if client_info.client_id is None: + raise ValueError("client_id is required for client registration") proxy_client: ProxyDCRClient = ProxyDCRClient( client_id=client_info.client_id, client_secret=client_info.client_secret, @@ -970,7 +973,7 @@ class OAuthProxy(OAuthProvider): logger.debug( "Registered client %s with %d redirect URIs", client_info.client_id, - len(proxy_client.redirect_uris), + len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0, ) # ------------------------------------------------------------------------- @@ -1007,6 +1010,10 @@ class OAuthProxy(OAuthProvider): ) # Store transaction data for IdP callback processing + if client.client_id is None: + raise AuthorizeError( + error="invalid_client", error_description="Client ID is required" + ) transaction = OAuthTransaction( txn_id=txn_id, client_id=client.client_id, @@ -1085,6 +1092,10 @@ class OAuthProxy(OAuthProvider): return None # Create authorization code object with PKCE challenge + if client.client_id is None: + raise AuthorizeError( + error="invalid_client", error_description="Client ID is required" + ) return AuthorizationCode( code=authorization_code, client_id=client.client_id, @@ -1170,7 +1181,7 @@ class OAuthProxy(OAuthProvider): expires_at=time.time() + expires_in, token_type=idp_tokens.get("token_type", "Bearer"), scope=" ".join(authorization_code.scopes), - client_id=client.client_id, + client_id=client.client_id or "", created_at=time.time(), raw_token_data=idp_tokens, ) @@ -1182,6 +1193,8 @@ class OAuthProxy(OAuthProvider): logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) # Issue minimal FastMCP access token (just a reference via JTI) + if client.client_id is None: + raise TokenError("invalid_client", "Client ID is required") fastmcp_access_token = self._jwt_issuer.issue_access_token( client_id=client.client_id, scopes=authorization_code.scopes, @@ -1377,6 +1390,8 @@ class OAuthProxy(OAuthProvider): ) # Issue new minimal FastMCP access token (just a reference via JTI) + if client.client_id is None: + raise TokenError("invalid_client", "Client ID is required") new_access_jti = secrets.token_urlsafe(32) new_fastmcp_access = self._jwt_issuer.issue_access_token( client_id=client.client_id, diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py index 24d8acb60..c0275faa4 100644 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -80,6 +80,8 @@ class InMemoryOAuthProvider(OAuthProvider): f"Requested scopes are not valid: {', '.join(invalid_scopes)}" ) + if client_info.client_id is None: + raise ValueError("client_id is required for client registration") if client_info.client_id in self.clients: # As per RFC 7591, if client_id is already known, it's an update. # For this simple provider, we'll treat it as re-registration. @@ -105,7 +107,7 @@ class InMemoryOAuthProvider(OAuthProvider): # OAuthClientInformationFull should have a method like validate_redirect_uri # For this test provider, we assume it's valid if it matches one in client_info # The AuthorizationHandler already does robust validation using client.validate_redirect_uri - if params.redirect_uri not in client.redirect_uris: + if client.redirect_uris and params.redirect_uri not in client.redirect_uris: # This check might be too simplistic if redirect_uris can be patterns # or if params.redirect_uri is None and client has a default. # However, the AuthorizationHandler handles the primary validation. @@ -124,6 +126,10 @@ class InMemoryOAuthProvider(OAuthProvider): client_allowed_scopes = set(client.scope.split()) scopes_list = [s for s in scopes_list if s in client_allowed_scopes] + if client.client_id is None: + raise AuthorizeError( + error="invalid_client", error_description="Client ID is required" + ) auth_code = AuthorizationCode( code=auth_code_value, client_id=client.client_id, @@ -180,6 +186,8 @@ class InMemoryOAuthProvider(OAuthProvider): time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS ) + if client.client_id is None: + raise TokenError("invalid_client", "Client ID is required") self.access_tokens[access_token_value] = AccessToken( token=access_token_value, client_id=client.client_id, @@ -250,6 +258,8 @@ class InMemoryOAuthProvider(OAuthProvider): time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS ) + if client.client_id is None: + raise TokenError("invalid_client", "Client ID is required") self.access_tokens[new_access_token_value] = AccessToken( token=new_access_token_value, client_id=client.client_id, diff --git a/tests/server/test_auth_integration.py b/tests/server/test_auth_integration.py index 3ef2b4018..1eb232f8b 100644 --- a/tests/server/test_auth_integration.py +++ b/tests/server/test_auth_integration.py @@ -49,6 +49,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider): ) -> str: # toy authorize implementation which just immediately generates an authorization # code and completes the redirect + if client.client_id is None: + raise ValueError("client_id is required") code = AuthorizationCode( code=f"code_{int(time.time())}", client_id=client.client_id, @@ -79,6 +81,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider): refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the tokens + if client.client_id is None: + raise ValueError("client_id is required") self.tokens[access_token] = AccessToken( token=access_token, client_id=client.client_id, @@ -142,6 +146,8 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider): new_refresh_token = f"refresh_{secrets.token_hex(32)}" # Store the new tokens + if client.client_id is None: + raise ValueError("client_id is required") self.tokens[new_access_token] = AccessToken( token=new_access_token, client_id=client.client_id,