Skip to content

Commit 12d0662

Browse files
authored
Merge branch 'main' into main
2 parents b3c6dc4 + 6a84a2f commit 12d0662

File tree

7 files changed

+147
-26
lines changed

7 files changed

+147
-26
lines changed

.github/CODEOWNERS

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# CODEOWNERS for MCP Python SDK
2+
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
3+
4+
# Default maintainers for everything
5+
* @modelcontextprotocol/python-sdk-maintainers
6+
7+
# 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
12+
13+
# 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
19+
20+
# 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

src/mcp/client/auth.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -517,16 +517,16 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.
517517
# Step 2: Discover OAuth metadata (with fallback for legacy servers)
518518
discovery_urls = self._get_discovery_urls()
519519
for url in discovery_urls:
520-
request = self._create_oauth_metadata_request(url)
521-
response = yield request
520+
oauth_metadata_request = self._create_oauth_metadata_request(url)
521+
oauth_metadata_response = yield oauth_metadata_request
522522

523-
if response.status_code == 200:
523+
if oauth_metadata_response.status_code == 200:
524524
try:
525-
await self._handle_oauth_metadata_response(response)
525+
await self._handle_oauth_metadata_response(oauth_metadata_response)
526526
break
527527
except ValidationError:
528528
continue
529-
elif response.status_code != 404:
529+
elif oauth_metadata_response.status_code != 404:
530530
break # Non-404 error, stop trying
531531

532532
# Step 3: Register client if needed

src/mcp/server/streamable_http_manager.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import contextlib
66
import logging
7-
import threading
87
from collections.abc import AsyncIterator
98
from http import HTTPStatus
109
from typing import Any
@@ -75,7 +74,7 @@ def __init__(
7574
# The task group will be set during lifespan
7675
self._task_group = None
7776
# Thread-safe tracking of run() calls
78-
self._run_lock = threading.Lock()
77+
self._run_lock = anyio.Lock()
7978
self._has_started = False
8079

8180
@contextlib.asynccontextmanager
@@ -97,7 +96,7 @@ async def lifespan(app: Starlette) -> AsyncIterator[None]:
9796
yield
9897
"""
9998
# Thread-safe check to ensure run() is only called once
100-
with self._run_lock:
99+
async with self._run_lock:
101100
if self._has_started:
102101
raise RuntimeError(
103102
"StreamableHTTPSessionManager .run() can only be called "

src/mcp/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
ProgressToken = str | int
3737
Cursor = str
3838
Role = Literal["user", "assistant"]
39-
RequestId = Annotated[int | str, Field(union_mode="left_to_right")]
39+
RequestId = Annotated[int, Field(strict=True)] | str
4040
AnyFunction: TypeAlias = Callable[..., Any]
4141

4242

@@ -849,7 +849,7 @@ class Tool(BaseMetadata):
849849
"""A JSON Schema object defining the expected parameters for the tool."""
850850
outputSchema: dict[str, Any] | None = None
851851
"""
852-
An optional JSON Schema object defining the structure of the tool's output
852+
An optional JSON Schema object defining the structure of the tool's output
853853
returned in the structuredContent field of a CallToolResult.
854854
"""
855855
annotations: ToolAnnotations | None = None

tests/client/test_auth.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ async def test_handle_metadata_response_success(self, oauth_provider):
343343
# Create minimal valid OAuth metadata
344344
content = b"""{
345345
"issuer": "https://auth.example.com",
346-
"authorization_endpoint": "https://auth.example.com/authorize",
346+
"authorization_endpoint": "https://auth.example.com/authorize",
347347
"token_endpoint": "https://auth.example.com/token"
348348
}"""
349349
response = httpx.Response(200, content=content)
@@ -572,6 +572,105 @@ async def test_auth_flow_with_valid_tokens(self, oauth_provider, mock_storage, v
572572
except StopAsyncIteration:
573573
pass # Expected
574574

575+
@pytest.mark.anyio
576+
async def test_auth_flow_with_no_tokens(self, oauth_provider, mock_storage):
577+
"""Test auth flow when no tokens are available, triggering the full OAuth flow."""
578+
# Ensure no tokens are stored
579+
oauth_provider.context.current_tokens = None
580+
oauth_provider.context.token_expiry_time = None
581+
oauth_provider._initialized = True
582+
583+
# Create a test request
584+
test_request = httpx.Request("GET", "https://api.example.com/mcp")
585+
586+
# Mock the auth flow
587+
auth_flow = oauth_provider.async_auth_flow(test_request)
588+
589+
# First request should be the original request without auth header
590+
request = await auth_flow.__anext__()
591+
assert "Authorization" not in request.headers
592+
593+
# Send a 401 response to trigger the OAuth flow
594+
response = httpx.Response(
595+
401,
596+
headers={
597+
"WWW-Authenticate": 'Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"'
598+
},
599+
request=test_request,
600+
)
601+
602+
# Next request should be to discover protected resource metadata
603+
discovery_request = await auth_flow.asend(response)
604+
assert discovery_request.method == "GET"
605+
assert str(discovery_request.url) == "https://api.example.com/.well-known/oauth-protected-resource"
606+
607+
# Send a successful discovery response with minimal protected resource metadata
608+
discovery_response = httpx.Response(
609+
200,
610+
content=b'{"resource": "https://api.example.com/mcp", "authorization_servers": ["https://auth.example.com"]}',
611+
request=discovery_request,
612+
)
613+
614+
# Next request should be to discover OAuth metadata
615+
oauth_metadata_request = await auth_flow.asend(discovery_response)
616+
assert oauth_metadata_request.method == "GET"
617+
assert str(oauth_metadata_request.url).startswith("https://auth.example.com/")
618+
assert "mcp-protocol-version" in oauth_metadata_request.headers
619+
620+
# Send a successful OAuth metadata response
621+
oauth_metadata_response = httpx.Response(
622+
200,
623+
content=(
624+
b'{"issuer": "https://auth.example.com", '
625+
b'"authorization_endpoint": "https://auth.example.com/authorize", '
626+
b'"token_endpoint": "https://auth.example.com/token", '
627+
b'"registration_endpoint": "https://auth.example.com/register"}'
628+
),
629+
request=oauth_metadata_request,
630+
)
631+
632+
# Next request should be to register client
633+
registration_request = await auth_flow.asend(oauth_metadata_response)
634+
assert registration_request.method == "POST"
635+
assert str(registration_request.url) == "https://auth.example.com/register"
636+
637+
# Send a successful registration response
638+
registration_response = httpx.Response(
639+
201,
640+
content=b'{"client_id": "test_client_id", "client_secret": "test_client_secret", "redirect_uris": ["http://localhost:3030/callback"]}',
641+
request=registration_request,
642+
)
643+
644+
# Mock the authorization process
645+
oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
646+
647+
# Next request should be to exchange token
648+
token_request = await auth_flow.asend(registration_response)
649+
assert token_request.method == "POST"
650+
assert str(token_request.url) == "https://auth.example.com/token"
651+
assert "code=test_auth_code" in token_request.content.decode()
652+
653+
# Send a successful token response
654+
token_response = httpx.Response(
655+
200,
656+
content=(
657+
b'{"access_token": "new_access_token", "token_type": "Bearer", "expires_in": 3600, '
658+
b'"refresh_token": "new_refresh_token"}'
659+
),
660+
request=token_request,
661+
)
662+
663+
# Final request should be the original request with auth header
664+
final_request = await auth_flow.asend(token_response)
665+
assert final_request.headers["Authorization"] == "Bearer new_access_token"
666+
assert final_request.method == "GET"
667+
assert str(final_request.url) == "https://api.example.com/mcp"
668+
669+
# Verify tokens were stored
670+
assert oauth_provider.context.current_tokens is not None
671+
assert oauth_provider.context.current_tokens.access_token == "new_access_token"
672+
assert oauth_provider.context.token_expiry_time is not None
673+
575674

576675
class TestClientCredentialsProvider:
577676
@pytest.mark.anyio

tests/issues/test_88_random_error.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ async def test_notification_validation_error(tmp_path: Path):
2828

2929
server = Server(name="test")
3030
request_count = 0
31-
slow_request_started = anyio.Event()
32-
slow_request_complete = anyio.Event()
31+
slow_request_lock = anyio.Event()
3332

3433
@server.list_tools()
3534
async def list_tools() -> list[types.Tool]:
@@ -52,16 +51,9 @@ async def slow_tool(name: str, arg) -> Sequence[ContentBlock]:
5251
request_count += 1
5352

5453
if name == "slow":
55-
# Signal that slow request has started
56-
slow_request_started.set()
57-
# Long enough to ensure timeout
58-
await anyio.sleep(0.2)
59-
# Signal completion
60-
slow_request_complete.set()
54+
await slow_request_lock.wait() # it should timeout here
6155
return [TextContent(type="text", text=f"slow {request_count}")]
6256
elif name == "fast":
63-
# Fast enough to complete before timeout
64-
await anyio.sleep(0.01)
6557
return [TextContent(type="text", text=f"fast {request_count}")]
6658
return [TextContent(type="text", text=f"unknown {request_count}")]
6759

@@ -95,16 +87,15 @@ async def client(read_stream, write_stream, scope):
9587
# First call should work (fast operation)
9688
result = await session.call_tool("fast")
9789
assert result.content == [TextContent(type="text", text="fast 1")]
98-
assert not slow_request_complete.is_set()
90+
assert not slow_request_lock.is_set()
9991

10092
# Second call should timeout (slow operation)
10193
with pytest.raises(McpError) as exc_info:
10294
await session.call_tool("slow")
10395
assert "Timed out while waiting" in str(exc_info.value)
10496

105-
# Wait for slow request to complete in the background
106-
with anyio.fail_after(1): # Timeout after 1 second
107-
await slow_request_complete.wait()
97+
# release the slow request not to have hanging process
98+
slow_request_lock.set()
10899

109100
# Third call should work (fast operation),
110101
# proving server is still responsive

tests/shared/test_sse.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,12 +466,21 @@ async def test_request_context_isolation(context_server: None, server_url: str)
466466

467467

468468
def test_sse_message_id_coercion():
469-
"""Test that string message IDs that look like integers are parsed as integers.
469+
"""Previously, the `RequestId` would coerce a string that looked like an integer into an integer.
470470
471471
See <https://github.com/modelcontextprotocol/python-sdk/pull/851> for more details.
472+
473+
As per the JSON-RPC 2.0 specification, the id in the response object needs to be the same type as the id in the
474+
request object. In other words, we can't perform the coercion.
475+
476+
See <https://www.jsonrpc.org/specification#response_object> for more details.
472477
"""
473478
json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}'
474479
msg = types.JSONRPCMessage.model_validate_json(json_message)
480+
assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id="123")))
481+
482+
json_message = '{"jsonrpc": "2.0", "id": 123, "method": "ping", "params": null}'
483+
msg = types.JSONRPCMessage.model_validate_json(json_message)
475484
assert msg == snapshot(types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123)))
476485

477486

0 commit comments

Comments
 (0)