Skip to content

Comments

all: WIP client side OAuth support#785

Draft
maciej-kisiel wants to merge 9 commits intomainfrom
mkisiel/client-auth
Draft

all: WIP client side OAuth support#785
maciej-kisiel wants to merge 9 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

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

h.tokenSource = ts
}

func (h *AuthorizationCodeOAuthHandler) FinalizeAuthorization(code, state string) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

ditto

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Developers are required to call this function to finalize the flow after they get the authorization code from the redirect. More details about the flow are present in the PR description.

@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
@maciej-kisiel
Copy link
Contributor Author

I have added an end-to-end example that uses the new APIs to showcase authorization. It's under examples/auth, there's both a client and a server binary. The examples require access to a configured Authorization Server.

I ran the example with a dev instance of the https://www.keycloak.org/ server. I was able to test a pre-registered client and dynamic client registration, as the server doesn't yet support client ID metadata documents.

I would like to highlight my feedback request about the design of the part where an authorization URL is returned to the user. In this flow, the authorization server after getting user's consent, redirects to an URL specified by the client (likely a local URL) and this request contains the authorization code that our handler is able to exchange for a token.

Currently this process happens with a return of control to the application: the SDK request fails with auth.ErrRedirected when it was initiated. The SDK request should be retried by the application after calling FinalizeAuthorization on the handler with the received authorization code. As you can see in the client example, this logic is not super easy to handle and calling client.Connect in a loop (or retrying in an if) is not elegant. I also worry about the UX during the "scope step-up" scenario. If application expects it, basically every relevant MCP call would need to be wrapped with such a loop. I don't know how widely this functionality is used though.

As mentioned before, the alternative could be to not return control to the caller and adjust AuthorizationURLHandler to be something like func(ctx context.Context, authorizationURL string) (AuthResult, error) and assume it will block for a longer time.

@jba
Copy link
Contributor

jba commented Feb 20, 2026

Thanks for testing it for real! keycloak.org was a good find.

I believe if you look at the TS or C# examples, they start a small local webserver that the auth server redirects to. That gets the code which can then be exchanged for a token, and then Authorize can return with a token.

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.

@jba
Copy link
Contributor

jba commented Feb 20, 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.

@IAmSurajBobade
Copy link
Contributor

IAmSurajBobade commented Feb 20, 2026

I ran the example with a dev instance of the https://www.keycloak.org/ server. I was able to test a pre-registered client and dynamic client registration, as the server doesn't yet support client ID metadata documents.

Hi @maciej-kisiel, I tried connecting example server with keycloak dev instance, it works from MCP inspector with connection type 'via Proxy', but does not when changed to 'direct'. From server logs I observed following -

Error in 'direct' connection:
Screenshot 2026-02-20 at 23 24 44

difference in logs:
for direct:

2026/02/20 23:19:29 [REQUEST] Session: 4M6T6ZMYWHGMIBBRRCAG5JHNLN | Method: initialize
2026/02/20 23:19:29 [RESPONSE] Session: 4M6T6ZMYWHGMIBBRRCAG5JHNLN | Method: initialize | Status: OK | Duration: 66.292µs

for 'via proxy':

2026/02/20 23:19:41 [REQUEST] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: initialize
2026/02/20 23:19:41 [RESPONSE] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: initialize | Status: OK | Duration: 38.959µs
2026/02/20 23:19:42 [REQUEST] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: notifications/initialized
2026/02/20 23:19:42 [RESPONSE] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: notifications/initialized | Status: OK | Duration: 304.333µs
2026/02/20 23:19:42 [REQUEST] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: logging/setLevel
2026/02/20 23:19:42 [RESPONSE] Session: HGSX5NQEKKAUMKORDVKYRKYUUP | Method: logging/setLevel | Status: OK | Duration: 36.625µs

Apart from missing call to notifications/initialized, I did not see anything different. Is this expected behavior?

@maciej-kisiel
Copy link
Contributor Author

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.

@neild
Copy link

neild commented Feb 20, 2026

As mentioned before, the alternative could be to not return control to the caller and adjust AuthorizationURLHandler to be something like func(ctx context.Context, authorizationURL string) (AuthResult, error) and assume it will block for a longer time.

I read over the proposed API and example client before reading this comment, and this was going to be my top suggestion: The current control flow is quite complicated, and could be simplified by making some operations blocking instead of callback-based.

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

Similarly, AuthorizationCodeOAuthHandler.AuthorizationURLHandler probably should block and return the code rather than returning it asynchronously. (But see the next section below.)


AuthorizationCodeOAuthHandler.RedirectURL seems like it might be a problem to me: This must be set before AuthorizationURLHandler is called (because the redirect URL is part of the authorization URL), but the example client does not start the localhost HTTP server until AuthorizationURLHandler is called. This makes it impossible for the server to listen on a non-well-known port (notably, it can't listen on localhost:0) unless the listener is preemptively created even when the authorization flow does not need to execute.

Perhaps there should be another type here, something along the lines of:

type AuthorizationHandler interface {
  // RedirectURL is the URL to redirect to after authorization.
  RedirectURL() string

  // Authorize handles the authorization URL.
  Authorize(ctx context.Context, authorizationURL string) (code, state string, err error)

  // Stop shuts down the localhost server (if one was started).
  Stop()
}

type AuthorizationCodeOAuthHandler struct {
  // NewAuthorizationHandler replaces RedirectURL and AuthorizationURLHandler.
  NewAuthorizationHandler func() (AuthorizationHandler, error)
}

Are most implementations of AuthorizationURLHandler going to use a localhost HTTP server? If so, it seems like we should provide a reasonable default implementation rather than requiring everyone to reimplement the same rather-subtle behavior.

A few subtleties:

The example client doesn't handle multiple visits to the localhost HTTP server. You can wedge the server by quickly sending it two requests, one of which will block on sending to r.authChan.

If multiple clients copy the same example code, they'll all listen on the same port (localhost:3142) and potentially conflict with each other. I don't know if this is a problem in practice.

The localhost server is started in a new goroutine, and there's a theoretical race condition where it hasn't started by the time the user visits it. (This seems fairly unlikely in practice.) This could be avoided by creating a net.Listener and then starting the server in a new goroutine with server.Serve instead of ListenAndServe.

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.

5 participants