Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (

"github.com/hashicorp/go-hclog"

"github.com/mozilla-ai/mcpd/v2/internal/context"
"github.com/mozilla-ai/mcpd/v2/internal/files"
)

// Cache manages cached registry manifests.
Expand Down Expand Up @@ -42,7 +42,7 @@ func NewCache(logger hclog.Logger, opts ...Option) (*Cache, error) {

// Only create cache directory if caching is enabled.
if options.enabled {
if err := context.EnsureAtLeastRegularDir(options.dir); err != nil {
if err := files.EnsureAtLeastRegularDir(options.dir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
}
Expand Down
37 changes: 37 additions & 0 deletions internal/config/plugin_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/mozilla-ai/mcpd/v2/internal/context"
"github.com/mozilla-ai/mcpd/v2/internal/files"
)

const (
Expand Down Expand Up @@ -247,9 +248,45 @@ func (p *PluginConfig) Validate() error {
}
}

// Validate directory and plugins if Dir is configured.
if err := p.validatePluginDirectory(); err != nil {
validationErrors = append(validationErrors, err)
}

return errors.Join(validationErrors...)
}

// validatePluginDirectory validates that the plugin directory exists and contains all configured plugins.
// Returns nil if Dir is empty (plugins disabled).
func (p *PluginConfig) validatePluginDirectory() error {
if strings.TrimSpace(p.Dir) == "" {
return nil
}

available, err := files.DiscoverExecutables(p.Dir)
if err != nil {
return fmt.Errorf("plugin directory %s: %w", p.Dir, err)
}

return p.validateConfiguredPluginsExist(available)
}

// validateConfiguredPluginsExist checks that all configured plugins exist in the available set.
func (p *PluginConfig) validateConfiguredPluginsExist(available map[string]struct{}) error {
var missingPlugins []error

for name := range p.PluginNamesDistinct() {
if _, exists := available[name]; !exists {
missingPlugins = append(
missingPlugins,
fmt.Errorf("plugin %s not found in directory %s", name, p.Dir),
)
}
}

return errors.Join(missingPlugins...)
}

// categorySlice returns a pointer to the category slice for the given category name.
func (p *PluginConfig) categorySlice(category Category) (*[]PluginEntry, error) {
switch category {
Expand Down
110 changes: 3 additions & 107 deletions internal/context/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,10 @@ import (

"github.com/BurntSushi/toml"

"github.com/mozilla-ai/mcpd/v2/internal/files"
"github.com/mozilla-ai/mcpd/v2/internal/perms"
)

const (
// EnvVarXDGConfigHome is the XDG Base Directory env var name for config files.
EnvVarXDGConfigHome = "XDG_CONFIG_HOME"

// EnvVarXDGCacheHome is the XDG Base Directory env var name for cache file.
EnvVarXDGCacheHome = "XDG_CACHE_HOME"
)

// DefaultLoader loads execution context configurations.
type DefaultLoader struct{}

Expand Down Expand Up @@ -126,13 +119,13 @@ func (c *ExecutionContextConfig) List() []ServerExecutionContext {
// SaveConfig saves the execution context configuration to a file with secure permissions.
// Used for runtime execution contexts that may contain sensitive data.
func (c *ExecutionContextConfig) SaveConfig() error {
return c.saveConfig(EnsureAtLeastSecureDir, perms.SecureFile)
return c.saveConfig(files.EnsureAtLeastSecureDir, perms.SecureFile)
}

// SaveExportedConfig saves the execution context configuration to a file with regular permissions.
// Used for exported configurations that are sanitized and suitable for sharing.
func (c *ExecutionContextConfig) SaveExportedConfig() error {
return c.saveConfig(EnsureAtLeastRegularDir, perms.RegularFile)
return c.saveConfig(files.EnsureAtLeastRegularDir, perms.RegularFile)
}

// Upsert updates the execution context for the given server name.
Expand Down Expand Up @@ -216,27 +209,6 @@ func (s *ServerExecutionContext) IsEmpty() bool {
return len(s.Args) == 0 && len(s.Env) == 0 && len(s.Volumes) == 0
}

// AppDirName returns the name of the application directory for use in user-specific operations where data is being written.
func AppDirName() string {
return "mcpd"
}

// EnsureAtLeastRegularDir creates a directory with standard permissions if it doesn't exist,
// and verifies that it has at least the required regular permissions if it already exists.
// It does not attempt to repair ownership or permissions: if they are wrong, it returns an error.
// Used for cache directories, data directories, and documentation.
func EnsureAtLeastRegularDir(path string) error {
return ensureAtLeastDir(path, perms.RegularDir)
}

// EnsureAtLeastSecureDir creates a directory with secure permissions if it doesn't exist,
// and verifies that it has at least the required secure permissions if it already exists.
// It does not attempt to repair ownership or permissions: if they are wrong,
// it returns an error.
func EnsureAtLeastSecureDir(path string) error {
return ensureAtLeastDir(path, perms.SecureDir)
}

// NewExecutionContextConfig returns a newly initialized ExecutionContextConfig.
func NewExecutionContextConfig(path string) *ExecutionContextConfig {
return &ExecutionContextConfig{
Expand All @@ -245,46 +217,6 @@ func NewExecutionContextConfig(path string) *ExecutionContextConfig {
}
}

// UserSpecificCacheDir returns the directory that should be used to store any user-specific cache files.
// It adheres to the XDG Base Directory Specification, respecting the XDG_CACHE_HOME environment variable.
// When XDG_CACHE_HOME is not set, it defaults to ~/.cache/mcpd/
// See: https://specifications.freedesktop.org/basedir-spec/latest/
func UserSpecificCacheDir() (string, error) {
return userSpecificDir(EnvVarXDGCacheHome, ".cache")
}

// UserSpecificConfigDir returns the directory that should be used to store any user-specific configuration.
// It adheres to the XDG Base Directory Specification, respecting the XDG_CONFIG_HOME environment variable.
// When XDG_CONFIG_HOME is not set, it defaults to ~/.config/mcpd/
// See: https://specifications.freedesktop.org/basedir-spec/latest/
func UserSpecificConfigDir() (string, error) {
return userSpecificDir(EnvVarXDGConfigHome, ".config")
}

// ensureAtLeastDir creates a directory with the specified permissions if it doesn't exist,
// and verifies that it has at least the required permissions if it already exists.
// It does not attempt to repair ownership or permissions: if they are wrong, it returns an error.
func ensureAtLeastDir(path string, perm os.FileMode) error {
if err := os.MkdirAll(path, perm); err != nil {
return fmt.Errorf("could not ensure directory exists for '%s': %w", path, err)
}

info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("could not stat directory '%s': %w", path, err)
}

if !isPermissionAcceptable(info.Mode().Perm(), perm) {
return fmt.Errorf(
"incorrect permissions for directory '%s' (%#o, want %#o or more restrictive)",
path, info.Mode().Perm(),
perm,
)
}

return nil
}

// equalSlices compares two string slices for equality, ignoring order.
func equalSlices(a []string, b []string) bool {
if len(a) != len(b) {
Expand All @@ -300,14 +232,6 @@ func equalSlices(a []string, b []string) bool {
return slices.Equal(sortedA, sortedB)
}

// isPermissionAcceptable checks if the actual permissions are acceptable for the required permissions.
// It returns true if the actual permissions are equal to or more restrictive than required.
// "More restrictive" means: no permission bit set in actual that isn't also set in required.
func isPermissionAcceptable(actual, required os.FileMode) bool {
// Check that actual doesn't grant any permissions that required doesn't grant
return (actual & ^required) == 0
}

// loadExecutionContextConfig loads a runtime execution context file from disk and expands environment variables.
//
// The function parses the TOML file at the specified path and automatically expands all ${VAR} references
Expand Down Expand Up @@ -396,31 +320,3 @@ func (c *ExecutionContextConfig) saveConfig(ensureDirFunc func(string) error, fi

return nil
}

// userSpecificDir returns a user-specific directory following XDG Base Directory Specification.
// It respects the given environment variable, falling back to homeDir/dir/AppDirName() if not set.
// The envVar must have XDG_ prefix to follow the specification.
func userSpecificDir(envVar string, dir string) (string, error) {
envVar = strings.TrimSpace(envVar)
// Validate that the environment variable follows XDG naming convention.
if !strings.HasPrefix(envVar, "XDG_") {
return "", fmt.Errorf(
"environment variable '%s' does not follow XDG Base Directory Specification",
envVar,
)
}

// If the relevant environment variable is present and configured, then use it.
if ch, ok := os.LookupEnv(envVar); ok && strings.TrimSpace(ch) != "" {
home := strings.TrimSpace(ch)
return filepath.Join(home, AppDirName()), nil
}

// Attempt to locate the home directory for the current user and return the path that follows the spec.
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get user home directory: %w", err)
}

return filepath.Join(homeDir, dir, AppDirName()), nil
}
Loading