Skip to content

Commit c5e3c6d

Browse files
committed
Merge branch 'main' into ihrpr/RFC-8707
2 parents 681a718 + 17f9c00 commit c5e3c6d

File tree

9 files changed

+47
-103
lines changed

9 files changed

+47
-103
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,8 @@ mcp = FastMCP(
444444
"My App",
445445
token_verifier=MyTokenVerifier(),
446446
auth=AuthSettings(
447-
authorization_servers=["https://auth.example.com"],
447+
issuer_url="https://auth.example.com",
448+
resource_server_url="http://localhost:3001",
448449
required_scopes=["mcp:read", "mcp:write"],
449450
),
450451
)

examples/servers/simple-auth/README.md

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This example demonstrates OAuth 2.0 authentication with the Model Context Protoc
1313

1414
**Set environment variables:**
1515
```bash
16-
export MCP_GITHUB_CLIENT_ID="your_client_id_here"
16+
export MCP_GITHUB_CLIENT_ID="your_client_id_here"
1717
export MCP_GITHUB_CLIENT_SECRET="your_client_secret_here"
1818
```
1919

@@ -28,7 +28,7 @@ export MCP_GITHUB_CLIENT_SECRET="your_client_secret_here"
2828
cd examples/servers/simple-auth
2929

3030
# Start Authorization Server on port 9000
31-
python -m mcp_simple_auth.auth_server --port=9000
31+
uv run mcp-simple-auth-as --port=9000
3232
```
3333

3434
**What it provides:**
@@ -46,25 +46,20 @@ python -m mcp_simple_auth.auth_server --port=9000
4646
cd examples/servers/simple-auth
4747

4848
# Start Resource Server on port 8001, connected to Authorization Server
49-
python -m mcp_simple_auth.server --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
49+
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
5050

5151
# With RFC 8707 strict resource validation (recommended for production)
52-
python -m mcp_simple_auth.server --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
53-
```
52+
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
5453

55-
**OAuth Strict Mode (`--oauth-strict`):**
56-
- Enables RFC 8707 resource indicator validation
57-
- Ensures tokens are only accepted if they were issued for this specific resource server
58-
- Prevents token misuse across different services
59-
- Recommended for production environments where security is critical
54+
```
6055

6156

6257
### Step 3: Test with Client
6358

6459
```bash
6560
cd examples/clients/simple-auth-client
66-
# Start client with streamable HTTP
67-
MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable_http python -m mcp_simple_auth_client.main
61+
# Start client with streamable HTTP
62+
MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable_http uv run mcp-simple-auth-client
6863
```
6964

7065

@@ -103,7 +98,7 @@ For backwards compatibility with older MCP implementations, a legacy server is p
10398

10499
```bash
105100
# Start legacy authorization server on port 8002
106-
python -m mcp_simple_auth.legacy_as_server --port=8002
101+
uv run mcp-simple-auth-legacy --port=8002
107102
```
108103

109104
**Differences from the new architecture:**
@@ -117,12 +112,13 @@ python -m mcp_simple_auth.legacy_as_server --port=8002
117112

118113
```bash
119114
# Test with client (will automatically fall back to legacy discovery)
120-
MCP_SERVER_PORT=8002 MCP_TRANSPORT_TYPE=streamable_http python -m mcp_simple_auth_client.main
115+
cd examples/clients/simple-auth-client
116+
MCP_SERVER_PORT=8002 MCP_TRANSPORT_TYPE=streamable_http uv run mcp-simple-auth-client
121117
```
122118

123119
The client will:
124120
1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server)
125-
2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server`
121+
2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server`
126122
3. Complete authentication with the MCP server acting as its own AS
127123

128124
This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only.

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

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ def create_authorization_server(server_settings: AuthServerSettings, github_sett
6868
default_scopes=[github_settings.mcp_scope],
6969
),
7070
required_scopes=[github_settings.mcp_scope],
71-
authorization_servers=None,
71+
resource_server_url=None,
7272
)
7373

7474
# Create OAuth routes
@@ -113,20 +113,16 @@ async def introspect_handler(request: Request) -> Response:
113113
return JSONResponse({"active": False})
114114

115115
# Return token info for Resource Server
116-
response_data = {
117-
"active": True,
118-
"client_id": access_token.client_id,
119-
"scope": " ".join(access_token.scopes),
120-
"exp": access_token.expires_at,
121-
"iat": int(time.time()),
122-
"token_type": "Bearer",
123-
}
124-
125-
# Include audience claim for RFC 8707 resource validation
126-
if access_token.resource:
127-
response_data["aud"] = access_token.resource
128-
129-
return JSONResponse(response_data)
116+
return JSONResponse(
117+
{
118+
"active": True,
119+
"client_id": access_token.client_id,
120+
"scope": " ".join(access_token.scopes),
121+
"exp": access_token.expires_at,
122+
"iat": int(time.time()),
123+
"token_type": "Bearer",
124+
}
125+
)
130126

131127
routes.append(
132128
Route(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ def create_simple_mcp_server(server_settings: ServerSettings, github_settings: G
5959
default_scopes=[github_settings.mcp_scope],
6060
),
6161
required_scopes=[github_settings.mcp_scope],
62-
# No authorization_servers parameter in legacy mode
63-
authorization_servers=None,
62+
# No resource_server_url parameter in legacy mode
63+
resource_server_url=None,
6464
)
6565

6666
app = FastMCP(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
7777
# Auth configuration for RS mode
7878
token_verifier=token_verifier,
7979
auth=AuthSettings(
80-
issuer_url=settings.server_url,
80+
issuer_url=settings.auth_server_url,
8181
required_scopes=[settings.mcp_scope],
82-
authorization_servers=[settings.auth_server_url],
82+
resource_server_url=settings.server_url,
8383
),
8484
)
8585

examples/servers/simple-auth/pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ dependencies = [
1818
]
1919

2020
[project.scripts]
21-
mcp-simple-auth = "mcp_simple_auth.server:main"
21+
mcp-simple-auth-rs = "mcp_simple_auth.server:main"
22+
mcp-simple-auth-as = "mcp_simple_auth.auth_server:main"
23+
mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main"
2224

2325
[build-system]
2426
requires = ["hatchling"]
@@ -28,4 +30,4 @@ build-backend = "hatchling.build"
2830
packages = ["mcp_simple_auth"]
2931

3032
[tool.uv]
31-
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
33+
dev-dependencies = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]

src/mcp/server/auth/settings.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@ class RevocationOptions(BaseModel):
1515
class AuthSettings(BaseModel):
1616
issuer_url: AnyHttpUrl = Field(
1717
...,
18-
description="Base URL where this server is reachable. For AS: OAuth issuer URL. For RS: Resource server URL.",
18+
description="OAuth authorization server URL that issues tokens for this resource server.",
1919
)
2020
service_documentation_url: AnyHttpUrl | None = None
2121
client_registration_options: ClientRegistrationOptions | None = None
2222
revocation_options: RevocationOptions | None = None
2323
required_scopes: list[str] | None = None
2424

2525
# Resource Server settings (when operating as RS only)
26-
authorization_servers: list[AnyHttpUrl] | None = Field(
27-
None,
28-
description="Authorization servers that can issue tokens for this resource (RS mode)",
26+
resource_server_url: AnyHttpUrl | None = Field(
27+
...,
28+
description="The URL of the MCP server to be used as the resource identifier "
29+
"and base route to look up OAuth Protected Resource Metadata.",
2930
)

src/mcp/server/fastmcp/server.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -743,11 +743,11 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send):
743743
if self._token_verifier:
744744
# Determine resource metadata URL
745745
resource_metadata_url = None
746-
if self.settings.auth and self.settings.auth.authorization_servers:
746+
if self.settings.auth and self.settings.auth.resource_server_url:
747747
from pydantic import AnyHttpUrl
748748

749749
resource_metadata_url = AnyHttpUrl(
750-
str(self.settings.auth.issuer_url).rstrip("/") + "/.well-known/oauth-protected-resource"
750+
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
751751
)
752752

753753
# Auth is enabled, wrap the endpoints with RequireAuthMiddleware
@@ -785,13 +785,13 @@ async def sse_endpoint(request: Request) -> Response:
785785
)
786786
)
787787
# Add protected resource metadata endpoint if configured as RS
788-
if self.settings.auth and self.settings.auth.authorization_servers:
788+
if self.settings.auth and self.settings.auth.resource_server_url:
789789
from mcp.server.auth.routes import create_protected_resource_routes
790790

791791
routes.extend(
792792
create_protected_resource_routes(
793-
resource_url=self.settings.auth.issuer_url,
794-
authorization_servers=self.settings.auth.authorization_servers,
793+
resource_url=self.settings.auth.resource_server_url,
794+
authorization_servers=[self.settings.auth.issuer_url],
795795
scopes_supported=self.settings.auth.required_scopes,
796796
)
797797
)
@@ -858,11 +858,11 @@ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) ->
858858
if self._token_verifier:
859859
# Determine resource metadata URL
860860
resource_metadata_url = None
861-
if self.settings.auth and self.settings.auth.authorization_servers:
861+
if self.settings.auth and self.settings.auth.resource_server_url:
862862
from pydantic import AnyHttpUrl
863863

864864
resource_metadata_url = AnyHttpUrl(
865-
str(self.settings.auth.issuer_url).rstrip("/") + "/.well-known/oauth-protected-resource"
865+
str(self.settings.auth.resource_server_url).rstrip("/") + "/.well-known/oauth-protected-resource"
866866
)
867867

868868
routes.append(
@@ -881,14 +881,14 @@ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) ->
881881
)
882882

883883
# Add protected resource metadata endpoint if configured as RS
884-
if self.settings.auth and self.settings.auth.authorization_servers:
884+
if self.settings.auth and self.settings.auth.resource_server_url:
885885
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
886886
from mcp.server.auth.routes import cors_middleware
887887
from mcp.shared.auth import ProtectedResourceMetadata
888888

889889
protected_resource_metadata = ProtectedResourceMetadata(
890-
resource=self.settings.auth.issuer_url,
891-
authorization_servers=self.settings.auth.authorization_servers,
890+
resource=self.settings.auth.resource_server_url,
891+
authorization_servers=[self.settings.auth.issuer_url],
892892
scopes_supported=self.settings.auth.required_scopes,
893893
)
894894
routes.append(

tests/client/test_auth.py

Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -259,56 +259,6 @@ async def test_token_exchange_request(self, oauth_provider):
259259
assert "code_verifier=test_verifier" in content
260260
assert "client_id=test_client" in content
261261
assert "client_secret=test_secret" in content
262-
# Resource parameter should be included per RFC 8707
263-
assert "resource=https%3A%2F%2Fapi.example.com%2Fv1%2Fmcp" in content
264-
265-
@pytest.mark.anyio
266-
async def test_authorization_url_request(self, oauth_provider):
267-
"""Test authorization URL construction with resource parameter."""
268-
from unittest.mock import patch
269-
270-
# Set up required context
271-
oauth_provider.context.client_info = OAuthClientInformationFull(
272-
client_id="test_client",
273-
client_secret="test_secret",
274-
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
275-
)
276-
277-
# Mock the redirect handler to capture the URL
278-
captured_url = None
279-
280-
async def mock_redirect_handler(url: str):
281-
nonlocal captured_url
282-
captured_url = url
283-
284-
oauth_provider.context.redirect_handler = mock_redirect_handler
285-
286-
# Mock callback handler
287-
async def mock_callback_handler():
288-
return "test_auth_code", "test_state"
289-
290-
oauth_provider.context.callback_handler = mock_callback_handler
291-
292-
# Mock pkce and state generation for predictable testing
293-
with (
294-
patch("mcp.client.auth.PKCEParameters.generate") as mock_pkce,
295-
patch("mcp.client.auth.secrets.token_urlsafe") as mock_state,
296-
):
297-
mock_pkce.return_value.code_verifier = "test_verifier"
298-
mock_pkce.return_value.code_challenge = "test_challenge"
299-
mock_state.return_value = "test_state"
300-
301-
# Mock compare_digest to return True
302-
with patch("mcp.client.auth.secrets.compare_digest", return_value=True):
303-
await oauth_provider._perform_authorization()
304-
305-
# Verify the captured URL contains resource parameter
306-
assert captured_url is not None
307-
assert "resource=https%3A%2F%2Fapi.example.com%2Fv1%2Fmcp" in captured_url
308-
assert "client_id=test_client" in captured_url
309-
assert "response_type=code" in captured_url
310-
assert "code_challenge=test_challenge" in captured_url
311-
assert "code_challenge_method=S256" in captured_url
312262

313263
@pytest.mark.anyio
314264
async def test_refresh_token_request(self, oauth_provider, valid_tokens):
@@ -333,8 +283,6 @@ async def test_refresh_token_request(self, oauth_provider, valid_tokens):
333283
assert "refresh_token=test_refresh_token" in content
334284
assert "client_id=test_client" in content
335285
assert "client_secret=test_secret" in content
336-
# Resource parameter should be included per RFC 8707
337-
assert "resource=https%3A%2F%2Fapi.example.com%2Fv1%2Fmcp" in content
338286

339287

340288
class TestAuthFlow:

0 commit comments

Comments
 (0)