Skip to content

Add support for serverside oauth - #255

Merged
ihrpr merged 89 commits into
mainfrom
praboud/auth
May 1, 2025
Merged

Add support for serverside oauth#255
ihrpr merged 89 commits into
mainfrom
praboud/auth

Conversation

@praboud-ant

@praboud-antpraboud-ant commented Mar 10, 2025

Copy link
Copy Markdown
Contributor

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

If review wanted, happy to review it.

@dsp-ant

Copy link
Copy Markdown
Member

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

Comment threadsrc/mcp/server/auth/errors.py Outdated
Comment threadsrc/mcp/server/auth/errors.py Outdated
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
Copy Markdown
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
Copy Markdown
Member

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

Comment threadsrc/mcp/server/auth/handlers/authorize.py Outdated
redirect_uri=redirect_uri,
)

response = RedirectResponse(url="", status_code=302, headers={"Cache-Control": "no-store"})

Copy link
Copy Markdown
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 threadsrc/mcp/server/auth/handlers/register.py Outdated
Comment threadsrc/mcp/server/auth/handlers/register.py Outdated
dsp-ant
dsp-ant previously requested changes Mar 11, 2025
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
Copy Markdown
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"notinconn.headers:
returnNone
auth_header=conn.headers["Authorization"]
ifnotauth_header.startswith("Bearer "):
returnNone
token=auth_header[7:] # Remove "Bearer " prefix
auth_header=conn.headers.get("Authorization")
ifnotauth_headerornotauth_header.startswith("Bearer "):
returnNone
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
Copy Markdown
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.

Comment threadsrc/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

Copy link
Copy Markdown
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
Copy Markdown
ContributorAuthor

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?

Comment threadsrc/mcp/shared/auth.py Outdated
"""
access_token: str
token_type: str
expires_in: Optional[int] = None

Copy link
Copy Markdown
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.

Comment threadsrc/mcp/shared/auth.py Outdated
Corresponds to OAuthTokensSchema in src/shared/auth.ts
"""
access_token: str
token_type: str

Copy link
Copy Markdown
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
Copy Markdown
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.

@jerome3o-anthropicjerome3o-anthropic left a comment

Copy link
Copy Markdown
Member

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

Comment threadsrc/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") No newline at end of file

Copy link
Copy Markdown
Member

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
Copy Markdown
ContributorAuthor

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

Comment threadsrc/mcp/server/auth/errors.py Outdated
Comment threadsrc/mcp/server/auth/errors.py Outdated
@praboud-antpraboud-ant mentioned this pull request Mar 11, 2025
9 tasks
@Kludex

Kludex commented Mar 15, 2025

Copy link
Copy Markdown
Member

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

@bhosmer-antbhosmer-ant left a comment

Copy link
Copy Markdown
Contributor

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)

Comment threadsrc/mcp/server/sse.py Outdated
)

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

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

)


class TestFastMCPWithAuth:

Copy link
Copy Markdown
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
Copy Markdown
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:

@ihrprihrpr mentioned this pull request May 1, 2025

@jerome3o-anthropicjerome3o-anthropic left a comment

Copy link
Copy Markdown
Member

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!

Comment threadREADME.md
providing an implementation of the `OAuthServerProvider` protocol.

```
mcp = FastMCP("My App",

Copy link
Copy Markdown
Member

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
Copy Markdown
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-antMay 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-antMay 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 mainMay 1, 2025
@ihrpr
ihrpr deleted the praboud/auth branch May 1, 2025 18:43
@johnandersen777johnandersen777 mentioned this pull request May 1, 2025
Sign up for freeto 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

@praboud-ant@Kludex@dsp-ant@regismesquita@ihrpr@jerome3o-anthropic@bhosmer-ant