From ebbebf3d8166b67d59234de6690869c75cbba48c Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 27 Feb 2026 19:47:39 +0000 Subject: [PATCH] feat: add mcpd config volumes list command Allow users to list all configured volume mappings for a given MCP server from the runtime context configuration file. Closes #198 --- cmd/config/volumes/cmd.go | 3 +- cmd/config/volumes/list.go | 88 ++++++++++++++ cmd/config/volumes/list_test.go | 195 ++++++++++++++++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 cmd/config/volumes/list.go create mode 100644 cmd/config/volumes/list_test.go diff --git a/cmd/config/volumes/cmd.go b/cmd/config/volumes/cmd.go index 558e8c1..cfaf967 100644 --- a/cmd/config/volumes/cmd.go +++ b/cmd/config/volumes/cmd.go @@ -18,7 +18,8 @@ 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){ - NewSetCmd, // set + NewListCmd, // list + NewSetCmd, // set } for _, fn := range fns { diff --git a/cmd/config/volumes/list.go b/cmd/config/volumes/list.go new file mode 100644 index 0000000..7738ec5 --- /dev/null +++ b/cmd/config/volumes/list.go @@ -0,0 +1,88 @@ +package volumes + +import ( + "fmt" + "maps" + "slices" + "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" +) + +// listCmd handles listing volume mappings for an MCP server. +type listCmd struct { + *cmd.BaseCmd + ctxLoader context.Loader +} + +// NewListCmd creates a new list command for volume configuration. +func NewListCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) { + opts, err := cmdopts.NewOptions(opt...) + if err != nil { + return nil, err + } + + c := &listCmd{ + BaseCmd: baseCmd, + ctxLoader: opts.ContextLoader, + } + + cobraCmd := &cobra.Command{ + Use: "list ", + Short: "List configured volume mappings for a server", + Long: "List configured volume mappings for an MCP server from the " + + "runtime context configuration file (e.g. " + flags.RuntimeFile + ").", + RunE: c.run, + Args: cobra.ExactArgs(1), + } + + return cobraCmd, nil +} + +// run executes the list command, displaying volume mappings for the given server. +func (c *listCmd) run(cobraCmd *cobra.Command, args []string) error { + serverName := strings.TrimSpace(args[0]) + if serverName == "" { + return fmt.Errorf("server-name is required") + } + + 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) + } + + out := cobraCmd.OutOrStdout() + + if _, err := fmt.Fprintf(out, "Volumes for '%s':\n", serverName); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + + if len(server.Volumes) == 0 { + if _, err := fmt.Fprintln(out, " (No volumes set)"); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + return nil + } + + // Sort keys for deterministic output. + keys := slices.Collect(maps.Keys(server.Volumes)) + slices.Sort(keys) + + for _, k := range keys { + if _, err := fmt.Fprintf(out, " %s = %s\n", k, server.Volumes[k]); err != nil { + return fmt.Errorf("failed to write output: %w", err) + } + } + + return nil +} diff --git a/cmd/config/volumes/list_test.go b/cmd/config/volumes/list_test.go new file mode 100644 index 0000000..2d37909 --- /dev/null +++ b/cmd/config/volumes/list_test.go @@ -0,0 +1,195 @@ +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 TestNewListCmd(t *testing.T) { + t.Parallel() + + base := &cmd.BaseCmd{} + c, err := NewListCmd(base) + require.NoError(t, err) + require.NotNil(t, c) + + require.Equal(t, "list ", c.Use) + require.Contains(t, c.Short, "List configured volume mappings") + require.NotNil(t, c.RunE) +} + +func TestListCmd_run(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + serverName string + existingServers map[string]context.ServerExecutionContext + expectedOutput string + expectedError string + }{ + { + name: "list volumes for server with volumes", + serverName: "filesystem", + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + Volumes: context.VolumeExecutionContext{ + "workspace": "/Users/foo/repos/mcpd", + }, + }, + }, + expectedOutput: "Volumes for 'filesystem':\n workspace = /Users/foo/repos/mcpd", + }, + { + name: "list volumes for server with multiple volumes sorted", + serverName: "filesystem", + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + Volumes: context.VolumeExecutionContext{ + "workspace": "/Users/foo/repos", + "data": "my-named-volume", + "gdrive": "/mcp/gdrive", + }, + }, + }, + expectedOutput: "Volumes for 'filesystem':\n data = my-named-volume\n gdrive = /mcp/gdrive\n workspace = /Users/foo/repos", + }, + { + name: "list volumes for server with no volumes", + serverName: "filesystem", + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + Volumes: context.VolumeExecutionContext{}, + }, + }, + expectedOutput: "Volumes for 'filesystem':\n (No volumes set)", + }, + { + name: "list volumes for server with nil volumes", + serverName: "filesystem", + existingServers: map[string]context.ServerExecutionContext{ + "filesystem": { + Name: "filesystem", + }, + }, + expectedOutput: "Volumes for 'filesystem':\n (No volumes set)", + }, + { + name: "server not found", + serverName: "nonexistent", + existingServers: map[string]context.ServerExecutionContext{}, + expectedError: "server 'nonexistent' not found in configuration", + }, + } + + 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{} + listCmd, err := NewListCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + // Capture output. + var output bytes.Buffer + listCmd.SetOut(&output) + listCmd.SetErr(&output) + + listCmd.SetArgs([]string{tc.serverName}) + + err = listCmd.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) + }) + } +} + +func TestListCmd_Validation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expectedError string + }{ + { + name: "missing server name", + args: []string{}, + expectedError: "accepts 1 arg(s), received 0", + }, + { + name: "empty server name", + args: []string{""}, + expectedError: "server-name is required", + }, + { + name: "whitespace only server name", + args: []string{" "}, + expectedError: "server-name is required", + }, + { + name: "too many args", + args: []string{"server1", "server2"}, + expectedError: "accepts 1 arg(s), received 2", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + modifier := &mockModifier{ + servers: map[string]context.ServerExecutionContext{}, + } + loader := &mockLoader{modifier: modifier} + + base := &cmd.BaseCmd{} + listCmd, err := NewListCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + listCmd.SetArgs(tc.args) + err = listCmd.Execute() + require.EqualError(t, err, tc.expectedError) + }) + } +} + +func TestListCmd_LoaderError(t *testing.T) { + t.Parallel() + + loader := &mockLoader{ + loadError: fmt.Errorf("failed to load"), + } + + base := &cmd.BaseCmd{} + listCmd, err := NewListCmd(base, cmdopts.WithContextLoader(loader)) + require.NoError(t, err) + + listCmd.SetArgs([]string{"server"}) + err = listCmd.Execute() + require.EqualError(t, err, "failed to load execution context config: failed to load") +}