test_mcp_oauth_bidirectional.py
1 """Regression test for the ``HermesMCPOAuthProvider.async_auth_flow`` bidirectional 2 generator bridge. 3 4 PR #11383 introduced a subclass method that wrapped the SDK's ``auth_flow`` with:: 5 6 async for item in super().async_auth_flow(request): 7 yield item 8 9 ``httpx``'s auth_flow contract is a **bidirectional** async generator — the 10 driving code (``httpx._client._send_handling_auth``) does:: 11 12 next_request = await auth_flow.asend(response) 13 14 to feed HTTP responses back into the generator. The naive ``async for ...`` 15 wrapper discards those ``.asend(response)`` values and resumes the inner 16 generator with ``None``, so the SDK's ``response = yield request`` branch in 17 ``mcp/client/auth/oauth2.py`` sees ``response = None`` and crashes at 18 ``if response.status_code == 401`` with 19 ``AttributeError: 'NoneType' object has no attribute 'status_code'``. 20 21 This broke every OAuth MCP server on the first HTTP response regardless of 22 status code. The reason nothing caught it in CI: zero existing tests drive 23 the full ``.asend()`` round-trip — the integration tests in 24 ``test_mcp_oauth_integration.py`` stop at ``_initialize()`` and disk-watching. 25 26 These tests drive the wrapper through a manual ``.asend()`` sequence to prove 27 the bridge forwards responses correctly into the inner SDK generator. 28 """ 29 from __future__ import annotations 30 31 import pytest 32 33 34 pytest.importorskip("mcp.client.auth.oauth2", reason="MCP SDK 1.26.0+ required") 35 36 37 @pytest.mark.asyncio 38 async def test_hermes_provider_forwards_asend_values(tmp_path, monkeypatch): 39 """The wrapper MUST forward ``.asend(response)`` into the inner generator. 40 41 This is the primary regression test. With the broken wrapper, the inner 42 SDK generator sees ``response = None`` and raises ``AttributeError`` at 43 ``oauth2.py:505``. With the correct bridge, a 200 response finishes the 44 flow cleanly (``StopAsyncIteration``). 45 """ 46 import httpx 47 from mcp.shared.auth import OAuthClientMetadata, OAuthToken 48 from pydantic import AnyUrl 49 50 from tools.mcp_oauth import HermesTokenStorage 51 from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests 52 53 assert _HERMES_PROVIDER_CLS is not None, "SDK OAuth types must be available" 54 55 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 56 reset_manager_for_tests() 57 58 # Seed a valid-looking token so the SDK's _initialize loads something and 59 # can_refresh_token() is True (though we don't exercise refresh here — we 60 # go straight through the 200 path). 61 storage = HermesTokenStorage("srv") 62 await storage.set_tokens( 63 OAuthToken( 64 access_token="old_access", 65 token_type="Bearer", 66 expires_in=3600, 67 refresh_token="old_refresh", 68 ) 69 ) 70 # Also seed client_info so the SDK doesn't attempt registration. 71 from mcp.shared.auth import OAuthClientInformationFull 72 73 await storage.set_client_info( 74 OAuthClientInformationFull( 75 client_id="test-client", 76 redirect_uris=[AnyUrl("http://127.0.0.1:12345/callback")], 77 grant_types=["authorization_code", "refresh_token"], 78 response_types=["code"], 79 token_endpoint_auth_method="none", 80 ) 81 ) 82 83 metadata = OAuthClientMetadata( 84 redirect_uris=[AnyUrl("http://127.0.0.1:12345/callback")], 85 client_name="Hermes Agent", 86 ) 87 provider = _HERMES_PROVIDER_CLS( 88 server_name="srv", 89 server_url="https://example.com/mcp", 90 client_metadata=metadata, 91 storage=storage, 92 redirect_handler=_noop_redirect, 93 callback_handler=_noop_callback, 94 ) 95 96 req = httpx.Request("POST", "https://example.com/mcp") 97 flow = provider.async_auth_flow(req) 98 99 # First anext() drives the wrapper + inner generator until the inner 100 # yields the outbound request (at oauth2.py:503 ``response = yield request``). 101 outbound = await flow.__anext__() 102 assert outbound is not None, "wrapper must yield the outbound request" 103 assert outbound.url.host == "example.com" 104 105 # Simulate httpx returning a 200 response. 106 fake_response = httpx.Response(200, request=outbound) 107 108 # The broken wrapper would crash here with AttributeError: 'NoneType' 109 # object has no attribute 'status_code', because the SDK's inner generator 110 # resumes with response=None and dereferences .status_code at line 505. 111 # 112 # The correct wrapper forwards the response, the SDK takes the non-401 113 # non-403 exit, and the generator ends cleanly (StopAsyncIteration). 114 with pytest.raises(StopAsyncIteration): 115 await flow.asend(fake_response) 116 117 118 @pytest.mark.asyncio 119 async def test_hermes_provider_forwards_401_triggers_refresh(tmp_path, monkeypatch): 120 """A 401 response MUST flow into the inner generator and trigger the 121 SDK's 401 recovery branch. 122 123 With the broken wrapper, the inner generator sees ``response = None`` 124 and the 401 check short-circuits into AttributeError. With the correct 125 bridge, the 401 is routed into the SDK's ``response.status_code == 401`` 126 branch which begins discovery (yielding a metadata-discovery request). 127 """ 128 import httpx 129 from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken 130 from pydantic import AnyUrl 131 132 from tools.mcp_oauth import HermesTokenStorage 133 from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS, reset_manager_for_tests 134 135 assert _HERMES_PROVIDER_CLS is not None 136 137 monkeypatch.setenv("HERMES_HOME", str(tmp_path)) 138 reset_manager_for_tests() 139 140 storage = HermesTokenStorage("srv") 141 await storage.set_tokens( 142 OAuthToken( 143 access_token="old_access", 144 token_type="Bearer", 145 expires_in=3600, 146 refresh_token="old_refresh", 147 ) 148 ) 149 await storage.set_client_info( 150 OAuthClientInformationFull( 151 client_id="test-client", 152 redirect_uris=[AnyUrl("http://127.0.0.1:12345/callback")], 153 grant_types=["authorization_code", "refresh_token"], 154 response_types=["code"], 155 token_endpoint_auth_method="none", 156 ) 157 ) 158 159 metadata = OAuthClientMetadata( 160 redirect_uris=[AnyUrl("http://127.0.0.1:12345/callback")], 161 client_name="Hermes Agent", 162 ) 163 provider = _HERMES_PROVIDER_CLS( 164 server_name="srv", 165 server_url="https://example.com/mcp", 166 client_metadata=metadata, 167 storage=storage, 168 redirect_handler=_noop_redirect, 169 callback_handler=_noop_callback, 170 ) 171 172 req = httpx.Request("POST", "https://example.com/mcp") 173 flow = provider.async_auth_flow(req) 174 175 # Drive to the first yield (outbound MCP request). 176 outbound = await flow.__anext__() 177 178 # Reply with a 401 including a minimal WWW-Authenticate so the SDK's 179 # 401 branch can parse resource metadata from it. We just need something 180 # the SDK accepts before it tries to yield the metadata-discovery request. 181 fake_401 = httpx.Response( 182 401, 183 request=outbound, 184 headers={"www-authenticate": 'Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"'}, 185 ) 186 187 # The correct bridge forwards the 401 into the SDK; the SDK then yields 188 # its NEXT request (a metadata-discovery GET). We assert we get a request 189 # back — any request. The broken bridge would have crashed with 190 # AttributeError before we ever reach this point. 191 next_request = await flow.asend(fake_401) 192 assert isinstance(next_request, httpx.Request), ( 193 "wrapper must forward .asend() so the SDK's 401 branch can yield the " 194 "next request in the discovery flow" 195 ) 196 197 # Clean up the generator — we don't need to complete the full dance. 198 await flow.aclose() 199 200 201 async def _noop_redirect(_url: str) -> None: 202 """Redirect handler that does nothing (won't be invoked in these tests).""" 203 return None 204 205 206 async def _noop_callback() -> tuple[str, str | None]: 207 """Callback handler that won't be invoked in these tests.""" 208 raise AssertionError( 209 "callback handler should not be invoked in bidirectional-generator tests" 210 )