Skip to content
3 changes: 3 additions & 0 deletions .github/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@
handleGHRelease: true
packageName: mcp-toolbox-sdk-go
releaseType: go
extraFiles: [
"core/transport/mcp/version.go",
]
66 changes: 45 additions & 21 deletions core/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,24 @@ package core
import (
"context"
"fmt"
"log"
"net/http"
"strings"

"github.com/googleapis/mcp-toolbox-sdk-go/core/transport"
mcp20241105 "github.com/googleapis/mcp-toolbox-sdk-go/core/transport/mcp/v20241105"
mcp20250326 "github.com/googleapis/mcp-toolbox-sdk-go/core/transport/mcp/v20250326"
mcp20250618 "github.com/googleapis/mcp-toolbox-sdk-go/core/transport/mcp/v20250618"
"github.com/googleapis/mcp-toolbox-sdk-go/core/transport/toolboxtransport"
"golang.org/x/oauth2"
)

// The synchronous interface for a Toolbox service client.
type ToolboxClient struct {
baseURL string
httpClient *http.Client
protocol Protocol
protocolSet bool
transport transport.Transport
clientHeaderSources map[string]oauth2.TokenSource
defaultToolOptions []ToolOption
defaultOptionsSet bool
Expand All @@ -39,17 +46,19 @@ type ToolboxClient struct {
// Inputs:
// - url: The base URL of the Toolbox server.
// - opts: A variadic list of ClientOption functions to configure the client,
// such as setting a custom http.Client or default headers.
// such as setting a custom http.Client, default headers, or the underlying protocol.
//
// Returns:
//
// A configured *ToolboxClient and a nil error on success, or a nil client
// and an error if configuration fails.
func NewToolboxClient(url string, opts ...ClientOption) (*ToolboxClient, error) {
// Initialize the client with default values.
// We default to ProtocolMCP (the newest version alias) if not overridden.
tc := &ToolboxClient{
baseURL: url,
httpClient: &http.Client{},
protocol: MCP, // Default
clientHeaderSources: make(map[string]oauth2.TokenSource),
defaultToolOptions: []ToolOption{},
}
Expand All @@ -64,7 +73,22 @@ func NewToolboxClient(url string, opts ...ClientOption) (*ToolboxClient, error)
}
}

return tc, nil
// Initialize the Transport based on the selected Protocol.
var transportErr error = nil
switch tc.protocol {
case MCPv20250618:
tc.transport, transportErr = mcp20250618.New(tc.baseURL, tc.httpClient)
case MCPv20250326:
tc.transport, transportErr = mcp20250326.New(tc.baseURL, tc.httpClient)
case MCPv20241105:
tc.transport, transportErr = mcp20241105.New(tc.baseURL, tc.httpClient)
case Toolbox:
tc.transport = toolboxtransport.New(tc.baseURL, tc.httpClient)
default:
return nil, fmt.Errorf("unsupported protocol version: %s", tc.protocol)
}

return tc, transportErr
}

// newToolboxTool is an internal factory method that constructs a
Expand All @@ -87,6 +111,7 @@ func (tc *ToolboxClient) newToolboxTool(
schema ToolSchema,
finalConfig *ToolConfig,
isStrict bool,
tr transport.Transport,
) (*ToolboxTool, []string, []string, error) {

// These will be the parameters that the end-user must provide at invocation time.
Expand Down Expand Up @@ -151,17 +176,12 @@ func (tc *ToolboxClient) newToolboxTool(
finalConfig.AuthTokenSources,
)

if (len(remainingAuthnParams) > 0 || len(remainingAuthzTokens) > 0 || len(tc.clientHeaderSources) > 0) && !strings.HasPrefix(tc.baseURL, "https://") {
log.Println("WARNING: Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication.")
}

// Construct the final tool object.
tt := &ToolboxTool{
name: name,
description: schema.Description,
parameters: finalParameters,
invocationURL: fmt.Sprintf("%s/api/tool/%s%s", tc.baseURL, name, toolInvokeSuffix),
httpClient: tc.httpClient,
transport: tr,
authTokenSources: finalConfig.AuthTokenSources,
boundParams: localBoundParams,
requiredAuthnParams: remainingAuthnParams,
Expand Down Expand Up @@ -204,9 +224,14 @@ func (tc *ToolboxClient) LoadTool(name string, ctx context.Context, opts ...Tool
}
}

resolvedHeaders, err := resolveClientHeaders(tc.clientHeaderSources)
if err != nil {
return nil, err
}

// Fetch the manifest for the specified tool.
url := fmt.Sprintf("%s/api/tool/%s", tc.baseURL, name)
manifest, err := loadManifest(ctx, url, tc.httpClient, tc.clientHeaderSources)
manifest, err := tc.transport.GetTool(ctx, name, resolvedHeaders)

if err != nil {
return nil, fmt.Errorf("failed to load tool manifest for '%s': %w", name, err)
}
Expand All @@ -219,7 +244,7 @@ func (tc *ToolboxClient) LoadTool(name string, ctx context.Context, opts ...Tool
}

// Construct the tool from its schema and the final configuration.
tool, usedAuthKeys, usedBoundKeys, err := tc.newToolboxTool(name, schema, finalConfig, true)
tool, usedAuthKeys, usedBoundKeys, err := tc.newToolboxTool(name, schema, finalConfig, true, tc.transport)
if err != nil {
return nil, fmt.Errorf("failed to create toolbox tool from schema for '%s': %w", name, err)
}
Expand Down Expand Up @@ -291,15 +316,14 @@ func (tc *ToolboxClient) LoadToolset(name string, ctx context.Context, opts ...T
}
}

// Determine the manifest URL based on whether a specific toolset name was provided.
var url string
if name == "" {
url = fmt.Sprintf("%s/api/toolset/", tc.baseURL)
} else {
url = fmt.Sprintf("%s/api/toolset/%s", tc.baseURL, name)
}
// Fetch the manifest for the toolset.
manifest, err := loadManifest(ctx, url, tc.httpClient, tc.clientHeaderSources)
resolvedHeaders, err := resolveClientHeaders(tc.clientHeaderSources)
if err != nil {
return nil, err
}

// Fetch Manifest via Transport
manifest, err := tc.transport.ListTools(ctx, name, resolvedHeaders)
if err != nil {
return nil, fmt.Errorf("failed to load toolset manifest for '%s': %w", name, err)
}
Expand All @@ -322,7 +346,7 @@ func (tc *ToolboxClient) LoadToolset(name string, ctx context.Context, opts ...T

for toolName, schema := range manifest.Tools {
// Construct each tool from its schema and the shared configuration.
tool, usedAuthKeys, usedBoundKeys, err := tc.newToolboxTool(toolName, schema, finalConfig, finalConfig.Strict)
tool, usedAuthKeys, usedBoundKeys, err := tc.newToolboxTool(toolName, schema, finalConfig, finalConfig.Strict, tc.transport)
if err != nil {
return nil, fmt.Errorf("failed to create tool '%s': %w", toolName, err)
}
Expand Down
47 changes: 19 additions & 28 deletions core/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ func TestNewToolboxClient(t *testing.T) {
if client.httpClient.Timeout != 0 {
t.Errorf("expected no timeout, got %v", client.httpClient.Timeout)
}

if client.protocol != ProtocolMCP {
t.Errorf("expected default protocol to be ProtocolMCP, got %v", client.protocol)
}

})

t.Run("Returns error when a nil option is provided", func(t *testing.T) {
Expand Down Expand Up @@ -259,7 +264,7 @@ func TestLoadToolAndLoadToolset(t *testing.T) {
defer server.Close()

t.Run("LoadTool - Success", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
tool, err := client.LoadTool("toolA",
context.Background(),
WithBindParamString("param1", "value1"),
Expand All @@ -274,7 +279,7 @@ func TestLoadToolAndLoadToolset(t *testing.T) {
})

t.Run("LoadTool - Negative Test - Unused bound parameter", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadTool("toolA",
context.Background(),
WithBindParamString("param1", "value1"),
Expand All @@ -289,7 +294,7 @@ func TestLoadToolAndLoadToolset(t *testing.T) {
})

t.Run("LoadToolset - Success with non-strict mode", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
tools, err := client.LoadToolset(
"",
context.Background(),
Expand All @@ -306,7 +311,7 @@ func TestLoadToolAndLoadToolset(t *testing.T) {
})

t.Run("LoadToolset - Negative Test - Unused parameter in non-strict mode", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadToolset(
"",
context.Background(),
Expand All @@ -322,7 +327,7 @@ func TestLoadToolAndLoadToolset(t *testing.T) {
})

t.Run("LoadToolset - Negative Test - Unused parameter in strict mode", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadToolset(
"",
context.Background(),
Expand Down Expand Up @@ -434,7 +439,7 @@ func TestNegativeAndEdgeCases(t *testing.T) {

t.Run("LoadTool fails when a nil ToolOption is provided", func(t *testing.T) {

client, _ := NewToolboxClient(server.URL)
client, _ := NewToolboxClient(server.URL, WithProtocol(Toolbox))
_, err := client.LoadTool("any-tool", context.Background(), nil)
if err == nil {
t.Fatal("Expected an error when a nil option is passed to LoadTool, but got nil")
Expand Down Expand Up @@ -474,7 +479,7 @@ func TestNegativeAndEdgeCases(t *testing.T) {
}))
defer serverWithNoTools.Close()

client, _ := NewToolboxClient(serverWithNoTools.URL, WithHTTPClient(serverWithNoTools.Client()))
client, _ := NewToolboxClient(serverWithNoTools.URL, WithHTTPClient(serverWithNoTools.Client()), WithProtocol(Toolbox))

// This call would panic if the code doesn't check for a nil map.
_, err := client.LoadTool("any-tool", context.Background())
Expand Down Expand Up @@ -567,25 +572,11 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
log.SetOutput(&buf)
defer log.SetOutput(originalOutput)

t.Run("logs warning for HTTP with headers", func(t *testing.T) {
buf.Reset()

client, _ := NewToolboxClient(server.URL,
WithHTTPClient(server.Client()),
)

_, _ = client.LoadTool("toolA", context.Background())

expectedLog := "WARNING: Sending ID token over HTTP"
if !strings.Contains(buf.String(), expectedLog) {
t.Errorf("expected log message '%s' not found in output: '%s'", expectedLog, buf.String())
}
})

t.Run("LoadTool fails when a default option is invalid", func(t *testing.T) {
// Setup client with duplicate default options
client, _ := NewToolboxClient(server.URL,
WithHTTPClient(server.Client()),
WithProtocol(Toolbox),
WithDefaultToolOptions(
WithStrict(true),
WithStrict(false),
Expand All @@ -605,7 +596,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
})

t.Run("LoadTool fails when tool is not in the manifest", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadTool("tool-that-does-not-exist", context.Background())

if err == nil {
Expand All @@ -621,7 +612,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
errorServer.Close()

client, _ := NewToolboxClient(errorServer.URL, WithHTTPClient(errorServer.Client()))
client, _ := NewToolboxClient(errorServer.URL, WithHTTPClient(errorServer.Client()), WithProtocol(Toolbox))
_, err := client.LoadTool("any-tool", context.Background())

if err == nil {
Expand All @@ -633,7 +624,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
})

t.Run("LoadTool fails with unused auth tokens", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadTool("toolA", context.Background(),
WithAuthTokenString("unused-auth", "token"), // This auth is not needed by toolA
)
Expand All @@ -646,7 +637,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
})

t.Run("LoadTool fails with unused bound parameters", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadTool("toolA", context.Background(),
WithBindParamString("unused-param", "value"), // This param is not defined on toolA
)
Expand All @@ -661,7 +652,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
})

t.Run("LoadToolset fails with unused parameters in strict mode", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadToolset(
"",
context.Background(),
Expand All @@ -679,7 +670,7 @@ func TestLoadToolAndLoadToolset_ErrorPaths(t *testing.T) {
})

t.Run("LoadToolset fails with unused parameters in non-strict mode", func(t *testing.T) {
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()))
client, _ := NewToolboxClient(server.URL, WithHTTPClient(server.Client()), WithProtocol(Toolbox))
_, err := client.LoadToolset(
"",
context.Background(),
Expand Down
Loading
Loading