Skip to content

Commit c2f8bb1

Browse files
committed
Merge branch 'main' of https://github.com/modelcontextprotocol/python-sdk into feat/async-tools
2 parents 7255e4f + 61399b3 commit c2f8bb1

File tree

9 files changed

+401
-39
lines changed

9 files changed

+401
-39
lines changed

CLAUDE.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ This document contains critical information about working with this codebase. Fo
4848
the problem it tries to solve, and how it is solved. Don't go into the specifics of the
4949
code unless it adds clarity.
5050

51-
- Always add `jerome3o-anthropic` and `jspahrsummers` as reviewer.
52-
5351
- NEVER ever mention a `co-authored-by` or similar aspects. In particular, never
5452
mention the tool used to create the commit message or PR.
5553

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
[![MIT licensed][mit-badge]][mit-url]
99
[![Python Version][python-badge]][python-url]
1010
[![Documentation][docs-badge]][docs-url]
11+
[![Protocol][protocol-badge]][protocol-url]
1112
[![Specification][spec-badge]][spec-url]
12-
[![GitHub Discussions][discussions-badge]][discussions-url]
1313

1414
</div>
1515

@@ -74,12 +74,12 @@
7474
[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE
7575
[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg
7676
[python-url]: https://www.python.org/downloads/
77-
[docs-badge]: https://img.shields.io/badge/docs-modelcontextprotocol.io-blue.svg
78-
[docs-url]: https://modelcontextprotocol.io
77+
[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg
78+
[docs-url]: https://modelcontextprotocol.github.io/python-sdk/
79+
[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg
80+
[protocol-url]: https://modelcontextprotocol.io
7981
[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg
8082
[spec-url]: https://spec.modelcontextprotocol.io
81-
[discussions-badge]: https://img.shields.io/github/discussions/modelcontextprotocol/python-sdk
82-
[discussions-url]: https://github.com/modelcontextprotocol/python-sdk/discussions
8383

8484
## Overview
8585

examples/servers/simple-auth/mcp_simple_auth/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ResourceServerSettings(BaseSettings):
3232
# Server settings
3333
host: str = "localhost"
3434
port: int = 8001
35-
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001")
35+
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp")
3636

3737
# Authorization Server settings
3838
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
@@ -137,7 +137,7 @@ def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http
137137

138138
# Create settings
139139
host = "localhost"
140-
server_url = f"http://{host}:{port}"
140+
server_url = f"http://{host}:{port}/mcp"
141141
settings = ResourceServerSettings(
142142
host=host,
143143
port=port,

src/mcp/server/auth/routes.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections.abc import Awaitable, Callable
22
from typing import Any
3+
from urllib.parse import urlparse
34

45
from pydantic import AnyHttpUrl
56
from starlette.middleware.cors import CORSMiddleware
@@ -186,6 +187,25 @@ def build_metadata(
186187
return metadata
187188

188189

190+
def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl:
191+
"""
192+
Build RFC 9728 compliant protected resource metadata URL.
193+
194+
Inserts /.well-known/oauth-protected-resource between host and resource path
195+
as specified in RFC 9728 §3.1.
196+
197+
Args:
198+
resource_server_url: The resource server URL (e.g., https://example.com/mcp)
199+
200+
Returns:
201+
The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp)
202+
"""
203+
parsed = urlparse(str(resource_server_url))
204+
# Handle trailing slash: if path is just "/", treat as empty
205+
resource_path = parsed.path if parsed.path != "/" else ""
206+
return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}")
207+
208+
189209
def create_protected_resource_routes(
190210
resource_url: AnyHttpUrl,
191211
authorization_servers: list[AnyHttpUrl],
@@ -218,9 +238,15 @@ def create_protected_resource_routes(
218238

219239
handler = ProtectedResourceMetadataHandler(metadata)
220240

241+
# RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path
242+
metadata_url = build_resource_metadata_url(resource_url)
243+
# Extract just the path part for route registration
244+
parsed = urlparse(str(metadata_url))
245+
well_known_path = parsed.path
246+
221247
return [
222248
Route(
223-
"/.well-known/oauth-protected-resource",
249+
well_known_path,
224250
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
225251
methods=["GET", "OPTIONS"],
226252
)

src/mcp/server/fastmcp/server.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,17 @@ def add_tool(
510510
immediate_result=immediate_result,
511511
)
512512

513+
def remove_tool(self, name: str) -> None:
514+
"""Remove a tool from the server by name.
515+
516+
Args:
517+
name: The name of the tool to remove
518+
519+
Raises:
520+
ToolError: If the tool does not exist
521+
"""
522+
self._tool_manager.remove_tool(name)
523+
513524
def tool(
514525
self,
515526
name: str | None = None,
@@ -978,11 +989,10 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
978989
# Determine resource metadata URL
979990
resource_metadata_url = None
980991
if self.settings.auth and self.settings.auth.resource_server_url:
981-
from pydantic import AnyHttpUrl
992+
from mcp.server.auth.routes import build_resource_metadata_url
982993

983-
resource_metadata_url = AnyHttpUrl(
984-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
985-
)
994+
# Build compliant metadata URL for WWW-Authenticate header
995+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
986996

987997
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
988998
routes.append(
@@ -1091,11 +1101,10 @@ def streamable_http_app(self) -> Starlette:
10911101
# Determine resource metadata URL
10921102
resource_metadata_url = None
10931103
if self.settings.auth and self.settings.auth.resource_server_url:
1094-
from pydantic import AnyHttpUrl
1104+
from mcp.server.auth.routes import build_resource_metadata_url
10951105

1096-
resource_metadata_url = AnyHttpUrl(
1097-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
1098-
)
1106+
# Build compliant metadata URL for WWW-Authenticate header
1107+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
10991108

11001109
routes.append(
11011110
Route(
@@ -1114,23 +1123,13 @@ def streamable_http_app(self) -> Starlette:
11141123

11151124
# Add protected resource metadata endpoint if configured as RS
11161125
if self.settings.auth and self.settings.auth.resource_server_url:
1117-
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
1118-
from mcp.server.auth.routes import cors_middleware
1119-
from mcp.shared.auth import ProtectedResourceMetadata
1120-
1121-
protected_resource_metadata = ProtectedResourceMetadata(
1122-
resource=self.settings.auth.resource_server_url,
1123-
authorization_servers=[self.settings.auth.issuer_url],
1124-
scopes_supported=self.settings.auth.required_scopes,
1125-
)
1126-
routes.append(
1127-
Route(
1128-
"/.well-known/oauth-protected-resource",
1129-
endpoint=cors_middleware(
1130-
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
1131-
["GET", "OPTIONS"],
1132-
),
1133-
methods=["GET", "OPTIONS"],
1126+
from mcp.server.auth.routes import create_protected_resource_routes
1127+
1128+
routes.extend(
1129+
create_protected_resource_routes(
1130+
resource_url=self.settings.auth.resource_server_url,
1131+
authorization_servers=[self.settings.auth.issuer_url],
1132+
scopes_supported=self.settings.auth.required_scopes,
11341133
)
11351134
)
11361135

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ def add_tool(
7878
self._tools[tool.name] = tool
7979
return tool
8080

81+
def remove_tool(self, name: str) -> None:
82+
"""Remove a tool by name."""
83+
if name not in self._tools:
84+
raise ToolError(f"Unknown tool: {name}")
85+
del self._tools[name]
86+
8187
async def call_tool(
8288
self,
8389
name: str,

tests/server/auth/test_protected_resource.py

Lines changed: 150 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pydantic import AnyHttpUrl
99
from starlette.applications import Starlette
1010

11-
from mcp.server.auth.routes import create_protected_resource_routes
11+
from mcp.server.auth.routes import build_resource_metadata_url, create_protected_resource_routes
1212

1313

1414
@pytest.fixture
@@ -36,10 +36,11 @@ async def test_client(test_app: Starlette):
3636

3737

3838
@pytest.mark.anyio
39-
async def test_metadata_endpoint(test_client: httpx.AsyncClient):
40-
"""Test the OAuth 2.0 Protected Resource metadata endpoint."""
39+
async def test_metadata_endpoint_with_path(test_client: httpx.AsyncClient):
40+
"""Test the OAuth 2.0 Protected Resource metadata endpoint for path-based resource."""
4141

42-
response = await test_client.get("/.well-known/oauth-protected-resource")
42+
# For resource with path "/resource", metadata should be accessible at the path-aware location
43+
response = await test_client.get("/.well-known/oauth-protected-resource/resource")
4344
assert response.json() == snapshot(
4445
{
4546
"resource": "https://example.com/resource",
@@ -50,3 +51,148 @@ async def test_metadata_endpoint(test_client: httpx.AsyncClient):
5051
"bearer_methods_supported": ["header"],
5152
}
5253
)
54+
55+
56+
@pytest.mark.anyio
57+
async def test_metadata_endpoint_root_path_returns_404(test_client: httpx.AsyncClient):
58+
"""Test that root path returns 404 for path-based resource."""
59+
60+
# Root path should return 404 for path-based resources
61+
response = await test_client.get("/.well-known/oauth-protected-resource")
62+
assert response.status_code == 404
63+
64+
65+
@pytest.fixture
66+
def root_resource_app():
67+
"""Fixture to create protected resource routes for root-level resource."""
68+
69+
# Create routes for a resource without path component
70+
protected_resource_routes = create_protected_resource_routes(
71+
resource_url=AnyHttpUrl("https://example.com"),
72+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
73+
scopes_supported=["read"],
74+
resource_name="Root Resource",
75+
)
76+
77+
app = Starlette(routes=protected_resource_routes)
78+
return app
79+
80+
81+
@pytest.fixture
82+
async def root_resource_client(root_resource_app: Starlette):
83+
"""Fixture to create an HTTP client for the root resource app."""
84+
async with httpx.AsyncClient(
85+
transport=httpx.ASGITransport(app=root_resource_app), base_url="https://mcptest.com"
86+
) as client:
87+
yield client
88+
89+
90+
@pytest.mark.anyio
91+
async def test_metadata_endpoint_without_path(root_resource_client: httpx.AsyncClient):
92+
"""Test metadata endpoint for root-level resource."""
93+
94+
# For root resource, metadata should be at standard location
95+
response = await root_resource_client.get("/.well-known/oauth-protected-resource")
96+
assert response.status_code == 200
97+
assert response.json() == snapshot(
98+
{
99+
"resource": "https://example.com/",
100+
"authorization_servers": ["https://auth.example.com/"],
101+
"scopes_supported": ["read"],
102+
"resource_name": "Root Resource",
103+
"bearer_methods_supported": ["header"],
104+
}
105+
)
106+
107+
108+
class TestMetadataUrlConstruction:
109+
"""Test URL construction utility function."""
110+
111+
def test_url_without_path(self):
112+
"""Test URL construction for resource without path component."""
113+
resource_url = AnyHttpUrl("https://example.com")
114+
result = build_resource_metadata_url(resource_url)
115+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
116+
117+
def test_url_with_path_component(self):
118+
"""Test URL construction for resource with path component."""
119+
resource_url = AnyHttpUrl("https://example.com/mcp")
120+
result = build_resource_metadata_url(resource_url)
121+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource/mcp"
122+
123+
def test_url_with_trailing_slash_only(self):
124+
"""Test URL construction for resource with trailing slash only."""
125+
resource_url = AnyHttpUrl("https://example.com/")
126+
result = build_resource_metadata_url(resource_url)
127+
# Trailing slash should be treated as empty path
128+
assert str(result) == "https://example.com/.well-known/oauth-protected-resource"
129+
130+
@pytest.mark.parametrize(
131+
"resource_url,expected_url",
132+
[
133+
("https://example.com", "https://example.com/.well-known/oauth-protected-resource"),
134+
("https://example.com/", "https://example.com/.well-known/oauth-protected-resource"),
135+
("https://example.com/mcp", "https://example.com/.well-known/oauth-protected-resource/mcp"),
136+
("http://localhost:8001/mcp", "http://localhost:8001/.well-known/oauth-protected-resource/mcp"),
137+
],
138+
)
139+
def test_various_resource_configurations(self, resource_url: str, expected_url: str):
140+
"""Test URL construction with various resource configurations."""
141+
result = build_resource_metadata_url(AnyHttpUrl(resource_url))
142+
assert str(result) == expected_url
143+
144+
145+
class TestRouteConsistency:
146+
"""Test consistency between URL generation and route registration."""
147+
148+
def test_route_path_matches_metadata_url(self):
149+
"""Test that route path matches the generated metadata URL."""
150+
resource_url = AnyHttpUrl("https://example.com/mcp")
151+
152+
# Generate metadata URL
153+
metadata_url = build_resource_metadata_url(resource_url)
154+
155+
# Create routes
156+
routes = create_protected_resource_routes(
157+
resource_url=resource_url,
158+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
159+
)
160+
161+
# Extract path from metadata URL
162+
from urllib.parse import urlparse
163+
164+
metadata_path = urlparse(str(metadata_url)).path
165+
166+
# Verify consistency
167+
assert len(routes) == 1
168+
assert routes[0].path == metadata_path
169+
170+
@pytest.mark.parametrize(
171+
"resource_url,expected_path",
172+
[
173+
("https://example.com", "/.well-known/oauth-protected-resource"),
174+
("https://example.com/", "/.well-known/oauth-protected-resource"),
175+
("https://example.com/mcp", "/.well-known/oauth-protected-resource/mcp"),
176+
],
177+
)
178+
def test_consistent_paths_for_various_resources(self, resource_url: str, expected_path: str):
179+
"""Test that URL generation and route creation are consistent."""
180+
resource_url_obj = AnyHttpUrl(resource_url)
181+
182+
# Test URL generation
183+
metadata_url = build_resource_metadata_url(resource_url_obj)
184+
from urllib.parse import urlparse
185+
186+
url_path = urlparse(str(metadata_url)).path
187+
188+
# Test route creation
189+
routes = create_protected_resource_routes(
190+
resource_url=resource_url_obj,
191+
authorization_servers=[AnyHttpUrl("https://auth.example.com")],
192+
)
193+
route_path = routes[0].path
194+
195+
# Both should match expected path
196+
assert url_path == expected_path
197+
assert route_path == expected_path
198+
assert url_path == route_path

0 commit comments

Comments
 (0)