/ tests / tools / test_mcp_oauth_bidirectional.py
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      )