From 42460480de275257348911f1e77643115bc140b2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:13:00 -0400 Subject: [PATCH 1/3] Fix BearerAuthProvider audience type annotations to support List[str] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audience parameter was typed as `str | None` but the implementation already supported `List[str]`. This fix aligns the type annotations with the actual functionality and adds comprehensive validation logic for all audience type combinations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/fastmcp/server/auth/providers/bearer.py | 30 ++++++++++++----- tests/auth/providers/test_bearer.py | 37 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a4f3a8e48..a18d30b16 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -89,7 +89,7 @@ class RSAKeyPair: self, subject: str = "fastmcp-user", issuer: str = "https://fastmcp.example.com", - audience: str | None = None, + audience: str | list[str] | None = None, scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, @@ -102,7 +102,7 @@ class RSAKeyPair: private_key_pem: RSA private key in PEM format subject: Subject claim (usually user ID) issuer: Issuer claim - audience: Audience claim (optional) + audience: Audience claim - can be a string or list of strings (optional) scopes: List of scopes to include expires_in_seconds: Token expiration time in seconds additional_claims: Any additional claims to include @@ -161,7 +161,7 @@ class BearerAuthProvider(OAuthProvider): public_key: str | None = None, jwks_uri: str | None = None, issuer: str | None = None, - audience: str | None = None, + audience: str | list[str] | None = None, required_scopes: list[str] | None = None, ): """ @@ -171,7 +171,7 @@ class BearerAuthProvider(OAuthProvider): public_key: RSA public key in PEM format (for static key) jwks_uri: URI to fetch keys from (for key rotation) issuer: Expected issuer claim (optional) - audience: Expected audience claim (optional) + audience: Expected audience claim - can be a string or list of strings (optional) required_scopes: List of required scopes for access (optional) """ if not (public_key or jwks_uri): @@ -312,11 +312,25 @@ class BearerAuthProvider(OAuthProvider): # Validate audience if configured if self.audience: aud = claims.get("aud") - if isinstance(aud, list): - if self.audience not in aud: + + # Handle different combinations of audience types + if isinstance(self.audience, list): + # self.audience is a list - check if any expected audience is present + if isinstance(aud, list): + # Both are lists - check for intersection + if not any(expected in aud for expected in self.audience): + return None + else: + # aud is a string - check if it's in our expected list + if aud not in self.audience: + return None + else: + # self.audience is a string - use original logic + if isinstance(aud, list): + if self.audience not in aud: + return None + elif aud != self.audience: return None - elif aud != self.audience: - return None # Extract claims - prefer client_id over sub for OAuth application identification client_id = claims.get("client_id") or claims.get("sub") or "unknown" diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 31d623d2c..ba6692c54 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -446,6 +446,43 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None + async def test_provider_with_multiple_expected_audiences(self, rsa_key_pair: RSAKeyPair): + """Test provider configured with multiple expected audiences.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + audience=["https://api.example.com", "https://other-api.example.com"], + ) + + # Token with single audience that matches one of the expected + token1 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + access_token1 = await provider.load_access_token(token1) + assert access_token1 is not None + + # Token with multiple audiences, one of which matches + token2 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + additional_claims={ + "aud": ["https://api.example.com", "https://third-party.example.com"] + }, + ) + access_token2 = await provider.load_access_token(token2) + assert access_token2 is not None + + # Token with audience that doesn't match any expected + token3 = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://wrong-api.example.com", + ) + access_token3 = await provider.load_access_token(token3) + assert access_token3 is None + async def test_scope_extraction_string( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): From a2f406faa4bac1b23f449cd5dae0e35d45061a56 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:17:47 -0400 Subject: [PATCH 2/3] Apply pre-commit formatting fixes --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a18d30b16..6ffffa909 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -312,7 +312,7 @@ class BearerAuthProvider(OAuthProvider): # Validate audience if configured if self.audience: aud = claims.get("aud") - + # Handle different combinations of audience types if isinstance(self.audience, list): # self.audience is a list - check if any expected audience is present diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index ba6692c54..efed070d4 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -446,7 +446,9 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - async def test_provider_with_multiple_expected_audiences(self, rsa_key_pair: RSAKeyPair): + async def test_provider_with_multiple_expected_audiences( + self, rsa_key_pair: RSAKeyPair + ): """Test provider configured with multiple expected audiences.""" provider = BearerAuthProvider( public_key=rsa_key_pair.public_key, From ac252fc28f97182c1794e70cbd1cf2687d85c81d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:18:51 -0400 Subject: [PATCH 3/3] Fix CORS documentation example to properly handle preflight requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous example only allowed GET methods, causing browser preflight requests to fail with "Disallowed CORS method" errors. Updated to include the required parameters for proper CORS support with MCP clients. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 7 ++++++- docs/deployment/asgi.mdx | 8 +++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24f9846b1..1da059260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,4 +27,9 @@ Only use HTTP transport when testing network-specific features. Prefer Streamabl # Only when network testing is required async with Client(transport=StreamableHttpTransport(server_url)) as client: result = await client.ping() -``` \ No newline at end of file +``` + +## Development Workflow + +- You must always run pre-commit if you open a PR, because it is run as part of a required check. +- When opening PRs, apply labels appropriately for bugs/breaking changes/enhancements/features. Generally, improvements are enhancements (not features) unless told otherwise. \ No newline at end of file diff --git a/docs/deployment/asgi.mdx b/docs/deployment/asgi.mdx index 06da46374..56947cde9 100644 --- a/docs/deployment/asgi.mdx +++ b/docs/deployment/asgi.mdx @@ -96,7 +96,13 @@ mcp = FastMCP("MyServer") # Define custom middleware custom_middleware = [ - Middleware(CORSMiddleware, allow_origins=["*"]), + Middleware( + CORSMiddleware, + allow_origins=["https://example.com", "https://app.example.com"], + allow_credentials=True, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type", "Authorization"], + ), ] # Create ASGI app with custom middleware