Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/en/integrations/http/tools/http-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ headerParams:
type: string
```

#### Google access tokens from ADC

For Google APIs that accept an OAuth 2.0 access token, set
`sendGoogleAccessToken` to `true`. Toolbox obtains a token from
[Application Default Credentials][adc-doc] using the Google Cloud Platform
scope and sends it as a Bearer token in the `Authorization` header:

```yaml
kind: tool
name: my-google-api-tool
type: http
source: my-http-source
method: GET
path: /search
description: Search a Google API
sendGoogleAccessToken: true
```

When enabled, the ADC-generated `Authorization` header overrides a header with
the same name configured on the source, in `headers`, or in `headerParams`.
Only enable this option for trusted HTTPS endpoints to avoid exposing the
access token.

### Query parameters

Query parameters are key-value pairs appended to a URL after a question mark (?)
Expand Down Expand Up @@ -289,5 +312,7 @@ headerParams:
| queryParams | [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) | false | List of [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) that will be inserted into the query string. |
| bodyParams | [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) | false | List of [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) that will be inserted into the request body payload. |
| headerParams | [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) | false | List of [parameters](../../../documentation/configuration/tools/_index.md#specifying-parameters) that will be inserted as the request headers. |
| sendGoogleAccessToken | boolean | false | Whether to obtain an OAuth 2.0 access token from Application Default Credentials using the Google Cloud Platform scope and send it as a Bearer token in the `Authorization` header. |

[go-template-doc]: <https://pkg.go.dev/text/template#pkg-overview>
[adc-doc]: <https://cloud.google.com/docs/authentication/application-default-credentials>
107 changes: 107 additions & 0 deletions internal/tools/http/google_access_token_test.go
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"}),
},
)
Comment on lines +118 to +124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the test to pass context.Background() to setGoogleAccessToken and remove the ctx field from the adcTokenProvider initialization.

Suggested change
err = setGoogleAccessToken(
req,
&adcTokenProvider{
ctx: context.Background(),
tokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "adc-token"}),
},
)
err = setGoogleAccessToken(
context.Background(),
req,
&adcTokenProvider{
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the test to pass context.Background() to setGoogleAccessToken and remove the ctx field from the adcTokenProvider initialization.

Suggested change
err = setGoogleAccessToken(
req,
&adcTokenProvider{
ctx: context.Background(),
tokenSource: errorTokenSource{err: errors.New("credentials unavailable")},
},
)
err = setGoogleAccessToken(
context.Background(),
req,
&adcTokenProvider{
tokenSource: errorTokenSource{err: errors.New("credentials unavailable")},
},
)

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
}
98 changes: 85 additions & 13 deletions internal/tools/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Storing context.Context in a struct is a Go anti-pattern. Additionally, using the initialization context ctx (which is typically short-lived or cancelled after startup) for lazy credential loading during invocation will cause runtime failures (e.g., context canceled) when the tool is invoked later. Instead, we should pass the request context dynamically during Invoke.

Suggested change
var googleAccessTokenProvider *adcTokenProvider
if cfg.SendGoogleAccessToken {
googleAccessTokenProvider = &adcTokenProvider{ctx: ctx}
}
var googleAccessTokenProvider *adcTokenProvider
if cfg.SendGoogleAccessToken {
googleAccessTokenProvider = &adcTokenProvider{}
}


// Create a slice for all parameters
allParameters := slices.Concat(cfg.PathParams, cfg.QueryParams, cfg.BodyParams, cfg.HeaderParams)

Expand All @@ -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
}

Expand All @@ -113,6 +123,7 @@ var _ tools.Tool = Tool{}

type Tool struct {
tools.BaseTool[Config]
googleAccessTokenProvider *adcTokenProvider
}

func (t Tool) GetSourceName() string {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Remove the ctx field from adcTokenProvider and update its methods to accept context.Context dynamically. This ensures that the active request context is used for credential discovery and token fetching, avoiding potential context cancellation issues.

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 {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Pass the active request context ctx to setGoogleAccessToken during tool invocation.

Suggested change
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)
}
}
if t.Cfg.SendGoogleAccessToken {
if err := setGoogleAccessToken(ctx, req, t.googleAccessTokenProvider); err != nil {
return nil, util.NewClientServerError("error authenticating HTTP request with Google ADC", http.StatusInternalServerError, err)
}
}


resp, err := source.RunRequest(ctx, req)
if err != nil {
Expand Down
8 changes: 5 additions & 3 deletions internal/tools/http/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ func TestParseFromYamlHTTP(t *testing.T) {
headers:
Authorization: API_KEY
Content-Type: application/json
sendGoogleAccessToken: true
headerParams:
- name: Language
type: string
Expand Down Expand Up @@ -135,9 +136,10 @@ func TestParseFromYamlHTTP(t *testing.T) {
"food": {{.food}}
}
`,
BodyParams: []parameters.Parameter{parameters.NewIntParameter("age", "age num"), parameters.NewStringParameter("city", "city string")},
Headers: map[string]string{"Authorization": "API_KEY", "Content-Type": "application/json"},
HeaderParams: []parameters.Parameter{parameters.NewStringParameter("Language", "language string")},
BodyParams: []parameters.Parameter{parameters.NewIntParameter("age", "age num"), parameters.NewStringParameter("city", "city string")},
Headers: map[string]string{"Authorization": "API_KEY", "Content-Type": "application/json"},
HeaderParams: []parameters.Parameter{parameters.NewStringParameter("Language", "language string")},
SendGoogleAccessToken: true,
},
},
},
Expand Down
Loading