From bffb3130ea9f7fab72d4b8e62be23600713e04b0 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Tue, 2 Sep 2025 08:44:52 -0400 Subject: [PATCH 1/5] auth: client-side OAuth flow This is a preliminary implementation of OAuth 2.1 for the client. It provides an http.RoundTripper, auth.HTTPTransport, that invokes a user-provided callback of type auth.OAuthHandler. The latter is responsible for all the OAuth work. We will add code to make that easier in later PRs. Much remains to be done : Dynamic client registration is not implemented. Since it is optional, we also need another way of supplying the client ID and secret to this code. Resource Indicators, as described in section 2.5.1 of the MCP spec. And, of course, tests. We should test against fake implementations but also, if we can find any, real reference implementations. --- auth/client.go | 102 +++++++++++++++++++++++++ examples/server/auth-middleware/go.mod | 5 +- examples/server/auth-middleware/go.sum | 2 + examples/server/rate-limiting/go.mod | 5 +- examples/server/rate-limiting/go.sum | 2 + go.mod | 6 +- go.sum | 2 + internal/oauthex/resource_meta.go | 12 +-- 8 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 auth/client.go diff --git a/auth/client.go b/auth/client.go new file mode 100644 index 00000000..628b67cd --- /dev/null +++ b/auth/client.go @@ -0,0 +1,102 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +package auth + +import ( + "context" + "log" + "net/http" + "sync" + + "github.com/modelcontextprotocol/go-sdk/internal/oauthex" + "golang.org/x/oauth2" +) + +// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization +// is approved, or an error if not. +type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error) + +// OAuthHandlerArgs are arguments to an [OAuthHandler]. +type OAuthHandlerArgs struct { + // The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header. + // Empty if not present or there was an error obtaining it. + ResourceMetadataURL string +} + +// HTTPTransport is an [http.RoundTripper] that follows the MCP +// OAuth protocol when it encounters a 401 Unauthorized response. +type HTTPTransport struct { + handler OAuthHandler + mu sync.Mutex // protects opts.Base + opts HTTPTransportOptions +} + +// NewHTTPTransport returns a new [*HTTPTransport]. +// The handler is invoked when an HTTP request results in a 401 Unauthorized status. +// It is called only once per transport. Once a TokenSource is obtained, it is used +// for the lifetime of the transport; subsequent 401s are not processed. +func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) { + t := &HTTPTransport{} + if opts != nil { + t.opts = *opts + } + if t.opts.Base == nil { + t.opts.Base = http.DefaultTransport + } + return t, nil +} + +// HTTPTransportOptions are options to [NewHTTPTransport]. +type HTTPTransportOptions struct { + // Base is the [http.RoundTripper] to use. + // If nil, [http.DefaultTransport] is used. + Base http.RoundTripper +} + +func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.mu.Lock() + base := t.opts.Base + _, haveTokenSource := base.(*oauth2.Transport) + t.mu.Unlock() + + resp, err := base.RoundTrip(req) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusUnauthorized { + return resp, nil + } + if haveTokenSource { + // We failed to authorize even with a token source; give up. + return resp, nil + } + // Try to authorize. + t.mu.Lock() + // If we don't have a token source, get one by following the OAuth flow. + // (We may have obtained one while t.mu was not held above.) + if _, ok := t.opts.Base.(*oauth2.Transport); !ok { + authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")] + ts, err := t.handler(req.Context(), OAuthHandlerArgs{ + ResourceMetadataURL: extractResourceMetadataURL(authHeaders), + }) + if err != nil { + t.mu.Unlock() + return nil, err + } + t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} + } + t.mu.Unlock() + // Only one level of recursion, because we now have a token source. + return t.RoundTrip(req) +} + +func extractResourceMetadataURL(authHeaders []string) string { + cs, err := oauthex.ParseWWWAuthenticate(authHeaders) + if err != nil { + log.Printf("parsing auth headers %q: %v", authHeaders, err) + return "" + } + return oauthex.ResourceMetadataURL(cs) +} diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index cbc89c78..46f49f9c 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -1,6 +1,8 @@ module auth-middleware-example -go 1.23.0 +go 1.24.0 + +toolchain go1.24.4 require ( github.com/golang-jwt/jwt/v5 v5.2.2 @@ -10,6 +12,7 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.31.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 7b7a8e56..9d0a4841 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -6,5 +6,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index adf535b2..cb2c2d08 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -1,6 +1,8 @@ module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting -go 1.23.0 +go 1.24.0 + +toolchain go1.24.4 require ( github.com/modelcontextprotocol/go-sdk v0.3.0 @@ -10,6 +12,7 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.31.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index 92c27394..f8a7fed3 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -4,6 +4,8 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/go.mod b/go.mod index b78c25e7..f2b258d0 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/modelcontextprotocol/go-sdk -go 1.23.0 +go 1.24.0 + +toolchain go1.24.4 require ( github.com/golang-jwt/jwt/v5 v5.2.2 @@ -9,3 +11,5 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 golang.org/x/tools v0.34.0 ) + +require golang.org/x/oauth2 v0.31.0 // indirect diff --git a/go.sum b/go.sum index 2006a674..ae84ba5e 100644 --- a/go.sum +++ b/go.sum @@ -10,5 +10,7 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/internal/oauthex/resource_meta.go b/internal/oauthex/resource_meta.go index 71d52cde..2387b0b5 100644 --- a/internal/oauthex/resource_meta.go +++ b/internal/oauthex/resource_meta.go @@ -146,11 +146,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea if len(headers) == 0 { return nil, nil } - cs, err := parseWWWAuthenticate(headers) + cs, err := ParseWWWAuthenticate(headers) if err != nil { return nil, err } - url := resourceMetadataURL(cs) + url := ResourceMetadataURL(cs) if url == "" { return nil, nil } @@ -187,9 +187,9 @@ type challenge struct { Params map[string]string } -// resourceMetadataURL returns a resource metadata URL from the given challenges, +// ResourceMetadataURL returns a resource metadata URL from the given challenges, // or the empty string if there is none. -func resourceMetadataURL(cs []challenge) string { +func ResourceMetadataURL(cs []challenge) string { for _, c := range cs { if u := c.Params["resource_metadata"]; u != "" { return u @@ -198,11 +198,11 @@ func resourceMetadataURL(cs []challenge) string { return "" } -// parseWWWAuthenticate parses a WWW-Authenticate header string. +// ParseWWWAuthenticate parses a WWW-Authenticate header string. // The header format is defined in RFC 9110, Section 11.6.1, and can contain // one or more challenges, separated by commas. // It returns a slice of challenges or an error if one of the headers is malformed. -func parseWWWAuthenticate(headers []string) ([]challenge, error) { +func ParseWWWAuthenticate(headers []string) ([]challenge, error) { // GENERATED BY GEMINI 2.5 (human-tweaked) var challenges []challenge for _, h := range headers { From ddb40cea3bd05322093a33faa2e5436ebfb61de5 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Wed, 24 Sep 2025 19:33:57 -0400 Subject: [PATCH 2/5] add experiment build tag --- auth/client.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/auth/client.go b/auth/client.go index 628b67cd..534539c6 100644 --- a/auth/client.go +++ b/auth/client.go @@ -2,6 +2,8 @@ // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file. +//go:build mcp_go_client_oauth + package auth import ( From c83604c238bd03c0643a0918a48a897730a40a4b Mon Sep 17 00:00:00 2001 From: Sam Thanawalla Date: Mon, 29 Sep 2025 20:04:30 +0000 Subject: [PATCH 3/5] address comments --- auth/client.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/auth/client.go b/auth/client.go index 534539c6..d9ffac5e 100644 --- a/auth/client.go +++ b/auth/client.go @@ -8,7 +8,7 @@ package auth import ( "context" - "log" + "errors" "net/http" "sync" @@ -40,7 +40,12 @@ type HTTPTransport struct { // It is called only once per transport. Once a TokenSource is obtained, it is used // for the lifetime of the transport; subsequent 401s are not processed. func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) { - t := &HTTPTransport{} + if handler == nil { + return nil, errors.New("handler cannot be nil") + } + t := &HTTPTransport{ + handler: handler, + } if opts != nil { t.opts = *opts } @@ -60,7 +65,6 @@ type HTTPTransportOptions struct { func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { t.mu.Lock() base := t.opts.Base - _, haveTokenSource := base.(*oauth2.Transport) t.mu.Unlock() resp, err := base.RoundTrip(req) @@ -70,12 +74,15 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { if resp.StatusCode != http.StatusUnauthorized { return resp, nil } - if haveTokenSource { + if _, ok := base.(*oauth2.Transport); ok { // We failed to authorize even with a token source; give up. return resp, nil } + + resp.Body.Close() // Try to authorize. t.mu.Lock() + defer t.mu.Unlock() // If we don't have a token source, get one by following the OAuth flow. // (We may have obtained one while t.mu was not held above.) if _, ok := t.opts.Base.(*oauth2.Transport); !ok { @@ -84,20 +91,16 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { ResourceMetadataURL: extractResourceMetadataURL(authHeaders), }) if err != nil { - t.mu.Unlock() return nil, err } t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts} } - t.mu.Unlock() - // Only one level of recursion, because we now have a token source. - return t.RoundTrip(req) + return t.opts.Base.RoundTrip(req.Clone(req.Context())) } func extractResourceMetadataURL(authHeaders []string) string { cs, err := oauthex.ParseWWWAuthenticate(authHeaders) if err != nil { - log.Printf("parsing auth headers %q: %v", authHeaders, err) return "" } return oauthex.ResourceMetadataURL(cs) From 5e8bfda1a9d82d06e6ebacd4443dde36e145c8d1 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla Date: Tue, 30 Sep 2025 14:32:24 +0000 Subject: [PATCH 4/5] change go.mod back to 1.23.0 and comment lock inefficiency --- auth/client.go | 2 ++ examples/server/auth-middleware/go.mod | 5 +---- examples/server/auth-middleware/go.sum | 2 -- examples/server/rate-limiting/go.mod | 5 +---- examples/server/rate-limiting/go.sum | 2 -- go.mod | 7 ++----- go.sum | 8 ++------ 7 files changed, 8 insertions(+), 23 deletions(-) diff --git a/auth/client.go b/auth/client.go index d9ffac5e..2dda1351 100644 --- a/auth/client.go +++ b/auth/client.go @@ -85,6 +85,8 @@ func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { defer t.mu.Unlock() // If we don't have a token source, get one by following the OAuth flow. // (We may have obtained one while t.mu was not held above.) + // TODO: We hold the lock for the entire OAuth flow. This could be a long + // time. Is there a better way? if _, ok := t.opts.Base.(*oauth2.Transport); !ok { authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")] ts, err := t.handler(req.Context(), OAuthHandlerArgs{ diff --git a/examples/server/auth-middleware/go.mod b/examples/server/auth-middleware/go.mod index 46f49f9c..cbc89c78 100644 --- a/examples/server/auth-middleware/go.mod +++ b/examples/server/auth-middleware/go.mod @@ -1,8 +1,6 @@ module auth-middleware-example -go 1.24.0 - -toolchain go1.24.4 +go 1.23.0 require ( github.com/golang-jwt/jwt/v5 v5.2.2 @@ -12,7 +10,6 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.31.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/auth-middleware/go.sum b/examples/server/auth-middleware/go.sum index 9d0a4841..7b7a8e56 100644 --- a/examples/server/auth-middleware/go.sum +++ b/examples/server/auth-middleware/go.sum @@ -6,7 +6,5 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/examples/server/rate-limiting/go.mod b/examples/server/rate-limiting/go.mod index cb2c2d08..adf535b2 100644 --- a/examples/server/rate-limiting/go.mod +++ b/examples/server/rate-limiting/go.mod @@ -1,8 +1,6 @@ module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting -go 1.24.0 - -toolchain go1.24.4 +go 1.23.0 require ( github.com/modelcontextprotocol/go-sdk v0.3.0 @@ -12,7 +10,6 @@ require ( require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.31.0 // indirect ) replace github.com/modelcontextprotocol/go-sdk => ../../../ diff --git a/examples/server/rate-limiting/go.sum b/examples/server/rate-limiting/go.sum index f8a7fed3..92c27394 100644 --- a/examples/server/rate-limiting/go.sum +++ b/examples/server/rate-limiting/go.sum @@ -4,8 +4,6 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= diff --git a/go.mod b/go.mod index f2b258d0..d5917a16 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,12 @@ module github.com/modelcontextprotocol/go-sdk -go 1.24.0 - -toolchain go1.24.4 +go 1.23.0 require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/go-cmp v0.7.0 github.com/google/jsonschema-go v0.3.0 github.com/yosida95/uritemplate/v3 v3.0.2 + golang.org/x/oauth2 v0.30.0 golang.org/x/tools v0.34.0 ) - -require golang.org/x/oauth2 v0.31.0 // indirect diff --git a/go.sum b/go.sum index ae84ba5e..32ceedfe 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,11 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM= -github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371 h1:e1VCqWtKpTYBOBhPcgGV5whTlMFpTbH5Ghm56wpxBsk= -github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= From a60c60bc12512994ee8393e4fb8a037affa834b1 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla Date: Tue, 30 Sep 2025 15:12:59 +0000 Subject: [PATCH 5/5] add basic client_test --- auth/client_test.go | 102 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 auth/client_test.go diff --git a/auth/client_test.go b/auth/client_test.go new file mode 100644 index 00000000..310fc56e --- /dev/null +++ b/auth/client_test.go @@ -0,0 +1,102 @@ +// Copyright 2025 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file. + +//go:build mcp_go_client_oauth + +package auth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" +) + +// TestHTTPTransport validates the OAuth HTTPTransport. +func TestHTTPTransport(t *testing.T) { + const testToken = "test-token-123" + fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: testToken, + TokenType: "Bearer", + }) + + // authServer simulates a resource that requires OAuth. + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == fmt.Sprintf("Bearer %s", testToken) { + w.WriteHeader(http.StatusOK) + return + } + + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer authServer.Close() + + t.Run("successful auth flow", func(t *testing.T) { + var handlerCalls int + handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + handlerCalls++ + if args.ResourceMetadataURL != "http://metadata.example.com" { + t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com") + } + return fakeTokenSource, nil + } + + transport, err := NewHTTPTransport(handler, nil) + if err != nil { + t.Fatalf("NewHTTPTransport() failed: %v", err) + } + client := &http.Client{Transport: transport} + + resp, err := client.Get(authServer.URL) + if err != nil { + t.Fatalf("client.Get() failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK) + } + if handlerCalls != 1 { + t.Errorf("handler was called %d times, want 1", handlerCalls) + } + + // Second request should reuse the token and not call the handler again. + resp2, err := client.Get(authServer.URL) + if err != nil { + t.Fatalf("second client.Get() failed: %v", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK) + } + if handlerCalls != 1 { + t.Errorf("handler should still be called only once, but was %d", handlerCalls) + } + }) + + t.Run("handler returns error", func(t *testing.T) { + handlerErr := errors.New("user rejected auth") + handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) { + return nil, handlerErr + } + + transport, err := NewHTTPTransport(handler, nil) + if err != nil { + t.Fatalf("NewHTTPTransport() failed: %v", err) + } + client := &http.Client{Transport: transport} + + _, err = client.Get(authServer.URL) + if !errors.Is(err, handlerErr) { + t.Errorf("client.Get() returned error %v, want %v", err, handlerErr) + } + }) +}