Files
wehub-resource-sync e04ed9c211
CF: Deploy Dev Docs / deploy (push) Has been cancelled
Sync Labels / build (push) Has been cancelled
tests / unit tests (macos-latest) (push) Has been cancelled
tests / unit tests (windows-latest) (push) Has been cancelled
tests / unit tests (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:32:45 +08:00

460 lines
14 KiB
Go

// 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 internal
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/goccy/go-yaml"
"github.com/google/go-cmp/cmp"
"github.com/googleapis/mcp-toolbox/internal/auth/generic"
"github.com/googleapis/mcp-toolbox/internal/server"
)
type Config struct {
Sources server.SourceConfigs `yaml:"sources"`
AuthServices server.AuthServiceConfigs `yaml:"authServices"`
EmbeddingModels server.EmbeddingModelConfigs `yaml:"embeddingModels"`
Tools server.ToolConfigs `yaml:"tools"`
Toolsets server.ToolsetConfigs `yaml:"toolsets"`
Prompts server.PromptConfigs `yaml:"prompts"`
}
type ConfigParser struct {
EnvVars map[string]string
OptionalEnvVars []string
requiredEnvVars []string
// AllowMissingEnvVars, when true, substitutes the variable name for an unset
// required ${VAR} placeholder instead of erroring. Used by offline flows like
// skills-generate, where source env vars are needed only to satisfy config
// parsing/validation, never to connect. A non-empty placeholder is used (not
// "") so required string fields still pass validation. The served path leaves
// this false so missing config still fails fast.
AllowMissingEnvVars bool
}
// parseEnv replaces environment variables ${ENV_NAME} with their values.
// also support ${ENV_NAME:default_value}.
func (p *ConfigParser) parseEnv(input string) (string, error) {
re := regexp.MustCompile(`\$\{(\w+)(:([^}]*))?\}`)
if p.EnvVars == nil {
p.EnvVars = make(map[string]string)
}
var err error
matches := re.FindAllStringSubmatchIndex(input, -1)
var output strings.Builder
lastIndex := 0
for _, match := range matches {
start, end := match[0], match[1]
output.WriteString(input[lastIndex:start])
variableName := input[match[2]:match[3]]
defaultValue := ""
defaultProvided := match[4] != -1 && match[5] != -1
if defaultProvided {
defaultValue = input[match[6]:match[7]]
}
if defaultProvided {
p.OptionalEnvVars = append(p.OptionalEnvVars, variableName)
} else {
p.requiredEnvVars = append(p.requiredEnvVars, variableName)
}
if value, found := os.LookupEnv(variableName); found {
p.EnvVars[variableName] = value
output.WriteString(value)
} else if defaultProvided {
p.EnvVars[variableName] = defaultValue
output.WriteString(defaultValue)
} else {
if p.AllowMissingEnvVars {
p.EnvVars[variableName] = variableName
output.WriteString(variableName)
} else if err == nil {
line, column := lineColumnAt(input, start)
err = fmt.Errorf("environment variable not found: %q (line %d, column %d)", variableName, line, column)
}
}
lastIndex = end
}
output.WriteString(input[lastIndex:])
// Filter out OptionalEnvVars that were also found as required
var finalOptional []string
for _, v := range p.OptionalEnvVars {
if !slices.Contains(p.requiredEnvVars, v) && !slices.Contains(finalOptional, v) {
finalOptional = append(finalOptional, v)
}
}
p.OptionalEnvVars = finalOptional
return output.String(), err
}
// ParseConfig parses the provided yaml into appropriate configs.
func lineColumnAt(input string, index int) (int, int) {
line := 1
column := 1
for i, r := range input {
if i >= index {
break
}
if r == '\n' {
line++
column = 1
} else {
column++
}
}
return line, column
}
func (p *ConfigParser) ParseConfig(ctx context.Context, raw []byte) (Config, error) {
var config Config
// Replace environment variables if found
output, err := p.parseEnv(string(raw))
if err != nil {
return config, fmt.Errorf("error parsing environment variables: %s", err)
}
raw = []byte(output)
raw, err = ConvertConfig(raw)
if err != nil {
return config, fmt.Errorf("error converting config file: %s", err)
}
// Parse contents
config.Sources, config.AuthServices, config.EmbeddingModels, config.Tools, config.Toolsets, config.Prompts, err = server.UnmarshalResourceConfig(ctx, raw)
if err != nil {
return config, err
}
return config, nil
}
// ConvertConfig converts configuration file to flat format.
func ConvertConfig(raw []byte) ([]byte, error) {
var buf bytes.Buffer
// Manually copy top-level comments and empty lines from the source
scanner := bufio.NewScanner(bytes.NewReader(raw))
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// If the line is a comment or whitespace, preserve it
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
buf.WriteString(line + "\n")
} else {
// Stop at the first line of actual data
break
}
}
// convert configuration file to flat format
var input yaml.MapSlice
decoder := yaml.NewDecoder(bytes.NewReader(raw), yaml.UseOrderedMap())
encoder := yaml.NewEncoder(&buf, yaml.UseLiteralStyleIfMultiline(true))
nestedFormatKey := []string{"sources", "authServices", "embeddingModels", "tools", "toolsets", "prompts"}
docIndex := 0
for {
if err := decoder.Decode(&input); err != nil {
if err == io.EOF {
break
}
return nil, err
}
docIndex++
for _, item := range input {
key, ok := item.Key.(string)
if !ok {
return nil, fmt.Errorf("doc %d: unexpected non-string key in input: %v", docIndex, item.Key)
}
if hasKindField(input) {
// this doc is already in flat format, encode to buf
if err := encoder.Encode(input); err != nil {
return nil, err
}
break
}
// check if value conversion to yaml.MapSlice successfully
if slice, ok := item.Value.(yaml.MapSlice); slices.Contains(nestedFormatKey, key) && ok {
switch key {
case "authServices":
key = "authService"
case "sources":
key = "source"
case "embeddingModels":
key = "embeddingModel"
case "tools":
key = "tool"
case "toolsets":
key = "toolset"
case "prompts":
key = "prompt"
}
transformed, err := transformDocs(key, slice)
if err != nil {
return nil, fmt.Errorf("doc %d: invalid config format at key %q: %w", docIndex, key, err)
}
// encode per-doc
for _, doc := range transformed {
if err := encoder.Encode(doc); err != nil {
return nil, err
}
}
} else {
return nil, fmt.Errorf("doc %d: invalid config format at key %q: expected nested format keys and type map", docIndex, key)
}
}
}
return buf.Bytes(), nil
}
// hasKindField is a helper function to check if an input is in flat format
func hasKindField(input yaml.MapSlice) bool {
for _, item := range input {
if key, ok := item.Key.(string); ok && key == "kind" {
return true
}
}
return false
}
// transformDocs transforms the configuration file from nested to flat format
// yaml.MapSlice will preserve the order in a map
func transformDocs(kind string, input yaml.MapSlice) ([]yaml.MapSlice, error) {
var transformed []yaml.MapSlice
for _, entry := range input {
entryName, ok := entry.Key.(string)
if !ok {
return nil, fmt.Errorf("unexpected non-string key for entry in '%s': %v", kind, entry.Key)
}
entryBody := processValue(entry.Value, kind == "toolset")
currentTransformed := yaml.MapSlice{
{Key: "kind", Value: kind},
{Key: "name", Value: entryName},
}
// Merge the transformed body into our result
if bodySlice, ok := entryBody.(yaml.MapSlice); ok {
currentTransformed = append(currentTransformed, bodySlice...)
} else {
return nil, fmt.Errorf("unable to convert entryBody to MapSlice")
}
transformed = append(transformed, currentTransformed)
}
return transformed, nil
}
// processValue recursively looks for MapSlices to rename 'kind' -> 'type'
func processValue(v any, isToolset bool) any {
switch val := v.(type) {
case yaml.MapSlice:
// creating a new MapSlice is safer for recursive transformation
newVal := make(yaml.MapSlice, len(val))
for i, item := range val {
// Perform renaming
if item.Key == "kind" {
item.Key = "type"
}
// Recursive call for nested values (e.g., nested objects or lists)
item.Value = processValue(item.Value, false)
newVal[i] = item
}
return newVal
case []any:
// Process lists: If it's a toolset top-level list, wrap it.
if isToolset {
return yaml.MapSlice{{Key: "tools", Value: val}}
}
// Otherwise, recurse into list items (to catch nested objects)
newVal := make([]any, len(val))
for i := range val {
newVal[i] = processValue(val[i], false)
}
return newVal
default:
return val
}
}
// mergeConfigs merges multiple Config structs into one.
// Detects and raises errors for resource conflicts in sources, authServices, tools, and toolsets.
// All resource names (sources, authServices, tools, toolsets) must be unique across all files.
func mergeConfigs(files ...Config) (Config, error) {
merged := Config{
Sources: make(server.SourceConfigs),
AuthServices: make(server.AuthServiceConfigs),
EmbeddingModels: make(server.EmbeddingModelConfigs),
Tools: make(server.ToolConfigs),
Toolsets: make(server.ToolsetConfigs),
Prompts: make(server.PromptConfigs),
}
var conflicts []string
for fileIndex, file := range files {
// Check for conflicts and merge sources
for name, source := range file.Sources {
if mergedSource, exists := merged.Sources[name]; exists {
if !cmp.Equal(mergedSource, source) {
conflicts = append(conflicts, fmt.Sprintf("source '%s' (file #%d)", name, fileIndex+1))
}
} else {
merged.Sources[name] = source
}
}
// Check for conflicts and merge authServices
for name, authService := range file.AuthServices {
if _, exists := merged.AuthServices[name]; exists {
conflicts = append(conflicts, fmt.Sprintf("authService '%s' (file #%d)", name, fileIndex+1))
} else {
merged.AuthServices[name] = authService
}
}
// Check for conflicts and merge embeddingModels
for name, em := range file.EmbeddingModels {
if _, exists := merged.EmbeddingModels[name]; exists {
conflicts = append(conflicts, fmt.Sprintf("embedding model '%s' (file #%d)", name, fileIndex+1))
} else {
merged.EmbeddingModels[name] = em
}
}
// Check for conflicts and merge tools
for name, tool := range file.Tools {
if _, exists := merged.Tools[name]; exists {
conflicts = append(conflicts, fmt.Sprintf("tool '%s' (file #%d)", name, fileIndex+1))
} else {
merged.Tools[name] = tool
}
}
// Check for conflicts and merge toolsets
for name, toolset := range file.Toolsets {
if _, exists := merged.Toolsets[name]; exists {
conflicts = append(conflicts, fmt.Sprintf("toolset '%s' (file #%d)", name, fileIndex+1))
} else {
merged.Toolsets[name] = toolset
}
}
// Check for conflicts and merge prompts
for name, prompt := range file.Prompts {
if _, exists := merged.Prompts[name]; exists {
conflicts = append(conflicts, fmt.Sprintf("prompt '%s' (file #%d)", name, fileIndex+1))
} else {
merged.Prompts[name] = prompt
}
}
}
// If conflicts were detected, return an error
if len(conflicts) > 0 {
return Config{}, fmt.Errorf("resource conflicts detected:\n - %s\n\nPlease ensure each source, authService, tool, toolset and prompt has a unique name across all files", strings.Join(conflicts, "\n - "))
}
// Ensure only one authService has mcpEnabled = true
var mcpEnabledAuthServers []string
for name, authService := range merged.AuthServices {
// Only generic type has McpEnabled right now
if genericService, ok := authService.(generic.Config); ok && genericService.McpEnabled {
mcpEnabledAuthServers = append(mcpEnabledAuthServers, name)
}
}
if len(mcpEnabledAuthServers) > 1 {
return Config{}, fmt.Errorf("multiple authServices with mcpEnabled=true detected: %s. Only one MCP authorization server is currently supported", strings.Join(mcpEnabledAuthServers, ", "))
}
return merged, nil
}
// LoadAndMergeConfigs loads multiple YAML files and merges them
func (p *ConfigParser) LoadAndMergeConfigs(ctx context.Context, filePaths []string) (Config, error) {
var configs []Config
for _, filePath := range filePaths {
buf, err := os.ReadFile(filePath)
if err != nil {
return Config{}, fmt.Errorf("unable to read config file at %q: %w", filePath, err)
}
config, err := p.ParseConfig(ctx, buf)
if err != nil {
return Config{}, fmt.Errorf("unable to parse config file at %q: %w", filePath, err)
}
configs = append(configs, config)
}
if len(configs) == 0 {
return Config{}, fmt.Errorf("no YAML files found")
}
if len(configs) > 1 {
mergedFile, err := mergeConfigs(configs...)
if err != nil {
return Config{}, fmt.Errorf("unable to merge config files: %w", err)
}
return mergedFile, nil
}
return configs[0], nil
}
// GetPathsFromConfigFolder loads all YAML files from a directory and merges them
func GetPathsFromConfigFolder(ctx context.Context, folderPath string) ([]string, error) {
// Check if directory exists
info, err := os.Stat(folderPath)
if err != nil {
return nil, fmt.Errorf("unable to access config folder at %q: %w", folderPath, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("path %q is not a directory", folderPath)
}
// Find all YAML files in the directory
pattern := filepath.Join(folderPath, "*.yaml")
yamlFiles, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("error finding YAML files in %q: %w", folderPath, err)
}
// Also find .yml files
ymlPattern := filepath.Join(folderPath, "*.yml")
ymlFiles, err := filepath.Glob(ymlPattern)
if err != nil {
return nil, fmt.Errorf("error finding YML files in %q: %w", folderPath, err)
}
// Combine both file lists
allFiles := append(yamlFiles, ymlFiles...)
return allFiles, nil
}