Skip to content

Commit e087e30

Browse files
committed
server
1 parent 59d9bfd commit e087e30

File tree

10 files changed

+296
-234
lines changed

10 files changed

+296
-234
lines changed

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,7 @@ def create_authorization_server(settings: AuthServerSettings) -> Starlette:
325325
default_scopes=[settings.mcp_scope],
326326
),
327327
required_scopes=[settings.mcp_scope],
328-
resource_url=settings.server_url,
329-
resource_name="MCP Authorization Server",
328+
authorization_servers=None,
330329
)
331330

332331
# Create OAuth routes
@@ -336,8 +335,6 @@ def create_authorization_server(settings: AuthServerSettings) -> Starlette:
336335
service_documentation_url=auth_settings.service_documentation_url,
337336
client_registration_options=auth_settings.client_registration_options,
338337
revocation_options=auth_settings.revocation_options,
339-
resource_url=settings.server_url, # Enable protected resource metadata
340-
resource_name="MCP Authorization Server",
341338
)
342339

343340
# Add GitHub callback route

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

Lines changed: 13 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,11 @@
1515
import httpx
1616
from pydantic import AnyHttpUrl
1717
from pydantic_settings import BaseSettings, SettingsConfigDict
18-
from starlette.authentication import AuthCredentials, AuthenticationBackend
19-
from starlette.requests import HTTPConnection
20-
from starlette.responses import JSONResponse
2118

2219
from mcp.server.auth.middleware.auth_context import get_access_token
23-
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
24-
from mcp.server.auth.provider import AccessToken
20+
from mcp.server.auth.settings import AuthSettings
21+
from mcp.server.auth.token_verifier import IntrospectionTokenVerifier
2522
from mcp.server.fastmcp.server import FastMCP
26-
from mcp.shared.auth import ProtectedResourceMetadata
2723

2824
logger = logging.getLogger(__name__)
2925

@@ -51,63 +47,6 @@ def __init__(self, **data):
5147
super().__init__(**data)
5248

5349

54-
class TokenIntrospectionAuthBackend(AuthenticationBackend):
55-
"""
56-
Authentication backend for Resource Server that validates tokens via AS introspection.
57-
58-
This backend:
59-
1. Extracts Bearer tokens from Authorization header
60-
2. Calls Authorization Server's introspection endpoint
61-
3. Creates AuthenticatedUser from token info
62-
"""
63-
64-
def __init__(self, settings: ResourceServerSettings):
65-
self.settings = settings
66-
self.introspection_endpoint = settings.auth_server_introspection_endpoint
67-
68-
async def authenticate(self, conn: HTTPConnection):
69-
auth_header = next(
70-
(conn.headers.get(key) for key in conn.headers if key.lower() == "authorization"),
71-
None,
72-
)
73-
if not auth_header or not auth_header.lower().startswith("bearer "):
74-
return None
75-
76-
token = auth_header[7:] # Remove "Bearer " prefix
77-
78-
# Introspect token with Authorization Server
79-
async with httpx.AsyncClient() as client:
80-
try:
81-
response = await client.post(
82-
self.introspection_endpoint,
83-
data={"token": token},
84-
headers={"Content-Type": "application/x-www-form-urlencoded"},
85-
)
86-
87-
if response.status_code != 200:
88-
logger.debug(f"Token introspection failed with status {response.status_code}")
89-
return None
90-
91-
data = response.json()
92-
if not data.get("active", False):
93-
logger.debug("Token is not active")
94-
return None
95-
96-
# Create auth info from introspection response
97-
auth_info = AccessToken(
98-
token=token,
99-
client_id=data.get("client_id", "unknown"),
100-
scopes=data.get("scope", "").split() if data.get("scope") else [],
101-
expires_at=data.get("exp"),
102-
)
103-
104-
return AuthCredentials(auth_info.scopes), AuthenticatedUser(auth_info)
105-
106-
except Exception:
107-
logger.exception("Token introspection failed")
108-
return None
109-
110-
11150
def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
11251
"""
11352
Create MCP Resource Server with token introspection.
@@ -117,35 +56,24 @@ def create_resource_server(settings: ResourceServerSettings) -> FastMCP:
11756
2. Validates tokens via Authorization Server introspection
11857
3. Serves MCP tools and resources
11958
"""
120-
# Create FastMCP server WITHOUT auth settings (since we'll use custom middleware)
121-
# This avoids the FastMCP validation error that requires auth_server_provider
59+
# Create token verifier for introspection
60+
token_verifier = IntrospectionTokenVerifier(settings.auth_server_introspection_endpoint)
61+
62+
# Create FastMCP server as a Resource Server
12263
app = FastMCP(
12364
name="MCP Resource Server",
12465
instructions="Resource Server that validates tokens via Authorization Server introspection",
12566
host=settings.host,
12667
port=settings.port,
12768
debug=True,
128-
# No auth settings - this is RS, not AS
129-
)
130-
131-
# Add the protected resource metadata route using FastMCP's custom_route
132-
@app.custom_route("/.well-known/oauth-protected-resource", methods=["GET", "OPTIONS"])
133-
async def protected_resource_metadata(_request):
134-
"""Handle requests for protected resource metadata."""
135-
metadata = ProtectedResourceMetadata(
136-
resource=settings.server_url,
69+
# Auth configuration for RS mode
70+
token_verifier=token_verifier,
71+
auth=AuthSettings(
72+
issuer_url=settings.server_url,
73+
required_scopes=[settings.mcp_scope],
13774
authorization_servers=[settings.auth_server_url],
138-
scopes_supported=[settings.mcp_scope],
139-
bearer_methods_supported=["header"],
140-
)
141-
# Convert to dict with string URLs for JSON serialization
142-
response_data = {
143-
"resource": str(metadata.resource),
144-
"authorization_servers": [str(url) for url in metadata.authorization_servers],
145-
"scopes_supported": metadata.scopes_supported,
146-
"bearer_methods_supported": metadata.bearer_methods_supported,
147-
}
148-
return JSONResponse(response_data)
75+
),
76+
)
14977

15078
async def get_github_user_data() -> dict[str, Any]:
15179
"""

src/mcp/server/auth/middleware/bearer_auth.py

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1+
import json
12
import time
23
from typing import Any
34

45
from pydantic import AnyHttpUrl
56
from starlette.authentication import AuthCredentials, AuthenticationBackend, SimpleUser
6-
from starlette.exceptions import HTTPException
77
from starlette.requests import HTTPConnection
88
from starlette.types import Receive, Scope, Send
99

10-
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider
10+
from mcp.server.auth.provider import AccessToken
11+
from mcp.server.auth.token_verifier import TokenVerifier
1112

1213

1314
class AuthenticatedUser(SimpleUser):
@@ -21,14 +22,11 @@ def __init__(self, auth_info: AccessToken):
2122

2223
class BearerAuthBackend(AuthenticationBackend):
2324
"""
24-
Authentication backend that validates Bearer tokens.
25+
Authentication backend that validates Bearer tokens using a TokenVerifier.
2526
"""
2627

27-
def __init__(
28-
self,
29-
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
30-
):
31-
self.provider = provider
28+
def __init__(self, token_verifier: TokenVerifier):
29+
self.token_verifier = token_verifier
3230

3331
async def authenticate(self, conn: HTTPConnection):
3432
auth_header = next(
@@ -40,8 +38,8 @@ async def authenticate(self, conn: HTTPConnection):
4038

4139
token = auth_header[7:] # Remove "Bearer " prefix
4240

43-
# Validate the token with the provider
44-
auth_info = await self.provider.load_access_token(token)
41+
# Validate the token with the verifier
42+
auth_info = await self.token_verifier.verify_token(token)
4543

4644
if not auth_info:
4745
return None
@@ -65,7 +63,6 @@ def __init__(
6563
app: Any,
6664
required_scopes: list[str],
6765
resource_metadata_url: AnyHttpUrl | None = None,
68-
realm: str | None = None,
6966
):
7067
"""
7168
Initialize the middleware.
@@ -74,22 +71,57 @@ def __init__(
7471
app: ASGI application
7572
required_scopes: List of scopes that the token must have
7673
resource_metadata_url: Optional protected resource metadata URL for WWW-Authenticate header
77-
realm: Optional realm for WWW-Authenticate header
7874
"""
7975
self.app = app
8076
self.required_scopes = required_scopes
8177
self.resource_metadata_url = resource_metadata_url
82-
self.realm = realm or "mcp"
8378

8479
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
8580
auth_user = scope.get("user")
8681
if not isinstance(auth_user, AuthenticatedUser):
87-
raise HTTPException(status_code=401, detail="Unauthorized")
82+
await self._send_auth_error(
83+
send, status_code=401, error="invalid_token", description="Authentication required"
84+
)
85+
return
86+
8887
auth_credentials = scope.get("auth")
8988

9089
for required_scope in self.required_scopes:
9190
# auth_credentials should always be provided; this is just paranoia
9291
if auth_credentials is None or required_scope not in auth_credentials.scopes:
93-
raise HTTPException(status_code=403, detail="Insufficient scope")
92+
await self._send_auth_error(
93+
send, status_code=403, error="insufficient_scope", description=f"Required scope: {required_scope}"
94+
)
95+
return
9496

9597
await self.app(scope, receive, send)
98+
99+
async def _send_auth_error(self, send: Send, status_code: int, error: str, description: str) -> None:
100+
"""Send an authentication error response with WWW-Authenticate header."""
101+
# Build WWW-Authenticate header value
102+
www_auth_parts = [f'error="{error}"', f'error_description="{description}"']
103+
if self.resource_metadata_url:
104+
www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"')
105+
106+
www_authenticate = f"Bearer {', '.join(www_auth_parts)}"
107+
108+
# Send response
109+
await send(
110+
{
111+
"type": "http.response.start",
112+
"status": status_code,
113+
"headers": [
114+
(b"content-type", b"application/json"),
115+
(b"www-authenticate", www_authenticate.encode()),
116+
],
117+
}
118+
)
119+
120+
# Send body
121+
body = {"error": error, "error_description": description}
122+
await send(
123+
{
124+
"type": "http.response.body",
125+
"body": json.dumps(body).encode(),
126+
}
127+
)

src/mcp/server/auth/routes.py

Lines changed: 29 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@
99
from starlette.types import ASGIApp
1010

1111
from mcp.server.auth.handlers.authorize import AuthorizationHandler
12-
from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler
12+
from mcp.server.auth.handlers.metadata import MetadataHandler
1313
from mcp.server.auth.handlers.register import RegistrationHandler
1414
from mcp.server.auth.handlers.revoke import RevocationHandler
1515
from mcp.server.auth.handlers.token import TokenHandler
1616
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
1717
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1818
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
1919
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
20-
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
20+
from mcp.shared.auth import OAuthMetadata
2121

2222

2323
def validate_issuer_url(url: AnyHttpUrl):
@@ -67,8 +67,6 @@ def create_auth_routes(
6767
service_documentation_url: AnyHttpUrl | None = None,
6868
client_registration_options: ClientRegistrationOptions | None = None,
6969
revocation_options: RevocationOptions | None = None,
70-
resource_url: AnyHttpUrl | None = None,
71-
resource_name: str | None = None,
7270
) -> list[Route]:
7371
validate_issuer_url(issuer_url)
7472

@@ -97,25 +95,6 @@ def create_auth_routes(
9795
),
9896
]
9997

100-
# Add protected resource metadata endpoint if resource is configured
101-
if resource_url:
102-
protected_resource_metadata = build_protected_resource_metadata(
103-
resource_url,
104-
issuer_url,
105-
client_registration_options,
106-
resource_name,
107-
)
108-
routes.append(
109-
Route(
110-
"/.well-known/oauth-protected-resource",
111-
endpoint=cors_middleware(
112-
ProtectedResourceMetadataHandler(protected_resource_metadata).handle,
113-
["GET", "OPTIONS"],
114-
),
115-
methods=["GET", "OPTIONS"],
116-
)
117-
)
118-
11998
# Add remaining auth routes
12099
routes.extend(
121100
[
@@ -209,34 +188,38 @@ def build_metadata(
209188
return metadata
210189

211190

212-
def build_protected_resource_metadata(
191+
def create_protected_resource_routes(
213192
resource_url: AnyHttpUrl,
214-
issuer_url: AnyHttpUrl,
215-
client_registration_options: ClientRegistrationOptions,
216-
resource_name: str | None = None,
217-
) -> ProtectedResourceMetadata:
193+
authorization_servers: list[AnyHttpUrl],
194+
scopes_supported: list[str] | None = None,
195+
) -> list[Route]:
218196
"""
219-
Build protected resource metadata according to RFC 9728.
220-
197+
Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728).
198+
221199
Args:
222-
resource_url: The resource server URL
223-
issuer_url: The authorization server URL
224-
client_registration_options: Client registration options for scopes
225-
resource_name: Optional resource name
226-
200+
resource_url: The URL of this resource server
201+
authorization_servers: List of authorization servers that can issue tokens
202+
scopes_supported: Optional list of scopes supported by this resource
203+
227204
Returns:
228-
ProtectedResourceMetadata: The protected resource metadata
205+
List of Starlette routes for protected resource metadata
229206
"""
207+
from mcp.server.auth.handlers.metadata import ProtectedResourceMetadataHandler
208+
from mcp.shared.auth import ProtectedResourceMetadata
209+
230210
metadata = ProtectedResourceMetadata(
231211
resource=resource_url,
232-
authorization_servers=[issuer_url],
233-
scopes_supported=client_registration_options.valid_scopes,
234-
bearer_methods_supported=["header"],
212+
authorization_servers=authorization_servers,
213+
scopes_supported=scopes_supported,
214+
# bearer_methods_supported defaults to ["header"] in the model
235215
)
236-
237-
if resource_name:
238-
# Set resource documentation URL if resource name is provided
239-
# This could be enhanced to include actual documentation URLs
240-
pass
241-
242-
return metadata
216+
217+
handler = ProtectedResourceMetadataHandler(metadata)
218+
219+
return [
220+
Route(
221+
"/.well-known/oauth-protected-resource",
222+
endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]),
223+
methods=["GET", "OPTIONS"],
224+
)
225+
]

0 commit comments

Comments
 (0)