Skip to content

Commit 9fb50a1

Browse files
committed
test: add end-to-end OAuth authorization tests with an in-process AS/RS harness
1 parent 4a7d563 commit 9fb50a1

18 files changed

Lines changed: 2767 additions & 90 deletions

File tree

src/mcp/client/auth/oauth2.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,10 @@ async def _perform_authorization_code_grant(self) -> tuple[str, str]:
360360
auth_code, returned_state = await self.context.callback_handler()
361361

362362
if returned_state is None or not secrets.compare_digest(returned_state, state):
363-
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}") # pragma: no cover
363+
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
364364

365365
if not auth_code:
366-
raise OAuthFlowError("No authorization code received") # pragma: no cover
366+
raise OAuthFlowError("No authorization code received")
367367

368368
# Return auth code and code verifier for token exchange
369369
return auth_code, pkce_params.code_verifier
@@ -452,7 +452,7 @@ async def _refresh_token(self) -> httpx.Request:
452452

453453
return httpx.Request("POST", token_url, data=refresh_data, headers=headers)
454454

455-
async def _handle_refresh_response(self, response: httpx.Response) -> bool: # pragma: no cover
455+
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
456456
"""Handle token refresh response. Returns True if successful."""
457457
if response.status_code != 200:
458458
logger.warning(f"Token refresh failed: {response.status_code}")
@@ -468,12 +468,12 @@ async def _handle_refresh_response(self, response: httpx.Response) -> bool: # p
468468
await self.context.storage.set_tokens(token_response)
469469

470470
return True
471-
except ValidationError:
471+
except ValidationError: # pragma: no cover
472472
logger.exception("Invalid refresh response")
473473
self.context.clear_tokens()
474474
return False
475475

476-
async def _initialize(self) -> None: # pragma: no cover
476+
async def _initialize(self) -> None:
477477
"""Load stored tokens and client info."""
478478
self.context.current_tokens = await self.context.storage.get_tokens()
479479
self.context.client_info = await self.context.storage.get_client_info()
@@ -507,17 +507,17 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
507507
"""HTTPX auth flow integration."""
508508
async with self.context.lock:
509509
if not self._initialized:
510-
await self._initialize() # pragma: no cover
510+
await self._initialize()
511511

512512
# Capture protocol version from request headers
513513
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
514514

515515
if not self.context.is_token_valid() and self.context.can_refresh_token():
516516
# Try to refresh token
517-
refresh_request = await self._refresh_token() # pragma: no cover
518-
refresh_response = yield refresh_request # pragma: no cover
517+
refresh_request = await self._refresh_token()
518+
refresh_response = yield refresh_request
519519

520-
if not await self._handle_refresh_response(refresh_response): # pragma: no cover
520+
if not await self._handle_refresh_response(refresh_response):
521521
# Refresh failed, need full re-authentication
522522
self._initialized = False
523523

@@ -612,7 +612,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
612612
# Step 5: Perform authorization and complete token exchange
613613
token_response = yield await self._perform_authorization()
614614
await self._handle_token_response(token_response)
615-
except Exception: # pragma: no cover
615+
except Exception:
616616
logger.exception("OAuth flow error")
617617
raise
618618

src/mcp/server/auth/handlers/authorize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ async def error_response(
117117
pass
118118

119119
# the error response MUST contain the state specified by the client, if any
120-
if state is None: # pragma: no cover
120+
if state is None:
121121
# make last-ditch effort to load state
122122
state = best_effort_extract_string("state", params)
123123

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ async def _send_auth_error(self, send: Send, status_code: int, error: str, descr
9595
"""Send an authentication error response with WWW-Authenticate header."""
9696
# Build WWW-Authenticate header value
9797
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
98-
if self.resource_metadata_url: # pragma: no cover
98+
if self.resource_metadata_url:
9999
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
100100

101101
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"

src/mcp/server/lowlevel/server.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -603,7 +603,7 @@ def streamable_http_app(
603603
required_scopes: list[str] = []
604604

605605
# Set up auth if configured
606-
if auth: # pragma: no cover
606+
if auth:
607607
required_scopes = auth.required_scopes or []
608608

609609
# Add auth middleware if token verifier is available
@@ -629,10 +629,10 @@ def streamable_http_app(
629629
)
630630

631631
# Set up routes with or without auth
632-
if token_verifier: # pragma: no cover
632+
if token_verifier:
633633
# Determine resource metadata URL
634634
resource_metadata_url = None
635-
if auth and auth.resource_server_url:
635+
if auth and auth.resource_server_url: # pragma: no branch
636636
# Build compliant metadata URL for WWW-Authenticate header
637637
resource_metadata_url = build_resource_metadata_url(auth.resource_server_url)
638638

@@ -652,7 +652,7 @@ def streamable_http_app(
652652
)
653653

654654
# Add protected resource metadata endpoint if configured as RS
655-
if auth and auth.resource_server_url: # pragma: no cover
655+
if auth and auth.resource_server_url:
656656
routes.extend(
657657
create_protected_resource_routes(
658658
resource_url=auth.resource_server_url,

src/mcp/shared/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None:
9393
for scope in requested_scopes:
9494
if scope not in allowed_scopes: # pragma: no branch
9595
raise InvalidScopeError(f"Client was not registered with scope {scope}")
96-
return requested_scopes # pragma: no cover
96+
return requested_scopes
9797

9898
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
9999
if redirect_uri is not None:

src/mcp/shared/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ async def _handle_session_message(message: SessionMessage) -> None:
451451
try:
452452
await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error))
453453
await stream.aclose()
454-
except Exception: # pragma: no cover
454+
except Exception: # pragma: lax no cover
455455
# Stream might already be closed
456456
pass
457457
self._response_streams.clear()

tests/interaction/_connect.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import warnings
1212
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
1313
from contextlib import AbstractAsyncContextManager, asynccontextmanager
14-
from typing import Protocol
14+
from typing import Any, Protocol
1515

1616
import httpx
1717
from httpx_sse import ServerSentEvent, aconnect_sse
@@ -25,6 +25,8 @@
2525
from mcp.client.sse import sse_client
2626
from mcp.client.streamable_http import streamable_http_client
2727
from mcp.server import Server
28+
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
29+
from mcp.server.auth.settings import AuthSettings
2830
from mcp.server.mcpserver import MCPServer
2931
from mcp.server.sse import SseServerTransport
3032
from mcp.server.streamable_http import EventStore
@@ -154,6 +156,9 @@ async def mounted_app(
154156
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
155157
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
156158
headers: dict[str, str] | None = None,
159+
auth: AuthSettings | None = None,
160+
token_verifier: TokenVerifier | None = None,
161+
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
157162
) -> AsyncIterator[tuple[httpx.AsyncClient, StreamableHTTPSessionManager]]:
158163
"""Mount the server's streamable HTTP app on the in-process bridge and yield an httpx client.
159164
@@ -167,11 +172,15 @@ async def mounted_app(
167172
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
168173
localhost auto-enable behaviour) to test the protection itself.
169174
"""
170-
app = server.streamable_http_app(
175+
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
176+
app = lowlevel.streamable_http_app(
171177
stateless_http=stateless_http,
172178
event_store=event_store,
173179
retry_interval=retry_interval,
174180
transport_security=transport_security,
181+
auth=auth,
182+
token_verifier=token_verifier,
183+
auth_server_provider=auth_server_provider,
175184
)
176185
event_hooks = {"request": [on_request]} if on_request is not None else None
177186
async with server.session_manager.run():

0 commit comments

Comments
 (0)