From d8ed6c8ea4e6d2d111449bc017859817c2d42240 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 4 Jun 2025 22:54:22 +0100 Subject: [PATCH 1/2] Setup CLI project * Setup CLI project * Add Makefile * Add Cobra framework for CLI * Add commands: init, add, remove * Add tests --- .gitignore | 41 +++ Makefile | 46 ++++ cmd/config/config.go | 18 ++ cmd/init.go | 73 +++++ cmd/root.go | 107 ++++++++ cmd/server/add.go | 91 +++++++ cmd/server/add_test.go | 245 +++++++++++++++++ cmd/server/remove.go | 61 +++++ cmd/server/remove_test.go | 151 +++++++++++ go.mod | 22 ++ go.sum | 38 +++ internal/cmd/basecmd.go | 7 + internal/config/config.go | 246 +++++++++++++++++ internal/config/config_test.go | 476 +++++++++++++++++++++++++++++++++ internal/flags/config.go | 24 ++ main.go | 12 + 16 files changed, 1658 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/config/config.go create mode 100644 cmd/init.go create mode 100644 cmd/root.go create mode 100644 cmd/server/add.go create mode 100644 cmd/server/add_test.go create mode 100644 cmd/server/remove.go create mode 100644 cmd/server/remove_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/cmd/basecmd.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/flags/config.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ebba4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +.idea/ +.vscode/ + +# Binary +mcpd + +# Project config files +.mcpd.toml + +# Log files +*.log \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77cca33 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: build install clean uninstall + +MODULE_PATH := github.com/mozilla-ai/mcpd-cli/v2 + +# /usr/local/bin is a common default for user-installed binaries +INSTALL_DIR := /usr/local/bin + +# Get the version string dynamically +# This will be: +# - e.g., "v1.0.0" if on a tag +# - e.g., "v0.1.0-2-gabcdef123" if 2 commits past tag v0.1.0 (with hash abcdef123) +# - e.g., "abcdef123-dirty" if on a commit and dirty +# - e.g., "dev" if git is not available or no commits yet +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") + +# Linker flags for injecting version +# The path is MODULE_PATH/package.variableName +LDFLAGS := -X '$(MODULE_PATH)/cmd.version=$(VERSION)' + +test: + go test ./... + +benchmark: + go test -bench=. -benchmem ./... + +build: + @echo "building mcpd (with flags: ${LDFLAGS})..." + @go build -o mcpd -ldflags="${LDFLAGS}" . + +install: build + # Copy the executable to the install directory + # Requires sudo if INSTALL_DIR is a system path like /usr/local/bin + @cp mcpd $(INSTALL_DIR)/mcpd + @echo "mcpd installed to $(INSTALL_DIR)/mcpd" + +clean: + # Remove the built executable and any temporary files + @rm -f mcpd # The executable itself + # Add any other build artifacts here if they accumulate (e.g., cache files) + @echo "Build artifacts cleaned." + +uninstall: + # Remove the installed executable from the system + # Requires sudo if INSTALL_DIR is a system path + @rm -f $(INSTALL_DIR)/mcpd + @echo "mcpd uninstalled from $(INSTALL_DIR)/mcpd" \ No newline at end of file diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 0000000..dd9667e --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,18 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "config", + Short: "Manages MCP server configuration.", + Long: "Manages MCP server configuration values and environment variable export.", +} + +func init() { + // TODO: Re-add subcommands. + // Add subcommands to the config command. + // Cmd.AddCommand(exportEnvCmd) + // Cmd.AddCommand(setCmd) +} diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..d66bc04 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/cmd" + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +type InitCmd struct { + *cmd.BaseCmd +} + +func NewInitCmd(log hclog.Logger) *cobra.Command { + c := &InitCmd{ + BaseCmd: &cmd.BaseCmd{Logger: log}, + } + + cobraCommand := &cobra.Command{ + Use: "init", + Short: "Initializes the current directory as an mcpd project.", + Long: c.longDescription(), + RunE: c.run, + } + + return cobraCommand +} + +func (c *InitCmd) longDescription() string { + return fmt.Sprintf( + "Initializes the current directory as an mcpd project, creating an %s configuration file. "+ + "This command sets up the basic structure required for an mcpd project.", flags.ConfigFile) +} + +func (c *InitCmd) run(_ *cobra.Command, _ []string) error { + fmt.Fprintln(os.Stdout, "Initializing mcpd project in current directory...") + + cwd, err := os.Getwd() + if err != nil { + c.Logger.Error("Failed to get working directory", "error", err) + return fmt.Errorf("error getting current directory: %w", err) + } + + if err := initializeProject(cwd); err != nil { + c.Logger.Error("Project initialization failed", "error", err) + return fmt.Errorf("error initializing mcpd project: %w", err) + } + + fmt.Fprintf(os.Stdout, "%s created successfully.\n", flags.ConfigFile) + + return nil +} + +func initializeProject(path string) error { + if _, err := os.Stat(flags.ConfigFile); err == nil { + return fmt.Errorf("%s already exists", flags.ConfigFile) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat %s: %w", flags.ConfigFile, err) + } + + // TODO: Look at off-loading the data structure to the internal/config package + content := `servers = []` + + if err := os.WriteFile(flags.ConfigFile, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", flags.ConfigFile, err) + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..ee1cc27 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + + "github.com/mozilla-ai/mcpd-cli/v2/cmd/config" + "github.com/mozilla-ai/mcpd-cli/v2/cmd/server" + "github.com/mozilla-ai/mcpd-cli/v2/internal/cmd" + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +var version = "dev" // Set at build time using -ldflags + +type RootCmd struct { + *cmd.BaseCmd +} + +func Execute() { + logger, err := configureLogger() + if err != nil { + fmt.Fprintf(os.Stderr, "error executing root command: %s", err) + os.Exit(1) + } + + rootCmd := NewRootCmd(logger) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func NewRootCmd(logger hclog.Logger) *cobra.Command { + c := &RootCmd{ + BaseCmd: &cmd.BaseCmd{Logger: logger}, + } + + rootCmd := &cobra.Command{ + Use: "mcpd [args]", + Short: "'mcpd' CLI is the primary interface for developers to interact with mcpd.", + Long: c.longDescription(), + SilenceUsage: true, + Version: version, + } + + // Global flags + flags.InitFlags(rootCmd.PersistentFlags()) + + // Add top-level commands that are NOT part of a resource group + rootCmd.AddCommand(NewInitCmd(logger)) + // TODO: Re-add commands: + // rootCmd.AddCommand(listToolsCmd) + // rootCmd.AddCommand(loginCmd) + + // Add commands from specific resource/service packages, they remain top-level commands in the CLI's usage. + // TODO: Re-add daemon + // rootCmd.AddCommand(server.NewDaemonCmd(logger)) + rootCmd.AddCommand(server.NewAddCmd(logger)) + rootCmd.AddCommand(server.NewRemoveCmd(logger)) + // TODO: Update to add: NewConfigCmd etc. + rootCmd.AddCommand(config.Cmd) + + return rootCmd +} + +func (c *RootCmd) longDescription() string { + return `The 'mcpd' CLI is the primary interface for developers to interact with the +mcpd Control Plane, define their agent projects, and manage MCP server dependencies.` +} + +func configureLogger() (hclog.Logger, error) { + logPath := strings.TrimSpace(os.Getenv("MCPD_LOG_PATH")) + + // If MCPD_LOG_PATH is not set, don't log anywhere. + logOutput := io.Discard + + if logPath != "" { + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return nil, fmt.Errorf("failed to open log file (%s): %w", logPath, err) + } + logOutput = f + } + + logger := hclog.New(&hclog.LoggerOptions{ + Name: "mcpd", + Level: hclog.LevelFromString(getLogLevel()), + Output: logOutput, + }) + + return logger, nil +} + +func getLogLevel() string { + lvl := strings.ToLower(os.Getenv("LOG_LEVEL")) + switch lvl { + case "trace", "debug", "info", "warn", "error", "off": + return lvl + default: + return "info" + } +} diff --git a/cmd/server/add.go b/cmd/server/add.go new file mode 100644 index 0000000..470972b --- /dev/null +++ b/cmd/server/add.go @@ -0,0 +1,91 @@ +package server + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/cmd" + "github.com/mozilla-ai/mcpd-cli/v2/internal/config" +) + +// AddCmd should be used to represent the 'add' command. +type AddCmd struct { + *cmd.BaseCmd + Version string + Tools []string +} + +// NewAddCmd creates a newly configured (Cobra) command. +func NewAddCmd(logger hclog.Logger) *cobra.Command { + c := &AddCmd{ + BaseCmd: &cmd.BaseCmd{Logger: logger}, + } + + cobraCommand := &cobra.Command{ + Use: "add ", + Short: "Adds an MCP server dependency to the project.", + Long: c.longDescription(), + RunE: c.run, + } + + cobraCommand.Flags().StringVar( + &c.Version, + "version", + "latest", + "Specify the version of the server package", + ) + cobraCommand.Flags().StringArrayVar( + &c.Tools, + "tool", + nil, + "Optional, when specified limits the available tools on the server (can be repeated)", + ) + + return cobraCommand +} + +// longDescription returns the long version of the command description. +func (c *AddCmd) longDescription() string { + return `Adds an MCP server dependency to the project. +mcpd will search the registry for the server and attempt to return information on the version specified, +or 'latest' if no version specified.` +} + +// run is configured (via NewAddCmd) to be called by the Cobra framework when the command is executed. +// It may return an error (or nil, when there is no error). +func (c *AddCmd) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("server name is required and cannot be empty") + } + + name := strings.TrimSpace(args[0]) + if name == "" { + return fmt.Errorf("server name cannot be empty") + } + + // TODO: Make an actual call to the mcpd registry to get information here. + // Currently, we just fake the response here so we can deal with the config file. + pkg := fmt.Sprintf("modelcontextprotocol/%s@%s", name, c.Version) + + entry := config.ServerEntry{ + Name: name, + Package: pkg, + Tools: c.Tools, + } + + if err := config.AddServer(entry); err != nil { + return err + } + + // User-friendly output + logging + fmt.Fprintf(cmd.OutOrStdout(), "✓ Added server '%s' (version: %s)\n", name, c.Version) + if len(c.Tools) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), " Tools: %s\n", strings.Join(c.Tools, ", ")) + } + c.Logger.Debug("Server added", "name", name, "version", c.Version, "tools", c.Tools) + + return nil +} diff --git a/cmd/server/add_test.go b/cmd/server/add_test.go new file mode 100644 index 0000000..60e876f --- /dev/null +++ b/cmd/server/add_test.go @@ -0,0 +1,245 @@ +package server + +import ( + "bytes" + "fmt" + "os" + "strings" + "testing" + + "github.com/BurntSushi/toml" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/cmd" + "github.com/mozilla-ai/mcpd-cli/v2/internal/config" + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +func TestAddCmd_Execute(t *testing.T) { + tests := []struct { + name string + args []string + expectedNumServers int + expectedVersion string + expectedTools []string + expectedOutputs []string + expectedError string + setupFn func(t *testing.T, configPath string) // Optional setup function + }{ + { + name: "basic server add", + args: []string{"testserver"}, + expectedNumServers: 1, + expectedOutputs: []string{ + "✓ Added server 'testserver'", + "version: latest", + }, + }, + { + name: "server add with version", + args: []string{"testserver", "--version", "1.2.3"}, + expectedNumServers: 1, + expectedVersion: "1.2.3", + expectedOutputs: []string{ + "✓ Added server 'testserver'", + "version: 1.2.3", + }, + }, + { + name: "server add with tools", + args: []string{"testserver", "--tool", "tool1", "--tool", "tool2"}, + expectedNumServers: 1, + expectedTools: []string{"tool1", "tool2"}, + expectedOutputs: []string{ + "✓ Added server 'testserver'", + "Tools: tool1, tool2", + }, + }, + { + name: "missing server name", + args: []string{}, + expectedError: "server name is required and cannot be empty", + }, + { + name: "empty server name", + args: []string{" "}, + expectedError: "server name is required and cannot be empty", + }, + { + name: "server name with whitespace", + args: []string{" test-server-with-spaces "}, + expectedNumServers: 1, + expectedOutputs: []string{ + "✓ Added server 'test-server-with-spaces'", + }, + }, + { + name: "existing config file should append", + args: []string{"second-server"}, + expectedNumServers: 2, + expectedOutputs: []string{ + "✓ Added server 'second-server'", + }, + setupFn: func(t *testing.T, configPath string) { + // Create a config file with an existing server + initialContent := `[[servers]] +name = "first-server" +package = "modelcontextprotocol/first-server@latest" +` + err := os.WriteFile(configPath, []byte(initialContent), 0o644) + require.NoError(t, err) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + tempFile, err := os.CreateTemp(tempDir, "config.toml") + require.NoError(t, err) + + // Run any setup function if provided + if tc.setupFn != nil { + tc.setupFn(t, tempFile.Name()) + } + + // Create a buffer to capture output + output := &bytes.Buffer{} + + // Create a test logger that won't output during tests + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: output, + }) + + // Create the command + c := NewAddCmd(logger) + c.SetOut(output) + c.SetErr(output) + c.SetArgs(tc.args) + + // Temporarily modify the config file flag value. + previousConfigFile := flags.ConfigFile + defer func() { flags.ConfigFile = previousConfigFile }() + flags.ConfigFile = tempFile.Name() + + // Execute the command + err = c.Execute() + + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + return + } + assert.NoError(t, err) + + outputStr := output.String() + for _, expectedOutput := range tc.expectedOutputs { + assert.Contains(t, outputStr, expectedOutput) + } + + var parsed config.Config + _, err = toml.DecodeFile(tempFile.Name(), &parsed) + require.NoError(t, err) + + require.Len(t, parsed.Servers, tc.expectedNumServers) + serverName := strings.TrimSpace(tc.args[0]) + + // May have >1 server (if we already had config). + findByName := func(name string) (config.ServerEntry, bool) { + for _, entry := range parsed.Servers { + if entry.Name == name { + return entry, true + } + } + return config.ServerEntry{}, false + } + + server, exists := findByName(serverName) + assert.True(t, exists) + assert.Equal(t, serverName, server.Name) + + version := "latest" + if tc.expectedVersion != "" { + version = tc.expectedVersion + } + assert.Equal(t, fmt.Sprintf("modelcontextprotocol/%s@%s", serverName, version), server.Package) + + if tc.expectedTools != nil { + assert.Equal(t, tc.expectedTools, server.Tools) + } else { + assert.Empty(t, server.Tools) + } + }) + } +} + +func TestAddCmd_WithCustomConfigPath(t *testing.T) { + tempDir := t.TempDir() + tempFile, err := os.CreateTemp(tempDir, "custom-config.toml") + require.NoError(t, err) + + // Create a buffer to capture output + output := &bytes.Buffer{} + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: output, + }) + + // Create the command + c := NewAddCmd(logger) + c.SetOut(output) + c.SetErr(output) + c.SetArgs([]string{"custom-server", "--version", "2.0.0"}) + + // Temporarily modify the config file flag value. + previousConfigFile := flags.ConfigFile + defer func() { flags.ConfigFile = previousConfigFile }() + flags.ConfigFile = tempFile.Name() + + // Execute the command + err = c.Execute() + require.NoError(t, err) + + // Verify output + outputStr := output.String() + assert.Contains(t, outputStr, "✓ Added server 'custom-server'") + assert.Contains(t, outputStr, "version: 2.0.0") + + // Verify the config file was created at the custom path + assert.FileExists(t, tempFile.Name()) + + // Verify content + var parsed config.Config + _, err = toml.DecodeFile(tempFile.Name(), &parsed) + require.NoError(t, err) + + require.Len(t, parsed.Servers, 1) + server := parsed.Servers[0] + assert.Equal(t, "custom-server", server.Name) + assert.Equal(t, "modelcontextprotocol/custom-server@2.0.0", server.Package) + assert.Empty(t, server.Tools) +} + +func TestAddCmd_LongDescription(t *testing.T) { + t.Parallel() + + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: nil, + }) + + c := &AddCmd{ + BaseCmd: &cmd.BaseCmd{Logger: logger}, + } + + description := c.longDescription() + assert.Contains(t, description, "Adds an MCP server dependency") + assert.Contains(t, description, "mcpd will search the registry") +} diff --git a/cmd/server/remove.go b/cmd/server/remove.go new file mode 100644 index 0000000..e01d68d --- /dev/null +++ b/cmd/server/remove.go @@ -0,0 +1,61 @@ +package server + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/cmd" + "github.com/mozilla-ai/mcpd-cli/v2/internal/config" +) + +// RemoveCmd should be used to represent the 'remove' command. +type RemoveCmd struct { + *cmd.BaseCmd +} + +// NewRemoveCmd creates a newly configured (Cobra) command. +func NewRemoveCmd(logger hclog.Logger) *cobra.Command { + c := &RemoveCmd{ + BaseCmd: &cmd.BaseCmd{Logger: logger}, + } + + cobraCommand := &cobra.Command{ + Use: "remove ", + Short: "Removes an MCP server dependency from the project.", + Long: c.longDescription(), + RunE: c.run, + } + + return cobraCommand +} + +// longDescription returns the long version of the command description. +func (c *RemoveCmd) longDescription() string { + return `Removes an MCP server dependency from the project config file. +Specify the server name to remove it.` +} + +// run is configured (via NewRemoveCmd) to be called by the Cobra framework when the command is executed. +// It may return an error (or nil, when there is no error). +func (c *RemoveCmd) run(cmd *cobra.Command, args []string) error { + if len(args) == 0 || strings.TrimSpace(args[0]) == "" { + return fmt.Errorf("server name is required and cannot be empty") + } + + name := strings.TrimSpace(args[0]) + if name == "" { + return fmt.Errorf("server name cannot be empty") + } + + if err := config.RemoveServer(name); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ Removed server '%s'\n", name) + c.Logger.Debug("Server removed", "name", name) + + return nil +} diff --git a/cmd/server/remove_test.go b/cmd/server/remove_test.go new file mode 100644 index 0000000..610752e --- /dev/null +++ b/cmd/server/remove_test.go @@ -0,0 +1,151 @@ +package server + +import ( + "bytes" + "os" + "testing" + + "github.com/BurntSushi/toml" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/config" + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +func TestRemoveServer(t *testing.T) { + tests := []struct { + name string + args []string + expectedNumServers int + expectedVersion string + expectedTools []string + expectedOutputs []string + expectedError string + setupFn func(t *testing.T, configPath string) // Optional setup function + }{ + { + name: "basic server remove", + args: []string{"first-server"}, + expectedNumServers: 0, + expectedOutputs: []string{ + "✓ Removed server 'first-server'", + }, + setupFn: func(t *testing.T, configPath string) { + // Create a config file with an existing server + initialContent := `[[servers]] +name = "first-server" +package = "modelcontextprotocol/first-server@latest" +` + err := os.WriteFile(configPath, []byte(initialContent), 0o644) + require.NoError(t, err) + }, + }, + { + name: "missing server name", + args: []string{}, + expectedError: "server name is required and cannot be empty", + }, + { + name: "empty server name", + args: []string{" "}, + expectedError: "server name is required and cannot be empty", + }, + { + name: "server name with whitespace", + args: []string{" test-server-with-spaces "}, + expectedNumServers: 0, + expectedOutputs: []string{ + "✓ Removed server 'test-server-with-spaces'", + }, + setupFn: func(t *testing.T, configPath string) { + // Create a config file with an existing server + initialContent := `[[servers]] +name = "test-server-with-spaces" +package = "modelcontextprotocol/test-server-with-spaces@latest" +` + err := os.WriteFile(configPath, []byte(initialContent), 0o644) + require.NoError(t, err) + }, + }, + { + name: "existing config file should leave others", + args: []string{"second-server"}, + expectedNumServers: 1, + expectedOutputs: []string{ + "✓ Removed server 'second-server'", + }, + setupFn: func(t *testing.T, configPath string) { + // Create a config file with an existing server + initialContent := `[[servers]] +name = "first-server" +package = "modelcontextprotocol/first-server@latest" + +[[servers]] +name = "second-server" +package = "modelcontextprotocol/second-server@latest" +` + err := os.WriteFile(configPath, []byte(initialContent), 0o644) + require.NoError(t, err) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + tempFile, err := os.CreateTemp(tmpDir, ".mcpd.toml") + require.NoError(t, err) + + if tc.setupFn != nil { + tc.setupFn(t, tempFile.Name()) + } + + // Create a buffer to capture output + output := &bytes.Buffer{} + + // Create a test logger that won't output during tests + logger := hclog.New(&hclog.LoggerOptions{ + Name: "test", + Level: hclog.Debug, + Output: output, + }) + + // Create the command + c := NewRemoveCmd(logger) + c.SetOut(output) + c.SetErr(output) + c.SetArgs(tc.args) + + // Temporarily modify the config file flag value. + previousConfigFile := flags.ConfigFile + defer func() { flags.ConfigFile = previousConfigFile }() + flags.ConfigFile = tempFile.Name() + + // Execute the command + err = c.Execute() + + // Check error expectations + if tc.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedError) + return + } + + // No error expected + assert.NoError(t, err) + + // Check output expectations + outputStr := output.String() + for _, expectedOutput := range tc.expectedOutputs { + assert.Contains(t, outputStr, expectedOutput) + } + + var parsed config.Config + _, err = toml.DecodeFile(tempFile.Name(), &parsed) + require.NoError(t, err) + require.Len(t, parsed.Servers, tc.expectedNumServers) + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6d22d74 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/mozilla-ai/mcpd-cli/v2 + +go 1.24.3 + +require ( + github.com/BurntSushi/toml v1.5.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/spf13/cobra v1.9.1 + github.com/stretchr/testify v1.7.2 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0d3ad93 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/basecmd.go b/internal/cmd/basecmd.go new file mode 100644 index 0000000..526c189 --- /dev/null +++ b/internal/cmd/basecmd.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/hashicorp/go-hclog" + +type BaseCmd struct { + Logger hclog.Logger +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..373edf5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,246 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/BurntSushi/toml" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +// Config represents the .mcpd.toml file structure. +type Config struct { + Servers []ServerEntry `toml:"servers"` +} + +// ServerEntry represents the configuration of a single versioned MCP Server and optional tools. +type ServerEntry struct { + // Name is the unique name referenced by the user. + // e.g. 'github-server' + Name string `toml:"name"` + + // Package contains the identifier including version. + // e.g. 'modelcontextprotocol/github-server@latest' + Package string `toml:"package"` + + // Tools are optional and list the names of the allowed tools on this server. + // e.g. 'create_repository' + Tools []string `toml:"tools,omitempty"` +} + +type serverKey struct { + Name string + Package string // NOTE: without version +} + +// AddServer attempts to persist a new MCP Server to the configuration file (.mcpd.toml). +func AddServer(entry ServerEntry) error { + // Load config (validates existing) + cfg, err := loadConfig() + if err != nil { + return err + } + + // Add server + cfg.Servers = append(cfg.Servers, entry) + + // Validate servers + if err := cfg.validate(); err != nil { + return err + } + + // Save + if err := cfg.saveConfig(); err != nil { + return fmt.Errorf("failed to save updated config: %w", err) + } + + return nil +} + +// RemoveServer removes a server entry by name from the configuration file (.mcpd.toml). +func RemoveServer(name string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("server name cannot be empty") + } + + // Filter out servers matching the given name + filtered := make([]ServerEntry, 0, len(cfg.Servers)) + for _, s := range cfg.Servers { + if s.Name != name { + filtered = append(filtered, s) + } + } + + if len(filtered) == len(cfg.Servers) { + return fmt.Errorf("server '%s' not found in config", name) + } + + cfg.Servers = filtered + + if err := cfg.validate(); err != nil { + return err + } + + if err := cfg.saveConfig(); err != nil { + return fmt.Errorf("failed to save updated config: %w", err) + } + + return nil +} + +func loadConfig() (Config, error) { + path := configFilePath() + + var cfg Config + + _, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, fmt.Errorf("config file cannot be found, run: 'mcpd init'") + } + return cfg, fmt.Errorf("failed to stat config file (%s): %w", path, err) + } + + _, err = toml.DecodeFile(path, &cfg) + if err != nil { + return cfg, fmt.Errorf("failed to decode config from file (%s): %w", flags.DefaultConfigFile, err) + } + + if err := cfg.validate(); err != nil { + return cfg, fmt.Errorf("failed to validate existing config (%s): %w", path, err) + } + + return cfg, nil +} + +func (c *Config) saveConfig() error { + path := configFilePath() + + data, err := toml.Marshal(c) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) + + //file, err := os.Create(path) + // if err != nil { + // return fmt.Errorf("failed to open config file for writing: %w", err) + // } + // defer file.Close() + // + // encoder := toml.NewEncoder(file) + // if err := encoder.Encode(cfg); err != nil { + // return fmt.Errorf("failed to encode config: %w", err) + // } + + //// Upsert logic + //updated := false + //for i, s := range cfg.Servers { + // if s.Name == entry.Name { + // cfg.Servers[i] = entry + // updated = true + // break + // } + //} + //if !updated { + // cfg.Servers = append(cfg.Servers, entry) + //} + // + //newData, err := toml.Marshal(&cfg) + //if err != nil { + // return fmt.Errorf("failed to encode config: %w", err) + //} + //return os.WriteFile(path, newData, 0o644) +} + +// configFilePath returns the expected path of the .mcpd.toml file. +func configFilePath() string { + if flags.ConfigFile != "" { + return flags.ConfigFile + } else if env := strings.TrimSpace(os.Getenv(flags.EnvVarConfigFile)); env != "" { + return env + } else { + return flags.DefaultConfigFile + } +} + +// keyFor generates a temporary version of the ServerEntry to be used as a composite key. +// It consists of the name of the server and the package without version information. +func keyFor(entry ServerEntry) serverKey { + return serverKey{ + Name: entry.Name, + Package: stripVersion(entry.Package), + } +} + +// stripVersion removes any version information present at the end of the package. +func stripVersion(pkg string) string { + // Find the last @ symbol (version separator) + if idx := strings.LastIndex(pkg, "@"); idx != -1 { + return pkg[:idx] + } + return pkg +} + +// validate orchestrates validation of all aspects of the configuration. +func (c *Config) validate() error { + if err := c.validateServers(); err != nil { + return err + } + + // TODO: Add more sub-validation as we add more parts to the config file. + + return nil +} + +// validateServers checks the server config section to ensure there are no errors. +func (c *Config) validateServers() error { + if err := c.validateFields(); err != nil { + return err + } + if err := c.validateDistinct(); err != nil { + return err + } + return nil + + // TODO: Reqs: + // Check with the registry that the package exists + // Check we have configuration for the server stored? + // ... +} + +// validateFields ensures that all ServerEntry's in Config have a name and package. +func (c *Config) validateFields() error { + for _, entry := range c.Servers { + if strings.TrimSpace(entry.Name) == "" { + return fmt.Errorf("server entry has empty name") + } + if strings.TrimSpace(entry.Package) == "" { + return fmt.Errorf("server entry has empty package") + } + } + return nil +} + +// validateDistinct ensures that all ServerEntry's in Config are distinct (no duplicate servers allowed). +func (c *Config) validateDistinct() error { + seen := map[serverKey]struct{}{} + + for _, entry := range c.Servers { + k := keyFor(entry) + if _, exists := seen[k]; exists { + return fmt.Errorf("duplicate server entry: name: %q package: %q", k.Name, k.Package) + } + seen[k] = struct{}{} + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7d7bc4f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,476 @@ +package config + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd-cli/v2/internal/flags" +) + +func TestAddServer(t *testing.T) { + tests := []struct { + name string + config *Config + newEntry ServerEntry + isErrorExpected bool + expectedErrMsg string + shouldSetupConfig bool + }{ + { + name: "add server to existing config", + config: &Config{ + Servers: []ServerEntry{ + {Name: "existing-server", Package: "modelcontextprotocol/existing@v1.0.0"}, + }, + }, + newEntry: ServerEntry{ + Name: "new-server", + Package: "modelcontextprotocol/new-server@latest", + Tools: []string{"tool1"}, + }, + shouldSetupConfig: true, + isErrorExpected: false, + }, + { + name: "add server to empty config", + config: &Config{Servers: []ServerEntry{}}, + newEntry: ServerEntry{ + Name: "first-server", + Package: "modelcontextprotocol/first-server@latest", + }, + shouldSetupConfig: true, + isErrorExpected: false, + }, + { + name: "add duplicate server (same name and package base)", + config: &Config{ + Servers: []ServerEntry{ + {Name: "test-server", Package: "modelcontextprotocol/test-server@v1.0.0"}, + }, + }, + newEntry: ServerEntry{ + Name: "test-server", + Package: "modelcontextprotocol/test-server@v2.0.0", + }, + shouldSetupConfig: true, + isErrorExpected: true, + expectedErrMsg: "duplicate server entry", + }, + { + name: "add server with empty name", + config: &Config{Servers: []ServerEntry{}}, + newEntry: ServerEntry{ + Name: "", + Package: "modelcontextprotocol/test-server@latest", + }, + shouldSetupConfig: true, + isErrorExpected: true, + expectedErrMsg: "server entry has empty name", + }, + { + name: "add server with empty package", + config: &Config{Servers: []ServerEntry{}}, + newEntry: ServerEntry{ + Name: "test-server", + Package: "", + }, + shouldSetupConfig: true, + isErrorExpected: true, + expectedErrMsg: "server entry has empty package", + }, + { + name: "no config file exists", + config: nil, + newEntry: ServerEntry{ + Name: "test-server", + Package: "modelcontextprotocol/test-server@latest", + }, + shouldSetupConfig: false, + isErrorExpected: true, + expectedErrMsg: "config file cannot be found, run: 'mcpd init'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + if tc.shouldSetupConfig && tc.config != nil { + createTestConfigFile(t, tempDir, *tc.config) + } + + // Override global config flag to some fake path + if !tc.shouldSetupConfig { + previousConfigFile := flags.ConfigFile + flags.ConfigFile = "/foo/bar/baz.toml" + defer func() { flags.ConfigFile = previousConfigFile }() + } + + err := AddServer(tc.newEntry) + + if tc.isErrorExpected { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + + config, err := loadConfig() + require.NoError(t, err) + + found := false + for _, server := range config.Servers { + if server.Name == tc.newEntry.Name && server.Package == tc.newEntry.Package { + found = true + assert.Equal(t, tc.newEntry.Tools, server.Tools) + break + } + } + assert.True(t, found, "Added server not found in config") + }) + } +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + shouldSetupConfig bool + configContent *Config + isErrorExpected bool + expectedErrMsg string + }{ + { + name: "load valid config", + shouldSetupConfig: true, + configContent: &Config{ + Servers: []ServerEntry{ + {Name: "test-server", Package: "modelcontextprotocol/test@v1.0.0"}, + }, + }, + isErrorExpected: false, + }, + { + name: "config file does not exist", + shouldSetupConfig: false, + isErrorExpected: true, + expectedErrMsg: "config file cannot be found, run: 'mcpd init'", + }, + { + name: "load empty config", + shouldSetupConfig: true, + configContent: &Config{ + Servers: []ServerEntry{}, + }, + isErrorExpected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + + if tc.shouldSetupConfig { + createTestConfigFile(t, tempDir, *tc.configContent) + } else { + // Override global config flag to use test-specific file path that doesn't exist + previousConfigFile := flags.ConfigFile + flags.ConfigFile = "/foo/bar/baz.toml" + defer func() { flags.ConfigFile = previousConfigFile }() + } + + config, err := loadConfig() + + if tc.isErrorExpected { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + require.NoError(t, err) + assert.Equal(t, len(tc.configContent.Servers), len(config.Servers)) + } + }) + } +} + +func TestConfig_SaveConfig(t *testing.T) { + tests := []struct { + name string + config Config + }{ + { + name: "save config with servers", + config: Config{ + Servers: []ServerEntry{ + {Name: "server1", Package: "modelcontextprotocol/server1@v1.0.0"}, + {Name: "server2", Package: "modelcontextprotocol/server2@latest", Tools: []string{"tool1", "tool2"}}, + }, + }, + }, + { + name: "save empty config", + config: Config{ + Servers: []ServerEntry{}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + tempFile, err := os.CreateTemp(tempDir, "config.toml") + require.NoError(t, err) + + previousConfigFile := flags.ConfigFile + defer func() { flags.ConfigFile = previousConfigFile }() + flags.ConfigFile = tempFile.Name() + + err = tc.config.saveConfig() + require.NoError(t, err) + + assert.FileExists(t, tempFile.Name()) + loadedConfig, err := loadConfig() + require.NoError(t, err) + assert.Equal(t, tc.config, loadedConfig) + }) + } +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config Config + isErrorExpected bool + expectedErrMsg string + }{ + { + name: "valid config", + config: Config{ + Servers: []ServerEntry{ + {Name: "server1", Package: "modelcontextprotocol/server1@v1.0.0"}, + {Name: "server2", Package: "modelcontextprotocol/server2@latest"}, + }, + }, + isErrorExpected: false, + }, + { + name: "empty name", + config: Config{ + Servers: []ServerEntry{ + {Name: "", Package: "modelcontextprotocol/server1@v1.0.0"}, + }, + }, + isErrorExpected: true, + expectedErrMsg: "server entry has empty name", + }, + { + name: "whitespace-only name", + config: Config{ + Servers: []ServerEntry{ + {Name: " ", Package: "modelcontextprotocol/server1@v1.0.0"}, + }, + }, + isErrorExpected: true, + expectedErrMsg: "server entry has empty name", + }, + { + name: "empty package", + config: Config{ + Servers: []ServerEntry{ + {Name: "server1", Package: ""}, + }, + }, + isErrorExpected: true, + expectedErrMsg: "server entry has empty package", + }, + { + name: "duplicate servers", + config: Config{ + Servers: []ServerEntry{ + {Name: "server1", Package: "modelcontextprotocol/server1@v1.0.0"}, + {Name: "server1", Package: "modelcontextprotocol/server1@v2.0.0"}, // Different version, same base + }, + }, + isErrorExpected: true, + expectedErrMsg: "duplicate server entry", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.config.validate() + + if tc.isErrorExpected { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestStripVersion(t *testing.T) { + tests := []struct { + name string + pkg string + expected string + }{ + { + name: "package with version", + pkg: "modelcontextprotocol/server@v1.0.0", + expected: "modelcontextprotocol/server", + }, + { + name: "package with latest", + pkg: "modelcontextprotocol/server@latest", + expected: "modelcontextprotocol/server", + }, + { + name: "package without version", + pkg: "modelcontextprotocol/server", + expected: "modelcontextprotocol/server", + }, + { + name: "package with multiple @ symbols", + pkg: "scope@org/package@v1.0.0", + expected: "scope@org/package", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := stripVersion(tc.pkg) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestKeyFor(t *testing.T) { + tests := []struct { + name string + entry ServerEntry + expected serverKey + }{ + { + name: "basic server entry", + entry: ServerEntry{ + Name: "test-server", + Package: "modelcontextprotocol/test-server@v1.0.0", + }, + expected: serverKey{ + Name: "test-server", + Package: "modelcontextprotocol/test-server", + }, + }, + { + name: "server entry with tools", + entry: ServerEntry{ + Name: "tool-server", + Package: "modelcontextprotocol/tool-server@latest", + Tools: []string{"tool1", "tool2"}, + }, + expected: serverKey{ + Name: "tool-server", + Package: "modelcontextprotocol/tool-server", + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := keyFor(tc.entry) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConfigFilePath(t *testing.T) { + expected := flags.DefaultConfigFile + result := configFilePath() + assert.Equal(t, expected, result) +} + +// Helper functions for test setup and cleanup +func createTestConfigFile(t *testing.T, dir string, config Config) { + t.Helper() + + previousConfigFile := flags.ConfigFile + flags.ConfigFile = dir + defer func() { flags.ConfigFile = previousConfigFile }() + + err := config.saveConfig() + require.NoError(t, err) +} + +// Benchmark tests +func BenchmarkLoadConfig(b *testing.B) { + tempDir, _ := os.MkdirTemp("", "mcpd-bench-*") + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + b.Fatal(err) + } + }(tempDir) + + originalDir, _ := os.Getwd() + err := os.Chdir(tempDir) + require.NoError(b, err) + defer func(dir string) { + err := os.Chdir(dir) + if err != nil { + b.Fatal(err) + } + }(originalDir) + + config := Config{ + Servers: []ServerEntry{ + {Name: "server1", Package: "modelcontextprotocol/server1@v1.0.0"}, + {Name: "server2", Package: "modelcontextprotocol/server2@latest"}, + }, + } + err = config.saveConfig() + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = loadConfig() + } +} + +func BenchmarkAddServer(b *testing.B) { + // Setup + tempDir, _ := os.MkdirTemp("", "mcpd-bench-*") + defer func(path string) { + err := os.RemoveAll(path) + if err != nil { + b.Fatal(err) + } + }(tempDir) + + originalDir, _ := os.Getwd() + err := os.Chdir(tempDir) + require.NoError(b, err) + defer func(dir string) { + err := os.Chdir(dir) + if err != nil { + b.Fatal(err) + } + }(originalDir) + + // Create initial config + config := Config{Servers: []ServerEntry{}} + err = config.saveConfig() + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + entry := ServerEntry{ + Name: fmt.Sprintf("server-%d", i), + Package: fmt.Sprintf("modelcontextprotocol/server-%d@latest", i), + } + _ = AddServer(entry) + } +} diff --git a/internal/flags/config.go b/internal/flags/config.go new file mode 100644 index 0000000..011474f --- /dev/null +++ b/internal/flags/config.go @@ -0,0 +1,24 @@ +package flags + +import ( + "os" + + "github.com/spf13/pflag" +) + +const ( + EnvVarConfigFile = "MCPD_CONFIG_FILE" + DefaultConfigFile = ".mcpd.toml" + FlagNameConfigFile = "config-file" +) + +var ConfigFile string + +func InitFlags(fs *pflag.FlagSet) { + defaultVal := os.Getenv(EnvVarConfigFile) + if defaultVal == "" { + defaultVal = DefaultConfigFile + } + + fs.StringVar(&ConfigFile, FlagNameConfigFile, defaultVal, "path to config file") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d17ddb8 --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/mozilla-ai/mcpd-cli/v2/cmd" +) + +func main() { + // Execute the root command. + cmd.Execute() + + // TODO: Document env vars! +} From e4044576f9c08ea5400105692e261c5f4b011aaa Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Wed, 4 Jun 2025 23:10:09 +0100 Subject: [PATCH 2/2] Remove commented out old code --- internal/config/config.go | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 373edf5..ba903db 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -130,36 +130,6 @@ func (c *Config) saveConfig() error { } return os.WriteFile(path, data, 0o644) - - //file, err := os.Create(path) - // if err != nil { - // return fmt.Errorf("failed to open config file for writing: %w", err) - // } - // defer file.Close() - // - // encoder := toml.NewEncoder(file) - // if err := encoder.Encode(cfg); err != nil { - // return fmt.Errorf("failed to encode config: %w", err) - // } - - //// Upsert logic - //updated := false - //for i, s := range cfg.Servers { - // if s.Name == entry.Name { - // cfg.Servers[i] = entry - // updated = true - // break - // } - //} - //if !updated { - // cfg.Servers = append(cfg.Servers, entry) - //} - // - //newData, err := toml.Marshal(&cfg) - //if err != nil { - // return fmt.Errorf("failed to encode config: %w", err) - //} - //return os.WriteFile(path, newData, 0o644) } // configFilePath returns the expected path of the .mcpd.toml file.