mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-02-05 12:45:11 -05:00
This PR introduces a significant update to the Toolbox configuration file format, which is one of the primary **breaking changes** required for the implementation of the Advanced Control Plane. # Summary of Changes The configuration schema has been updated to enforce resource isolation and facilitate atomic, incremental updates. * Resource Isolation: Resource definitions are now separated into individual blocks, using a distinct structure for each resource type (Source, Tool, Toolset, etc.). This improves readability, management, and auditing of configuration files. * Field Name Modification: Internal field names have been modified to align with declarative methodologies. Specifically, the configuration now separates kind (general resource type, e.g., Source) from type (specific implementation, e.g., Postgres). # User Impact Existing tools.yaml configuration files are now in an outdated format. Users must eventually update their files to the new YAML format. # Mitigation & Compatibility Backward compatibility is maintained during this transition to ensure no immediate user action is required for existing files. * Immediate Backward Compatibility: The source code includes a pre-processing layer that automatically detects outdated configuration files (v1 format) and converts them to the new v2 format under the hood. * [COMING SOON] Migration Support: The new toolbox migrate subcommand will be introduced to allow users to automatically convert their old configuration files to the latest format. # Example Example for config file v2: ``` kind: sources name: my-pg-instance type: cloud-sql-postgres project: my-project region: my-region instance: my-instance database: my_db user: my_user password: my_pass --- kind: authServices name: my-google-auth type: google clientId: testing-id --- kind: tools name: example_tool type: postgres-sql source: my-pg-instance description: some description statement: SELECT * FROM SQL_STATEMENT; parameters: - name: country type: string description: some description --- kind: tools name: example_tool_2 type: postgres-sql source: my-pg-instance description: returning the number one statement: SELECT 1; --- kind: toolsets name: example_toolset tools: - example_tool ``` --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Averi Kitsch <akitsch@google.com>
411 lines
14 KiB
Go
411 lines
14 KiB
Go
// Copyright 2024 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 server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
yaml "github.com/goccy/go-yaml"
|
|
"github.com/googleapis/genai-toolbox/internal/auth"
|
|
"github.com/googleapis/genai-toolbox/internal/auth/google"
|
|
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
|
|
"github.com/googleapis/genai-toolbox/internal/embeddingmodels/gemini"
|
|
"github.com/googleapis/genai-toolbox/internal/prompts"
|
|
"github.com/googleapis/genai-toolbox/internal/sources"
|
|
"github.com/googleapis/genai-toolbox/internal/tools"
|
|
"github.com/googleapis/genai-toolbox/internal/util"
|
|
)
|
|
|
|
type ServerConfig struct {
|
|
// Server version
|
|
Version string
|
|
// Address is the address of the interface the server will listen on.
|
|
Address string
|
|
// Port is the port the server will listen on.
|
|
Port int
|
|
// SourceConfigs defines what sources of data are available for tools.
|
|
SourceConfigs SourceConfigs
|
|
// AuthServiceConfigs defines what sources of authentication are available for tools.
|
|
AuthServiceConfigs AuthServiceConfigs
|
|
// EmbeddingModelConfigs defines a models used to embed parameters.
|
|
EmbeddingModelConfigs EmbeddingModelConfigs
|
|
// ToolConfigs defines what tools are available.
|
|
ToolConfigs ToolConfigs
|
|
// ToolsetConfigs defines what tools are available.
|
|
ToolsetConfigs ToolsetConfigs
|
|
// PromptConfigs defines what prompts are available
|
|
PromptConfigs PromptConfigs
|
|
// PromptsetConfigs defines what prompts are available
|
|
PromptsetConfigs PromptsetConfigs
|
|
// LoggingFormat defines whether structured loggings are used.
|
|
LoggingFormat logFormat
|
|
// LogLevel defines the levels to log.
|
|
LogLevel StringLevel
|
|
// TelemetryGCP defines whether GCP exporter is used.
|
|
TelemetryGCP bool
|
|
// TelemetryOTLP defines OTLP collector url for telemetry exports.
|
|
TelemetryOTLP string
|
|
// TelemetryServiceName defines the value of service.name resource attribute.
|
|
TelemetryServiceName string
|
|
// Stdio indicates if Toolbox is listening via MCP stdio.
|
|
Stdio bool
|
|
// DisableReload indicates if the user has disabled dynamic reloading for Toolbox.
|
|
DisableReload bool
|
|
// UI indicates if Toolbox UI endpoints (/ui) are available.
|
|
UI bool
|
|
// Specifies a list of origins permitted to access this server.
|
|
AllowedOrigins []string
|
|
// Specifies a list of hosts permitted to access this server.
|
|
AllowedHosts []string
|
|
// UserAgentMetadata specifies additional metadata to append to the User-Agent string.
|
|
UserAgentMetadata []string
|
|
}
|
|
|
|
type logFormat string
|
|
|
|
// String is used by both fmt.Print and by Cobra in help text
|
|
func (f *logFormat) String() string {
|
|
if string(*f) != "" {
|
|
return strings.ToLower(string(*f))
|
|
}
|
|
return "standard"
|
|
}
|
|
|
|
// validate logging format flag
|
|
func (f *logFormat) Set(v string) error {
|
|
switch strings.ToLower(v) {
|
|
case "standard", "json":
|
|
*f = logFormat(v)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf(`log format must be one of "standard", or "json"`)
|
|
}
|
|
}
|
|
|
|
// Type is used in Cobra help text
|
|
func (f *logFormat) Type() string {
|
|
return "logFormat"
|
|
}
|
|
|
|
type StringLevel string
|
|
|
|
// String is used by both fmt.Print and by Cobra in help text
|
|
func (s *StringLevel) String() string {
|
|
if string(*s) != "" {
|
|
return strings.ToLower(string(*s))
|
|
}
|
|
return "info"
|
|
}
|
|
|
|
// validate log level flag
|
|
func (s *StringLevel) Set(v string) error {
|
|
switch strings.ToLower(v) {
|
|
case "debug", "info", "warn", "error":
|
|
*s = StringLevel(v)
|
|
return nil
|
|
default:
|
|
return fmt.Errorf(`log level must be one of "debug", "info", "warn", or "error"`)
|
|
}
|
|
}
|
|
|
|
// Type is used in Cobra help text
|
|
func (s *StringLevel) Type() string {
|
|
return "stringLevel"
|
|
}
|
|
|
|
type SourceConfigs map[string]sources.SourceConfig
|
|
type AuthServiceConfigs map[string]auth.AuthServiceConfig
|
|
type EmbeddingModelConfigs map[string]embeddingmodels.EmbeddingModelConfig
|
|
type ToolConfigs map[string]tools.ToolConfig
|
|
type ToolsetConfigs map[string]tools.ToolsetConfig
|
|
type PromptConfigs map[string]prompts.PromptConfig
|
|
type PromptsetConfigs map[string]prompts.PromptsetConfig
|
|
|
|
func UnmarshalResourceConfig(ctx context.Context, raw []byte) (SourceConfigs, AuthServiceConfigs, EmbeddingModelConfigs, ToolConfigs, ToolsetConfigs, PromptConfigs, error) {
|
|
// prepare configs map
|
|
var sourceConfigs SourceConfigs
|
|
var authServiceConfigs AuthServiceConfigs
|
|
var embeddingModelConfigs EmbeddingModelConfigs
|
|
var toolConfigs ToolConfigs
|
|
var toolsetConfigs ToolsetConfigs
|
|
var promptConfigs PromptConfigs
|
|
// promptset configs is not yet supported
|
|
|
|
decoder := yaml.NewDecoder(bytes.NewReader(raw))
|
|
// for loop to unmarshal documents with the `---` separator
|
|
for {
|
|
var resource map[string]any
|
|
if err := decoder.DecodeContext(ctx, &resource); err != nil {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to decode YAML document: %w", err)
|
|
}
|
|
var kind, name string
|
|
var ok bool
|
|
if kind, ok = resource["kind"].(string); !ok {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("missing 'kind' field or it is not a string: %v", resource)
|
|
}
|
|
if name, ok = resource["name"].(string); !ok {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("missing 'name' field or it is not a string")
|
|
}
|
|
// remove 'kind' from map for strict unmarshaling
|
|
delete(resource, "kind")
|
|
|
|
switch kind {
|
|
case "sources":
|
|
c, err := UnmarshalYAMLSourceConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if sourceConfigs == nil {
|
|
sourceConfigs = make(SourceConfigs)
|
|
}
|
|
sourceConfigs[name] = c
|
|
case "authServices":
|
|
c, err := UnmarshalYAMLAuthServiceConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if authServiceConfigs == nil {
|
|
authServiceConfigs = make(AuthServiceConfigs)
|
|
}
|
|
authServiceConfigs[name] = c
|
|
case "tools":
|
|
c, err := UnmarshalYAMLToolConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if toolConfigs == nil {
|
|
toolConfigs = make(ToolConfigs)
|
|
}
|
|
toolConfigs[name] = c
|
|
case "toolsets":
|
|
c, err := UnmarshalYAMLToolsetConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if toolsetConfigs == nil {
|
|
toolsetConfigs = make(ToolsetConfigs)
|
|
}
|
|
toolsetConfigs[name] = c
|
|
case "embeddingModels":
|
|
c, err := UnmarshalYAMLEmbeddingModelConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if embeddingModelConfigs == nil {
|
|
embeddingModelConfigs = make(EmbeddingModelConfigs)
|
|
}
|
|
embeddingModelConfigs[name] = c
|
|
case "prompts":
|
|
c, err := UnmarshalYAMLPromptConfig(ctx, name, resource)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %s", kind, err)
|
|
}
|
|
if promptConfigs == nil {
|
|
promptConfigs = make(PromptConfigs)
|
|
}
|
|
promptConfigs[name] = c
|
|
default:
|
|
return nil, nil, nil, nil, nil, nil, fmt.Errorf("invalid kind %s", kind)
|
|
}
|
|
}
|
|
return sourceConfigs, authServiceConfigs, embeddingModelConfigs, toolConfigs, toolsetConfigs, promptConfigs, nil
|
|
}
|
|
|
|
func UnmarshalYAMLSourceConfig(ctx context.Context, name string, r map[string]any) (sources.SourceConfig, error) {
|
|
resourceType, ok := r["type"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing 'type' field or it is not a string")
|
|
}
|
|
dec, err := util.NewStrictDecoder(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating decoder: %w", err)
|
|
}
|
|
sourceConfig, err := sources.DecodeConfig(ctx, resourceType, name, dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return sourceConfig, nil
|
|
}
|
|
|
|
func UnmarshalYAMLAuthServiceConfig(ctx context.Context, name string, r map[string]any) (auth.AuthServiceConfig, error) {
|
|
resourceType, ok := r["type"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing 'type' field or it is not a string")
|
|
}
|
|
if resourceType != google.AuthServiceType {
|
|
return nil, fmt.Errorf("%s is not a valid type of auth service", resourceType)
|
|
}
|
|
dec, err := util.NewStrictDecoder(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating decoder: %s", err)
|
|
}
|
|
actual := google.Config{Name: name}
|
|
if err := dec.DecodeContext(ctx, &actual); err != nil {
|
|
return nil, fmt.Errorf("unable to parse as %s: %w", name, err)
|
|
}
|
|
return actual, nil
|
|
}
|
|
|
|
func UnmarshalYAMLEmbeddingModelConfig(ctx context.Context, name string, r map[string]any) (embeddingmodels.EmbeddingModelConfig, error) {
|
|
resourceType, ok := r["type"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing 'type' field or it is not a string")
|
|
}
|
|
if resourceType != gemini.EmbeddingModelType {
|
|
return nil, fmt.Errorf("%s is not a valid type of embedding model", resourceType)
|
|
}
|
|
dec, err := util.NewStrictDecoder(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating decoder: %s", err)
|
|
}
|
|
actual := gemini.Config{Name: name}
|
|
if err := dec.DecodeContext(ctx, &actual); err != nil {
|
|
return nil, fmt.Errorf("unable to parse as %q: %w", name, err)
|
|
}
|
|
return actual, nil
|
|
}
|
|
|
|
func UnmarshalYAMLToolConfig(ctx context.Context, name string, r map[string]any) (tools.ToolConfig, error) {
|
|
resourceType, ok := r["type"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("missing 'type' field or it is not a string")
|
|
}
|
|
// `authRequired` and `useClientOAuth` cannot be specified together
|
|
if r["authRequired"] != nil && r["useClientOAuth"] == true {
|
|
return nil, fmt.Errorf("`authRequired` and `useClientOAuth` are mutually exclusive. Choose only one authentication method")
|
|
}
|
|
// Make `authRequired` an empty list instead of nil for Tool manifest
|
|
if r["authRequired"] == nil {
|
|
r["authRequired"] = []string{}
|
|
}
|
|
|
|
// validify parameter references
|
|
if rawParams, ok := r["parameters"]; ok {
|
|
if paramsList, ok := rawParams.([]any); ok {
|
|
// Turn params into a map
|
|
validParamNames := make(map[string]bool)
|
|
for _, rawP := range paramsList {
|
|
if pMap, ok := rawP.(map[string]any); ok {
|
|
if pName, ok := pMap["name"].(string); ok && pName != "" {
|
|
validParamNames[pName] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate references
|
|
for i, rawP := range paramsList {
|
|
pMap, ok := rawP.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
pName, _ := pMap["name"].(string)
|
|
refName, _ := pMap["valueFromParam"].(string)
|
|
|
|
if refName != "" {
|
|
// Check if the referenced parameter exists
|
|
if !validParamNames[refName] {
|
|
return nil, fmt.Errorf("tool %q config error: parameter %q (index %d) references '%q' in the 'valueFromParam' field, which is not a defined parameter", name, pName, i, refName)
|
|
}
|
|
|
|
// Check for self-reference
|
|
if refName == pName {
|
|
return nil, fmt.Errorf("tool %q config error: parameter %q cannot copy value from itself", name, pName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dec, err := util.NewStrictDecoder(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating decoder: %s", err)
|
|
}
|
|
toolCfg, err := tools.DecodeConfig(ctx, resourceType, name, dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return toolCfg, nil
|
|
}
|
|
|
|
func UnmarshalYAMLToolsetConfig(ctx context.Context, name string, r map[string]any) (tools.ToolsetConfig, error) {
|
|
var toolsetConfig tools.ToolsetConfig
|
|
toolList, ok := r["tools"].([]any)
|
|
if !ok {
|
|
return toolsetConfig, fmt.Errorf("tools is missing or not a list of strings: %v", r)
|
|
}
|
|
justTools := map[string]any{"tools": toolList}
|
|
dec, err := util.NewStrictDecoder(justTools)
|
|
if err != nil {
|
|
return toolsetConfig, fmt.Errorf("error creating decoder: %s", err)
|
|
}
|
|
var raw map[string][]string
|
|
if err := dec.DecodeContext(ctx, &raw); err != nil {
|
|
return toolsetConfig, fmt.Errorf("unable to unmarshal tools: %s", err)
|
|
}
|
|
return tools.ToolsetConfig{Name: name, ToolNames: raw["tools"]}, nil
|
|
}
|
|
|
|
func UnmarshalYAMLPromptConfig(ctx context.Context, name string, r map[string]any) (prompts.PromptConfig, error) {
|
|
// Look for the 'type' field. If it's not present, typeStr will be an
|
|
// empty string, which prompts.DecodeConfig will correctly default to "custom".
|
|
var resourceType string
|
|
if typeVal, ok := r["type"]; ok {
|
|
var isString bool
|
|
resourceType, isString = typeVal.(string)
|
|
if !isString {
|
|
return nil, fmt.Errorf("invalid 'type' field for prompt %q (must be a string)", name)
|
|
}
|
|
}
|
|
dec, err := util.NewStrictDecoder(r)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error creating decoder: %s", err)
|
|
}
|
|
|
|
// Use the central registry to decode the prompt based on its type.
|
|
promptCfg, err := prompts.DecodeConfig(ctx, resourceType, name, dec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return promptCfg, nil
|
|
}
|
|
|
|
// Tools naming validation is added in the MCP v2025-11-25, but we'll be
|
|
// implementing it across Toolbox
|
|
// Tool names SHOULD be between 1 and 128 characters in length (inclusive).
|
|
// Tool names SHOULD be considered case-sensitive.
|
|
// The following SHOULD be the only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.)
|
|
// Tool names SHOULD NOT contain spaces, commas, or other special characters.
|
|
// Tool names SHOULD be unique within a server.
|
|
func NameValidation(name string) error {
|
|
strLen := len(name)
|
|
if strLen < 1 || strLen > 128 {
|
|
return fmt.Errorf("resource name SHOULD be between 1 and 128 characters in length (inclusive)")
|
|
}
|
|
validChars := regexp.MustCompile("^[a-zA-Z0-9_.-]+$")
|
|
isValid := validChars.MatchString(name)
|
|
if !isValid {
|
|
return fmt.Errorf("invalid character for resource name; only uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.) is allowed")
|
|
}
|
|
return nil
|
|
}
|