Skip to content

Commit 557fc34

Browse files
authored
feat: add mcpd config volumes set command (#244)
* feat: add mcpd config volumes set command Add CLI command to set host paths for Docker volume mappings on MCP servers. Volumes are stored in the runtime execution context config file and can be referenced by server configurations. Usage: mcpd config volumes set <server> -- --<volume>=<path> Closes #197 * fix: use RawVolumes to avoid persisting expanded env vars in volumes set When Load() reads the TOML config, it expands environment variables into server.Volumes and preserves the originals in server.RawVolumes. The set command was updating server.Volumes (already expanded) and persisting those back to disk, which would destroy ${MCPD__...} placeholders needed for cross-server reference filtering. Use RawVolumes as the source of truth when updating volume mappings so unexpanded values are preserved on disk. * fix: clone RawVolumes into Volumes to prevent map aliasing Address review feedback: use maps.Clone when syncing Volumes from RawVolumes to decouple the two maps. Also tighten the "add multiple volumes" test to assert exact sorted output and verify RawVolumes alongside Volumes on upsert. * fix: initialise RawVolumes from Volumes when nil to preserve existing mappings When RawVolumes is nil (e.g. from a loader that doesn't populate it), fall back to cloning Volumes so existing volume mappings are not lost when adding new ones. Adds a test case covering this scenario. * fix: clone volume maps before mutation to avoid corrupting config state Maps are reference types in Go, so mutating server.RawVolumes via maps.Copy directly modifies the config's internal state before Upsert is called. If Upsert fails, the in-memory config is silently corrupted. Clone into a working map before applying changes, then assign back.
1 parent 6040e7a commit 557fc34

File tree

4 files changed

+746
-0
lines changed

4 files changed

+746
-0
lines changed

cmd/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/mozilla-ai/mcpd/cmd/config/export"
1010
"github.com/mozilla-ai/mcpd/cmd/config/plugins"
1111
"github.com/mozilla-ai/mcpd/cmd/config/tools"
12+
"github.com/mozilla-ai/mcpd/cmd/config/volumes"
1213
"github.com/mozilla-ai/mcpd/internal/cmd"
1314
"github.com/mozilla-ai/mcpd/internal/cmd/options"
1415
)
@@ -27,6 +28,7 @@ func NewConfigCmd(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Comman
2728
env.NewCmd, // env
2829
plugins.NewCmd, // plugins
2930
tools.NewCmd, // tools
31+
volumes.NewCmd, // volumes
3032
export.NewCmd, // export
3133
}
3234

cmd/config/volumes/cmd.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package volumes
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/mozilla-ai/mcpd/internal/cmd"
7+
cmdopts "github.com/mozilla-ai/mcpd/internal/cmd/options"
8+
)
9+
10+
// NewCmd creates a new volumes command with its sub-commands.
11+
func NewCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) {
12+
cobraCmd := &cobra.Command{
13+
Use: "volumes",
14+
Short: "Manages volume configuration for MCP servers",
15+
Long: "Manages Docker volume configuration for MCP servers, " +
16+
"dealing with setting, removing, clearing and listing volume mappings.",
17+
}
18+
19+
// Sub-commands for: mcpd config volumes
20+
fns := []func(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error){
21+
NewSetCmd, // set
22+
}
23+
24+
for _, fn := range fns {
25+
tempCmd, err := fn(baseCmd, opt...)
26+
if err != nil {
27+
return nil, err
28+
}
29+
cobraCmd.AddCommand(tempCmd)
30+
}
31+
32+
return cobraCmd, nil
33+
}

cmd/config/volumes/set.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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+
// setCmd handles setting volume mappings for MCP servers.
18+
type setCmd struct {
19+
*cmd.BaseCmd
20+
ctxLoader context.Loader
21+
}
22+
23+
// NewSetCmd creates a new set command for volume configuration.
24+
func NewSetCmd(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 := &setCmd{
31+
BaseCmd: baseCmd,
32+
ctxLoader: opts.ContextLoader,
33+
}
34+
35+
cobraCmd := &cobra.Command{
36+
Use: "set <server-name> -- --<volume-name>=<host-path> [--<volume-name>=<host-path>...]",
37+
Short: "Set or update volume mappings for an MCP server",
38+
Long: "Set volume mappings for an MCP server in the runtime context " +
39+
"configuration file (e.g. " + flags.RuntimeFile + ").\n\n" +
40+
"Volume mappings associate volume names with host paths or named Docker volumes.\n" +
41+
"Use -- to separate the server name from the volume mappings.\n\n" +
42+
"Examples:\n" +
43+
" # Set a single volume mapping\n" +
44+
" mcpd config volumes set filesystem -- --workspace=/Users/foo/repos/mcpd\n\n" +
45+
" # Set multiple volume mappings\n" +
46+
" mcpd config volumes set filesystem -- --workspace=\"/Users/foo/repos\" --gdrive=\"/mcp/gdrive\"\n\n" +
47+
" # Use a named Docker volume\n" +
48+
" mcpd config volumes set myserver -- --data=my-named-volume",
49+
RunE: c.run,
50+
Args: validateSetArgs,
51+
}
52+
53+
return cobraCmd, nil
54+
}
55+
56+
// validateSetArgs validates the arguments for the set command.
57+
// It wraps validateArgs to extract the dash position from the cobra command.
58+
func validateSetArgs(cmd *cobra.Command, args []string) error {
59+
return validateArgs(cmd.ArgsLenAtDash(), args)
60+
}
61+
62+
// validateArgs validates the set command arguments given the dash position and args slice.
63+
func validateArgs(dashPos int, args []string) error {
64+
// No args at all.
65+
if len(args) == 0 {
66+
return fmt.Errorf("server-name is required")
67+
}
68+
// Args provided but no -- separator (user forgot --).
69+
if dashPos == -1 {
70+
return fmt.Errorf(
71+
"missing '--' separator: usage: mcpd config volumes set <server-name> -- --<volume>=<path>",
72+
)
73+
}
74+
// -- at position 0 (no server name before it) or server name is empty.
75+
if dashPos < 1 || strings.TrimSpace(args[0]) == "" {
76+
return fmt.Errorf("server-name is required")
77+
}
78+
if dashPos > 1 {
79+
return fmt.Errorf("too many arguments before --")
80+
}
81+
if len(args) < 2 {
82+
return fmt.Errorf("volume mapping(s) required after --")
83+
}
84+
return nil
85+
}
86+
87+
// run executes the set command, parsing volume mappings and updating the server config.
88+
func (c *setCmd) run(cmd *cobra.Command, args []string) error {
89+
serverName := strings.TrimSpace(args[0])
90+
91+
// volumeArgs contains everything after the -- separator.
92+
// validateSetArgs guarantees len(args) >= 2.
93+
volumeArgs := args[1:]
94+
volumeMap, err := parseVolumeArgs(volumeArgs)
95+
if err != nil {
96+
return err
97+
}
98+
99+
cfg, err := c.ctxLoader.Load(flags.RuntimeFile)
100+
if err != nil {
101+
return fmt.Errorf("failed to load execution context config: %w", err)
102+
}
103+
104+
server, exists := cfg.Get(serverName)
105+
if !exists {
106+
server.Name = serverName
107+
}
108+
109+
// Clone into a working map to avoid mutating the config's internal state.
110+
// Use RawVolumes as the source of truth to avoid persisting expanded
111+
// environment variables (e.g. ${MCPD__...} placeholders) back to disk.
112+
// Fall back to Volumes if RawVolumes is nil to preserve existing mappings.
113+
working := maps.Clone(server.RawVolumes)
114+
if working == nil {
115+
working = maps.Clone(server.Volumes)
116+
}
117+
if working == nil {
118+
working = context.VolumeExecutionContext{}
119+
}
120+
121+
maps.Copy(working, volumeMap)
122+
123+
// Assign the working map back so Upsert persists unexpanded values.
124+
// RawVolumes is the single source of truth; Volumes is derived.
125+
server.RawVolumes = working
126+
server.Volumes = maps.Clone(working)
127+
128+
res, err := cfg.Upsert(server)
129+
if err != nil {
130+
return fmt.Errorf("error setting volumes for server '%s': %w", serverName, err)
131+
}
132+
133+
volumeNames := slices.Collect(maps.Keys(volumeMap))
134+
slices.Sort(volumeNames)
135+
if _, err := fmt.Fprintf(
136+
cmd.OutOrStdout(),
137+
"✓ Volumes set for server '%s' (operation: %s): %v\n", serverName, string(res), volumeNames,
138+
); err != nil {
139+
return fmt.Errorf("failed to write output: %w", err)
140+
}
141+
142+
return nil
143+
}
144+
145+
// parseVolumeArgs parses volume arguments in the format --name=path or --name="path".
146+
func parseVolumeArgs(args []string) (map[string]string, error) {
147+
volumes := make(map[string]string, len(args))
148+
149+
for _, arg := range args {
150+
originalArg := arg
151+
152+
// Expect format: --name=path
153+
if !strings.HasPrefix(arg, "--") {
154+
return nil, fmt.Errorf("invalid volume format '%s': must start with --", arg)
155+
}
156+
157+
// Remove the -- prefix for parsing.
158+
arg = strings.TrimPrefix(arg, "--")
159+
160+
parts := strings.SplitN(arg, "=", 2)
161+
if len(parts) != 2 {
162+
return nil, fmt.Errorf("invalid volume format '%s': expected --<volume-name>=<host-path>", originalArg)
163+
}
164+
165+
name := strings.TrimSpace(parts[0])
166+
path := strings.TrimSpace(parts[1])
167+
168+
if name == "" {
169+
return nil, fmt.Errorf("volume name cannot be empty in '%s'", originalArg)
170+
}
171+
172+
if path == "" {
173+
return nil, fmt.Errorf("volume path cannot be empty for volume '%s'", name)
174+
}
175+
176+
// Remove surrounding quotes if present.
177+
path = trimQuotes(path)
178+
if path == "" {
179+
return nil, fmt.Errorf("volume path cannot be empty for volume '%s'", name)
180+
}
181+
182+
volumes[name] = path
183+
}
184+
185+
return volumes, nil
186+
}
187+
188+
// trimQuotes removes surrounding single or double quotes from a string.
189+
func trimQuotes(s string) string {
190+
if len(s) >= 2 {
191+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
192+
return s[1 : len(s)-1]
193+
}
194+
}
195+
return s
196+
}

0 commit comments

Comments
 (0)