From f348481def35639c26ef49c056aef2b2721a2ac8 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Mon, 4 Aug 2025 20:06:32 +0200 Subject: [PATCH 01/16] Improved supported for ProtectedResourceMetadata --- src/mcp/server/auth/routes.py | 4 ++++ src/mcp/shared/auth.py | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index e4db806e7..bce32df52 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -190,6 +190,8 @@ def create_protected_resource_routes( resource_url: AnyHttpUrl, authorization_servers: list[AnyHttpUrl], scopes_supported: list[str] | None = None, + resource_name: str | None = None, + resource_documentation: AnyHttpUrl | None = None, ) -> list[Route]: """ Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). @@ -209,6 +211,8 @@ def create_protected_resource_routes( resource=resource_url, authorization_servers=authorization_servers, scopes_supported=scopes_supported, + resource_name=resource_name, + resource_documentation=resource_documentation, # bearer_methods_supported defaults to ["header"] in the model ) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 33878ee15..a5d476809 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -139,6 +139,17 @@ class ProtectedResourceMetadata(BaseModel): resource: AnyHttpUrl authorization_servers: list[AnyHttpUrl] = Field(..., min_length=1) + jwks_uri: AnyHttpUrl | None = None scopes_supported: list[str] | None = None bearer_methods_supported: list[str] | None = Field(default=["header"]) # MCP only supports header method + resource_signing_alg_values_supported: list[str] | None = None + resource_name: str | None = None resource_documentation: AnyHttpUrl | None = None + resource_policy_uri: AnyHttpUrl | None = None + resource_tos_uri: AnyHttpUrl | None = None + #tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity + tls_client_certificate_bound_access_tokens: bool | None = None + authorization_details_types_supported: list[str] | None = None + dpop_signing_alg_values_supported: list[str] | None = None + #dpop_bound_access_tokens_required default is False, but ommited here for clarity + dpop_bound_access_tokens_required: bool | None = None From 39e7576c50cb17db13bdf58500404fcefff6d912 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 4 Aug 2025 19:26:31 +0100 Subject: [PATCH 02/16] formatting --- src/mcp/shared/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index a5d476809..53a56882b 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -147,9 +147,9 @@ class ProtectedResourceMetadata(BaseModel): resource_documentation: AnyHttpUrl | None = None resource_policy_uri: AnyHttpUrl | None = None resource_tos_uri: AnyHttpUrl | None = None - #tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity + # tls_client_certificate_bound_access_tokens default is False, but ommited here for clarity tls_client_certificate_bound_access_tokens: bool | None = None authorization_details_types_supported: list[str] | None = None dpop_signing_alg_values_supported: list[str] | None = None - #dpop_bound_access_tokens_required default is False, but ommited here for clarity + # dpop_bound_access_tokens_required default is False, but ommited here for clarity dpop_bound_access_tokens_required: bool | None = None From e3c1eef9bd0a6b61f0344453e880cdc50fa65d99 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:09:55 +0200 Subject: [PATCH 03/16] Add test for ProtectedResourceMetadata parsing --- tests/shared/test_auth.py | 84 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index fd39eb255..ba37a2cf9 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,6 @@ """Tests for OAuth 2.0 shared code.""" - -from mcp.shared.auth import OAuthMetadata +import pytest +from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata class TestOAuthMetadata: @@ -37,3 +37,83 @@ def test_oidc(self): "userinfo_endpoint": "https://example.com/oauth2/userInfo", } ) + +class TestProtectedResourceMetadataInvalid: + """Tests for ProtectedResourceMetadata parsing.""" + + def test_invalid_metadata(self): + """Should throw when parsing invalid metadata.""" + with pytest.raises(ValueError): + ProtectedResourceMetadata.model_validate( + { + "resource": "Not a valid URL", + "authorization_servers": ["https://example.com/oauth2/authorize"], + "scopes_supported": ["read", "write"], + "bearer_methods_supported": ["header"], + } + ) + + def test_valid_metadata(self): + """Should not throw when parsing protected resource metadata.""" + + ProtectedResourceMetadata.model_validate( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://example.com/oauth2/authorize"], + "scopes_supported": ["read", "write"], + "bearer_methods_supported": ["header"], + } + ) + + def test_valid_with_resource_metadata(self): + """Should not throw when parsing metadata with resource_name and resource_documentation.""" + + ProtectedResourceMetadata.model_validate( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://example.com/oauth2/authorize"], + "scopes_supported": ["read", "write"], + "bearer_methods_supported": ["header"], + "resource_name": "Example Resource", + "resource_documentation": "https://example.com/resource/documentation", + } + ) + + def test_valid_witn_invalid_resource_documentation(self): + """Should throw when parsing metadata with resource_name and resource_documentation.""" + with pytest.raises(ValueError): + ProtectedResourceMetadata.model_validate( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://example.com/oauth2/authorize"], + "scopes_supported": ["read", "write"], + "bearer_methods_supported": ["header"], + "resource_name": "Example Resource", + "resource_documentation": "Not a valid URL", + } + ) + + def test_valid_full_protected_resource_metadata(self): + """Should not throw when parsing full metadata.""" + + ProtectedResourceMetadata.model_validate( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://example.com/oauth2/authorize"], + "jwks_uri": "https://example.com/.well-known/jwks.json", + "scopes_supported": ["read", "write"], + "bearer_methods_supported": ["header"], + "resource_signing_alg_values_supported": ["RS256"], + "resource_name": "Example Resource", + "resource_documentation": "https://example.com/resource/documentation", + "resource_policy_uri": "https://example.com/resource/policy", + "resource_tos_uri": "https://example.com/resource/tos", + "tls_client_certificate_bound_access_tokens": True, + # authorization_details_types_supported is a complext type + # so we use an empty list for simplicity + # see RFC9396 + "authorization_details_types_supported": [], + "dpop_signing_alg_values_supported": ["RS256", "ES256"], + "dpop_signing_access_tokens": True, + } + ) From e4de61bbf6e2a00ce234073d376c824eaee9903e Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:11:18 +0200 Subject: [PATCH 04/16] typo fix --- tests/shared/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index ba37a2cf9..27a663dbd 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -80,7 +80,7 @@ def test_valid_with_resource_metadata(self): ) def test_valid_witn_invalid_resource_documentation(self): - """Should throw when parsing metadata with resource_name and resource_documentation.""" + """Should throw when parsing metadata with resource_name and invalid resource_documentation.""" with pytest.raises(ValueError): ProtectedResourceMetadata.model_validate( { From 12eddcc5dafd9a4f4dd3919b31c8afd6d97d2e65 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Mon, 4 Aug 2025 22:18:37 +0200 Subject: [PATCH 05/16] fix precommit hook --- tests/shared/test_auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 27a663dbd..ed2a14d1c 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,4 +1,5 @@ """Tests for OAuth 2.0 shared code.""" + import pytest from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata @@ -38,6 +39,7 @@ def test_oidc(self): } ) + class TestProtectedResourceMetadataInvalid: """Tests for ProtectedResourceMetadata parsing.""" From e8bfe35b8a99cbc3b32648485ac18b119d7456b5 Mon Sep 17 00:00:00 2001 From: Yann Jouanin <4557670+yannj-fr@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:10:45 +0200 Subject: [PATCH 06/16] Update tests/shared/test_auth.py Co-authored-by: Paul Carleton --- tests/shared/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index ed2a14d1c..4c046c83c 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -81,7 +81,7 @@ def test_valid_with_resource_metadata(self): } ) - def test_valid_witn_invalid_resource_documentation(self): + def test_valid_with_invalid_resource_documentation(self): """Should throw when parsing metadata with resource_name and invalid resource_documentation.""" with pytest.raises(ValueError): ProtectedResourceMetadata.model_validate( From 79e1b162b915fc0b8c9d2df3e55ada781b51c55b Mon Sep 17 00:00:00 2001 From: Yann Jouanin <4557670+yannj-fr@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:10:54 +0200 Subject: [PATCH 07/16] Update tests/shared/test_auth.py Co-authored-by: Paul Carleton --- tests/shared/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 4c046c83c..15586c20c 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -111,7 +111,7 @@ def test_valid_full_protected_resource_metadata(self): "resource_policy_uri": "https://example.com/resource/policy", "resource_tos_uri": "https://example.com/resource/tos", "tls_client_certificate_bound_access_tokens": True, - # authorization_details_types_supported is a complext type + # authorization_details_types_supported is a complex type # so we use an empty list for simplicity # see RFC9396 "authorization_details_types_supported": [], From 32151a645c9ea26b2534141ea0ebbdc33672d455 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:34:31 +0200 Subject: [PATCH 08/16] Add test for protected resources through .well-known/oauth-protected-resource checking --- .../fastmcp/auth/test_auth_integration.py | 43 +++++++++++++++++++ tests/shared/test_auth.py | 1 + 2 files changed, 44 insertions(+) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index e4a8f3f4c..5501bbb82 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -26,6 +26,7 @@ ClientRegistrationOptions, RevocationOptions, create_auth_routes, + create_protected_resource_routes, ) from mcp.shared.auth import ( OAuthClientInformationFull, @@ -216,6 +217,25 @@ def auth_app(mock_oauth_provider): return app +@pytest.fixture +def protected_resource_app(auth_app): + """Fixture to create protected resource routes for testing.""" + + print(auth_app.router.routes) + # Create the protected resource routes + protected_resource_routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com/resource"), + authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], + scopes_supported=["read", "write"], + resource_name="Example Resource", + resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), + ) + + # add routes to the auth app + auth_app.router.routes.extend(protected_resource_routes) + return auth_app + + @pytest.fixture async def test_client(auth_app): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com") as client: @@ -1201,3 +1221,26 @@ async def test_authorize_invalid_scope(self, test_client: httpx.AsyncClient, reg # State should be preserved assert "state" in query_params assert query_params["state"][0] == "test_state" + + +class TestProtectedResourceMetadata: + """Test the Protected Resource Metadata model.""" + + @pytest.mark.anyio + async def test_metadata_endpoint(self, protected_resource_app: Starlette, test_client: httpx.AsyncClient): + """Test the OAuth 2.0 Protected Resource metadata endpoint.""" + print("Sending request to protected resource metadata endpoint") + response = await test_client.get("/.well-known/oauth-protected-resource") + print(f"Got response: {response.status_code}") + if response.status_code != 200: + print(f"Response content: {response.content}") + assert response.status_code == 200 + + metadata = response.json() + print(f"Protected Resource Metadata: {metadata}") + assert metadata["resource"] == "https://example.com/resource" + assert metadata["authorization_servers"] == ["https://auth.example.com/authorization"] + assert metadata["scopes_supported"] == ["read", "write"] + assert metadata["resource_name"] == "Example Resource" + assert metadata["resource_documentation"] == "https://docs.example.com/resource" + assert metadata["bearer_methods_supported"] == ["header"] diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 15586c20c..9b3de2b53 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,6 +1,7 @@ """Tests for OAuth 2.0 shared code.""" import pytest + from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata From dd22d7681ee5bad227f5b30c52eab2ef34c6ea3d Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:41:32 +0200 Subject: [PATCH 09/16] remove prints in the entire file --- .../fastmcp/auth/test_auth_integration.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index 5501bbb82..96337ff03 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -221,7 +221,6 @@ def auth_app(mock_oauth_provider): def protected_resource_app(auth_app): """Fixture to create protected resource routes for testing.""" - print(auth_app.router.routes) # Create the protected resource routes protected_resource_routes = create_protected_resource_routes( resource_url=AnyHttpUrl("https://example.com/resource"), @@ -355,11 +354,8 @@ class TestAuthEndpoints: @pytest.mark.anyio async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): """Test the OAuth 2.0 metadata endpoint.""" - print("Sending request to metadata endpoint") + response = await test_client.get("/.well-known/oauth-authorization-server") - print(f"Got response: {response.status_code}") - if response.status_code != 200: - print(f"Response content: {response.content}") assert response.status_code == 200 metadata = response.json() @@ -407,9 +403,7 @@ async def test_token_invalid_auth_code(self, test_client, registered_client, pkc "redirect_uri": "https://client.example.com/callback", }, ) - print(f"Status code: {response.status_code}") - print(f"Response body: {response.content}") - print(f"Response JSON: {response.json()}") + assert response.status_code == 400 error_response = response.json() assert error_response["error"] == "invalid_grant" @@ -1229,15 +1223,10 @@ class TestProtectedResourceMetadata: @pytest.mark.anyio async def test_metadata_endpoint(self, protected_resource_app: Starlette, test_client: httpx.AsyncClient): """Test the OAuth 2.0 Protected Resource metadata endpoint.""" - print("Sending request to protected resource metadata endpoint") + response = await test_client.get("/.well-known/oauth-protected-resource") - print(f"Got response: {response.status_code}") - if response.status_code != 200: - print(f"Response content: {response.content}") assert response.status_code == 200 - metadata = response.json() - print(f"Protected Resource Metadata: {metadata}") assert metadata["resource"] == "https://example.com/resource" assert metadata["authorization_servers"] == ["https://auth.example.com/authorization"] assert metadata["scopes_supported"] == ["read", "write"] From 149f7520571bd13a34e15b33f32ac51b95373a10 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:03:39 +0200 Subject: [PATCH 10/16] remove prints and change test class, use snapshot --- .../fastmcp/auth/test_auth_integration.py | 73 +++++++++++-------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index 96337ff03..b0401d843 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -11,6 +11,7 @@ import httpx import pytest +from inline_snapshot import snapshot from pydantic import AnyHttpUrl from starlette.applications import Starlette @@ -197,7 +198,7 @@ def mock_oauth_provider(): @pytest.fixture -def auth_app(mock_oauth_provider): +def auth_app(mock_oauth_provider: MockOAuthProvider): # Create auth router auth_routes = create_auth_routes( mock_oauth_provider, @@ -218,25 +219,7 @@ def auth_app(mock_oauth_provider): @pytest.fixture -def protected_resource_app(auth_app): - """Fixture to create protected resource routes for testing.""" - - # Create the protected resource routes - protected_resource_routes = create_protected_resource_routes( - resource_url=AnyHttpUrl("https://example.com/resource"), - authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], - scopes_supported=["read", "write"], - resource_name="Example Resource", - resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), - ) - - # add routes to the auth app - auth_app.router.routes.extend(protected_resource_routes) - return auth_app - - -@pytest.fixture -async def test_client(auth_app): +async def test_client(auth_app: Starlette): async with httpx.AsyncClient(transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com") as client: yield client @@ -268,6 +251,32 @@ async def registered_client(test_client: httpx.AsyncClient, request): return client_info +@pytest.fixture +def protected_resource_app(): + """Fixture to create protected resource routes for testing.""" + + # Create the protected resource routes + protected_resource_routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com/resource"), + authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], + scopes_supported=["read", "write"], + resource_name="Example Resource", + resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), + ) + + app = Starlette(routes=protected_resource_routes) + return app + + +@pytest.fixture +async def protected_resource_test_client(protected_resource_app: Starlette): + """Fixture to create an HTTP client for the protected resource app.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=protected_resource_app), base_url="https://mcptest.com" + ) as client: + yield client + + @pytest.fixture def pkce_challenge(): """Create a PKCE challenge with code_verifier and code_challenge.""" @@ -354,7 +363,7 @@ class TestAuthEndpoints: @pytest.mark.anyio async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): """Test the OAuth 2.0 metadata endpoint.""" - + response = await test_client.get("/.well-known/oauth-authorization-server") assert response.status_code == 200 @@ -1221,15 +1230,19 @@ class TestProtectedResourceMetadata: """Test the Protected Resource Metadata model.""" @pytest.mark.anyio - async def test_metadata_endpoint(self, protected_resource_app: Starlette, test_client: httpx.AsyncClient): + async def test_metadata_endpoint(self, protected_resource_test_client: httpx.AsyncClient): """Test the OAuth 2.0 Protected Resource metadata endpoint.""" - - response = await test_client.get("/.well-known/oauth-protected-resource") + + response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") assert response.status_code == 200 metadata = response.json() - assert metadata["resource"] == "https://example.com/resource" - assert metadata["authorization_servers"] == ["https://auth.example.com/authorization"] - assert metadata["scopes_supported"] == ["read", "write"] - assert metadata["resource_name"] == "Example Resource" - assert metadata["resource_documentation"] == "https://docs.example.com/resource" - assert metadata["bearer_methods_supported"] == ["header"] + assert metadata == snapshot( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://auth.example.com/authorization"], + "scopes_supported": ["read", "write"], + "resource_name": "Example Resource", + "resource_documentation": "https://docs.example.com/resource", + "bearer_methods_supported": ["header"], + } + ) From 1fea1f3194904a6557aa667429c9ec0818599bce Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:07:02 +0200 Subject: [PATCH 11/16] removing uneeded assert on response code --- tests/server/fastmcp/auth/test_auth_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index b0401d843..b44311b8d 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -1234,7 +1234,6 @@ async def test_metadata_endpoint(self, protected_resource_test_client: httpx.Asy """Test the OAuth 2.0 Protected Resource metadata endpoint.""" response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") - assert response.status_code == 200 metadata = response.json() assert metadata == snapshot( { From 154b7549c18799fb9fb8e51c721e573cb147e908 Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:18:08 +0200 Subject: [PATCH 12/16] separate test in a new file --- tests/server/auth/test_protected_resource.py | 57 +++++++++++++++++++ .../fastmcp/auth/test_auth_integration.py | 47 --------------- 2 files changed, 57 insertions(+), 47 deletions(-) create mode 100644 tests/server/auth/test_protected_resource.py diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py new file mode 100644 index 000000000..0f636a1c2 --- /dev/null +++ b/tests/server/auth/test_protected_resource.py @@ -0,0 +1,57 @@ +""" +Integration tests for MCP Oauth Protected Resource. +""" + +import httpx +import pytest +from inline_snapshot import snapshot +from mcp.server.auth.routes import create_protected_resource_routes +from mcp.shared.auth import ProtectedResourceMetadata +from pydantic import AnyHttpUrl, Field +from starlette.applications import Starlette + +@pytest.fixture +def protected_resource_app(): + """Fixture to create protected resource routes for testing.""" + + # Create the protected resource routes + protected_resource_routes = create_protected_resource_routes( + resource_url=AnyHttpUrl("https://example.com/resource"), + authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], + scopes_supported=["read", "write"], + resource_name="Example Resource", + resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), + ) + + app = Starlette(routes=protected_resource_routes) + return app + + +@pytest.fixture +async def protected_resource_test_client(protected_resource_app: Starlette): + """Fixture to create an HTTP client for the protected resource app.""" + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=protected_resource_app), base_url="https://mcptest.com" + ) as client: + yield client + + +class TestProtectedResourceMetadata: + """Test the Protected Resource Metadata model.""" + + @pytest.mark.anyio + async def test_metadata_endpoint(self, protected_resource_test_client: httpx.AsyncClient): + """Test the OAuth 2.0 Protected Resource metadata endpoint.""" + + response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") + metadata = response.json() + assert metadata == snapshot( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://auth.example.com/authorization"], + "scopes_supported": ["read", "write"], + "resource_name": "Example Resource", + "resource_documentation": "https://docs.example.com/resource", + "bearer_methods_supported": ["header"], + } + ) \ No newline at end of file diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index b44311b8d..4f06ac881 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -27,7 +27,6 @@ ClientRegistrationOptions, RevocationOptions, create_auth_routes, - create_protected_resource_routes, ) from mcp.shared.auth import ( OAuthClientInformationFull, @@ -251,32 +250,6 @@ async def registered_client(test_client: httpx.AsyncClient, request): return client_info -@pytest.fixture -def protected_resource_app(): - """Fixture to create protected resource routes for testing.""" - - # Create the protected resource routes - protected_resource_routes = create_protected_resource_routes( - resource_url=AnyHttpUrl("https://example.com/resource"), - authorization_servers=[AnyHttpUrl("https://auth.example.com/authorization")], - scopes_supported=["read", "write"], - resource_name="Example Resource", - resource_documentation=AnyHttpUrl("https://docs.example.com/resource"), - ) - - app = Starlette(routes=protected_resource_routes) - return app - - -@pytest.fixture -async def protected_resource_test_client(protected_resource_app: Starlette): - """Fixture to create an HTTP client for the protected resource app.""" - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=protected_resource_app), base_url="https://mcptest.com" - ) as client: - yield client - - @pytest.fixture def pkce_challenge(): """Create a PKCE challenge with code_verifier and code_challenge.""" @@ -1225,23 +1198,3 @@ async def test_authorize_invalid_scope(self, test_client: httpx.AsyncClient, reg assert "state" in query_params assert query_params["state"][0] == "test_state" - -class TestProtectedResourceMetadata: - """Test the Protected Resource Metadata model.""" - - @pytest.mark.anyio - async def test_metadata_endpoint(self, protected_resource_test_client: httpx.AsyncClient): - """Test the OAuth 2.0 Protected Resource metadata endpoint.""" - - response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") - metadata = response.json() - assert metadata == snapshot( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://auth.example.com/authorization"], - "scopes_supported": ["read", "write"], - "resource_name": "Example Resource", - "resource_documentation": "https://docs.example.com/resource", - "bearer_methods_supported": ["header"], - } - ) From 69238ad5a6027fa7d99d92197ed3a1e69848bcfe Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:23:10 +0200 Subject: [PATCH 13/16] fix pre check commit --- tests/server/auth/test_protected_resource.py | 9 +++++---- tests/server/fastmcp/auth/test_auth_integration.py | 2 -- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 0f636a1c2..2c5e1e017 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -5,11 +5,12 @@ import httpx import pytest from inline_snapshot import snapshot -from mcp.server.auth.routes import create_protected_resource_routes -from mcp.shared.auth import ProtectedResourceMetadata -from pydantic import AnyHttpUrl, Field +from pydantic import AnyHttpUrl from starlette.applications import Starlette +from mcp.server.auth.routes import create_protected_resource_routes + + @pytest.fixture def protected_resource_app(): """Fixture to create protected resource routes for testing.""" @@ -54,4 +55,4 @@ async def test_metadata_endpoint(self, protected_resource_test_client: httpx.Asy "resource_documentation": "https://docs.example.com/resource", "bearer_methods_supported": ["header"], } - ) \ No newline at end of file + ) diff --git a/tests/server/fastmcp/auth/test_auth_integration.py b/tests/server/fastmcp/auth/test_auth_integration.py index 4f06ac881..a09a9ea71 100644 --- a/tests/server/fastmcp/auth/test_auth_integration.py +++ b/tests/server/fastmcp/auth/test_auth_integration.py @@ -11,7 +11,6 @@ import httpx import pytest -from inline_snapshot import snapshot from pydantic import AnyHttpUrl from starlette.applications import Starlette @@ -1197,4 +1196,3 @@ async def test_authorize_invalid_scope(self, test_client: httpx.AsyncClient, reg # State should be preserved assert "state" in query_params assert query_params["state"][0] == "test_state" - From 82852be3c69d8ce1947ad431256d5e68fe988d16 Mon Sep 17 00:00:00 2001 From: Yann Jouanin <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:44:22 +0200 Subject: [PATCH 14/16] Update tests/server/auth/test_protected_resource.py Co-authored-by: Marcelo Trylesinski --- tests/server/auth/test_protected_resource.py | 35 +++++++++----------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index 2c5e1e017..b25d43f19 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -37,22 +37,19 @@ async def protected_resource_test_client(protected_resource_app: Starlette): yield client -class TestProtectedResourceMetadata: - """Test the Protected Resource Metadata model.""" - - @pytest.mark.anyio - async def test_metadata_endpoint(self, protected_resource_test_client: httpx.AsyncClient): - """Test the OAuth 2.0 Protected Resource metadata endpoint.""" - - response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") - metadata = response.json() - assert metadata == snapshot( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://auth.example.com/authorization"], - "scopes_supported": ["read", "write"], - "resource_name": "Example Resource", - "resource_documentation": "https://docs.example.com/resource", - "bearer_methods_supported": ["header"], - } - ) +@pytest.mark.anyio +async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): + """Test the OAuth 2.0 Protected Resource metadata endpoint.""" + + response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") + metadata = response.json() + assert metadata == snapshot( + { + "resource": "https://example.com/resource", + "authorization_servers": ["https://auth.example.com/authorization"], + "scopes_supported": ["read", "write"], + "resource_name": "Example Resource", + "resource_documentation": "https://docs.example.com/resource", + "bearer_methods_supported": ["header"], + } + ) From 0826232de8a2761dba65becd1a2feed4351ad65b Mon Sep 17 00:00:00 2001 From: Yann Jouanin <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:44:34 +0200 Subject: [PATCH 15/16] Update tests/server/auth/test_protected_resource.py Co-authored-by: Marcelo Trylesinski --- tests/server/auth/test_protected_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index b25d43f19..a24024c28 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -29,7 +29,7 @@ def protected_resource_app(): @pytest.fixture -async def protected_resource_test_client(protected_resource_app: Starlette): +async def test_client(protected_resource_app: Starlette): """Fixture to create an HTTP client for the protected resource app.""" async with httpx.AsyncClient( transport=httpx.ASGITransport(app=protected_resource_app), base_url="https://mcptest.com" From 0b800fe5345a43f8d31512e73fa32b1969416a8a Mon Sep 17 00:00:00 2001 From: yannj-fr <4557670+yannj-fr@users.noreply.github.com> Date: Wed, 6 Aug 2025 13:12:51 +0200 Subject: [PATCH 16/16] remove uneeded test and rename methods --- tests/server/auth/test_protected_resource.py | 15 ++-- tests/shared/test_auth.py | 85 +------------------- 2 files changed, 7 insertions(+), 93 deletions(-) diff --git a/tests/server/auth/test_protected_resource.py b/tests/server/auth/test_protected_resource.py index a24024c28..0dc34625d 100644 --- a/tests/server/auth/test_protected_resource.py +++ b/tests/server/auth/test_protected_resource.py @@ -12,7 +12,7 @@ @pytest.fixture -def protected_resource_app(): +def test_app(): """Fixture to create protected resource routes for testing.""" # Create the protected resource routes @@ -29,21 +29,18 @@ def protected_resource_app(): @pytest.fixture -async def test_client(protected_resource_app: Starlette): +async def test_client(test_app: Starlette): """Fixture to create an HTTP client for the protected resource app.""" - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=protected_resource_app), base_url="https://mcptest.com" - ) as client: + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=test_app), base_url="https://mcptest.com") as client: yield client @pytest.mark.anyio -async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): +async def test_metadata_endpoint(test_client: httpx.AsyncClient): """Test the OAuth 2.0 Protected Resource metadata endpoint.""" - response = await protected_resource_test_client.get("/.well-known/oauth-protected-resource") - metadata = response.json() - assert metadata == snapshot( + response = await test_client.get("/.well-known/oauth-protected-resource") + assert response.json() == snapshot( { "resource": "https://example.com/resource", "authorization_servers": ["https://auth.example.com/authorization"], diff --git a/tests/shared/test_auth.py b/tests/shared/test_auth.py index 9b3de2b53..fd39eb255 100644 --- a/tests/shared/test_auth.py +++ b/tests/shared/test_auth.py @@ -1,8 +1,6 @@ """Tests for OAuth 2.0 shared code.""" -import pytest - -from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata +from mcp.shared.auth import OAuthMetadata class TestOAuthMetadata: @@ -39,84 +37,3 @@ def test_oidc(self): "userinfo_endpoint": "https://example.com/oauth2/userInfo", } ) - - -class TestProtectedResourceMetadataInvalid: - """Tests for ProtectedResourceMetadata parsing.""" - - def test_invalid_metadata(self): - """Should throw when parsing invalid metadata.""" - with pytest.raises(ValueError): - ProtectedResourceMetadata.model_validate( - { - "resource": "Not a valid URL", - "authorization_servers": ["https://example.com/oauth2/authorize"], - "scopes_supported": ["read", "write"], - "bearer_methods_supported": ["header"], - } - ) - - def test_valid_metadata(self): - """Should not throw when parsing protected resource metadata.""" - - ProtectedResourceMetadata.model_validate( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://example.com/oauth2/authorize"], - "scopes_supported": ["read", "write"], - "bearer_methods_supported": ["header"], - } - ) - - def test_valid_with_resource_metadata(self): - """Should not throw when parsing metadata with resource_name and resource_documentation.""" - - ProtectedResourceMetadata.model_validate( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://example.com/oauth2/authorize"], - "scopes_supported": ["read", "write"], - "bearer_methods_supported": ["header"], - "resource_name": "Example Resource", - "resource_documentation": "https://example.com/resource/documentation", - } - ) - - def test_valid_with_invalid_resource_documentation(self): - """Should throw when parsing metadata with resource_name and invalid resource_documentation.""" - with pytest.raises(ValueError): - ProtectedResourceMetadata.model_validate( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://example.com/oauth2/authorize"], - "scopes_supported": ["read", "write"], - "bearer_methods_supported": ["header"], - "resource_name": "Example Resource", - "resource_documentation": "Not a valid URL", - } - ) - - def test_valid_full_protected_resource_metadata(self): - """Should not throw when parsing full metadata.""" - - ProtectedResourceMetadata.model_validate( - { - "resource": "https://example.com/resource", - "authorization_servers": ["https://example.com/oauth2/authorize"], - "jwks_uri": "https://example.com/.well-known/jwks.json", - "scopes_supported": ["read", "write"], - "bearer_methods_supported": ["header"], - "resource_signing_alg_values_supported": ["RS256"], - "resource_name": "Example Resource", - "resource_documentation": "https://example.com/resource/documentation", - "resource_policy_uri": "https://example.com/resource/policy", - "resource_tos_uri": "https://example.com/resource/tos", - "tls_client_certificate_bound_access_tokens": True, - # authorization_details_types_supported is a complex type - # so we use an empty list for simplicity - # see RFC9396 - "authorization_details_types_supported": [], - "dpop_signing_alg_values_supported": ["RS256", "ES256"], - "dpop_signing_access_tokens": True, - } - )