Skip to content

Add support for serverside oauth #255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 89 commits into from
May 1, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
682ff1e
wip
praboud-ant Mar 6, 2025
331d51e
Unwind changes
praboud-ant Mar 6, 2025
d283f56
wip
praboud-ant Mar 7, 2025
e96d280
Get tests passing
praboud-ant Mar 10, 2025
1e9dd4c
Clean up provider interface
praboud-ant Mar 10, 2025
d535089
Lint
praboud-ant Mar 10, 2025
031cadf
Clean up registration endpoint
praboud-ant Mar 10, 2025
765efb6
Lint
praboud-ant Mar 10, 2025
0637bc3
update token + revoke to use form data
praboud-ant Mar 10, 2025
b99633a
Adjust more things to fit spec
praboud-ant Mar 10, 2025
9ae1c21
Lint
praboud-ant Mar 10, 2025
50683b9
Remove dup
praboud-ant Mar 10, 2025
2c5f26a
Comment
praboud-ant Mar 10, 2025
e605994
Refactor back to authorize()
praboud-ant Mar 10, 2025
e7c5f87
Improve validation for /token
praboud-ant Mar 11, 2025
83c0c9f
Improve validation for registration
praboud-ant Mar 11, 2025
0c1aae9
Improve /authorize validation & add tests
praboud-ant Mar 11, 2025
038fb04
Hoist oauth token expiration check into bearer auth middleware
praboud-ant Mar 11, 2025
a4e17f3
Add tests for /revoke validation
praboud-ant Mar 11, 2025
5f11c60
Lint + typecheck
praboud-ant Mar 11, 2025
571913a
Clean up unused error classes
praboud-ant Mar 11, 2025
d43647f
Update to use Python 3.10 types
praboud-ant Mar 11, 2025
9d72c1e
Use classes for handlers
praboud-ant Mar 11, 2025
a5079af
Refactor
praboud-ant Mar 11, 2025
c4c2608
Simplify bearer auth logic
praboud-ant Mar 11, 2025
bc62d73
Avoid asyncio dependency in tests
praboud-ant Mar 11, 2025
3852179
Add comment
praboud-ant Mar 11, 2025
874838a
Lint
praboud-ant Mar 11, 2025
f788d79
Add json_response.py comment
praboud-ant Mar 11, 2025
152feb9
Format
praboud-ant Mar 11, 2025
f37ebc4
Move around the response models to be closer to the handlers
praboud-ant Mar 11, 2025
c2873fd
Get rid of silly TS comments
praboud-ant Mar 11, 2025
fe2c029
Remove ClientAuthRequest
praboud-ant Mar 11, 2025
3a13f5d
Reorganize AuthInfo
praboud-ant Mar 11, 2025
37c5fc4
Refactor client metadata endpoint
praboud-ant Mar 11, 2025
792d302
Make metadata more spec compliant
praboud-ant Mar 12, 2025
6c48b11
Use python 3.10 types everywhere
praboud-ant Mar 12, 2025
a437566
Add back authorization to the /revoke endpoint, simplify revoke
praboud-ant Mar 12, 2025
9fee929
Move around validation logic
praboud-ant Mar 12, 2025
d79be8f
Fixups while integrating new auth capabilities
praboud-ant Mar 19, 2025
8d637b4
Pull all auth settings out into a separate config
praboud-ant Mar 19, 2025
8c86bce
Move router file to be routes
praboud-ant Mar 19, 2025
31618c1
Add auth context middleware
praboud-ant Mar 19, 2025
5ebbc19
Validate scopes + provide default
praboud-ant Mar 19, 2025
50673c6
Validate grant_types on registration
praboud-ant Mar 19, 2025
02d76f3
auth: client implementation
dsp-ant Mar 12, 2025
88edddc
update lock
dsp-ant Mar 12, 2025
d774be7
fix
dsp-ant Mar 12, 2025
a09e958
foo
dsp-ant Mar 14, 2025
4e73552
Format
praboud-ant Mar 19, 2025
56f694e
Move StreamingASGITransport into the library code, so MCP integration…
praboud-ant Mar 19, 2025
60da682
Improved error handling, generic types for provider
praboud-ant Mar 21, 2025
374a0b4
Rename AuthInfo to AccessToken
praboud-ant Mar 21, 2025
fb5a568
Rename
praboud-ant Mar 22, 2025
76ddc65
Add docs
praboud-ant Mar 22, 2025
e42dbf5
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Mar 22, 2025
10e00e7
Typecheck
praboud-ant Mar 22, 2025
87571d8
Return 401 on missing auth, not 403
praboud-ant Mar 25, 2025
c6f991b
Convert AuthContextMiddleware to plain ASGI middleware & add tests
praboud-ant Mar 25, 2025
482149e
Fix redirect_uri handling
praboud-ant Mar 25, 2025
5230180
Remove client for now
praboud-ant Mar 25, 2025
8e15abc
Add test for auth context middleware
praboud-ant Mar 25, 2025
0a1a408
Add CORS support
praboud-ant Mar 25, 2025
3069aa3
Comment
praboud-ant Mar 27, 2025
16f0688
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Mar 27, 2025
5ecc7f0
Remove client tests
praboud-ant Mar 27, 2025
8c251c9
Add ignores
praboud-ant Mar 27, 2025
f46dcb1
Merge remote-tracking branch 'origin/main' into praboud/auth
praboud-ant Apr 18, 2025
d3725cf
Review feedback
praboud-ant Apr 18, 2025
07f4e3a
Fix stream resource leaks and upgrade Starlette
bhosmer-ant Apr 13, 2025
1237148
Lint
praboud-ant Apr 18, 2025
1ad1842
Review comments
praboud-ant Apr 18, 2025
fa068dd
Rename OAuthServerProvider to OAuthAuthorizationServerProvider
praboud-ant Apr 18, 2025
9b5709a
Merge branch 'main' into praboud/auth
ihrpr Apr 30, 2025
67d568b
revert starlette upgrade
ihrpr Apr 30, 2025
16a7efa
add python-multipart - was missing
ihrpr Apr 30, 2025
91c09a4
ruff
ihrpr Apr 30, 2025
0582bf5
try fixing test
ihrpr Apr 30, 2025
8194bce
increse timeout
ihrpr Apr 30, 2025
2c63020
fix test
ihrpr May 1, 2025
2ea68f2
test
ihrpr May 1, 2025
b0fe041
fix test
ihrpr May 1, 2025
ba366e3
test
ihrpr May 1, 2025
e1a9fec
test
ihrpr May 1, 2025
af4221f
skip test
ihrpr May 1, 2025
f2840fe
Test auth (#609)
ihrpr May 1, 2025
cda4401
remove pyright upgrade and ruff format
ihrpr May 1, 2025
f2cc6ee
uv lock
ihrpr May 1, 2025
23ef519
Merge branch 'main' into praboud/auth
ihrpr May 1, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ dependencies = [
"httpx>=0.27",
"httpx-sse>=0.4",
"pydantic>=2.7.2,<3.0.0",
"starlette>=0.46",
"starlette>=0.27",
"python-multipart>=0.0.9",
"sse-starlette>=1.6.1",
"pydantic-settings>=2.5.2",
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
Expand All @@ -46,7 +47,7 @@ default-groups = ["dev", "docs"]

[dependency-groups]
dev = [
"pyright>=1.1.396",
"pyright>=1.1.391",
"pytest>=8.3.4",
"ruff>=0.8.5",
"trio>=0.26.2",
Expand Down Expand Up @@ -114,5 +115,5 @@ filterwarnings = [
# This should be fixed on Uvicorn's side.
"ignore::DeprecationWarning:websockets",
"ignore:websockets.server.WebSocketServerProtocol is deprecated:DeprecationWarning",
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel",
"ignore:Returning str or bytes.*:DeprecationWarning:mcp.server.lowlevel"
]
6 changes: 5 additions & 1 deletion src/mcp/server/auth/handlers/revoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
AuthenticationError,
ClientAuthenticator,
)
from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, RefreshToken
from mcp.server.auth.provider import (
AccessToken,
OAuthAuthorizationServerProvider,
RefreshToken,
)


class RevocationRequest(BaseModel):
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/server/auth/handlers/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
AuthenticationError,
ClientAuthenticator,
)
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode
from mcp.server.auth.provider import (
OAuthAuthorizationServerProvider,
TokenError,
TokenErrorCode,
)
from mcp.shared.auth import OAuthToken


Expand Down
20 changes: 9 additions & 11 deletions src/mcp/server/fastmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@
from pydantic import BaseModel, Field
from pydantic.networks import AnyUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route, request_response # type: ignore
from starlette.routing import Mount, Route
from starlette.types import Receive, Scope, Send

from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import (
Expand Down Expand Up @@ -128,7 +128,8 @@ def __init__(
self,
name: str | None = None,
instructions: str | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any]
| None = None,
**settings: Any,
):
self.settings = Settings(**settings)
Expand Down Expand Up @@ -575,20 +576,19 @@ def sse_app(self) -> Starlette:

sse = SseServerTransport(self.settings.message_path)

async def handle_sse(request: Request) -> EventSourceResponse:
async def handle_sse(scope: Scope, receive: Receive, send: Send):
# Add client ID from auth context into request context if available

async with sse.connect_sse(
request.scope,
request.receive,
request._send, # type: ignore[reportPrivateUsage]
scope,
receive,
send,
) as streams:
await self._mcp_server.run(
streams[0],
streams[1],
self._mcp_server.create_initialization_options(),
)
return streams[2]

# Create routes
routes: list[Route | Mount] = []
Expand Down Expand Up @@ -627,9 +627,7 @@ async def handle_sse(request: Request) -> EventSourceResponse:
routes.append(
Route(
self.settings.sse_path,
endpoint=RequireAuthMiddleware(
request_response(handle_sse), required_scopes
),
endpoint=RequireAuthMiddleware(handle_sse, required_scopes),
methods=["GET"],
)
)
Expand Down
20 changes: 9 additions & 11 deletions src/mcp/server/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,15 @@ async def sse_writer():
}
)

# Ensure all streams are properly closed
async with read_stream, write_stream, read_stream_writer, sse_stream_reader:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ooc was the outer with unnecessary or was it actively messing things up?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just reverted all the changes to see transport, returning response was not needed at all.

The fun part for the top async with was that it was masking "bad test" and sse tests were just hanging. When reverted all the sse changes notices that some other test randomly fails with " ResourceWarning: Unclosed <MemoryObjectReceiveStream at 106c0dd20>"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a nice update, I would prefer to have it in a separate PR and test separately. There are a bunch of open PRs addressing different parts of memory leaks, maybe we can just bundle all of them together

async with anyio.create_task_group() as tg:
response = EventSourceResponse(
content=sse_stream_reader, data_sender_callable=sse_writer
)
logger.debug("Starting SSE response task")
tg.start_soon(response, scope, receive, send)

logger.debug("Yielding read and write streams")
yield (read_stream, write_stream, response)
async with anyio.create_task_group() as tg:
response = EventSourceResponse(
content=sse_stream_reader, data_sender_callable=sse_writer
)
logger.debug("Starting SSE response task")
tg.start_soon(response, scope, receive, send)

logger.debug("Yielding read and write streams")
yield (read_stream, write_stream)

async def handle_post_message(
self, scope: Scope, receive: Receive, send: Send
Expand Down
4 changes: 3 additions & 1 deletion tests/server/auth/middleware/test_bearer_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ async def load_access_token(self, token: str) -> AccessToken | None:


def add_token_to_provider(
provider: OAuthAuthorizationServerProvider[Any, Any, Any], token: str, access_token: AccessToken
provider: OAuthAuthorizationServerProvider[Any, Any, Any],
token: str,
access_token: AccessToken,
) -> None:
"""Helper function to add a token to a provider.

Expand Down
173 changes: 4 additions & 169 deletions tests/server/fastmcp/auth/test_auth_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@

import base64
import hashlib
import json
import secrets
import time
import unittest.mock
from urllib.parse import parse_qs, urlparse

import anyio
import httpx
import pytest
from httpx_sse import aconnect_sse
from pydantic import AnyHttpUrl
from starlette.applications import Starlette

Expand All @@ -30,14 +27,10 @@
RevocationOptions,
create_auth_routes,
)
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
from mcp.server.streaming_asgi_transport import StreamingASGITransport
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthToken,
)
from mcp.types import JSONRPCRequest


# Mock OAuth provider for testing
Expand Down Expand Up @@ -230,10 +223,11 @@ def auth_app(mock_oauth_provider):


@pytest.fixture
def test_client(auth_app) -> httpx.AsyncClient:
return httpx.AsyncClient(
async def test_client(auth_app):
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=auth_app), base_url="https://mcptest.com"
)
) as client:
yield client


@pytest.fixture
Expand Down Expand Up @@ -993,165 +987,6 @@ async def test_client_registration_invalid_grant_type(
)


class TestFastMCPWithAuth:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this the test that leaked and made the other ones hang?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, basically this :

async with aconnect_sse(
                test_client, "GET", "/sse", headers={"Authorization": authorization}
            ) as event_source:

"""Test FastMCP server with authentication."""

@pytest.mark.anyio
async def test_fastmcp_with_auth(
self, mock_oauth_provider: MockOAuthProvider, pkce_challenge
):
"""Test creating a FastMCP server with authentication."""
# Create FastMCP server with auth provider
mcp = FastMCP(
auth_server_provider=mock_oauth_provider,
require_auth=True,
auth=AuthSettings(
issuer_url=AnyHttpUrl("https://auth.example.com"),
client_registration_options=ClientRegistrationOptions(enabled=True),
revocation_options=RevocationOptions(enabled=True),
required_scopes=["read", "write"],
),
)

# Add a test tool
@mcp.tool()
def test_tool(x: int) -> str:
return f"Result: {x}"

async with anyio.create_task_group() as task_group:
transport = StreamingASGITransport(
app=mcp.sse_app(),
task_group=task_group,
)
test_client = httpx.AsyncClient(
transport=transport, base_url="http://mcptest.com"
)

# Test metadata endpoint
response = await test_client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200

# Test that auth is required for protected endpoints
response = await test_client.get("/sse")
assert response.status_code == 401

response = await test_client.post("/messages/")
assert response.status_code == 401, response.content

response = await test_client.post(
"/messages/",
headers={"Authorization": "invalid"},
)
assert response.status_code == 401

response = await test_client.post(
"/messages/",
headers={"Authorization": "Bearer invalid"},
)
assert response.status_code == 401

# now, become authenticated and try to go through the flow again
client_metadata = {
"redirect_uris": ["https://client.example.com/callback"],
"client_name": "Test Client",
}

response = await test_client.post(
"/register",
json=client_metadata,
)
assert response.status_code == 201
client_info = response.json()

# Request authorization using POST with form-encoded data
response = await test_client.post(
"/authorize",
data={
"response_type": "code",
"client_id": client_info["client_id"],
"redirect_uri": "https://client.example.com/callback",
"code_challenge": pkce_challenge["code_challenge"],
"code_challenge_method": "S256",
"state": "test_state",
},
)
assert response.status_code == 302

# Extract the authorization code from the redirect URL
redirect_url = response.headers["location"]
parsed_url = urlparse(redirect_url)
query_params = parse_qs(parsed_url.query)

assert "code" in query_params
auth_code = query_params["code"][0]

# Exchange the authorization code for tokens
response = await test_client.post(
"/token",
data={
"grant_type": "authorization_code",
"client_id": client_info["client_id"],
"client_secret": client_info["client_secret"],
"code": auth_code,
"code_verifier": pkce_challenge["code_verifier"],
"redirect_uri": "https://client.example.com/callback",
},
)
assert response.status_code == 200

token_response = response.json()
assert "access_token" in token_response
authorization = f"Bearer {token_response['access_token']}"

# Test the authenticated endpoint with valid token
async with aconnect_sse(
test_client, "GET", "/sse", headers={"Authorization": authorization}
) as event_source:
assert event_source.response.status_code == 200
events = event_source.aiter_sse()
sse = await events.__anext__()
assert sse.event == "endpoint"
assert sse.data.startswith("/messages/?session_id=")
messages_uri = sse.data

# verify that we can now post to the /messages endpoint,
# and get a response on the /sse endpoint
response = await test_client.post(
messages_uri,
headers={"Authorization": authorization},
content=JSONRPCRequest(
jsonrpc="2.0",
id="123",
method="initialize",
params={
"protocolVersion": "2024-11-05",
"capabilities": {
"roots": {"listChanged": True},
"sampling": {},
},
"clientInfo": {"name": "ExampleClient", "version": "1.0.0"},
},
).model_dump_json(),
)
assert response.status_code == 202
assert response.content == b"Accepted"

sse = await events.__anext__()
assert sse.event == "message"
sse_data = json.loads(sse.data)
assert sse_data["id"] == "123"
assert set(sse_data["result"]["capabilities"].keys()) == {
"experimental",
"prompts",
"resources",
"tools",
}
# the /sse endpoint will never finish; normally, the client could just
# disconnect, but in tests the easiest way to do this is to cancel the
# task group
task_group.cancel_scope.cancel()


class TestAuthorizeEndpointErrors:
"""Test error handling in the OAuth authorization endpoint."""

Expand Down
Loading
Loading