Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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