Skip to content

all: client side OAuth support#785

Open
maciej-kisiel wants to merge 26 commits intomainfrom
mkisiel/client-auth
Open

all: client side OAuth support#785
maciej-kisiel wants to merge 26 commits intomainfrom
mkisiel/client-auth

Conversation

@maciej-kisiel
Copy link
Contributor

@maciej-kisiel maciej-kisiel commented Feb 10, 2026

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:

  • Please ignore any log lines that were introduced for development.
  • Please restrain from providing code style feedback at this moment.
  • Commentary for the API is not finalized yet and will be improved.
  • Unit tests are largely missing.

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 main mcp package purposefully delegates almost all auth handling to OAuthHandler implementations. This way, the mcp package continues to be largely OAuth agnostic.

The auth package comes with a default OAuthHandler implementation – AuthorizationCodeOAuthHandler, which implements the authorization flow for the authorization_code grant type, as defined in the MCP specification. It is expected that the OAuthHandler implementation 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:

type OAuthHandler interface {
	// TokenSource returns a token source to be used for outgoing requests.
	TokenSource(context.Context) (oauth2.TokenSource, error)

	// Authorize is called when an HTTP request results in an error that may
	// be addressed by the authorization flow (currently 401 Unauthorized and 403 Forbidden).
	// It is responsible for initiating the OAuth flow to obtain a token source.
	// The arguments are the request that failed and the response that was received for it.
	// 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.
	// The function is responsible for closing the response body.
	Authorize(context.Context, *http.Request, *http.Response) error
}

StreamableClientTransport accepts this interface as a field and uses the TokenSource method to add an Authorization: Bearer header to outgoing requests to the MCP server. If the request fails with 401: Unauthorized or 403: Forbidden, the transport calls Authorize to perform the authorization flow. If Authorize returns a non-nil error, the request is retried once. Authorize errors 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 OAuthHandler type defined as part of a previous iteration of OAuth support. It was renamed to OAuthHandlerLegacy. This is the only backwards incompatible API change in the experimental client auth part (protected by the mcp_go_client_oauth Go build tag). The cost of replacing previous usages is deemed worthwhile to maintain clear naming, under the assumption that the experimental client auth part was not widely used.

AuthorizationCodeOAuthHandler (auth/authorization_code.go)

This is the OAuthHandler implementation that fulfills the MCP specification. In particular, in supports:

  • Protected Resource Metadata discovery and fetching
  • Authorization Server Metadata discovery and fetching
  • Client registration
    • OpenID Connect metadata document
    • Pre-registration
    • Dynamic Client Registration
  • PKCE
  • Providing custom state field generators
  • none, client_secret_post, client_secret_basic token endpoint auth methods
  • Automatic refresh of tokens
  • Persisting tokens in a provided storage implementation

Authorization Server redirect

As part of the flow for the authorization_code grant 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:

type AuthorizationCodeOAuthHandler struct {
	...

	// AuthorizationURLHandler is called to handle the authorization URL.
	// It is responsible for opening the URL in a browser.
	// It should return once the redirect has been issued.
	// The redirect callback should be handled by the caller and the authorization code
	// should be set by calling [SetAuthorizationCode] before retrying the request.
	AuthorizationURLHandler func(ctx context.Context, authorizationURL string) error

	...
}

The function is supposed as soon as the user was pointed to the authorization URL, after which Authorize will finish with auth.ErrRedirected. The application is expected to wait for the callback and provide the required authorization code and state values to the AuthorizationCodeOAuthHandler via FinalizeAuthorization call. After that, the client call (or connection) can be retried.

Token storage

AuthorizationCodeOAuthHandler includes a SetTokenSource method 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 initiated Authorize flow succeeds. Optionally, a TokenStore implementation can be provided to the handler.

// TokenStore is an interface than can be used by OAuthHandler implementations
// to save tokens to a persistent storage.
type TokenStore interface {
	Save(context.Context, *oauth2.Token) error
}

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.TokenSource utility function.

oauthex changes

Some additional/adjusted building blocks that may be useful when creating OAuthHandler implementations are present in the oauthex package:

  • auth_meta.go: Authorization Server Metadata utilities:
    • GetAuthServerMeta: get Authorization Server Metadata from a URL
    • AuthorizationServerMetadataURLs: generate ASM URL candidates based on MCP specification
  • dcr.go: Dynamic Client Registration utilities (unchanged)
  • resource_meta.go: Protected Resource Metadata utilities:
    • GetProtectedResourceMetadata: get Protected Resource Metadata from a URL
    • ProtectedResourceMetadataURLs: generate PRM URL candidates based on MCP specification
    • ResourceMetadataURL, Scopes, Error – helpers to retrieve fields from the WWW-Authenticate challenges
    • ParseWWWAuthenticate – as per name (unchanged)
    • [deprecated] GetProtectedResourceMetadataFromID and GetProtectedResourceMetadataFromHeader in favor of more generic GetProtectedResourceMetadata. 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:

  • Share thoughts on the following API areas:
    • Expected handler granularity: is the assumption that they will likely follow grant types correct?
    • Callback mechanism: returning ErrRedirected and 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 from Authorize and 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 a oauth2.Config object. Deriving the values to populate it is a significant part of the AuthorizationCodeOAuthHandler implementation and we can consider exposing some mechanisms to not require the users to replicate this logic.
    • Exposed extension points for AuthorizationCodeOAuthHandler: TokenSource and StateProvider – 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.
    • Error handling: Generally we assume that Unauthorized errors 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.
    • Naming: feedback always welcome.
    • Specification (both OAuth and MCP) non-compliance – I have tried to do everything by the book, but might have missed something.
  • Share your authorization use cases and whether they fit this design.
  • 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:

  • Switching to an appropriate alternative to the mcp_go_client_oauth build tag to make CI pass
  • Create an example OAuth client under examples/client (dev AS server would come in handy)
  • Adjust documentation
  • Add unit tests
  • Cleanup

Thank you!

@maciej-kisiel maciej-kisiel force-pushed the mkisiel/client-auth branch 5 times, most recently from d40d41f to ed54d46 Compare February 11, 2026 14:29
@maciej-kisiel
Copy link
Contributor Author

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.

@maciej-kisiel
Copy link
Contributor Author

cc @findleyr @jba @herczyn

@findleyr
Copy link
Contributor

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.

@maciej-kisiel maciej-kisiel requested a review from jba February 18, 2026 14:24
@maciej-kisiel maciej-kisiel force-pushed the mkisiel/client-auth branch 2 times, most recently from bd9ce4a to 3a9cbd0 Compare February 20, 2026 13:34
@jba
Copy link
Contributor

jba commented Feb 24, 2026

There should be a way for both flows to co-exist: one that returns control with an error, and one that does everything in a single call.

It looks like you already have this.

Do you suggest we should adjust AuthorizationURLHandler signature to be able to return the authorization code (and other required data) directly to allow immediate continuation? And if the developers would like to let the control to go back to the application, they could return an error of their choice and check for it in SDK calls? ErrRedirected could probably be removed in that case, the developers could define something similar themselves if needed.

I was referring to this code in streamable.go:

if err := c.oauthHandler.Authorize(ctx, req, resp); err != nil {
			// Wrap with ErrRejected so the jsonrpc2 connection doesn't set writeErr
			// and permanently break the connection.
			// Wrap the authorization error as well for client inspection.
			return fmt.Errorf("%s: %w: %w", requestSummary, jsonrpc2.ErrRejected, err)
		}
		// Retry the request after successful authorization.
		resp, err = doRequest()

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.

@jba
Copy link
Contributor

jba commented Feb 24, 2026

  • Renaming AuthorizationCodeOAuthHandler to AuthorizationCodeHandler (that would be consistent with the current name for the config struct, doing it the other way would result in unbearably long name)
  • Removing the StateProvider argument until users confirm the need and request it.

Let me know if anyone has any thoughts on these.

Both fine. In general, remove anything not absolutely necessary.

@jba
Copy link
Contributor

jba commented Feb 24, 2026

I think OAuthHandler.Authorize should block until authorization has completed. Perhaps this leaves an additional goroutine running for the duration of authorization, but goroutines are cheap.

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.

@jba
Copy link
Contributor

jba commented Feb 24, 2026

Returning ErrRedirected from client.Connect means that the client user needs to not only retry the error, but must also synchronize with the authorization flow to identify when it has completed. This is necessary because the OAuthHandler interface provides a way to start authorization, but no way to tell when authorization has completed.

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.

@neild
Copy link

neild commented Feb 24, 2026

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.

In the client example, there are the following layers (at least):

  • Top-level user code which calls client.Connect.
  • mcp.StreamableClientTransport, which calls StreamableClientTransport.OAuthHandler.Authorize.
  • auth.AuthorizationCodeHandler, which calls AuthorizationCodeHandlerConfig.AuthorizationURLHandler.
  • The AuthorizationURLHandler, which does something to obtain an authorization code.

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

How does this goroutine terminate? (How do we avoid leaking it?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, that's fine, I just missed it.

@jba
Copy link
Contributor

jba commented Feb 25, 2026

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.

@maciej-kisiel maciej-kisiel changed the title all: WIP client side OAuth support all: client side OAuth support Feb 26, 2026
@maciej-kisiel maciej-kisiel marked this pull request as ready for review February 26, 2026 12:20
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.
Copy link
Contributor

Choose a reason for hiding this comment

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

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..."

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did the replacement. I'm less sure what exactly you would like to see in this additional paragraph though.

Copy link
Contributor

Choose a reason for hiding this comment

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

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

Choose a reason for hiding this comment

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

Yes, that's fine, I just missed it.

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.

4 participants