Repository navigation
feat(tool/http): support Google access tokens from ADC #3792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
60dfe3e
e7f0506
99b4401
1f2cfc5
9a17d40
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,107 @@ | ||||||||||||||||||||||||||||||
| // Copyright 2026 Google LLC | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||||||||||||||||||||||||
| // you may not use this file except in compliance with the License. | ||||||||||||||||||||||||||||||
| // You may obtain a copy of the License at | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // Unless required by applicable law or agreed to in writing, software | ||||||||||||||||||||||||||||||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||||||||||||||||||||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||||||||||||||||||||||
| // See the License for the specific language governing permissions and | ||||||||||||||||||||||||||||||
| // limitations under the License. | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| package http | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||
| "errors" | ||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||
| "testing" | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| "github.com/googleapis/mcp-toolbox/internal/tools" | ||||||||||||||||||||||||||||||
| "golang.org/x/oauth2" | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func TestInitializeGoogleAccessTokenIsLazy(t *testing.T) { | ||||||||||||||||||||||||||||||
| t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(t.TempDir(), "missing-credentials.json")) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| initialized, err := (Config{ | ||||||||||||||||||||||||||||||
| ConfigBase: tools.ConfigBase{Name: "google-api", Description: "Call a Google API."}, | ||||||||||||||||||||||||||||||
| Type: "http", | ||||||||||||||||||||||||||||||
| Source: "my-http-source", | ||||||||||||||||||||||||||||||
| Path: "/resource", | ||||||||||||||||||||||||||||||
| Method: tools.HTTPMethod(http.MethodGet), | ||||||||||||||||||||||||||||||
| SendGoogleAccessToken: true, | ||||||||||||||||||||||||||||||
| }).Initialize(context.Background()) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| t.Fatalf("Initialize returned an error before the tool was invoked: %s", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| httpTool, ok := initialized.(Tool) | ||||||||||||||||||||||||||||||
| if !ok { | ||||||||||||||||||||||||||||||
| t.Fatalf("Initialize returned %T, want Tool", initialized) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if httpTool.googleAccessTokenProvider == nil { | ||||||||||||||||||||||||||||||
| t.Fatal("Google access token provider was not configured") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if httpTool.googleAccessTokenProvider.tokenSource != nil { | ||||||||||||||||||||||||||||||
| t.Fatal("Google ADC was resolved during tool initialization") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func TestSetGoogleAccessToken(t *testing.T) { | ||||||||||||||||||||||||||||||
| req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| t.Fatalf("unable to create request: %s", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| req.Header.Set("Authorization", "Bearer configured-token") | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| err = setGoogleAccessToken( | ||||||||||||||||||||||||||||||
| req, | ||||||||||||||||||||||||||||||
| &adcTokenProvider{ | ||||||||||||||||||||||||||||||
| ctx: context.Background(), | ||||||||||||||||||||||||||||||
| tokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}), | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| t.Fatalf("setGoogleAccessToken returned an error: %s", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if got, want := req.Header.Get("Authorization"), "Bearer adc-token"; got != want { | ||||||||||||||||||||||||||||||
| t.Fatalf("unexpected Authorization header: got %q, want %q", got, want) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func TestSetGoogleAccessTokenError(t *testing.T) { | ||||||||||||||||||||||||||||||
| req, err := http.NewRequest(http.MethodGet, "https://example.com", nil) | ||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||
| t.Fatalf("unable to create request: %s", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| err = setGoogleAccessToken( | ||||||||||||||||||||||||||||||
| req, | ||||||||||||||||||||||||||||||
| &adcTokenProvider{ | ||||||||||||||||||||||||||||||
| ctx: context.Background(), | ||||||||||||||||||||||||||||||
| tokenSource: errorTokenSource{err: errors.New("credentials unavailable")}, | ||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
Comment on lines
+144
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update the test to pass
Suggested change
|
||||||||||||||||||||||||||||||
| if err == nil { | ||||||||||||||||||||||||||||||
| t.Fatal("expected setGoogleAccessToken to return an error") | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if !strings.Contains(err.Error(), "credentials unavailable") { | ||||||||||||||||||||||||||||||
| t.Fatalf("unexpected error: %s", err) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| if got := req.Header.Get("Authorization"); got != "" { | ||||||||||||||||||||||||||||||
| t.Fatalf("Authorization header was set after token retrieval failed: %q", got) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| type errorTokenSource struct { | ||||||||||||||||||||||||||||||
| err error | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| func (s errorTokenSource) Token() (*oauth2.Token, error) { | ||||||||||||||||||||||||||||||
| return nil, s.err | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -22,13 +22,16 @@ import ( | |||||||||||||||||||||
| "net/url" | ||||||||||||||||||||||
| "slices" | ||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||
| "text/template" | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| yaml "github.com/goccy/go-yaml" | ||||||||||||||||||||||
| "github.com/googleapis/mcp-toolbox/internal/sources" | ||||||||||||||||||||||
| "github.com/googleapis/mcp-toolbox/internal/tools" | ||||||||||||||||||||||
| "github.com/googleapis/mcp-toolbox/internal/util" | ||||||||||||||||||||||
| "github.com/googleapis/mcp-toolbox/internal/util/parameters" | ||||||||||||||||||||||
| "golang.org/x/oauth2" | ||||||||||||||||||||||
| "golang.org/x/oauth2/google" | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const resourceType string = "http" | ||||||||||||||||||||||
|
|
@@ -55,18 +58,19 @@ type compatibleSource interface { | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type Config struct { | ||||||||||||||||||||||
| tools.ConfigBase `yaml:",inline"` | ||||||||||||||||||||||
| Type string `yaml:"type" validate:"required"` | ||||||||||||||||||||||
| Source string `yaml:"source" validate:"required"` | ||||||||||||||||||||||
| Path string `yaml:"path" validate:"required"` | ||||||||||||||||||||||
| Method tools.HTTPMethod `yaml:"method" validate:"required"` | ||||||||||||||||||||||
| Headers map[string]string `yaml:"headers"` | ||||||||||||||||||||||
| RequestBody string `yaml:"requestBody"` | ||||||||||||||||||||||
| PathParams parameters.Parameters `yaml:"pathParams"` | ||||||||||||||||||||||
| QueryParams parameters.Parameters `yaml:"queryParams"` | ||||||||||||||||||||||
| BodyParams parameters.Parameters `yaml:"bodyParams"` | ||||||||||||||||||||||
| HeaderParams parameters.Parameters `yaml:"headerParams"` | ||||||||||||||||||||||
| Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"` | ||||||||||||||||||||||
| tools.ConfigBase `yaml:",inline"` | ||||||||||||||||||||||
| Type string `yaml:"type" validate:"required"` | ||||||||||||||||||||||
| Source string `yaml:"source" validate:"required"` | ||||||||||||||||||||||
| Path string `yaml:"path" validate:"required"` | ||||||||||||||||||||||
| Method tools.HTTPMethod `yaml:"method" validate:"required"` | ||||||||||||||||||||||
| Headers map[string]string `yaml:"headers"` | ||||||||||||||||||||||
| RequestBody string `yaml:"requestBody"` | ||||||||||||||||||||||
| PathParams parameters.Parameters `yaml:"pathParams"` | ||||||||||||||||||||||
| QueryParams parameters.Parameters `yaml:"queryParams"` | ||||||||||||||||||||||
| BodyParams parameters.Parameters `yaml:"bodyParams"` | ||||||||||||||||||||||
| HeaderParams parameters.Parameters `yaml:"headerParams"` | ||||||||||||||||||||||
| SendGoogleAccessToken bool `yaml:"sendGoogleAccessToken"` | ||||||||||||||||||||||
| Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"` | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // validate interface | ||||||||||||||||||||||
|
|
@@ -76,11 +80,16 @@ func (cfg Config) ToolConfigType() string { | |||||||||||||||||||||
| return resourceType | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (cfg Config) Initialize(context.Context) (tools.Tool, error) { | ||||||||||||||||||||||
| func (cfg Config) Initialize(ctx context.Context) (tools.Tool, error) { | ||||||||||||||||||||||
| if cfg.Description == "" { | ||||||||||||||||||||||
| return nil, fmt.Errorf("description is required for tool %q", cfg.Name) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| var googleAccessTokenProvider *adcTokenProvider | ||||||||||||||||||||||
| if cfg.SendGoogleAccessToken { | ||||||||||||||||||||||
| googleAccessTokenProvider = &adcTokenProvider{ctx: ctx} | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+87
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Storing
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Create a slice for all parameters | ||||||||||||||||||||||
| allParameters := slices.Concat(cfg.PathParams, cfg.QueryParams, cfg.BodyParams, cfg.HeaderParams) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -105,6 +114,7 @@ func (cfg Config) Initialize(context.Context) (tools.Tool, error) { | |||||||||||||||||||||
| tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired}, | ||||||||||||||||||||||
| allParameters, | ||||||||||||||||||||||
| ), | ||||||||||||||||||||||
| googleAccessTokenProvider: googleAccessTokenProvider, | ||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -113,6 +123,7 @@ var _ tools.Tool = Tool{} | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| type Tool struct { | ||||||||||||||||||||||
| tools.BaseTool[Config] | ||||||||||||||||||||||
| googleAccessTokenProvider *adcTokenProvider | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (t Tool) GetSourceName() string { | ||||||||||||||||||||||
|
|
@@ -268,6 +279,62 @@ func getHeaders(headerParams parameters.Parameters, defaultHeaders map[string]st | |||||||||||||||||||||
| return allHeaders, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| type adcTokenProvider struct { | ||||||||||||||||||||||
| ctx context.Context | ||||||||||||||||||||||
| mu sync.Mutex | ||||||||||||||||||||||
| tokenSource oauth2.TokenSource | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (p *adcTokenProvider) getTokenSource() (oauth2.TokenSource, error) { | ||||||||||||||||||||||
| if p == nil { | ||||||||||||||||||||||
| return nil, fmt.Errorf("google ADC token provider is not initialized") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if p.ctx == nil { | ||||||||||||||||||||||
| return nil, fmt.Errorf("google ADC token provider context is not initialized") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| p.mu.Lock() | ||||||||||||||||||||||
| defer p.mu.Unlock() | ||||||||||||||||||||||
| if p.tokenSource == nil { | ||||||||||||||||||||||
| credentials, err := google.FindDefaultCredentials(p.ctx, sources.CloudPlatformScope) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return nil, fmt.Errorf("unable to initialize Google ADC: %w", err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if credentials.TokenSource == nil { | ||||||||||||||||||||||
| return nil, fmt.Errorf("google ADC did not provide a token source") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| p.tokenSource = credentials.TokenSource | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return p.tokenSource, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (p *adcTokenProvider) Token() (*oauth2.Token, error) { | ||||||||||||||||||||||
| tokenSource, err := p.getTokenSource() | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return nil, err | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| token, err := tokenSource.Token() | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return nil, fmt.Errorf("unable to get Google access token from ADC: %w", err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| return token, nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func setGoogleAccessToken( | ||||||||||||||||||||||
| req *http.Request, | ||||||||||||||||||||||
| tokenProvider *adcTokenProvider, | ||||||||||||||||||||||
| ) error { | ||||||||||||||||||||||
| token, err := tokenProvider.Token() | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
| return err | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if !token.Valid() { | ||||||||||||||||||||||
| return fmt.Errorf("google ADC returned an invalid or expired access token") | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| req.Header.Set("Authorization", "Bearer "+token.AccessToken) | ||||||||||||||||||||||
| return nil | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+281
to
+361
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the type adcTokenProvider struct {
mu sync.Mutex
tokenSource oauth2.TokenSource
}
func (p *adcTokenProvider) getTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
if p == nil {
return nil, fmt.Errorf("google ADC token provider is not initialized")
}
p.mu.Lock()
defer p.mu.Unlock()
if p.tokenSource == nil {
credentials, err := google.FindDefaultCredentials(ctx, sources.CloudPlatformScope)
if err != nil {
return nil, fmt.Errorf("unable to initialize Google ADC: %w", err)
}
if credentials.TokenSource == nil {
return nil, fmt.Errorf("google ADC did not provide a token source")
}
p.tokenSource = credentials.TokenSource
}
return p.tokenSource, nil
}
func (p *adcTokenProvider) Token(ctx context.Context) (*oauth2.Token, error) {
tokenSource, err := p.getTokenSource(ctx)
if err != nil {
return nil, err
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("unable to get Google access token from ADC: %w", err)
}
return token, nil
}
func setGoogleAccessToken(
ctx context.Context,
req *http.Request,
tokenProvider *adcTokenProvider,
) error {
token, err := tokenProvider.Token(ctx)
if err != nil {
return err
}
if !token.Valid() {
return fmt.Errorf("google ADC returned an invalid or expired access token")
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
return nil
} |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| func (t Tool) Invoke(ctx context.Context, s sources.Source, params parameters.ParamValues, accessToken tools.AccessToken) (any, util.ToolboxError) { | ||||||||||||||||||||||
| source, ok := s.(compatibleSource) | ||||||||||||||||||||||
| if !ok { | ||||||||||||||||||||||
|
|
@@ -307,6 +374,11 @@ func (t Tool) Invoke(ctx context.Context, s sources.Source, params parameters.Pa | |||||||||||||||||||||
| for k, v := range allHeaders { | ||||||||||||||||||||||
| req.Header.Set(k, v) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| if t.Cfg.SendGoogleAccessToken { | ||||||||||||||||||||||
| if err := setGoogleAccessToken(req, t.googleAccessTokenProvider); err != nil { | ||||||||||||||||||||||
| return nil, util.NewClientServerError("error authenticating HTTP request with Google ADC", http.StatusInternalServerError, err) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
Comment on lines
+402
to
+406
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pass the active request context
Suggested change
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| resp, err := source.RunRequest(ctx, req) | ||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update the test to pass
context.Background()tosetGoogleAccessTokenand remove thectxfield from theadcTokenProviderinitialization.