feat: Add support for HTTP Tool pathParams (#726)

Allow users to specify dynamic path for HTTP tools.

fix: https://github.com/googleapis/genai-toolbox/issues/680
This commit is contained in:
Wenxin Du
2025-06-20 14:53:49 -04:00
committed by GitHub
parent 4827771b78
commit fd300dc606
4 changed files with 133 additions and 50 deletions

View File

@@ -16,7 +16,12 @@ Toolbox allows you to configure the request URL, method, headers, query paramete
### URL
An HTTP request URL identifies the target the client wants to access.
Toolbox composes the request URL from the HTTP Source's `baseUrl` and the HTTP Tool's `path`.
Toolbox composes the request URL from 3 places:
1. The HTTP Source's `baseUrl`.
2. The HTTP Tool's `path` field.
3. The HTTP Tool's `pathParams` for dynamic path composed during Tool invocation.
For example, the following config allows you to reach different paths of the same server using multiple Tools:
```yaml
@@ -39,6 +44,17 @@ tools:
method: GET
path: /search
description: Tool to search information from the example API
my-dynamic-path-tool:
kind: http
source: my-http-source
method: GET
path: /{{.myPathParam}}/search
description: Tool to reach endpoint based on the input to `myPathParam`
pathParams:
- name: myPathParam
type: string
description: The dynamic path parameter
```

View File

@@ -59,6 +59,7 @@ type Config struct {
Method tools.HTTPMethod `yaml:"method" validate:"required"`
Headers map[string]string `yaml:"headers"`
RequestBody string `yaml:"requestBody"`
PathParams tools.Parameters `yaml:"pathParams"`
QueryParams tools.Parameters `yaml:"queryParams"`
BodyParams tools.Parameters `yaml:"bodyParams"`
HeaderParams tools.Parameters `yaml:"headerParams"`
@@ -84,20 +85,6 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `http`", kind)
}
// Create URL based on BaseURL and Path
// Attach query parameters
u, err := url.Parse(s.BaseURL + cfg.Path)
if err != nil {
return nil, fmt.Errorf("error parsing URL: %s", err)
}
// Get existing query parameters from the URL
queryParameters := u.Query()
for key, value := range s.QueryParams {
queryParameters.Add(key, value)
}
u.RawQuery = queryParameters.Encode()
// Combine Source and Tool headers.
// In case of conflict, Tool header overrides Source header
combinedHeaders := make(map[string]string)
@@ -105,10 +92,11 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
maps.Copy(combinedHeaders, cfg.Headers)
// Create a slice for all parameters
allParameters := slices.Concat(cfg.BodyParams, cfg.HeaderParams, cfg.QueryParams)
allParameters := slices.Concat(cfg.PathParams, cfg.BodyParams, cfg.HeaderParams, cfg.QueryParams)
// Create parameter MCP manifest
paramManifest := slices.Concat(
cfg.PathParams.Manifest(),
cfg.QueryParams.Manifest(),
cfg.BodyParams.Manifest(),
cfg.HeaderParams.Manifest(),
@@ -116,13 +104,14 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
if paramManifest == nil {
paramManifest = make([]tools.ParameterManifest, 0)
}
pathMcpManifest := cfg.PathParams.McpManifest()
queryMcpManifest := cfg.QueryParams.McpManifest()
bodyMcpManifest := cfg.BodyParams.McpManifest()
headerMcpManifest := cfg.HeaderParams.McpManifest()
// Concatenate parameters for MCP `required` field
concatRequiredManifest := slices.Concat(
pathMcpManifest.Required,
queryMcpManifest.Required,
bodyMcpManifest.Required,
headerMcpManifest.Required,
@@ -133,6 +122,9 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
// Concatenate parameters for MCP `properties` field
concatPropertiesManifest := make(map[string]tools.ParameterMcpManifest)
for name, p := range pathMcpManifest.Properties {
concatPropertiesManifest[name] = p
}
for name, p := range queryMcpManifest.Properties {
concatPropertiesManifest[name] = p
}
@@ -167,20 +159,23 @@ func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error)
// finish tool setup
return Tool{
Name: cfg.Name,
Kind: kind,
URL: u,
Method: cfg.Method,
AuthRequired: cfg.AuthRequired,
RequestBody: cfg.RequestBody,
QueryParams: cfg.QueryParams,
BodyParams: cfg.BodyParams,
HeaderParams: cfg.HeaderParams,
Headers: combinedHeaders,
Client: s.Client,
AllParams: allParameters,
manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
Name: cfg.Name,
Kind: kind,
BaseURL: s.BaseURL,
Path: cfg.Path,
Method: cfg.Method,
AuthRequired: cfg.AuthRequired,
RequestBody: cfg.RequestBody,
PathParams: cfg.PathParams,
QueryParams: cfg.QueryParams,
BodyParams: cfg.BodyParams,
HeaderParams: cfg.HeaderParams,
Headers: combinedHeaders,
DefaultQueryParams: s.QueryParams,
Client: s.Client,
AllParams: allParameters,
manifest: tools.Manifest{Description: cfg.Description, Parameters: paramManifest, AuthRequired: cfg.AuthRequired},
mcpManifest: mcpManifest,
}, nil
}
@@ -193,14 +188,18 @@ type Tool struct {
Description string `yaml:"description"`
AuthRequired []string `yaml:"authRequired"`
URL *url.URL `yaml:"url"`
Method tools.HTTPMethod `yaml:"method"`
Headers map[string]string `yaml:"headers"`
RequestBody string `yaml:"requestBody"`
QueryParams tools.Parameters `yaml:"queryParams"`
BodyParams tools.Parameters `yaml:"bodyParams"`
HeaderParams tools.Parameters `yaml:"headerParams"`
AllParams tools.Parameters `yaml:"allParams"`
BaseURL string `yaml:"baseURL"`
Path string `yaml:"path"`
Method tools.HTTPMethod `yaml:"method"`
Headers map[string]string `yaml:"headers"`
DefaultQueryParams map[string]string `yaml:"defaultQueryParams"`
RequestBody string `yaml:"requestBody"`
PathParams tools.Parameters `yaml:"pathParams"`
QueryParams tools.Parameters `yaml:"queryParams"`
BodyParams tools.Parameters `yaml:"bodyParams"`
HeaderParams tools.Parameters `yaml:"headerParams"`
AllParams tools.Parameters `yaml:"allParams"`
Client *http.Client
manifest tools.Manifest
@@ -241,14 +240,45 @@ func getRequestBody(bodyParams tools.Parameters, requestBodyPayload string, para
}
// Helper function to generate the HTTP request URL upon Tool invocation.
func getURL(u *url.URL, queryParams tools.Parameters, paramsMap map[string]any) (string, error) {
func getURL(baseURL, path string, pathParams, queryParams tools.Parameters, defaultQueryParams map[string]string, paramsMap map[string]any) (string, error) {
// use Go template to replace path params
pathParamValues, err := tools.GetParams(pathParams, paramsMap)
if err != nil {
return "", err
}
pathParamsMap := pathParamValues.AsMap()
templ, err := template.New("url").Parse(path)
if err != nil {
return "", fmt.Errorf("error parsing URL: %s", err)
}
var templatedPath bytes.Buffer
err = templ.Execute(&templatedPath, pathParamsMap)
if err != nil {
return "", fmt.Errorf("error replacing pathParams: %s", err)
}
// Create URL based on BaseURL and Path
// Attach query parameters
parsedURL, err := url.Parse(baseURL + templatedPath.String())
if err != nil {
return "", fmt.Errorf("error parsing URL: %s", err)
}
// Get existing query parameters from the URL
queryParameters := parsedURL.Query()
for key, value := range defaultQueryParams {
queryParameters.Add(key, value)
}
parsedURL.RawQuery = queryParameters.Encode()
// Set dynamic query parameters
query := u.Query()
query := parsedURL.Query()
for _, p := range queryParams {
query.Add(p.GetName(), fmt.Sprintf("%v", paramsMap[p.GetName()]))
}
u.RawQuery = query.Encode()
return u.String(), nil
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
// Helper function to generate the HTTP headers upon Tool invocation.
@@ -279,9 +309,9 @@ func (t Tool) Invoke(ctx context.Context, params tools.ParamValues) ([]any, erro
}
// Calculate URL
urlString, err := getURL(t.URL, t.QueryParams, paramsMap)
urlString, err := getURL(t.BaseURL, t.Path, t.PathParams, t.QueryParams, t.DefaultQueryParams, paramsMap)
if err != nil {
return nil, fmt.Errorf("error populating query parameters: %s", err)
return nil, fmt.Errorf("error populating path parameters: %s", err)
}
req, _ := http.NewRequest(string(t.Method), urlString, strings.NewReader(requestBody))

View File

@@ -44,7 +44,30 @@ func TestParseFromYamlHTTP(t *testing.T) {
kind: http
source: my-instance
method: GET
path: "search?name=alice&pet=cat"
description: some description
path: search
`,
want: server.ToolConfigs{
"example_tool": http.Config{
Name: "example_tool",
Kind: "http",
Source: "my-instance",
Method: "GET",
Path: "search",
Description: "some description",
AuthRequired: []string{},
},
},
},
{
desc: "advanced example",
in: `
tools:
example_tool:
kind: http
source: my-instance
method: GET
path: "{{.pathParam}}?name=alice&pet=cat"
description: some description
authRequired:
- my-google-auth-service
@@ -58,6 +81,10 @@ func TestParseFromYamlHTTP(t *testing.T) {
field: user_id
- name: other-auth-service
field: user_id
pathParams:
- name: pathParam
type: string
description: path param
requestBody: |
{
"age": {{.age}},
@@ -85,7 +112,7 @@ func TestParseFromYamlHTTP(t *testing.T) {
Kind: "http",
Source: "my-instance",
Method: "GET",
Path: "search?name=alice&pet=cat",
Path: "{{.pathParam}}?name=alice&pet=cat",
Description: "some description",
AuthRequired: []string{"my-google-auth-service", "other-auth-service"},
QueryParams: []tools.Parameter{
@@ -93,6 +120,11 @@ func TestParseFromYamlHTTP(t *testing.T) {
[]tools.ParamAuthService{{Name: "my-google-auth-service", Field: "user_id"},
{Name: "other-auth-service", Field: "user_id"}}),
},
PathParams: tools.Parameters{
&tools.StringParameter{
CommonParameter: tools.CommonParameter{Name: "pathParam", Type: "string", Desc: "path param"},
},
},
RequestBody: `{
"age": {{.age}},
"city": "{{.city}}",

View File

@@ -282,7 +282,7 @@ func runAdvancedHTTPInvokeTest(t *testing.T) {
name: "invoke my-advanced-tool",
api: "http://127.0.0.1:5000/api/tool/my-advanced-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 3, "country": "US", "X-Other-Header": "test"}`)),
requestBody: bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 3, "path": "tool3", "country": "US", "X-Other-Header": "test"}`)),
want: `["Hello","World"]`,
isErr: false,
},
@@ -290,7 +290,7 @@ func runAdvancedHTTPInvokeTest(t *testing.T) {
name: "invoke my-advanced-tool with wrong params",
api: "http://127.0.0.1:5000/api/tool/my-advanced-tool/invoke",
requestHeader: map[string]string{},
requestBody: bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 4, "country": "US", "X-Other-Header": "test"}`)),
requestBody: bytes.NewBuffer([]byte(`{"animalArray": ["rabbit", "ostrich", "whale"], "id": 4, "path": "tool3", "country": "US", "X-Other-Header": "test"}`)),
isErr: true,
},
}
@@ -408,11 +408,16 @@ func getHTTPToolsConfig(sourceConfig map[string]any, toolKind string) map[string
"kind": toolKind,
"source": "other-instance",
"method": "get",
"path": "/tool3?id=2",
"path": "/{{.path}}?id=2",
"description": "some description",
"headers": map[string]string{
"X-Custom-Header": "example",
},
"pathParams": []tools.Parameter{
&tools.StringParameter{
CommonParameter: tools.CommonParameter{Name: "path", Type: "string", Desc: "path param"},
},
},
"queryParams": []tools.Parameter{
tools.NewIntParameter("id", "user ID"), tools.NewStringParameter("country", "country")},
"requestBody": `{