-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
Conversation
If review wanted, happy to review it. |
@Kludex its work in progress as far as I undersatnd but feel free to take a review pass. |
def validate_scope(requested_scope: str | None, client: OAuthClientInformationFull) -> list[str] | None: | ||
if requested_scope is None: | ||
return None | ||
requested_scopes = requested_scope.split(" ") | ||
allowed_scopes = [] if client.scope is None else client.scope.split(" ") | ||
for scope in requested_scopes: | ||
if scope not in allowed_scopes: | ||
raise InvalidRequestError(f"Client was not registered with scope {scope}") | ||
return requested_scopes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like a method on OAuthClientInformationFull or the underlying scope model, given we exclusively operate on client.scope here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[opinion] after hacking around in here, I actually think we should be thinking about OAuthClientInformationFull
as a type similar to the ones in mcp.types, and it would be odd to add methods there
redirect_uri=redirect_uri, | ||
) | ||
|
||
response = RedirectResponse(url="", status_code=302, headers={"Cache-Control": "no-store"}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should probalby not redirect without a URI?
if "Authorization" not in conn.headers: | ||
return None | ||
|
||
auth_header = conn.headers["Authorization"] | ||
if not auth_header.startswith("Bearer "): | ||
return None | ||
|
||
token = auth_header[7:] # Remove "Bearer " prefix |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this can be simplified by
if "Authorization" not in conn.headers: | |
return None | |
auth_header = conn.headers["Authorization"] | |
if not auth_header.startswith("Bearer "): | |
return None | |
token = auth_header[7:] # Remove "Bearer " prefix | |
auth_header = conn.headers.get("Authorization") | |
if not auth_header or not auth_header.startswith("Bearer "): | |
return None | |
token = auth_header[7:] # Remove "Bearer " prefix |
# Validate the token with the provider | ||
auth_info = await self.provider.verify_access_token(token) | ||
|
||
if auth_info.expires_at and auth_info.expires_at < int(time.time()): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should use datetime comparisions and not rely on timestamps.
src/mcp/shared/auth.py
Outdated
client_id: str | ||
client_secret: Optional[str] = None | ||
client_id_issued_at: Optional[int] = None | ||
client_secret_expires_at: Optional[int] = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably should be parsed as a Timedelta
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A timedelta
, or a datetime
? Don't we want an absolute time here?
src/mcp/shared/auth.py
Outdated
""" | ||
access_token: str | ||
token_type: str | ||
expires_in: Optional[int] = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also here, probably watn to parse as a timedelta.
src/mcp/shared/auth.py
Outdated
Corresponds to OAuthTokensSchema in src/shared/auth.ts | ||
""" | ||
access_token: str | ||
token_type: str |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we enumerate the potential types here to ensure pydnatic validation?
from httpx._transports.base import AsyncBaseTransport | ||
from httpx._models import Request, Response | ||
from httpx._types import AsyncByteStream | ||
import asyncio |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we don't use asyncio for testing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Working through the changes now
src/mcp/server/auth/json_response.py
Outdated
|
||
class PydanticJSONResponse(JSONResponse): | ||
def render(self, content: Any) -> bytes: | ||
return content.model_dump_json(exclude_none=True).encode("utf-8") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lemme add a comment - this uses Pydantic's JSON serialization, since the stock json
doesn't know how to serialize eg: AnyHttpUrl
65db7b6
to
5e7a0bf
Compare
The auth part on the client should be implemented using the httpx.Auth class. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New changes LGTM (and things look relatively undisturbed from the last time it was looked over in some depth) - based on that, tests passing, and your e2e check passing, I'm good with landing it. (would ofc benefit from @jerome3o-anthropic also looking before you push the button, mod available time)
src/mcp/server/sse.py
Outdated
@@ -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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>"
There was a problem hiding this comment.
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
@@ -993,165 +987,6 @@ async def test_client_registration_invalid_grant_type( | |||
) | |||
|
|||
|
|||
class TestFastMCPWithAuth: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for pushing this over the line @ihrpr - it's awesome to see it
I've looked over this, not with a fine-toothed comb but for the general shapes, and it LGTM!
providing an implementation of the `OAuthServerProvider` protocol. | ||
|
||
``` | ||
mcp = FastMCP("My App", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[non blocking] wonder if its worth noting that this wont scale beyond one instance
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that's something we can add to the example
Motivation and Context
Implements https://spec.modelcontextprotocol.io/specification/draft/basic/authorization/ from the specification.
How Has This Been Tested?
I've added some integration tests as part of this branch. I've also implemented a new MCP server (outside of this repo), and integrated it against the new parts of the the SDK, to play around with the interface a bit and see how it feels.
Tested with inspector:
Breaking Changes
No - this is strictly additive.
Types of changes
Checklist
Additional context