Skip to content

Commit 6c24b63

Browse files
authored
Merge branch 'main' into main
2 parents 57e5337 + 61399b3 commit 6c24b63

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
@@ -191,6 +192,25 @@ def build_metadata(
191192
return metadata
192193

193194

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

224244
handler = ProtectedResourceMetadataHandler(metadata)
225245

246+
# RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path
247+
metadata_url = build_resource_metadata_url(resource_url)
248+
# Extract just the path part for route registration
249+
parsed = urlparse(str(metadata_url))
250+
well_known_path = parsed.path
251+
226252
return [
227253
Route(
228-
"/.well-known/oauth-protected-resource",
254+
well_known_path,
229255
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
230256
methods=["GET", "OPTIONS"],
231257
)

src/mcp/server/fastmcp/server.py

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,17 @@ def add_tool(
391391
structured_output=structured_output,
392392
)
393393

394+
def remove_tool(self, name: str) -> None:
395+
"""Remove a tool from the server by name.
396+
397+
Args:
398+
name: The name of the tool to remove
399+
400+
Raises:
401+
ToolError: If the tool does not exist
402+
"""
403+
self._tool_manager.remove_tool(name)
404+
394405
def tool(
395406
self,
396407
name: str | None = None,
@@ -825,11 +836,10 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
825836
# Determine resource metadata URL
826837
resource_metadata_url = None
827838
if self.settings.auth and self.settings.auth.resource_server_url:
828-
from pydantic import AnyHttpUrl
839+
from mcp.server.auth.routes import build_resource_metadata_url
829840

830-
resource_metadata_url = AnyHttpUrl(
831-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
832-
)
841+
# Build compliant metadata URL for WWW-Authenticate header
842+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
833843

834844
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
835845
routes.append(
@@ -938,11 +948,10 @@ def streamable_http_app(self) -> Starlette:
938948
# Determine resource metadata URL
939949
resource_metadata_url = None
940950
if self.settings.auth and self.settings.auth.resource_server_url:
941-
from pydantic import AnyHttpUrl
951+
from mcp.server.auth.routes import build_resource_metadata_url
942952

943-
resource_metadata_url = AnyHttpUrl(
944-
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
945-
)
953+
# Build compliant metadata URL for WWW-Authenticate header
954+
resource_metadata_url = build_resource_metadata_url(self.settings.auth.resource_server_url)
946955

947956
routes.append(
948957
Route(
@@ -961,23 +970,13 @@ def streamable_http_app(self) -> Starlette:
961970

962971
# Add protected resource metadata endpoint if configured as RS
963972
if self.settings.auth and self.settings.auth.resource_server_url:
964-
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
965-
from mcp.server.auth.routes import cors_middleware
966-
from mcp.shared.auth import ProtectedResourceMetadata
967-
968-
protected_resource_metadata = ProtectedResourceMetadata(
969-
resource=self.settings.auth.resource_server_url,
970-
authorization_servers=[self.settings.auth.issuer_url],
971-
scopes_supported=self.settings.auth.required_scopes,
972-
)
973-
routes.append(
974-
Route(
975-
"/.well-known/oauth-protected-resource",
976-
endpoint=cors_middleware(
977-
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
978-
["GET", "OPTIONS"],
979-
),
980-
methods=["GET", "OPTIONS"],
973+
from mcp.server.auth.routes import create_protected_resource_routes
974+
975+
routes.extend(
976+
create_protected_resource_routes(
977+
resource_url=self.settings.auth.resource_server_url,
978+
authorization_servers=[self.settings.auth.issuer_url],
979+
scopes_supported=self.settings.auth.required_scopes,
981980
)
982981
)
983982

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ def add_tool(
7070
self._tools[tool.name] = tool
7171
return tool
7272

73+
def remove_tool(self, name: str) -> None:
74+
"""Remove a tool by name."""
75+
if name not in self._tools:
76+
raise ToolError(f"Unknown tool: {name}")
77+
del self._tools[name]
78+
7379
async def call_tool(
7480
self,
7581
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)