Conversation
d40d41f to
ed54d46
Compare
ed54d46 to
f2e2ddb
Compare
|
Hi all, this proposal tries to address a long-standing gap in the SDK, which is client-side OAuth. I'm sure many people have worked around it and thus have an understanding what of the flow that will allow them to assess if this proposal would address their needs. Please review and provide your feedback! I will go through OAuth related issues and tag people that have been active there. Sorry for the noise if you're not interested. |
|
The shape of this looks right to me, but I really have to defer to @jba, who has thought about this much more than me. |
e2bec4e to
21bf098
Compare
bd9ce4a to
3a9cbd0
Compare
e94f685 to
57e5b2d
Compare
3cd3246 to
86cdd9f
Compare
I was referring to this code in streamable.go: If Authorize returns an error, you return the error all the way up, and the caller has to deal with it and retry. But if it doesn't return an error, you proceed, which calls handler.TokenSource. So Authorize can do the full round-trip. |
Both fine. In general, remove anything not absolutely necessary. |
I'm not sure this is always possible. The MCP client might not be able to handle the redirect URL itself (in fact I think that will be the norm). It must rely on the user's browser, or in general on its caller. We've all seen the CLI interfaces where you have to copy a URL into your browser, then copy the resulting text back to the prompt. That is consistent with Authorize blocking. But what if the MCP client is embedded in a context where it can't (or doesn't know if it can) issue that prompt? I believe that's why there has to be a way for the MCP client to return control to its caller, so the caller can do whatever it needs to in order to obtain a token, and then restart the client connection from the beginning. |
I'm not sure I understand the problem. It's true that the client user has to know when the auth flow is completed. But that is not the job of anything in the MCP client. By returning ErrRedirected, we're basically giving up. The state of the current OAuthHandler is no longer relevant. The idea is that the client user handles the rest of the auth flow, handling the redirect URL and obtaining a token, and then calls client.Connect with a different OAuthHandler, a trivial one that just implements TokenSource to hand back the token. Or maybe AuthorizationCodeHandler has a SetToken method, so it can be reused. I'm fine with that if it simplifies the coding. |
4101914 to
d6810f1
Compare
d6810f1 to
69a0853
Compare
In the client example, there are the following layers (at least):
In the previous design which I reviewed, the authorization flow happened in both the top and bottom level: The AuthorizationURLHandler starts the authorization process, an ErrRedirected error bubbles up to the user, and the user is now responsible for figuring out when the authorization process completes. Which parts of this are "the caller"? If the AuthorizationURLHandler is "the caller", then the updated design (where the authorization flow happens entirely within the AuthorizationURLHandler) still does exactly what you say--control is passed to the caller's AuthorizationURLHandler. If the AuthorizationURLHandler is not the caller, then this was a layering violation in the prior design (with ErrRedirected), since the top-level user needs to interact with the AuthorizationURLHandler before retrying the connection. An alternative design might be to drop AuthorizationURLHandler entirely, and return the authorization URL to the caller. Something along the lines of: for {
session, err := client.Connect(ctx, transport, nil)
if err == nil {
break
}
e, ok := err.(*auth.ErrRedirected)
if !ok {
return err // some fatal error
}
authorizeUsing(e.AuthorizationURL)
// retry
}...but that doesn't seem like an improvement on the AuthorizationURLHandler callback to me. |
| if err != nil { | ||
| log.Fatalf("failed to listen: %v", err) | ||
| } | ||
| go receiver.serveRedirectHandler(listener) |
There was a problem hiding this comment.
How does this goroutine terminate? (How do we avoid leaking it?)
There was a problem hiding this comment.
It runs for the whole lifecycle of the program and is terminated by the defer call in the next line. I thought this will be simpler in an example rather than trying to create a temporary server.
There was a problem hiding this comment.
Yes, that's fine, I just missed it.
|
In the current design, everything happens in the AuthorizationURLHandler, which blocks. I now believe that can handle any kind of control flow, even the ones I described as non-blocking, by a suitable use of goroutines and channels. There is no specially defined error in case someone really wants to retry connecting from the top, but also nothing prevents one from defining such an error and returning it from AuthorizationURLHandler. So I think we're at a place where (1) the API is pretty minimal, (2) it conveniently supports some common control flows, and (3) it's general enough to support other control flows. I think that's as good as we're going to get at this point, before we have real-world users. |
bda491c to
707173d
Compare
auth/client.go
Outdated
| // Currently the body of the passed request is consumed by the transport | ||
| // before [Authorize] is called. Please file an issue if you need the body to be available. | ||
| // If the returned error is nil, [TokenSource] is expected to return a non-nil token source. | ||
| // After a successful call to [Authorize], the HTTP request should be retried by the transport. |
There was a problem hiding this comment.
s/should/will/
I think there should be a paragraph in the OAuthHandler doc (last paragraph) that outlines the responsibilities of transports. "If a transport wishes to support OAuth 2 authentication, it should..."
There was a problem hiding this comment.
I did the replacement. I'm less sure what exactly you would like to see in this additional paragraph though.
There was a problem hiding this comment.
"If a transport wishes to support OAuth 2 authentication, it should support being configured with an OAuthHandler. It should call the handler's TokenSource method whenever it sends an HTTP request to set the Authorization header. If a request fails with a 401 or 403, it should call Authorize, and if that returns nil, it should retry the request. It should not call Authorize after the second failure. See StreamableClientTransport int the mcp package for an example."
| if err != nil { | ||
| log.Fatalf("failed to listen: %v", err) | ||
| } | ||
| go receiver.serveRedirectHandler(listener) |
There was a problem hiding this comment.
Yes, that's fine, I just missed it.
This PR introduces a proposal for how client-side OAuth support could look like in the SDK. It is open for community feedback.
The main goal of the PR is to validate the proposed APIs. The code may not be polished yet. In particular:
Overall design philosophy
The main new interface introduced to support client-side OAuth is the
OAuthHandler. Contrary to its counter-parts in some other SDKs, it is intentionally small and generic so that it can support most of the OAuth authorization flow variants. The mainmcppackage purposefully delegates almost all auth handling toOAuthHandlerimplementations. This way, themcppackage continues to be largely OAuth agnostic.The
authpackage comes with a defaultOAuthHandlerimplementation –AuthorizationCodeOAuthHandler, which implements the authorization flow for theauthorization_codegrant type, as defined in the MCP specification. It is expected that theOAuthHandlerimplementation granularity will be roughly per grant type, as this parameter influences the authorization flow the most.Detailed design
OAuthHandler(auth/client.go)The main interface for the integration consists of two methods:
StreamableClientTransportaccepts this interface as a field and uses theTokenSourcemethod to add anAuthorization: Bearerheader to outgoing requests to the MCP server. If the request fails with401: Unauthorizedor403: Forbidden, the transport callsAuthorizeto perform the authorization flow. IfAuthorizereturns a non-nil error, the request is retried once.Authorizeerrors do not result in termination of the client session (unless they happen during connection), as some OAuth flows are multi-legged and it should be acceptable to retry for example when the authorization grant was received.Note: There was already an
OAuthHandlertype defined as part of a previous iteration of OAuth support. It was renamed toOAuthHandlerLegacy. This is the only backwards incompatible API change in the experimental clientauthpart (protected by themcp_go_client_oauthGo build tag). The cost of replacing previous usages is deemed worthwhile to maintain clear naming, under the assumption that the experimental clientauthpart was not widely used.AuthorizationCodeOAuthHandler(auth/authorization_code.go)This is the
OAuthHandlerimplementation that fulfills the MCP specification. In particular, in supports:statefield generatorsnone,client_secret_post,client_secret_basictoken endpoint auth methodsAuthorization Server redirect
As part of the flow for the
authorization_codegrant type, the client needs to point the user to the authorization URL, so that they confirm the access request. This design proposes that it will be supported via a pluggable dependency that will initiate this process in an application-specific way:The function is supposed as soon as the user was pointed to the authorization URL, after which
Authorizewill finish withauth.ErrRedirected. The application is expected to wait for the callback and provide the required authorization code and state values to theAuthorizationCodeOAuthHandlerviaFinalizeAuthorizationcall. After that, the client call (or connection) can be retried.Token storage
AuthorizationCodeOAuthHandlerincludes aSetTokenSourcemethod that allows the application to pre-populate the handler with tokens, for example fetched from a persistent store. The token source may be overridden by the handler if the token from the existing token source does not authorize the user and the automatically initiatedAuthorizeflow succeeds. Optionally, aTokenStoreimplementation can be provided to the handler.When provided, the token source created by the handler on a successful token exchange will save each token it returns. The same effect can be achieved in a pre-populated token source thanks to an exposed
func NewPersistentTokenSource(ctx context.Context, wrapped oauth2.TokenSource, store TokenStore) oauth2.TokenSourceutility function.oauthexchangesSome additional/adjusted building blocks that may be useful when creating
OAuthHandlerimplementations are present in theoauthexpackage:auth_meta.go: Authorization Server Metadata utilities:GetAuthServerMeta: get Authorization Server Metadata from a URLAuthorizationServerMetadataURLs: generate ASM URL candidates based on MCP specificationdcr.go: Dynamic Client Registration utilities (unchanged)resource_meta.go: Protected Resource Metadata utilities:GetProtectedResourceMetadata: get Protected Resource Metadata from a URLProtectedResourceMetadataURLs: generate PRM URL candidates based on MCP specificationResourceMetadataURL,Scopes,Error– helpers to retrieve fields from theWWW-AuthenticatechallengesParseWWWAuthenticate– as per name (unchanged)GetProtectedResourceMetadataFromIDandGetProtectedResourceMetadataFromHeaderin favor of more genericGetProtectedResourceMetadata. They will be kept under the Go build tag and removed two versions of the SDK in the future.Input requested
Please provide any feedback/suggestions on the proposed API surface you might have. In particular:
ErrRedirectedand retrying the initial call at the application level. Some alternatives could be proposed, for example making the authorization URL handler blocking and returning authorization code and state directly (likely through a channel). It would simplify the control flow (no error returned fromAuthorizeand application-level retries) and make the implementation a bit cleaner, but would come at a cost of additional goroutines being created.Persisted token's refreshes: in order to create a refreshing token source based on a stored token it's required to create aoauth2.Configobject. Deriving the values to populate it is a significant part of theAuthorizationCodeOAuthHandlerimplementation and we can consider exposing some mechanisms to not require the users to replicate this logic.Exposed extension points forAuthorizationCodeOAuthHandler:TokenSourceandStateProvider– they were added in an anticipation of being useful and because other SDKs provide it, but it would be great to validate if that's really the case.Unauthorizederrors do not close the client session (with the exception of session connection that will fail). Is this the right approach? It's unclear what should happen when getting a handler's token source fails, feedback welcome.Suggest any real Authorization Server implementations that could be easily deployed in dev mode to facilitate end-to-end testing. So far the implementation was tested only with MCP conformance tests.Further steps:
mcp_go_client_oauthbuild tag to make CI passexamples/client(dev AS server would come in handy)Thank you!