Skip to content

Commit a0ad300

Browse files
committed
Merge branch 'main' into feature/modify-request-issue-533
# Conflicts: # mcp/streamable_test.go
2 parents e16b01f + 547b5c1 commit a0ad300

18 files changed

+367
-44
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,11 @@ The top-level `README.md` file is generated from `internal/readme/README.src.md`
111111
and should not be edited directly. To update the README:
112112

113113
1. Make your changes to `internal/readme/README.src.md`
114-
2. Run `make` in the `internal/readme/` directory to regenerate `README.md`
114+
2. Run `go generate ./internal/readme` from the repository root to regenerate `README.md`
115115
3. Commit both files together
116116

117117
The CI system will automatically check that the README is up-to-date by running
118-
`make` and verifying no changes result. If you see a CI failure about the
118+
`go generate ./internal/readme` and verifying no changes result. If you see a CI failure about the
119119
README being out of sync, follow the steps above to regenerate it.
120120

121121
## Timeouts

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ servers.
125125

126126
## Contributing
127127

128-
We welcome contributions to the SDK! Please see See
128+
We welcome contributions to the SDK! Please see
129129
[CONTRIBUTING.md](/CONTRIBUTING.md) for details of how to contribute.
130130

131131
## Acknowledgements / Alternatives

auth/client.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build mcp_go_client_oauth
6+
7+
package auth
8+
9+
import (
10+
"context"
11+
"errors"
12+
"net/http"
13+
"sync"
14+
15+
"github.com/modelcontextprotocol/go-sdk/internal/oauthex"
16+
"golang.org/x/oauth2"
17+
)
18+
19+
// An OAuthHandler conducts an OAuth flow and returns a [oauth2.TokenSource] if the authorization
20+
// is approved, or an error if not.
21+
type OAuthHandler func(context.Context, OAuthHandlerArgs) (oauth2.TokenSource, error)
22+
23+
// OAuthHandlerArgs are arguments to an [OAuthHandler].
24+
type OAuthHandlerArgs struct {
25+
// The URL to fetch protected resource metadata, extracted from the WWW-Authenticate header.
26+
// Empty if not present or there was an error obtaining it.
27+
ResourceMetadataURL string
28+
}
29+
30+
// HTTPTransport is an [http.RoundTripper] that follows the MCP
31+
// OAuth protocol when it encounters a 401 Unauthorized response.
32+
type HTTPTransport struct {
33+
handler OAuthHandler
34+
mu sync.Mutex // protects opts.Base
35+
opts HTTPTransportOptions
36+
}
37+
38+
// NewHTTPTransport returns a new [*HTTPTransport].
39+
// The handler is invoked when an HTTP request results in a 401 Unauthorized status.
40+
// It is called only once per transport. Once a TokenSource is obtained, it is used
41+
// for the lifetime of the transport; subsequent 401s are not processed.
42+
func NewHTTPTransport(handler OAuthHandler, opts *HTTPTransportOptions) (*HTTPTransport, error) {
43+
if handler == nil {
44+
return nil, errors.New("handler cannot be nil")
45+
}
46+
t := &HTTPTransport{
47+
handler: handler,
48+
}
49+
if opts != nil {
50+
t.opts = *opts
51+
}
52+
if t.opts.Base == nil {
53+
t.opts.Base = http.DefaultTransport
54+
}
55+
return t, nil
56+
}
57+
58+
// HTTPTransportOptions are options to [NewHTTPTransport].
59+
type HTTPTransportOptions struct {
60+
// Base is the [http.RoundTripper] to use.
61+
// If nil, [http.DefaultTransport] is used.
62+
Base http.RoundTripper
63+
}
64+
65+
func (t *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
66+
t.mu.Lock()
67+
base := t.opts.Base
68+
t.mu.Unlock()
69+
70+
resp, err := base.RoundTrip(req)
71+
if err != nil {
72+
return nil, err
73+
}
74+
if resp.StatusCode != http.StatusUnauthorized {
75+
return resp, nil
76+
}
77+
if _, ok := base.(*oauth2.Transport); ok {
78+
// We failed to authorize even with a token source; give up.
79+
return resp, nil
80+
}
81+
82+
resp.Body.Close()
83+
// Try to authorize.
84+
t.mu.Lock()
85+
defer t.mu.Unlock()
86+
// If we don't have a token source, get one by following the OAuth flow.
87+
// (We may have obtained one while t.mu was not held above.)
88+
// TODO: We hold the lock for the entire OAuth flow. This could be a long
89+
// time. Is there a better way?
90+
if _, ok := t.opts.Base.(*oauth2.Transport); !ok {
91+
authHeaders := resp.Header[http.CanonicalHeaderKey("WWW-Authenticate")]
92+
ts, err := t.handler(req.Context(), OAuthHandlerArgs{
93+
ResourceMetadataURL: extractResourceMetadataURL(authHeaders),
94+
})
95+
if err != nil {
96+
return nil, err
97+
}
98+
t.opts.Base = &oauth2.Transport{Base: t.opts.Base, Source: ts}
99+
}
100+
return t.opts.Base.RoundTrip(req.Clone(req.Context()))
101+
}
102+
103+
func extractResourceMetadataURL(authHeaders []string) string {
104+
cs, err := oauthex.ParseWWWAuthenticate(authHeaders)
105+
if err != nil {
106+
return ""
107+
}
108+
return oauthex.ResourceMetadataURL(cs)
109+
}

auth/client_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
2+
// Use of this source code is governed by an MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build mcp_go_client_oauth
6+
7+
package auth
8+
9+
import (
10+
"context"
11+
"errors"
12+
"fmt"
13+
"net/http"
14+
"net/http/httptest"
15+
"testing"
16+
17+
"golang.org/x/oauth2"
18+
)
19+
20+
// TestHTTPTransport validates the OAuth HTTPTransport.
21+
func TestHTTPTransport(t *testing.T) {
22+
const testToken = "test-token-123"
23+
fakeTokenSource := oauth2.StaticTokenSource(&oauth2.Token{
24+
AccessToken: testToken,
25+
TokenType: "Bearer",
26+
})
27+
28+
// authServer simulates a resource that requires OAuth.
29+
authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30+
authHeader := r.Header.Get("Authorization")
31+
if authHeader == fmt.Sprintf("Bearer %s", testToken) {
32+
w.WriteHeader(http.StatusOK)
33+
return
34+
}
35+
36+
w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="http://metadata.example.com"`)
37+
w.WriteHeader(http.StatusUnauthorized)
38+
}))
39+
defer authServer.Close()
40+
41+
t.Run("successful auth flow", func(t *testing.T) {
42+
var handlerCalls int
43+
handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) {
44+
handlerCalls++
45+
if args.ResourceMetadataURL != "http://metadata.example.com" {
46+
t.Errorf("handler got metadata URL %q, want %q", args.ResourceMetadataURL, "http://metadata.example.com")
47+
}
48+
return fakeTokenSource, nil
49+
}
50+
51+
transport, err := NewHTTPTransport(handler, nil)
52+
if err != nil {
53+
t.Fatalf("NewHTTPTransport() failed: %v", err)
54+
}
55+
client := &http.Client{Transport: transport}
56+
57+
resp, err := client.Get(authServer.URL)
58+
if err != nil {
59+
t.Fatalf("client.Get() failed: %v", err)
60+
}
61+
defer resp.Body.Close()
62+
63+
if resp.StatusCode != http.StatusOK {
64+
t.Errorf("got status %d, want %d", resp.StatusCode, http.StatusOK)
65+
}
66+
if handlerCalls != 1 {
67+
t.Errorf("handler was called %d times, want 1", handlerCalls)
68+
}
69+
70+
// Second request should reuse the token and not call the handler again.
71+
resp2, err := client.Get(authServer.URL)
72+
if err != nil {
73+
t.Fatalf("second client.Get() failed: %v", err)
74+
}
75+
defer resp2.Body.Close()
76+
77+
if resp2.StatusCode != http.StatusOK {
78+
t.Errorf("second request got status %d, want %d", resp2.StatusCode, http.StatusOK)
79+
}
80+
if handlerCalls != 1 {
81+
t.Errorf("handler should still be called only once, but was %d", handlerCalls)
82+
}
83+
})
84+
85+
t.Run("handler returns error", func(t *testing.T) {
86+
handlerErr := errors.New("user rejected auth")
87+
handler := func(ctx context.Context, args OAuthHandlerArgs) (oauth2.TokenSource, error) {
88+
return nil, handlerErr
89+
}
90+
91+
transport, err := NewHTTPTransport(handler, nil)
92+
if err != nil {
93+
t.Fatalf("NewHTTPTransport() failed: %v", err)
94+
}
95+
client := &http.Client{Transport: transport}
96+
97+
_, err = client.Get(authServer.URL)
98+
if !errors.Is(err, handlerErr) {
99+
t.Errorf("client.Get() returned error %v, want %v", err, handlerErr)
100+
}
101+
})
102+
}

docs/client.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ func Example_elicitation() {
145145
}
146146
defer ss.Close()
147147

148-
c := mcp.NewClient(testImpl, &mcp.ClientOptions{
148+
c := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "v0.0.1"}, &mcp.ClientOptions{
149149
ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
150150
return &mcp.ElicitResult{Action: "accept", Content: map[string]any{"test": "value"}}, nil
151151
},

docs/protocol.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its
128128
stdin/stdout.
129129

130130
**Server-side**: the server side of the `stdio` transport is implemented by
131-
`StdioTransport`, which connects over the current processes `os.Stdin` and
132-
`os.Stdout`.
131+
[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport),
132+
which connects over the current processes `os.Stdin` and `os.Stdout`.
133133

134134
### Streamable Transport
135135

@@ -201,7 +201,7 @@ to see the logical session
201201
> Stateless mode is not directly discussed in the spec, and is still being
202202
> defined. See modelcontextprotocol/modelcontextprotocol#1364,
203203
> modelcontextprotocol/modelcontextprotocol#1372, or
204-
> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements.
204+
> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements.
205205
206206
_See [examples/server/distributed](../examples/server/distributed/main.go) for
207207
an example using statless mode to implement a server distributed across
@@ -267,7 +267,7 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/
267267

268268
Client-side OAuth is implemented by setting
269269
[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/[email protected]/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client)
270-
Additional support is forthcoming; see #493.
270+
Additional support is forthcoming; see modelcontextprotocol/go-sdk#493.
271271

272272
## Security
273273

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ require (
77
github.com/google/go-cmp v0.7.0
88
github.com/google/jsonschema-go v0.3.0
99
github.com/yosida95/uritemplate/v3 v3.0.2
10+
golang.org/x/oauth2 v0.30.0
1011
golang.org/x/tools v0.34.0
1112
)

go.sum

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeD
22
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
33
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
44
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5-
github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
6-
github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
7-
github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371 h1:e1VCqWtKpTYBOBhPcgGV5whTlMFpTbH5Ghm56wpxBsk=
8-
github.com/google/jsonschema-go v0.2.4-0.20250922144851-e08864c65371/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
95
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
106
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
117
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
128
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
9+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
10+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
1311
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
1412
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

internal/docs/protocol.src.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ which starts the a `exec.Cmd` as a subprocess and communicates over its
7474
stdin/stdout.
7575

7676
**Server-side**: the server side of the `stdio` transport is implemented by
77-
`StdioTransport`, which connects over the current processes `os.Stdin` and
78-
`os.Stdout`.
77+
[`StdioTransport`](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk/mcp#StdioTransport),
78+
which connects over the current processes `os.Stdin` and `os.Stdout`.
7979

8080
### Streamable Transport
8181

@@ -128,7 +128,7 @@ to see the logical session
128128
> Stateless mode is not directly discussed in the spec, and is still being
129129
> defined. See modelcontextprotocol/modelcontextprotocol#1364,
130130
> modelcontextprotocol/modelcontextprotocol#1372, or
131-
> modelcontextprotocol/modelcontextprotocol#11442 for potential refinements.
131+
> modelcontextprotocol/modelcontextprotocol#1442 for potential refinements.
132132
133133
_See [examples/server/distributed](../examples/server/distributed/main.go) for
134134
an example using statless mode to implement a server distributed across
@@ -194,7 +194,7 @@ The [_auth middleware example_](https://github.com/modelcontextprotocol/go-sdk/
194194

195195
Client-side OAuth is implemented by setting
196196
[`StreamableClientTransport.HTTPClient`](https://pkg.go.dev/github.com/modelcontextprotocol/[email protected]/mcp#StreamableClientTransport.HTTPClient) to a custom [`http.Client`](https://pkg.go.dev/net/http#Client)
197-
Additional support is forthcoming; see #493.
197+
Additional support is forthcoming; see modelcontextprotocol/go-sdk#493.
198198

199199
## Security
200200

internal/oauthex/resource_meta.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,11 @@ func GetProtectedResourceMetadataFromHeader(ctx context.Context, header http.Hea
146146
if len(headers) == 0 {
147147
return nil, nil
148148
}
149-
cs, err := parseWWWAuthenticate(headers)
149+
cs, err := ParseWWWAuthenticate(headers)
150150
if err != nil {
151151
return nil, err
152152
}
153-
url := resourceMetadataURL(cs)
153+
url := ResourceMetadataURL(cs)
154154
if url == "" {
155155
return nil, nil
156156
}
@@ -193,9 +193,9 @@ type challenge struct {
193193
Params map[string]string
194194
}
195195

196-
// resourceMetadataURL returns a resource metadata URL from the given challenges,
196+
// ResourceMetadataURL returns a resource metadata URL from the given challenges,
197197
// or the empty string if there is none.
198-
func resourceMetadataURL(cs []challenge) string {
198+
func ResourceMetadataURL(cs []challenge) string {
199199
for _, c := range cs {
200200
if u := c.Params["resource_metadata"]; u != "" {
201201
return u
@@ -204,11 +204,11 @@ func resourceMetadataURL(cs []challenge) string {
204204
return ""
205205
}
206206

207-
// parseWWWAuthenticate parses a WWW-Authenticate header string.
207+
// ParseWWWAuthenticate parses a WWW-Authenticate header string.
208208
// The header format is defined in RFC 9110, Section 11.6.1, and can contain
209209
// one or more challenges, separated by commas.
210210
// It returns a slice of challenges or an error if one of the headers is malformed.
211-
func parseWWWAuthenticate(headers []string) ([]challenge, error) {
211+
func ParseWWWAuthenticate(headers []string) ([]challenge, error) {
212212
// GENERATED BY GEMINI 2.5 (human-tweaked)
213213
var challenges []challenge
214214
for _, h := range headers {

0 commit comments

Comments
 (0)