From 47e4a807770f084b9aeeeb0752940b13d6cedfd6 Mon Sep 17 00:00:00 2001 From: Disha Prakash Date: Thu, 25 Jun 2026 11:31:36 +0000 Subject: [PATCH] feat: implement text and file resource types with path sandboxing, hidden file blocking, and boot-phase URI/collision validation --- cmd/internal/config.go | 30 ++- cmd/internal/invoke/command.go | 2 +- cmd/internal/options.go | 34 +++ cmd/root.go | 2 +- internal/resources/file.go | 231 +++++++++++++++++ internal/resources/resources.go | 94 +++++++ internal/resources/resources_test.go | 327 +++++++++++++++++++++++++ internal/resources/text.go | 132 ++++++++++ internal/server/config.go | 165 +++++++++---- internal/server/resources_boot_test.go | 213 ++++++++++++++++ internal/server/server.go | 141 ++++++++++- 11 files changed, 1300 insertions(+), 71 deletions(-) create mode 100644 internal/resources/file.go create mode 100644 internal/resources/resources.go create mode 100644 internal/resources/resources_test.go create mode 100644 internal/resources/text.go create mode 100644 internal/server/resources_boot_test.go diff --git a/cmd/internal/config.go b/cmd/internal/config.go index 337c2cd237ec..3e2a71532283 100644 --- a/cmd/internal/config.go +++ b/cmd/internal/config.go @@ -33,12 +33,14 @@ import ( ) 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"` + 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"` + Resources server.ResourceConfigs `yaml:"resources"` + ResourceTemplates server.ResourceConfigs `yaml:"resourceTemplates"` } type ConfigParser struct { @@ -150,10 +152,18 @@ func (p *ConfigParser) ParseConfig(ctx context.Context, raw []byte) (Config, err } // Parse contents - config.Sources, config.AuthServices, config.EmbeddingModels, config.Tools, config.Toolsets, config.Prompts, err = server.UnmarshalResourceConfig(ctx, raw) + unmarshaled, err := server.UnmarshalConfigs(ctx, raw) if err != nil { return config, err } + config.Sources = unmarshaled.Sources + config.AuthServices = unmarshaled.AuthServices + config.EmbeddingModels = unmarshaled.EmbeddingModels + config.Tools = unmarshaled.Tools + config.Toolsets = unmarshaled.Toolsets + config.Prompts = unmarshaled.Prompts + config.Resources = unmarshaled.Resources + config.ResourceTemplates = unmarshaled.ResourceTemplates return config, nil } @@ -180,7 +190,7 @@ func ConvertConfig(raw []byte) ([]byte, error) { decoder := yaml.NewDecoder(bytes.NewReader(raw), yaml.UseOrderedMap()) encoder := yaml.NewEncoder(&buf, yaml.UseLiteralStyleIfMultiline(true)) - nestedFormatKey := []string{"sources", "authServices", "embeddingModels", "tools", "toolsets", "prompts"} + nestedFormatKey := []string{"sources", "authServices", "embeddingModels", "tools", "toolsets", "prompts", "resources", "resourceTemplates"} docIndex := 0 for { if err := decoder.Decode(&input); err != nil { @@ -217,6 +227,10 @@ func ConvertConfig(raw []byte) ([]byte, error) { key = "toolset" case "prompts": key = "prompt" + case "resources": + key = "resource" + case "resourceTemplates": + key = "resourceTemplate" } transformed, err := transformDocs(key, slice) if err != nil { diff --git a/cmd/internal/invoke/command.go b/cmd/internal/invoke/command.go index 7f6354d0527a..3828b8b7357e 100644 --- a/cmd/internal/invoke/command.go +++ b/cmd/internal/invoke/command.go @@ -64,7 +64,7 @@ func runInvoke(cmd *cobra.Command, args []string, opts *internal.ToolboxOptions) } // Initialize Resources - sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, opts.Cfg) + sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, _, _, err := server.InitializeConfigs(ctx, opts.Cfg) if err != nil { errMsg := fmt.Errorf("failed to initialize resources: %w", err) opts.Logger.ErrorContext(ctx, errMsg.Error()) diff --git a/cmd/internal/options.go b/cmd/internal/options.go index 3affd3aaf4ee..afde2a6c8b23 100644 --- a/cmd/internal/options.go +++ b/cmd/internal/options.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "strings" @@ -304,6 +305,39 @@ func (opts *ToolboxOptions) LoadConfig(ctx context.Context, parser *ConfigParser opts.Cfg.ToolConfigs = finalConfig.Tools opts.Cfg.ToolsetConfigs = finalConfig.Toolsets opts.Cfg.PromptConfigs = finalConfig.Prompts + opts.Cfg.ResourceConfigs = finalConfig.Resources + opts.Cfg.ResourceTemplateConfigs = finalConfig.ResourceTemplates + + // Set configuration directory tracking + if opts.ConfigFolder != "" { + absDir, err := filepath.Abs(opts.ConfigFolder) + if err != nil { + return isCustomConfigured, fmt.Errorf("invalid config folder: %w", err) + } + opts.Cfg.ConfigDir = absDir + opts.Cfg.UsingConfigFolder = true + } else if opts.Config != "" { + absDir, err := filepath.Abs(filepath.Dir(opts.Config)) + if err != nil { + return isCustomConfigured, fmt.Errorf("invalid config file path: %w", err) + } + opts.Cfg.ConfigDir = absDir + opts.Cfg.UsingConfigFolder = false + } else if len(opts.Configs) > 0 { + absDir, err := filepath.Abs(filepath.Dir(opts.Configs[0])) + if err != nil { + return isCustomConfigured, fmt.Errorf("invalid config path: %w", err) + } + opts.Cfg.ConfigDir = absDir + opts.Cfg.UsingConfigFolder = false + } else { + absDir, err := filepath.Abs(".") + if err != nil { + return isCustomConfigured, fmt.Errorf("failed to resolve current directory: %w", err) + } + opts.Cfg.ConfigDir = absDir + opts.Cfg.UsingConfigFolder = false + } return isCustomConfigured, nil } diff --git a/cmd/root.go b/cmd/root.go index f3e8d9862431..3a6b32834657 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -181,7 +181,7 @@ func validateReloadEdits( IgnoreUnknownTools: util.IgnoreUnknownToolsFromContext(ctx), } - sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, reloadedConfig) + sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, _, _, err := server.InitializeConfigs(ctx, reloadedConfig) if err != nil { errMsg := fmt.Errorf("unable to initialize reloaded configs: %w", err) logger.WarnContext(ctx, errMsg.Error()) diff --git a/internal/resources/file.go b/internal/resources/file.go new file mode 100644 index 000000000000..9256389081ce --- /dev/null +++ b/internal/resources/file.go @@ -0,0 +1,231 @@ +// 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 resources + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + yaml "github.com/goccy/go-yaml" +) + +const FileResourceType = "file" +const MaxFileSize = 5 * 1024 * 1024 // 5 MB + +func init() { + Register(FileResourceType, newFileConfig) +} + +func newFileConfig(ctx context.Context, name string, decoder *yaml.Decoder) (ResourceConfig, error) { + cfg := &FileConfig{Name: name} + if err := decoder.DecodeContext(ctx, cfg); err != nil { + return nil, fmt.Errorf("failed to decode file resource config %q: %w", name, err) + } + return cfg, nil +} + +// FileConfig represents the configuration for a file-based resource or template. +type FileConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + URI string `yaml:"uri"` + Description string `yaml:"description,omitempty"` + Title string `yaml:"title,omitempty"` + MimeType string `yaml:"mimeType,omitempty"` + AllowedPaths []string `yaml:"allowedPaths,omitempty"` + Annotations *Annotations `yaml:"annotations,omitempty"` +} + +func (c *FileConfig) ResourceConfigType() string { + return FileResourceType +} + +// Initialize validates and prepares the FileResource. +func (c *FileConfig) Initialize(ctx context.Context, configDir string, usingConfigFolder bool) (Resource, error) { + // Strict Scheme Whitelisting + if !strings.HasPrefix(c.URI, "file://") { + return nil, fmt.Errorf("resource %q URI must start with file://", c.Name) + } + + // Safety Check: Enforce allowed paths if started with config-folder + if usingConfigFolder && len(c.AllowedPaths) == 0 { + return nil, fmt.Errorf("security error: resource %q of type 'file' must explicitly define 'allowedPaths' when starting the server with a config directory flag", c.Name) + } + + // Determine sandbox roots + var sandboxRoots []string + if len(c.AllowedPaths) > 0 { + for _, p := range c.AllowedPaths { + abs, err := filepath.Abs(p) + if err != nil { + return nil, fmt.Errorf("invalid allowed path %q for resource %q: %w", p, c.Name, err) + } + sandboxRoots = append(sandboxRoots, abs) + } + } else { + // Default sandbox is the directory where the configuration file lives + abs, err := filepath.Abs(configDir) + if err != nil { + return nil, fmt.Errorf("invalid config directory: %w", err) + } + sandboxRoots = append(sandboxRoots, abs) + } + + // Default annotations: priority to 1.0 if not defined + ann := c.Annotations + if ann == nil { + prio := 1.0 + ann = &Annotations{ + Priority: &prio, + } + } else if ann.Priority == nil { + prio := 1.0 + ann.Priority = &prio + } + + return &FileResource{ + cfg: c, + sandboxRoots: sandboxRoots, + ann: ann, + }, nil +} + +// FileResource represents the initialized file resource. +type FileResource struct { + cfg *FileConfig + sandboxRoots []string + ann *Annotations +} + +func (r *FileResource) ResourceName() string { + return r.cfg.Name +} + +func (r *FileResource) ResourceURI() string { + return r.cfg.URI +} + +func (r *FileResource) ResourceType() string { + return FileResourceType +} + +func (r *FileResource) ResourceDescription() string { + return r.cfg.Description +} + +func (r *FileResource) ResourceTitle() string { + return r.cfg.Title +} + +func (r *FileResource) ResourceMimeType() string { + if r.cfg.MimeType != "" { + return r.cfg.MimeType + } + return "text/plain" +} + +func (r *FileResource) ResourceAnnotations() *Annotations { + return r.ann +} + +// Read resolves the requested path, applies strict sandboxing and security checks, and returns the file content. +func (r *FileResource) Read(ctx context.Context, params map[string]any) ([]ResourceContent, error) { + // Substitute template parameters if this is a template + actualURI := r.cfg.URI + if strings.Contains(actualURI, "{path}") { + val, ok := params["path"] + if !ok { + return nil, fmt.Errorf("missing required parameter 'path' for resource template %q", r.cfg.Name) + } + pathStr, ok := val.(string) + if !ok { + return nil, fmt.Errorf("parameter 'path' must be a string for resource template %q", r.cfg.Name) + } + actualURI = strings.ReplaceAll(actualURI, "{path}", pathStr) + } + + // Extract file path from the URI + filePath := strings.TrimPrefix(actualURI, "file://") + + // 1. Clean the path to resolve traversals like '../' + cleanedPath := filepath.Clean(filePath) + + // 2. Resolve symlinks to find the true physical path + resolvedPath, err := filepath.EvalSymlinks(cleanedPath) + if err != nil { + return nil, fmt.Errorf("failed to access resource path: %w", err) + } + + // 3. Sandbox Enforcement: verify path is strictly inside one of the sandbox roots + isInside := false + for _, root := range r.sandboxRoots { + if isPathInside(resolvedPath, root) { + isInside = true + break + } + } + if !isInside { + return nil, fmt.Errorf("permission denied: path %q escapes the allowed sandbox roots", filePath) + } + + // 4. Hidden Files Check: block reading hidden files or directories (starting with '.') + segments := strings.Split(resolvedPath, string(filepath.Separator)) + for _, seg := range segments { + if strings.HasPrefix(seg, ".") && seg != "." && seg != ".." { + return nil, fmt.Errorf("permission denied: reading hidden files or directories is not allowed") + } + } + + // 5. Size and Directory validation + info, err := os.Stat(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to stat file: %w", err) + } + if info.IsDir() { + return nil, fmt.Errorf("invalid resource: %q is a directory, not a file", filePath) + } + if info.Size() > MaxFileSize { + return nil, fmt.Errorf("file size %d bytes exceeds the maximum limit of 5MB", info.Size()) + } + + // Read the file content + contentBytes, err := os.ReadFile(resolvedPath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return []ResourceContent{ + { + URI: actualURI, + MimeType: r.ResourceMimeType(), + Text: string(contentBytes), + }, + }, nil +} + +func (r *FileResource) ToConfig() ResourceConfig { + return r.cfg +} + +// isPathInside returns true if path matches root or lies within root. +func isPathInside(path, root string) bool { + if path == root { + return true + } + return strings.HasPrefix(path, root+string(filepath.Separator)) +} diff --git a/internal/resources/resources.go b/internal/resources/resources.go new file mode 100644 index 000000000000..56021d4616d9 --- /dev/null +++ b/internal/resources/resources.go @@ -0,0 +1,94 @@ +// 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 resources + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" +) + +// ResourceConfigFactory creates a configuration instance from YAML. +type ResourceConfigFactory func(ctx context.Context, name string, decoder *yaml.Decoder) (ResourceConfig, error) + +var resourceRegistry = make(map[string]ResourceConfigFactory) + +// Register adds a resource type handler to the system registry. +// Returns true if the registration was successful, or false if the type was already registered. +func Register(resourceType string, factory ResourceConfigFactory) bool { + if _, exists := resourceRegistry[resourceType]; exists { + return false + } + resourceRegistry[resourceType] = factory + return true +} + +// DecodeConfig looks up the factory and parses YAML into a ResourceConfig. +func DecodeConfig(ctx context.Context, resourceType, name string, decoder *yaml.Decoder) (ResourceConfig, error) { + factory, found := resourceRegistry[resourceType] + if !found { + return nil, fmt.Errorf("unknown resource type: %q", resourceType) + } + return factory(ctx, name, decoder) +} + +// ResourceConfig represents the uninitialized configuration data for a resource. +type ResourceConfig interface { + ResourceConfigType() string + Initialize(ctx context.Context, configDir string, usingConfigFolder bool) (Resource, error) +} + +// Resource represents the initialized and validated resource. +type Resource interface { + ResourceName() string + ResourceURI() string + ResourceType() string + ResourceDescription() string + ResourceTitle() string + ResourceMimeType() string + ResourceAnnotations() *Annotations + Read(ctx context.Context, params map[string]any) ([]ResourceContent, error) + ToConfig() ResourceConfig +} + +// Annotations represent standard MCP resource annotations. +type Annotations struct { + Audience []string `yaml:"audience,omitempty" json:"audience,omitempty"` + Priority *float64 `yaml:"priority,omitempty" json:"priority,omitempty"` +} + +// ResourceContent represents the payload content of a read resource. +type ResourceContent struct { + URI string `json:"uri"` + MimeType string `json:"mimeType,omitempty"` + Text string `json:"text"` +} + +// ResourceSet represents a grouping of resources, used for protocol-level filtering. +type ResourceSet struct { + Name string + ResourceURIs []string +} + +// ContainsResource checks if a resource URI is part of the resource set. +func (rs ResourceSet) ContainsResource(uri string) bool { + for _, u := range rs.ResourceURIs { + if u == uri { + return true + } + } + return false +} diff --git a/internal/resources/resources_test.go b/internal/resources/resources_test.go new file mode 100644 index 000000000000..09afa0641ae4 --- /dev/null +++ b/internal/resources/resources_test.go @@ -0,0 +1,327 @@ +// 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 resources_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + yaml "github.com/goccy/go-yaml" + "github.com/google/go-cmp/cmp" + "github.com/googleapis/mcp-toolbox/internal/resources" +) + +func TestTextResource(t *testing.T) { + ctx := context.Background() + + t.Run("basic text resource initialization and read", func(t *testing.T) { + yamlStr := ` +name: my-info +type: text +text: "Hello, this is static information." +description: "A test text resource" +title: "Test Resource" +` + var raw map[string]any + if err := yaml.Unmarshal([]byte(yamlStr), &raw); err != nil { + t.Fatalf("failed to unmarshal yaml: %v", err) + } + + cfg, err := resources.DecodeConfig(ctx, "text", "my-info", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, "/some/dir", false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + if res.ResourceName() != "my-info" { + t.Errorf("expected name %q, got %q", "my-info", res.ResourceName()) + } + if res.ResourceURI() != "info://my-info" { + t.Errorf("expected default URI %q, got %q", "info://my-info", res.ResourceURI()) + } + if res.ResourceMimeType() != "text/plain" { + t.Errorf("expected MIME type %q, got %q", "text/plain", res.ResourceMimeType()) + } + + // Verify default priority annotation + ann := res.ResourceAnnotations() + if ann == nil || ann.Priority == nil || *ann.Priority != 1.0 { + t.Errorf("expected default priority annotation 1.0, got %v", ann) + } + + contents, err := res.Read(ctx, nil) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + if len(contents) != 1 { + t.Fatalf("expected 1 content block, got %d", len(contents)) + } + + expectedContent := resources.ResourceContent{ + URI: "info://my-info", + MimeType: "text/plain", + Text: "Hello, this is static information.", + } + + if diff := cmp.Diff(expectedContent, contents[0]); diff != "" { + t.Errorf("ResourceContent mismatch (-want +got):\n%s", diff) + } + }) + + t.Run("missing text parameter validation", func(t *testing.T) { + yamlStr := ` +name: invalid-info +type: text +` + cfg, err := resources.DecodeConfig(ctx, "text", "invalid-info", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + _, err = cfg.Initialize(ctx, "/some/dir", false) + if err == nil || !strings.Contains(err.Error(), "text is required") { + t.Errorf("expected validation error for missing text, got: %v", err) + } + }) +} + +func TestFileResource(t *testing.T) { + ctx := context.Background() + + // Setup a temporary directory acting as our workspace/sandbox + tempDir := t.TempDir() + + // Create some test files inside the sandbox + goodFilePath := filepath.Join(tempDir, "data.txt") + if err := os.WriteFile(goodFilePath, []byte("This is safe content."), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + hiddenFilePath := filepath.Join(tempDir, ".env") + if err := os.WriteFile(hiddenFilePath, []byte("SECRET_KEY=12345"), 0644); err != nil { + t.Fatalf("failed to write hidden test file: %v", err) + } + + largeFilePath := filepath.Join(tempDir, "large.txt") + largeData := make([]byte, 6*1024*1024) // 6MB (exceeds 5MB cap) + if err := os.WriteFile(largeFilePath, largeData, 0644); err != nil { + t.Fatalf("failed to write large test file: %v", err) + } + + subDir := filepath.Join(tempDir, "logs") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatalf("failed to create sub directory: %v", err) + } + templateFilePath := filepath.Join(subDir, "app.log") + if err := os.WriteFile(templateFilePath, []byte("2026-06-24 Log trace"), 0644); err != nil { + t.Fatalf("failed to write template test file: %v", err) + } + + t.Run("basic file resource initialization and read", func(t *testing.T) { + yamlStr := ` +name: my-file +type: file +uri: "file://` + filepath.ToSlash(goodFilePath) + `" +description: "A static file resource" +` + cfg, err := resources.DecodeConfig(ctx, "file", "my-file", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, tempDir, false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + contents, err := res.Read(ctx, nil) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + + if len(contents) != 1 { + t.Fatalf("expected 1 content block, got %d", len(contents)) + } + + if contents[0].Text != "This is safe content." { + t.Errorf("expected content %q, got %q", "This is safe content.", contents[0].Text) + } + }) + + t.Run("allowed paths validation on config directory mode", func(t *testing.T) { + // When usingConfigFolder is true, allowedPaths MUST be defined + yamlStr := ` +name: my-file +type: file +uri: "file://` + filepath.ToSlash(goodFilePath) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "my-file", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + _, err = cfg.Initialize(ctx, tempDir, true) // usingConfigFolder = true + if err == nil || !strings.Contains(err.Error(), "must explicitly define 'allowedPaths'") { + t.Errorf("expected security error when allowedPaths is empty in config-folder mode, got: %v", err) + } + }) + + t.Run("directory traversal path escape rejection", func(t *testing.T) { + // Try to configure a file outside of the sandbox + // Sandbox root is tempDir/logs + sandboxRoot := subDir + outsideFile := goodFilePath // outside tempDir/logs + + yamlStr := ` +name: bad-file +type: file +uri: "file://` + filepath.ToSlash(outsideFile) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "bad-file", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, sandboxRoot, false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, err = res.Read(ctx, nil) + if err == nil || !strings.Contains(err.Error(), "escapes the allowed sandbox roots") { + t.Errorf("expected path escape permission denied, got: %v", err) + } + }) + + t.Run("hidden file block guardrail", func(t *testing.T) { + yamlStr := ` +name: secret-file +type: file +uri: "file://` + filepath.ToSlash(hiddenFilePath) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "secret-file", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, tempDir, false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, err = res.Read(ctx, nil) + if err == nil || !strings.Contains(err.Error(), "reading hidden files or directories is not allowed") { + t.Errorf("expected hidden file block error, got: %v", err) + } + }) + + t.Run("file size limit cap guardrail", func(t *testing.T) { + yamlStr := ` +name: huge-file +type: file +uri: "file://` + filepath.ToSlash(largeFilePath) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "huge-file", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, tempDir, false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, err = res.Read(ctx, nil) + if err == nil || !strings.Contains(err.Error(), "exceeds the maximum limit of 5MB") { + t.Errorf("expected file size limit error, got: %v", err) + } + }) + + t.Run("directory read rejection", func(t *testing.T) { + yamlStr := ` +name: dir-resource +type: file +uri: "file://` + filepath.ToSlash(tempDir) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "dir-resource", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, tempDir, false) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + _, err = res.Read(ctx, nil) + if err == nil || !strings.Contains(err.Error(), "is a directory, not a file") { + t.Errorf("expected directory rejection error, got: %v", err) + } + }) + + t.Run("resource template parameter substitution", func(t *testing.T) { + // URI contains {path} + templateURI := filepath.Join(subDir, "{path}") + yamlStr := ` +name: logs-template +type: file +uri: "file://` + filepath.ToSlash(templateURI) + `" +` + cfg, err := resources.DecodeConfig(ctx, "file", "logs-template", yaml.NewDecoder(strings.NewReader(yamlStr))) + if err != nil { + t.Fatalf("DecodeConfig failed: %v", err) + } + + res, err := cfg.Initialize(ctx, subDir, false) // Sandbox root is subDir + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Read with parameter "path" = "app.log" + params := map[string]any{"path": "app.log"} + contents, err := res.Read(ctx, params) + if err != nil { + t.Fatalf("Template Read failed: %v", err) + } + + if len(contents) != 1 { + t.Fatalf("expected 1 content block, got %d", len(contents)) + } + if contents[0].Text != "2026-06-24 Log trace" { + t.Errorf("expected content %q, got %q", "2026-06-24 Log trace", contents[0].Text) + } + + // Read with parameter attempting directory traversal traversal escape + badParams := map[string]any{"path": "../data.txt"} + _, err = res.Read(ctx, badParams) + if err == nil || !strings.Contains(err.Error(), "escapes the allowed sandbox roots") { + t.Errorf("expected template path escape to be rejected, got: %v", err) + } + + // Read with missing parameter + _, err = res.Read(ctx, nil) + if err == nil || !strings.Contains(err.Error(), "missing required parameter 'path'") { + t.Errorf("expected missing parameter error, got: %v", err) + } + }) +} diff --git a/internal/resources/text.go b/internal/resources/text.go new file mode 100644 index 000000000000..900ded97a440 --- /dev/null +++ b/internal/resources/text.go @@ -0,0 +1,132 @@ +// 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 resources + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" +) + +const TextResourceType = "text" + +func init() { + Register(TextResourceType, newTextConfig) +} + +func newTextConfig(ctx context.Context, name string, decoder *yaml.Decoder) (ResourceConfig, error) { + cfg := &TextConfig{Name: name} + if err := decoder.DecodeContext(ctx, cfg); err != nil { + return nil, fmt.Errorf("failed to decode text resource config %q: %w", name, err) + } + return cfg, nil +} + +// TextConfig represents the configuration for a static text/information resource. +type TextConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + URI string `yaml:"uri,omitempty"` + Description string `yaml:"description,omitempty"` + Title string `yaml:"title,omitempty"` + Text string `yaml:"text"` + Annotations *Annotations `yaml:"annotations,omitempty"` +} + +func (c *TextConfig) ResourceConfigType() string { + return TextResourceType +} + +// Initialize validates the configuration and builds the initialized TextResource. +func (c *TextConfig) Initialize(ctx context.Context, configDir string, usingConfigFolder bool) (Resource, error) { + if c.Text == "" { + return nil, fmt.Errorf("text is required for resource %q", c.Name) + } + + uri := c.URI + if uri == "" { + uri = fmt.Sprintf("info://%s", c.Name) + } + + // Set default annotations: priority to 1.0 if not defined + ann := c.Annotations + if ann == nil { + prio := 1.0 + ann = &Annotations{ + Priority: &prio, + } + } else if ann.Priority == nil { + prio := 1.0 + ann.Priority = &prio + } + + return &TextResource{ + cfg: c, + uri: uri, + ann: ann, + }, nil +} + +// TextResource is the initialized written information resource. +type TextResource struct { + cfg *TextConfig + uri string + ann *Annotations +} + +func (r *TextResource) ResourceName() string { + return r.cfg.Name +} + +func (r *TextResource) ResourceURI() string { + return r.uri +} + +func (r *TextResource) ResourceType() string { + return TextResourceType +} + +func (r *TextResource) ResourceDescription() string { + return r.cfg.Description +} + +func (r *TextResource) ResourceTitle() string { + return r.cfg.Title +} + +func (r *TextResource) ResourceMimeType() string { + // Standard written text resources default to plain text MIME type + return "text/plain" +} + +func (r *TextResource) ResourceAnnotations() *Annotations { + return r.ann +} + +// Read returns the configured written information. +func (r *TextResource) Read(ctx context.Context, params map[string]any) ([]ResourceContent, error) { + return []ResourceContent{ + { + URI: r.uri, + MimeType: r.ResourceMimeType(), + Text: r.cfg.Text, + }, + }, nil +} + +func (r *TextResource) ToConfig() ResourceConfig { + return r.cfg +} diff --git a/internal/server/config.go b/internal/server/config.go index e0a7e794e335..2ff2da26b45e 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -31,6 +31,7 @@ import ( "github.com/googleapis/mcp-toolbox/internal/embeddingmodels" "github.com/googleapis/mcp-toolbox/internal/embeddingmodels/gemini" "github.com/googleapis/mcp-toolbox/internal/prompts" + "github.com/googleapis/mcp-toolbox/internal/resources" "github.com/googleapis/mcp-toolbox/internal/sources" "github.com/googleapis/mcp-toolbox/internal/tools" "github.com/googleapis/mcp-toolbox/internal/util" @@ -61,6 +62,10 @@ type ServerConfig struct { PromptConfigs PromptConfigs // PromptsetConfigs defines what prompts are available PromptsetConfigs PromptsetConfigs + // ResourceConfigs defines what resources are available. + ResourceConfigs ResourceConfigs + // ResourceTemplateConfigs defines what resource templates are available. + ResourceTemplateConfigs ResourceConfigs // IgnoreUnknownTools logs warnings and skips unknown/unsupported tool types instead of failing to start. IgnoreUnknownTools bool // LoggingFormat defines whether structured loggings are used. @@ -99,6 +104,10 @@ type ServerConfig struct { PollInterval int // HttpMaxRequestBytes caps MCP HTTP request bodies. Zero uses the default. HttpMaxRequestBytes int64 + // ConfigDir is the absolute path to the directory containing configuration files. + ConfigDir string + // UsingConfigFolder is true if the server was started with a config/tools directory flag. + UsingConfigFolder bool } type logFormat string @@ -160,20 +169,27 @@ type ToolConfigs map[string]tools.ToolConfig type ToolsetConfigs map[string]tools.ToolsetConfig type PromptConfigs map[string]prompts.PromptConfig type PromptsetConfigs map[string]prompts.PromptsetConfig +type ResourceConfigs map[string]resources.ResourceConfig + +// UnmarshaledConfigs holds all the unmarshaled configuration collections. +type UnmarshaledConfigs struct { + Sources SourceConfigs + AuthServices AuthServiceConfigs + EmbeddingModels EmbeddingModelConfigs + Tools ToolConfigs + Toolsets ToolsetConfigs + Prompts PromptConfigs + Resources ResourceConfigs + ResourceTemplates ResourceConfigs +} -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 +// UnmarshalConfigs parses the YAML configuration into an UnmarshaledConfigs container. +func UnmarshalConfigs(ctx context.Context, raw []byte) (UnmarshaledConfigs, error) { + var res UnmarshaledConfigs file, err := parser.ParseBytes(raw, 0) if err != nil { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to parse YAML: %s", yaml.FormatError(err, false, false)) + return res, fmt.Errorf("unable to parse YAML: %s", yaml.FormatError(err, false, false)) } decoder := yaml.NewDecoder(bytes.NewReader(raw)) @@ -185,17 +201,17 @@ func UnmarshalResourceConfig(ctx context.Context, raw []byte) (SourceConfigs, Au var resource map[string]any if err := decoder.DecodeFromNodeContext(ctx, doc.Body, &resource); err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: %s", docIndex, yaml.FormatError(err, false, false)) + return res, fmt.Errorf("document %d: %s", docIndex, yaml.FormatError(err, false, false)) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to decode YAML document: %s", yaml.FormatError(err, false, false)) + return res, fmt.Errorf("unable to decode YAML document: %s", yaml.FormatError(err, false, false)) } var kind, name string var ok bool if kind, ok = resource["kind"].(string); !ok { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("%s missing 'kind' field or it is not a string", formatDocLocation(docIndex, keyToken(doc.Body, "kind"), doc.Body)) + return res, fmt.Errorf("%s missing 'kind' field or it is not a string", formatDocLocation(docIndex, keyToken(doc.Body, "kind"), doc.Body)) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("missing 'kind' field or it is not a string: %v", resource) + return res, fmt.Errorf("missing 'kind' field or it is not a string: %v", resource) } if name, ok = resource["name"].(string); !ok { if len(file.Docs) > 1 { @@ -203,9 +219,9 @@ func UnmarshalResourceConfig(ctx context.Context, raw []byte) (SourceConfigs, Au if fallbackToken == nil { fallbackToken = keyToken(doc.Body, "kind") } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("%s missing 'name' field or it is not a string", formatDocLocation(docIndex, fallbackToken, doc.Body)) + return res, fmt.Errorf("%s missing 'name' field or it is not a string", formatDocLocation(docIndex, fallbackToken, doc.Body)) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("missing 'name' field or it is not a string") + return res, fmt.Errorf("missing 'name' field or it is not a string") } // remove 'kind' from map for strict unmarshaling delete(resource, "kind") @@ -215,85 +231,136 @@ func UnmarshalResourceConfig(ctx context.Context, raw []byte) (SourceConfigs, Au c, err := UnmarshalYAMLSourceConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } - if sourceConfigs == nil { - sourceConfigs = make(SourceConfigs) + if res.Sources == nil { + res.Sources = make(SourceConfigs) } - sourceConfigs[name] = c + res.Sources[name] = c case "authService": c, err := UnmarshalYAMLAuthServiceConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } - if authServiceConfigs == nil { - authServiceConfigs = make(AuthServiceConfigs) + if res.AuthServices == nil { + res.AuthServices = make(AuthServiceConfigs) } - authServiceConfigs[name] = c + res.AuthServices[name] = c case "tool": c, err := UnmarshalYAMLToolConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } if c == nil { continue } - if toolConfigs == nil { - toolConfigs = make(ToolConfigs) + if res.Tools == nil { + res.Tools = make(ToolConfigs) } - toolConfigs[name] = c + res.Tools[name] = c case "toolset": c, err := UnmarshalYAMLToolsetConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } - if toolsetConfigs == nil { - toolsetConfigs = make(ToolsetConfigs) + if res.Toolsets == nil { + res.Toolsets = make(ToolsetConfigs) } - toolsetConfigs[name] = c + res.Toolsets[name] = c case "embeddingModel": c, err := UnmarshalYAMLEmbeddingModelConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } - if embeddingModelConfigs == nil { - embeddingModelConfigs = make(EmbeddingModelConfigs) + if res.EmbeddingModels == nil { + res.EmbeddingModels = make(EmbeddingModelConfigs) } - embeddingModelConfigs[name] = c + res.EmbeddingModels[name] = c case "prompt": c, err := UnmarshalYAMLPromptConfig(ctx, name, resource) if err != nil { if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + } + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) + } + if res.Prompts == nil { + res.Prompts = make(PromptConfigs) + } + res.Prompts[name] = c + case "resource": + c, err := UnmarshalYAMLResourceConfig(ctx, name, resource) + if err != nil { + if len(file.Docs) > 1 { + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("error unmarshaling %s: %w", kind, err) + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) } - if promptConfigs == nil { - promptConfigs = make(PromptConfigs) + if res.Resources == nil { + res.Resources = make(ResourceConfigs) } - promptConfigs[name] = c + res.Resources[name] = c + case "resourceTemplate": + c, err := UnmarshalYAMLResourceConfig(ctx, name, resource) + if err != nil { + if len(file.Docs) > 1 { + return res, fmt.Errorf("document %d: error unmarshaling %s %q: %w", docIndex, kind, name, err) + } + return res, fmt.Errorf("error unmarshaling %s: %w", kind, err) + } + if res.ResourceTemplates == nil { + res.ResourceTemplates = make(ResourceConfigs) + } + res.ResourceTemplates[name] = c default: if len(file.Docs) > 1 { - return nil, nil, nil, nil, nil, nil, fmt.Errorf("%s invalid kind %q", formatDocLocation(docIndex, keyToken(doc.Body, "kind"), doc.Body), kind) + return res, fmt.Errorf("%s invalid kind %q", formatDocLocation(docIndex, keyToken(doc.Body, "kind"), doc.Body), kind) } - return nil, nil, nil, nil, nil, nil, fmt.Errorf("invalid kind %s", kind) + return res, fmt.Errorf("invalid kind %s", kind) } } - return sourceConfigs, authServiceConfigs, embeddingModelConfigs, toolConfigs, toolsetConfigs, promptConfigs, nil + return res, nil +} + +// UnmarshalResourceConfig is a backward-compatible wrapper around UnmarshalConfigs. +// Deprecated: use UnmarshalConfigs instead. +func UnmarshalResourceConfig(ctx context.Context, raw []byte) (SourceConfigs, AuthServiceConfigs, EmbeddingModelConfigs, ToolConfigs, ToolsetConfigs, PromptConfigs, error) { + res, err := UnmarshalConfigs(ctx, raw) + if err != nil { + return nil, nil, nil, nil, nil, nil, err + } + return res.Sources, res.AuthServices, res.EmbeddingModels, res.Tools, res.Toolsets, res.Prompts, nil +} + +func UnmarshalYAMLResourceConfig(ctx context.Context, name string, r map[string]any) (resources.ResourceConfig, error) { + resourceType, ok := r["type"].(string) + if !ok { + return nil, fmt.Errorf("missing 'type' field or it is not a string") + } + raw, err := yaml.Marshal(r) + if err != nil { + return nil, err + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + resourceConfig, err := resources.DecodeConfig(ctx, resourceType, name, dec) + if err != nil { + return nil, err + } + return resourceConfig, nil } func UnmarshalYAMLSourceConfig(ctx context.Context, name string, r map[string]any) (sources.SourceConfig, error) { diff --git a/internal/server/resources_boot_test.go b/internal/server/resources_boot_test.go new file mode 100644 index 000000000000..05608ac83ac2 --- /dev/null +++ b/internal/server/resources_boot_test.go @@ -0,0 +1,213 @@ +// 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 server_test + +import ( + "context" + "os" + "strings" + "testing" + + "github.com/googleapis/mcp-toolbox/internal/log" + "github.com/googleapis/mcp-toolbox/internal/server" + "github.com/googleapis/mcp-toolbox/internal/telemetry" + "github.com/googleapis/mcp-toolbox/internal/util" +) + +func testContext() context.Context { + ctx := context.Background() + instrumentation, _ := telemetry.CreateTelemetryInstrumentation("1.0.0") + ctx = util.WithInstrumentation(ctx, instrumentation) + logger, _ := log.NewLogger("standard", "info", os.Stdout, os.Stderr) + ctx = util.WithLogger(ctx, logger) + return ctx +} + +func TestInitializeConfigs_Resources(t *testing.T) { + ctx := testContext() + + t.Run("valid configuration with resources and templates", func(t *testing.T) { + yamlStr := ` +--- +kind: resource +name: static-doc +type: text +text: "This is a static text document." +uri: "info://static-doc" +description: "A valid text resource" +--- +kind: resourceTemplate +name: logs-template +type: file +uri: "file:///var/log/{path}" +allowedPaths: + - "/var/log" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + Version: "1.0.0", + ResourceConfigs: unmarshaled.Resources, + ResourceTemplateConfigs: unmarshaled.ResourceTemplates, + ConfigDir: "/var/log", + UsingConfigFolder: false, + } + + _, _, _, _, _, _, _, resMap, tempMap, err := server.InitializeConfigs(ctx, cfg) + if err != nil { + t.Fatalf("InitializeConfigs failed: %v", err) + } + + if len(resMap) != 1 || resMap["static-doc"] == nil { + t.Errorf("expected 1 static resource, got: %v", resMap) + } + if len(tempMap) != 1 || tempMap["logs-template"] == nil { + t.Errorf("expected 1 resource template, got: %v", tempMap) + } + }) + + t.Run("name collision prevention", func(t *testing.T) { + yamlStr := ` +--- +kind: resource +name: duplicate-name +type: text +text: "First doc." +--- +kind: resourceTemplate +name: duplicate-name +type: file +uri: "file:///var/log/{path}" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + ResourceConfigs: unmarshaled.Resources, + ResourceTemplateConfigs: unmarshaled.ResourceTemplates, + } + + _, _, _, _, _, _, _, _, _, err = server.InitializeConfigs(ctx, cfg) + if err == nil || !strings.Contains(err.Error(), "resource name collision") { + t.Errorf("expected error for duplicate resource name, got: %v", err) + } + }) + + t.Run("URI collision prevention", func(t *testing.T) { + yamlStr := ` +--- +kind: resource +name: resource-one +type: text +text: "Doc one." +uri: "info://shared-uri" +--- +kind: resource +name: resource-two +type: text +text: "Doc two." +uri: "info://shared-uri" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + ResourceConfigs: unmarshaled.Resources, + } + + _, _, _, _, _, _, _, _, _, err = server.InitializeConfigs(ctx, cfg) + if err == nil || !strings.Contains(err.Error(), "resource URI collision") { + t.Errorf("expected error for duplicate resource URI, got: %v", err) + } + }) + + t.Run("scheme whitelisting checks", func(t *testing.T) { + yamlStr := ` +--- +kind: resource +name: invalid-scheme-file +type: file +uri: "http://var/log/app.log" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + ResourceConfigs: unmarshaled.Resources, + } + + _, _, _, _, _, _, _, _, _, err = server.InitializeConfigs(ctx, cfg) + if err == nil || !strings.Contains(err.Error(), "URI must start with file://") { + t.Errorf("expected initialization error for invalid file URI scheme, got: %v", err) + } + }) + + t.Run("local-only template scoping checks", func(t *testing.T) { + yamlStr := ` +--- +kind: resourceTemplate +name: bad-template +type: file +uri: "file:///var/log/{invalid_var}" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + ResourceTemplateConfigs: unmarshaled.ResourceTemplates, + } + + _, _, _, _, _, _, _, _, _, err = server.InitializeConfigs(ctx, cfg) + if err == nil || !strings.Contains(err.Error(), "only '{path}' is permitted") { + t.Errorf("expected error for invalid template variable, got: %v", err) + } + }) + + t.Run("allowed paths validation in config-folder mode at boot", func(t *testing.T) { + yamlStr := ` +--- +kind: resource +name: no-allowed-paths +type: file +uri: "file:///var/log/app.log" +` + unmarshaled, err := server.UnmarshalConfigs(ctx, []byte(yamlStr)) + if err != nil { + t.Fatalf("UnmarshalConfigs failed: %v", err) + } + + cfg := server.ServerConfig{ + ResourceConfigs: unmarshaled.Resources, + ConfigDir: "/var/log", + UsingConfigFolder: true, // Config folder mode is active + } + + _, _, _, _, _, _, _, _, _, err = server.InitializeConfigs(ctx, cfg) + if err == nil || !strings.Contains(err.Error(), "must explicitly define 'allowedPaths'") { + t.Errorf("expected error for missing allowedPaths in config-folder mode, got: %v", err) + } + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index 30af022d4154..ce0fd0653347 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -38,6 +38,7 @@ import ( "github.com/googleapis/mcp-toolbox/internal/embeddingmodels" "github.com/googleapis/mcp-toolbox/internal/log" "github.com/googleapis/mcp-toolbox/internal/prompts" + "github.com/googleapis/mcp-toolbox/internal/resources" "github.com/googleapis/mcp-toolbox/internal/server/mcp/jsonrpc" "github.com/googleapis/mcp-toolbox/internal/server/primitives" "github.com/googleapis/mcp-toolbox/internal/sources" @@ -46,6 +47,7 @@ import ( "github.com/googleapis/mcp-toolbox/internal/util" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "net/url" ) // Server contains info for running an instance of Toolbox. Should be instantiated with NewServer(). @@ -72,12 +74,14 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( map[string]tools.Toolset, map[string]prompts.Prompt, map[string]prompts.Promptset, + map[string]resources.Resource, + map[string]resources.Resource, error, ) { if cfg.EnableAPI { for _, sc := range cfg.AuthServiceConfigs { if sc.IsMCPEnabled() { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("MCP Auth cannot be enabled together with the legacy HTTP API (EnableAPI)") + return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("MCP Auth cannot be enabled together with the legacy HTTP API (EnableAPI)") } } } @@ -89,12 +93,12 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( ctx = util.WithUserAgent(ctx, metadataStr) instrumentation, err := util.InstrumentationFromContext(ctx) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get instrumentation from context: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get instrumentation from context: %w", err) } l, err := util.LoggerFromContext(ctx) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get logger from context: %w", err) + return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("failed to get logger from context: %w", err) } // initialize and validate the sources from configs @@ -115,7 +119,7 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( return s, nil }() if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } sourcesMap[name] = s } @@ -143,7 +147,7 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( return a, nil }() if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } authServicesMap[name] = a } @@ -172,7 +176,7 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( return em, nil }() if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } embeddingModelsMap[name] = em } @@ -184,12 +188,12 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( toolsMap, err := initializeTools(ctx, cfg, instrumentation, l) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } toolsetsMap, err := initializeToolsets(ctx, cfg, toolsMap, instrumentation, l) if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } // initialize and validate the prompts from configs @@ -210,7 +214,7 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( return p, nil }() if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } promptsMap[name] = p } @@ -247,7 +251,7 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( return p, err }() if err != nil { - return nil, nil, nil, nil, nil, nil, nil, err + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err } promptsetsMap[name] = p } @@ -261,7 +265,120 @@ func InitializeConfigs(ctx context.Context, cfg ServerConfig) ( } l.InfoContext(ctx, fmt.Sprintf("Initialized %d promptsets: %s", len(promptsetsMap), strings.Join(promptsetNames, ", "))) - return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, nil + // initialize and validate resources from configs + resourcesMap := make(map[string]resources.Resource) + for name, rc := range cfg.ResourceConfigs { + r, err := func() (resources.Resource, error) { + _, span := instrumentation.Tracer.Start( + ctx, + "toolbox/server/resource/init", + trace.WithAttributes(attribute.String("resource_type", rc.ResourceConfigType())), + trace.WithAttributes(attribute.String("resource_name", name)), + ) + defer span.End() + r, err := rc.Initialize(ctx, cfg.ConfigDir, cfg.UsingConfigFolder) + if err != nil { + return nil, fmt.Errorf("unable to initialize resource %q: %w", name, err) + } + return r, nil + }() + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + resourcesMap[name] = r + } + + // initialize and validate resource templates from configs + resourceTemplatesMap := make(map[string]resources.Resource) + for name, rc := range cfg.ResourceTemplateConfigs { + r, err := func() (resources.Resource, error) { + _, span := instrumentation.Tracer.Start( + ctx, + "toolbox/server/resource_template/init", + trace.WithAttributes(attribute.String("resource_template_type", rc.ResourceConfigType())), + trace.WithAttributes(attribute.String("resource_template_name", name)), + ) + defer span.End() + r, err := rc.Initialize(ctx, cfg.ConfigDir, cfg.UsingConfigFolder) + if err != nil { + return nil, fmt.Errorf("unable to initialize resource template %q: %w", name, err) + } + return r, nil + }() + if err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + resourceTemplatesMap[name] = r + } + + // Boot-Phase Security Validation + urisSeen := make(map[string]string) // URI -> Name + namesSeen := make(map[string]bool) + + validate := func(r resources.Resource, isTemplate bool) error { + name := r.ResourceName() + uri := r.ResourceURI() + resType := r.ResourceType() + + // 1. Name collision check + if namesSeen[name] { + return fmt.Errorf("boot validation error: resource name collision: %q is defined multiple times", name) + } + namesSeen[name] = true + + // 2. RFC 3986 Syntax Compliance & Scheme Checks + parsedURI, err := url.ParseRequestURI(uri) + if err != nil { + return fmt.Errorf("boot validation error: resource %q has invalid RFC 3986 URI %q: %w", name, uri, err) + } + + if resType == resources.FileResourceType { + if parsedURI.Scheme != "file" { + return fmt.Errorf("boot validation error: file resource %q URI must use the 'file://' scheme, got %q", name, uri) + } + } else if resType == resources.TextResourceType { + if parsedURI.Scheme != "info" && parsedURI.Scheme != "" { + return fmt.Errorf("boot validation error: text resource %q URI must use the 'info://' scheme, got %q", name, uri) + } + } + + // 3. Global URI Collision Prevention + uriKey := uri + if isTemplate { + uriKey = strings.ReplaceAll(uri, "{path}", "") + } + if existingName, exists := urisSeen[uriKey]; exists { + return fmt.Errorf("boot validation error: resource URI collision: resources %q and %q share the same target URI %q", name, existingName, uri) + } + urisSeen[uriKey] = name + + // 4. Local-only Template Scoping + if isTemplate { + if resType != resources.FileResourceType { + return fmt.Errorf("boot validation error: resource template %q must be of type 'file', got %q", name, resType) + } + openCount := strings.Count(uri, "{") + closeCount := strings.Count(uri, "}") + if openCount != 1 || closeCount != 1 || !strings.Contains(uri, "{path}") { + return fmt.Errorf("boot validation error: resource template %q URI %q has invalid template variables: only '{path}' is permitted", name, uri) + } + } + + return nil + } + + for _, r := range resourcesMap { + if err := validate(r, false); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + } + for _, r := range resourceTemplatesMap { + if err := validate(r, true); err != nil { + return nil, nil, nil, nil, nil, nil, nil, nil, nil, err + } + } + + return sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, resourcesMap, resourceTemplatesMap, nil } // InitializeOfflineConfigs initializes only tools and toolsets from the config, @@ -439,7 +556,7 @@ func NewServer(ctx context.Context, cfg ServerConfig) (*Server, error) { logger := l.SlogLogger() r.Use(httplog.RequestLogger(logger, httpOpts)) - sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := InitializeConfigs(ctx, cfg) + sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, _, _, err := InitializeConfigs(ctx, cfg) if err != nil { return nil, fmt.Errorf("unable to initialize configs: %w", err) }