Skip to content

Commit 70da343

Browse files
committed
feat: add mcpd config volumes remove command
Implement `mcpd config volumes remove <server-name> -- --<volume-name>` to remove volume mappings from MCP server runtime configurations. Also improves the existing `set` command: noop-aware messaging, consistent validation naming, and clearer withVolumes doc comment. Closes #200
1 parent ba86830 commit 70da343

File tree

5 files changed

+638
-61
lines changed

5 files changed

+638
-61
lines changed

cmd/config/volumes/cmd.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package volumes
22

33
import (
4+
"maps"
5+
46
"github.com/spf13/cobra"
57

68
"github.com/mozilla-ai/mcpd/internal/cmd"
79
cmdopts "github.com/mozilla-ai/mcpd/internal/cmd/options"
10+
"github.com/mozilla-ai/mcpd/internal/context"
811
)
912

1013
// NewCmd creates a new volumes command with its sub-commands.
@@ -18,8 +21,9 @@ func NewCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, err
1821

1922
// Sub-commands for: mcpd config volumes
2023
fns := []func(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error){
21-
NewListCmd, // list
22-
NewSetCmd, // set
24+
NewListCmd, // list
25+
NewRemoveCmd, // remove
26+
NewSetCmd, // set
2327
}
2428

2529
for _, fn := range fns {
@@ -32,3 +36,16 @@ func NewCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, err
3236

3337
return cobraCmd, nil
3438
}
39+
40+
// withVolumes returns a new ServerExecutionContext with both volume fields
41+
// set to unexpanded values. Volumes (the TOML-serialized field) preserves
42+
// env var references on disk. RawVolumes is kept in sync for Equals/IsEmpty
43+
// comparisons during Upsert.
44+
func withVolumes(
45+
server context.ServerExecutionContext,
46+
working context.VolumeExecutionContext,
47+
) context.ServerExecutionContext {
48+
server.RawVolumes = maps.Clone(working)
49+
server.Volumes = maps.Clone(working)
50+
return server
51+
}

cmd/config/volumes/remove.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package volumes
2+
3+
import (
4+
"fmt"
5+
"maps"
6+
"slices"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
11+
"github.com/mozilla-ai/mcpd/internal/cmd"
12+
cmdopts "github.com/mozilla-ai/mcpd/internal/cmd/options"
13+
"github.com/mozilla-ai/mcpd/internal/context"
14+
"github.com/mozilla-ai/mcpd/internal/flags"
15+
)
16+
17+
// removeCmd handles removing volume mappings from MCP servers.
18+
type removeCmd struct {
19+
*cmd.BaseCmd
20+
ctxLoader context.Loader
21+
}
22+
23+
// NewRemoveCmd creates a new remove command for volume configuration.
24+
func NewRemoveCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) {
25+
opts, err := cmdopts.NewOptions(opt...)
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
c := &removeCmd{
31+
BaseCmd: baseCmd,
32+
ctxLoader: opts.ContextLoader,
33+
}
34+
35+
cobraCmd := &cobra.Command{
36+
Use: "remove <server-name> -- --<volume-name> [--<volume-name>...]",
37+
Short: "Remove volume mappings from an MCP server",
38+
Long: "Remove volume mappings from an MCP server in the runtime context " +
39+
"configuration file (e.g. " + flags.RuntimeFile + ").\n\n" +
40+
"Use -- to separate the server name from the volume names to remove.\n\n" +
41+
"Examples:\n" +
42+
" # Remove a single volume mapping\n" +
43+
" mcpd config volumes remove filesystem -- --workspace\n\n" +
44+
" # Remove multiple volume mappings\n" +
45+
" mcpd config volumes remove filesystem -- --workspace --gdrive",
46+
RunE: c.run,
47+
Args: validateRemoveArgs,
48+
}
49+
50+
return cobraCmd, nil
51+
}
52+
53+
// validateRemoveArgs validates the arguments for the remove command.
54+
// It wraps validateRemoveArgsCore to extract the dash position from the cobra command.
55+
func validateRemoveArgs(cmd *cobra.Command, args []string) error {
56+
return validateRemoveArgsCore(cmd.ArgsLenAtDash(), args)
57+
}
58+
59+
// validateRemoveArgsCore validates the remove command arguments given the dash position and args slice.
60+
func validateRemoveArgsCore(dashPos int, args []string) error {
61+
if len(args) == 0 {
62+
return fmt.Errorf("server-name is required")
63+
}
64+
if dashPos == -1 {
65+
return fmt.Errorf(
66+
"missing '--' separator: usage: mcpd config volumes remove <server-name> -- --<volume-name>",
67+
)
68+
}
69+
// -- at position 0 means no server name before it.
70+
if dashPos < 1 {
71+
return fmt.Errorf("server-name is required")
72+
}
73+
if strings.TrimSpace(args[0]) == "" {
74+
return fmt.Errorf("server-name is required")
75+
}
76+
if dashPos > 1 {
77+
return fmt.Errorf("too many arguments before --")
78+
}
79+
if len(args) < 2 {
80+
return fmt.Errorf("volume name(s) required after --")
81+
}
82+
return nil
83+
}
84+
85+
// run executes the remove command, deleting volume mappings from the server config.
86+
func (c *removeCmd) run(cobraCmd *cobra.Command, args []string) error {
87+
serverName := strings.TrimSpace(args[0])
88+
89+
// volumeArgs contains everything after the -- separator.
90+
// validateRemoveArgs guarantees len(args) >= 2.
91+
volumeArgs := args[1:]
92+
volumeNames, err := parseRemoveArgs(volumeArgs)
93+
if err != nil {
94+
return err
95+
}
96+
97+
cfg, err := c.ctxLoader.Load(flags.RuntimeFile)
98+
if err != nil {
99+
return fmt.Errorf("failed to load execution context config: %w", err)
100+
}
101+
102+
// Get returns a value copy; safe to modify.
103+
server, ok := cfg.Get(serverName)
104+
if !ok {
105+
return fmt.Errorf("server '%s' not found in configuration", serverName)
106+
}
107+
108+
working := maps.Clone(server.RawVolumes)
109+
if working == nil {
110+
working = context.VolumeExecutionContext{}
111+
}
112+
for _, name := range volumeNames {
113+
delete(working, name)
114+
}
115+
server = withVolumes(server, working)
116+
117+
res, err := cfg.Upsert(server)
118+
if err != nil {
119+
return fmt.Errorf("error removing volumes for server '%s': %w", serverName, err)
120+
}
121+
122+
sorted := slices.Clone(volumeNames)
123+
slices.Sort(sorted)
124+
125+
out := cobraCmd.OutOrStdout()
126+
127+
var msg string
128+
switch res {
129+
case context.Noop:
130+
msg = fmt.Sprintf("No changes — specified volumes not present on server '%s': %v", serverName, sorted)
131+
default:
132+
msg = fmt.Sprintf("✓ Volumes removed for server '%s' (operation: %s): %v", serverName, string(res), sorted)
133+
}
134+
135+
if _, err := fmt.Fprintln(out, msg); err != nil {
136+
return fmt.Errorf("failed to write output: %w", err)
137+
}
138+
139+
return nil
140+
}
141+
142+
// parseRemoveArgs parses volume name arguments in the format --name.
143+
func parseRemoveArgs(args []string) ([]string, error) {
144+
names := make([]string, 0, len(args))
145+
146+
for _, arg := range args {
147+
if !strings.HasPrefix(arg, "--") {
148+
return nil, fmt.Errorf("invalid volume name '%s': must start with --", arg)
149+
}
150+
151+
name := strings.TrimSpace(strings.TrimPrefix(arg, "--"))
152+
if name == "" {
153+
return nil, fmt.Errorf("volume name cannot be empty in '%s'", arg)
154+
}
155+
156+
names = append(names, name)
157+
}
158+
159+
return names, nil
160+
}

0 commit comments

Comments
 (0)