diff --git a/cmd/config/volumes/clear.go b/cmd/config/volumes/clear.go new file mode 100644 index 0000000..286c26a --- /dev/null +++ b/cmd/config/volumes/clear.go @@ -0,0 +1,94 @@ +package volumes + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/mozilla-ai/mcpd/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/internal/cmd/options" + "github.com/mozilla-ai/mcpd/internal/context" + "github.com/mozilla-ai/mcpd/internal/flags" +) + +// clearCmd handles clearing all volume mappings from an MCP server. +type clearCmd struct { + *cmd.BaseCmd + force bool + ctxLoader context.Loader +} + +// NewClearCmd creates a new clear command for volume configuration. +func NewClearCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) { + opts, err := cmdopts.NewOptions(opt...) + if err != nil { + return nil, err + } + + c := &clearCmd{ + BaseCmd: baseCmd, + ctxLoader: opts.ContextLoader, + } + + cobraCmd := &cobra.Command{ + Use: "clear ", + Short: "Clear all volume mappings for a server", + Long: "Clears all volume mappings for the specified server from the " + + "runtime context configuration file (e.g. " + flags.RuntimeFile + ").\n\n" + + "This is a destructive operation and requires the --force flag.\n\n" + + "Examples:\n" + + " # Clear all volume mappings\n" + + " mcpd config volumes clear filesystem --force", + RunE: c.run, + Args: cobra.ExactArgs(1), + } + + cobraCmd.Flags().BoolVar( + &c.force, + "force", + false, + "Force clearing of all volume mappings for the specified server without confirmation", + ) + + return cobraCmd, nil +} + +// run executes the clear command, removing all volume mappings from the server config. +func (c *clearCmd) run(cobraCmd *cobra.Command, args []string) error { + serverName := strings.TrimSpace(args[0]) + if serverName == "" { + return fmt.Errorf("server-name is required") + } + + if !c.force { + return fmt.Errorf("this is a destructive operation. To clear all volumes for '%s', "+ + "please re-run the command with the --force flag", serverName) + } + + cfg, err := c.ctxLoader.Load(flags.RuntimeFile) + if err != nil { + return fmt.Errorf("failed to load execution context config: %w", err) + } + + server, ok := cfg.Get(serverName) + if !ok { + return fmt.Errorf("server '%s' not found in configuration", serverName) + } + + server = withVolumes(server, context.VolumeExecutionContext{}) + + res, err := cfg.Upsert(server) + if err != nil { + return fmt.Errorf("error clearing volumes for server '%s': %w", serverName, err) + } + + if _, err := fmt.Fprintf( + cobraCmd.OutOrStdout(), + "✓ Volumes cleared for server '%s' (operation: %s)\n", serverName, string(res), + ); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + + return nil +} diff --git a/cmd/config/volumes/clear_test.go b/cmd/config/volumes/clear_test.go new file mode 100644 index 0000000..44f4339 --- /dev/null +++ b/cmd/config/volumes/clear_test.go @@ -0,0 +1,181 @@ +package volumes + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/internal/cmd/options" + "github.com/mozilla-ai/mcpd/internal/context" +) + +func TestNewClearCmd(t *testing.T) { + t.Parallel() + + base := &cmd.BaseCmd{} + c, err := NewClearCmd(base) + require.NoError(t, err) + require.NotNil(t, c) + + require.True(t, strings.HasPrefix(c.Use, "clear ")) + require.Contains(t, c.Short, "Clear") + require.NotNil(t, c.RunE) + + forceFlag := c.Flags().Lookup("force") + require.NotNil(t, forceFlag) + require.Equal(t, "false", forceFlag.DefValue) +} + +func TestClearCmd_run(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverName string + force bool + existingServers map[string]context.ServerExecutionContext + expectedOutput string + expectedError string + expectedVolumes context.VolumeExecutionContext + }{ + { + name: "clear all volumes with force", + serverName: "filesystem", + force: true, + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + Volumes: context.VolumeExecutionContext{ + "workspace": "/Users/foo/repos", + "gdrive": "/mcp/gdrive", + }, + RawVolumes: context.VolumeExecutionContext{ + "workspace": "/Users/foo/repos", + "gdrive": "/mcp/gdrive", + }, + }, + }, + expectedOutput: "✓ Volumes cleared for server 'filesystem' (operation: deleted)", + expectedVolumes: context.VolumeExecutionContext{}, + }, + { + name: "clear without force returns error", + serverName: "filesystem", + force: false, + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + Volumes: context.VolumeExecutionContext{"workspace": "/path"}, + RawVolumes: context.VolumeExecutionContext{"workspace": "/path"}, + }, + }, + expectedError: "this is a destructive operation. To clear all volumes for 'filesystem', " + + "please re-run the command with the --force flag", + }, + { + name: "server not found", + serverName: "nonexistent", + force: true, + existingServers: map[string]context.ServerExecutionContext{}, + expectedError: "server 'nonexistent' not found in configuration", + }, + { + name: "clear server with no volumes is noop", + serverName: "filesystem", + force: true, + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + }, + }, + expectedOutput: "✓ Volumes cleared for server 'filesystem' (operation: noop)", + expectedVolumes: context.VolumeExecutionContext{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + modifier := &mockModifier{ + servers: tc.existingServers, + } + loader := &mockLoader{modifier: modifier} + + base := &cmd.BaseCmd{} + clearCmd, err := NewClearCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + var output bytes.Buffer + clearCmd.SetOut(&output) + clearCmd.SetErr(&output) + + args := []string{tc.serverName} + if tc.force { + args = append(args, "--force") + } + clearCmd.SetArgs(args) + + err = clearCmd.Execute() + + if tc.expectedError != "" { + require.EqualError(t, err, tc.expectedError) + return + } + + require.NoError(t, err) + + actualOutput := strings.TrimSpace(output.String()) + require.Equal(t, tc.expectedOutput, actualOutput) + + if tc.expectedVolumes != nil { + require.Equal(t, tc.expectedVolumes, modifier.lastUpsert.Volumes) + require.Equal(t, tc.expectedVolumes, modifier.lastUpsert.RawVolumes) + } + }) + } +} + +func TestClearCmd_LoaderError(t *testing.T) { + t.Parallel() + + loader := &mockLoader{ + loadError: fmt.Errorf("failed to load"), + } + + base := &cmd.BaseCmd{} + clearCmd, err := NewClearCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + clearCmd.SetArgs([]string{"server", "--force"}) + err = clearCmd.Execute() + require.EqualError(t, err, "failed to load execution context config: failed to load") +} + +func TestClearCmd_UpsertError(t *testing.T) { + t.Parallel() + + modifier := &mockModifier{ + servers: map[string]context.ServerExecutionContext{ + "server": { + Name: "server", + Volumes: context.VolumeExecutionContext{"workspace": "/path"}, + RawVolumes: context.VolumeExecutionContext{"workspace": "/path"}, + }, + }, + upsertError: fmt.Errorf("upsert failed"), + } + loader := &mockLoader{modifier: modifier} + + base := &cmd.BaseCmd{} + clearCmd, err := NewClearCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + clearCmd.SetArgs([]string{"server", "--force"}) + err = clearCmd.Execute() + require.EqualError(t, err, "error clearing volumes for server 'server': upsert failed") +} diff --git a/cmd/config/volumes/cmd.go b/cmd/config/volumes/cmd.go index 4701dc8..9e7e165 100644 --- a/cmd/config/volumes/cmd.go +++ b/cmd/config/volumes/cmd.go @@ -23,6 +23,7 @@ func NewCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, err // Sub-commands for: mcpd config volumes fns := []func(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error){ + NewClearCmd, // clear NewListCmd, // list NewRemoveCmd, // remove NewSetCmd, // set