Skip to content

Commit 31edf42

Browse files
authored
Merge branch 'main' into main
2 parents 8c9f31f + b34e720 commit 31edf42

File tree

5 files changed

+235
-15
lines changed

5 files changed

+235
-15
lines changed

.github/CODEOWNERS

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,22 @@
22
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
33

44
# Default maintainers for everything
5-
* @modelcontextprotocol/python-sdk-maintainers
5+
* @modelcontextprotocol/python-sdk
66

77
# Auth-related code requires additional review from auth team
8-
/src/mcp/client/auth.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
9-
/src/mcp/server/auth/ @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
10-
/src/mcp/server/transport_security.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
11-
/src/mcp/shared/auth*.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
8+
/src/mcp/client/auth.py @modelcontextprotocol/python-sdk-auth
9+
/src/mcp/server/auth/ @modelcontextprotocol/python-sdk-auth
10+
/src/mcp/server/transport_security.py @modelcontextprotocol/python-sdk-auth
11+
/src/mcp/shared/auth*.py @modelcontextprotocol/python-sdk-auth
1212

1313
# Auth-related tests
14-
/tests/client/test_auth.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
15-
/tests/server/auth/ @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
16-
/tests/server/test_*security.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
17-
/tests/server/fastmcp/auth/ @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
18-
/tests/shared/test_auth*.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
14+
/tests/client/test_auth.py @modelcontextprotocol/python-sdk-auth
15+
/tests/server/auth/ @modelcontextprotocol/python-sdk-auth
16+
/tests/server/test_*security.py @modelcontextprotocol/python-sdk-auth
17+
/tests/server/fastmcp/auth/ @modelcontextprotocol/python-sdk-auth
18+
/tests/shared/test_auth*.py @modelcontextprotocol/python-sdk-auth
1919

2020
# Auth-related examples
21-
/examples/clients/simple-auth-client/ @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
22-
/examples/snippets/clients/oauth_client.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
23-
/examples/snippets/servers/oauth_server.py @modelcontextprotocol/python-sdk-auth @modelcontextprotocol/python-sdk-maintainers
21+
/examples/clients/simple-auth-client/ @modelcontextprotocol/python-sdk-auth
22+
/examples/snippets/clients/oauth_client.py @modelcontextprotocol/python-sdk-auth
23+
/examples/snippets/servers/oauth_server.py @modelcontextprotocol/python-sdk-auth

src/mcp/client/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,8 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
526526
break
527527
except ValidationError:
528528
continue
529-
elif oauth_metadata_response.status_code != 404:
530-
break # Non-404 error, stop trying
529+
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
530+
break # Non-4XX error, stop trying
531531

532532
# Step 3: Register client if needed
533533
registration_request = await self._register_client()

src/mcp/server/lowlevel/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,12 @@ async def _handle_request(
647647
response = await handler(req)
648648
except McpError as err:
649649
response = err.error
650+
except anyio.get_cancelled_exc_class():
651+
logger.info(
652+
"Request %s cancelled - duplicate response suppressed",
653+
message.request_id,
654+
)
655+
return
650656
except Exception as err:
651657
if raise_exceptions:
652658
raise err

tests/client/test_auth.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,109 @@ async def test_oauth_discovery_fallback_order(self, oauth_provider):
337337
"https://api.example.com/v1/mcp/.well-known/openid-configuration",
338338
]
339339

340+
@pytest.mark.anyio
341+
async def test_oauth_discovery_fallback_conditions(self, oauth_provider):
342+
"""Test the conditions during which an AS metadata discovery fallback will be attempted."""
343+
# Ensure no tokens are stored
344+
oauth_provider.context.current_tokens = None
345+
oauth_provider.context.token_expiry_time = None
346+
oauth_provider._initialized = True
347+
348+
# Mock client info to skip DCR
349+
oauth_provider.context.client_info = OAuthClientInformationFull(
350+
client_id="existing_client",
351+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
352+
)
353+
354+
# Create a test request
355+
test_request = httpx.Request("GET", "https://api.example.com/v1/mcp")
356+
357+
# Mock the auth flow
358+
auth_flow = oauth_provider.async_auth_flow(test_request)
359+
360+
# First request should be the original request without auth header
361+
request = await auth_flow.__anext__()
362+
assert "Authorization" not in request.headers
363+
364+
# Send a 401 response to trigger the OAuth flow
365+
response = httpx.Response(
366+
401,
367+
headers={
368+
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
369+
},
370+
request=test_request,
371+
)
372+
373+
# Next request should be to discover protected resource metadata
374+
discovery_request = await auth_flow.asend(response)
375+
assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
376+
assert discovery_request.method == "GET"
377+
378+
# Send a successful discovery response with minimal protected resource metadata
379+
discovery_response = httpx.Response(
380+
200,
381+
content=b'{"resource": "https://api.example.com/v1/mcp", "authorization_servers": ["https://auth.example.com/v1/mcp"]}',
382+
request=discovery_request,
383+
)
384+
385+
# Next request should be to discover OAuth metadata
386+
oauth_metadata_request_1 = await auth_flow.asend(discovery_response)
387+
assert (
388+
str(oauth_metadata_request_1.url)
389+
== "https://auth.example.com/.well-known/oauth-authorization-server/v1/mcp"
390+
)
391+
assert oauth_metadata_request_1.method == "GET"
392+
393+
# Send a 404 response
394+
oauth_metadata_response_1 = httpx.Response(
395+
404,
396+
content=b"Not Found",
397+
request=oauth_metadata_request_1,
398+
)
399+
400+
# Next request should be to discover OAuth metadata at the next endpoint
401+
oauth_metadata_request_2 = await auth_flow.asend(oauth_metadata_response_1)
402+
assert str(oauth_metadata_request_2.url) == "https://auth.example.com/.well-known/oauth-authorization-server"
403+
assert oauth_metadata_request_2.method == "GET"
404+
405+
# Send a 400 response
406+
oauth_metadata_response_2 = httpx.Response(
407+
400,
408+
content=b"Bad Request",
409+
request=oauth_metadata_request_2,
410+
)
411+
412+
# Next request should be to discover OAuth metadata at the next endpoint
413+
oauth_metadata_request_3 = await auth_flow.asend(oauth_metadata_response_2)
414+
assert str(oauth_metadata_request_3.url) == "https://auth.example.com/.well-known/openid-configuration/v1/mcp"
415+
assert oauth_metadata_request_3.method == "GET"
416+
417+
# Send a 500 response
418+
oauth_metadata_response_3 = httpx.Response(
419+
500,
420+
content=b"Internal Server Error",
421+
request=oauth_metadata_request_3,
422+
)
423+
424+
# Mock the authorization process to minimize unnecessary state in this test
425+
oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
426+
427+
# Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token)
428+
token_request = await auth_flow.asend(oauth_metadata_response_3)
429+
assert str(token_request.url) == "https://api.example.com/token"
430+
assert token_request.method == "POST"
431+
432+
# Send a successful token response
433+
token_response = httpx.Response(
434+
200,
435+
content=(
436+
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
437+
b'"refresh_token": "new_refresh_token"}'
438+
),
439+
request=token_request,
440+
)
441+
token_request = await auth_flow.asend(token_response)
442+
340443
@pytest.mark.anyio
341444
async def test_handle_metadata_response_success(self, oauth_provider):
342445
"""Test successful metadata response handling."""

tests/server/test_cancel_handling.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Test that cancelled requests don't cause double responses."""
2+
3+
import anyio
4+
import pytest
5+
6+
import mcp.types as types
7+
from mcp.server.lowlevel.server import Server
8+
from mcp.shared.exceptions import McpError
9+
from mcp.shared.memory import create_connected_server_and_client_session
10+
from mcp.types import (
11+
CallToolRequest,
12+
CallToolRequestParams,
13+
CallToolResult,
14+
CancelledNotification,
15+
CancelledNotificationParams,
16+
ClientNotification,
17+
ClientRequest,
18+
Tool,
19+
)
20+
21+
22+
@pytest.mark.anyio
23+
async def test_server_remains_functional_after_cancel():
24+
"""Verify server can handle new requests after a cancellation."""
25+
26+
server = Server("test-server")
27+
28+
# Track tool calls
29+
call_count = 0
30+
ev_first_call = anyio.Event()
31+
first_request_id = None
32+
33+
@server.list_tools()
34+
async def handle_list_tools() -> list[Tool]:
35+
return [
36+
Tool(
37+
name="test_tool",
38+
description="Tool for testing",
39+
inputSchema={},
40+
)
41+
]
42+
43+
@server.call_tool()
44+
async def handle_call_tool(name: str, arguments: dict | None) -> list:
45+
nonlocal call_count, first_request_id
46+
if name == "test_tool":
47+
call_count += 1
48+
if call_count == 1:
49+
first_request_id = server.request_context.request_id
50+
ev_first_call.set()
51+
await anyio.sleep(5) # First call is slow
52+
return [types.TextContent(type="text", text=f"Call number: {call_count}")]
53+
raise ValueError(f"Unknown tool: {name}")
54+
55+
async with create_connected_server_and_client_session(server) as client:
56+
# First request (will be cancelled)
57+
async def first_request():
58+
try:
59+
await client.send_request(
60+
ClientRequest(
61+
CallToolRequest(
62+
method="tools/call",
63+
params=CallToolRequestParams(name="test_tool", arguments={}),
64+
)
65+
),
66+
CallToolResult,
67+
)
68+
pytest.fail("First request should have been cancelled")
69+
except McpError:
70+
pass # Expected
71+
72+
# Start first request
73+
async with anyio.create_task_group() as tg:
74+
tg.start_soon(first_request)
75+
76+
# Wait for it to start
77+
await ev_first_call.wait()
78+
79+
# Cancel it
80+
assert first_request_id is not None
81+
await client.send_notification(
82+
ClientNotification(
83+
CancelledNotification(
84+
method="notifications/cancelled",
85+
params=CancelledNotificationParams(
86+
requestId=first_request_id,
87+
reason="Testing server recovery",
88+
),
89+
)
90+
)
91+
)
92+
93+
# Second request (should work normally)
94+
result = await client.send_request(
95+
ClientRequest(
96+
CallToolRequest(
97+
method="tools/call",
98+
params=CallToolRequestParams(name="test_tool", arguments={}),
99+
)
100+
),
101+
CallToolResult,
102+
)
103+
104+
# Verify second request completed successfully
105+
assert len(result.content) == 1
106+
# Type narrowing for pyright
107+
content = result.content[0]
108+
assert content.type == "text"
109+
assert isinstance(content, types.TextContent)
110+
assert content.text == "Call number: 2"
111+
assert call_count == 2

0 commit comments

Comments
 (0)