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

Add support for serverside oauth #255

merged 89 commits into from
May 1, 2025

Conversation

praboud-ant
Copy link
Contributor

@praboud-ant praboud-ant commented Mar 10, 2025

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:

image

Breaking Changes

No - this is strictly additive.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

@Kludex
Copy link
Member

Kludex commented Mar 10, 2025

If review wanted, happy to review it.

@dsp-ant
Copy link
Member

dsp-ant commented Mar 11, 2025

@Kludex its work in progress as far as I undersatnd but feel free to take a review pass.

Comment on lines 46 to 65
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
Copy link
Member

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.

Copy link
Contributor

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"})
Copy link
Member

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?

Comment on lines 43 to 41
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
Copy link
Member

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

Suggested change
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()):
Copy link
Member

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.

client_id: str
client_secret: Optional[str] = None
client_id_issued_at: Optional[int] = None
client_secret_expires_at: Optional[int] = None
Copy link
Member

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

Copy link
Contributor Author

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?

"""
access_token: str
token_type: str
expires_in: Optional[int] = None
Copy link
Member

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.

Corresponds to OAuthTokensSchema in src/shared/auth.ts
"""
access_token: str
token_type: str
Copy link
Member

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
Copy link
Member

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.

Copy link
Contributor

@jerome3o-anthropic jerome3o-anthropic left a 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


class PydanticJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return content.model_dump_json(exclude_none=True).encode("utf-8")
Copy link
Contributor

Choose a reason for hiding this comment

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

What is this for?

Copy link
Contributor Author

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

@praboud-ant praboud-ant mentioned this pull request Mar 11, 2025
9 tasks
@Kludex
Copy link
Member

Kludex commented Mar 15, 2025

The auth part on the client should be implemented using the httpx.Auth class.

Copy link
Contributor

@bhosmer-ant bhosmer-ant left a 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)

@@ -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

@@ -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:

@ihrpr ihrpr mentioned this pull request May 1, 2025
Copy link
Contributor

@jerome3o-anthropic jerome3o-anthropic left a 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",
Copy link
Contributor

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

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 that's something we can add to the example

@ihrpr ihrpr enabled auto-merge (squash) May 1, 2025 18:33
@ihrpr ihrpr removed request for Kludex and dsp-ant May 1, 2025 18:34
@ihrpr ihrpr disabled auto-merge May 1, 2025 18:35
@ihrpr ihrpr requested review from Kludex and dsp-ant and removed request for Kludex and dsp-ant May 1, 2025 18:38
@dsp-ant dsp-ant dismissed stale reviews from Kludex and themself May 1, 2025 18:41

done

@ihrpr ihrpr merged commit 2210c1b into main May 1, 2025
10 checks passed
@ihrpr ihrpr deleted the praboud/auth branch May 1, 2025 18:43
@johnandersen777 johnandersen777 mentioned this pull request May 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants