Skip to content

Commit 9f414d9

Browse files
authored
Add Docker volume mount support (#201)
* Add core volume infrastructure types. * Add VolumeEntry and VolumesEntry types to config layer * Add VariableTypeVolume constant to packages layer * Add Path and From fields to ArgumentMetadata for volumes * Add execution context volume support. * Add VolumeExecutionContext type for volume mappings * Add Volumes, RawVolumes fields to ServerExecutionContext * Implement volume expansion and raw storage for filtering * Update Equals() and IsEmpty() to include volumes * Implement runtime volume handling and validation. * Add Volume type with String() method for Docker mount formatting * Add SafeVolumes() for filtered volume access * Add computeVolumes() to combine config with runtime mappings * Add validateRequiredVolumes() to check volume configuration * Add filterVolumes() for cross-server reference filtering * Wire volume computation into AggregateConfigs * Integrate Docker volume support in daemon. * Add volume arguments to Docker container startup * Insert --volume flags before environment variables * Use Volume.String() for proper Docker mount formatting * Update provider registries for volume support. * Add volume type to Mozilla AI schema with allOf constraints * Add Path and From field mappings in model * Add volume processing in registry loader * Update MCPM registry formatting * Add comprehensive test coverage for volumes. * Add TestVolume_String to verify Docker mount formatting * Add TestDaemon_DockerVolumeArguments to test volume flag generation * Add testdata fixtures for volume loading and cross-server filtering * Tests cover single/multiple volumes, named volumes, and optional volumes
1 parent c0e524b commit 9f414d9

File tree

15 files changed

+830
-39
lines changed

15 files changed

+830
-39
lines changed

internal/config/types.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,34 @@ type ServerEntry struct {
113113

114114
// RequiredBoolArgs captures any command line args that are boolean flags when present, which are required to run the server.
115115
RequiredBoolArgs []string `json:"requiredArgsBool,omitempty" toml:"required_args_bool,omitempty" yaml:"required_args_bool,omitempty"`
116+
117+
// Volumes maps volume names to their Docker volume configuration.
118+
Volumes VolumesEntry `json:"volumes,omitempty" toml:"volumes,omitempty" yaml:"volumes,omitempty"`
116119
}
117120

118-
type serverKey struct {
119-
Name string
120-
Package string // NOTE: without version
121+
// VolumeEntry represents a single Docker volume configuration.
122+
type VolumeEntry struct {
123+
// Path is the container mount path.
124+
// e.g., "/workspace", "/home/nonroot/.kube/config".
125+
Path string `toml:"path"`
126+
127+
// Required indicates whether the volume must be configured by the user.
128+
Required bool `toml:"required"`
121129
}
122130

123-
// argEntry represents a parsed command line argument.
131+
// VolumesEntry maps volume names to their configuration.
132+
type VolumesEntry map[string]VolumeEntry
133+
124134
type argEntry struct {
125135
key string
126136
value string
127137
}
128138

139+
type serverKey struct {
140+
Name string
141+
Package string // NOTE: without version
142+
}
143+
129144
func (s *ServerEntry) PackageVersion() string {
130145
versionDelim := "@"
131146
pkg := stripPrefix(s.Package)

internal/context/context.go

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,12 @@ type ExecutionContextConfig struct {
3434

3535
// ServerExecutionContext stores execution context data for an MCP server.
3636
//
37-
// The Args and Env fields contain expanded values with environment variables resolved.
37+
// The Args, Env, and Volumes fields contain expanded values with environment variables resolved.
3838
// These should not be used directly when starting MCP servers, as they may contain
3939
// cross-server references that pose security risks.
4040
//
41-
// Instead, use the server's SafeArgs() and SafeEnv() methods (in the runtime package)
42-
// which filter out cross-server references using the RawArgs and RawEnv fields.
41+
// Instead, use the server's SafeArgs(), SafeEnv(), and SafeVolumes() methods (in the runtime package)
42+
// which filter out cross-server references using the RawArgs, RawEnv, and RawVolumes fields.
4343
type ServerExecutionContext struct {
4444
// Name is the server name.
4545
Name string `toml:"-"`
@@ -52,13 +52,24 @@ type ServerExecutionContext struct {
5252
// NOTE: Use runtime.Server.SafeEnv() for filtered access when starting servers.
5353
Env map[string]string `toml:"env,omitempty"`
5454

55-
// RawEnv stores unexpanded environment variables used for cross-server filtering decisions.
56-
RawEnv map[string]string `toml:"-"`
55+
// Volumes maps volume names to their host paths or named volumes with environment variables expanded.
56+
// NOTE: Use runtime.Server.SafeVolumes() for filtered access when starting servers.
57+
Volumes VolumeExecutionContext `toml:"volumes,omitempty"`
5758

5859
// RawArgs stores unexpanded command-line arguments used for cross-server filtering decisions.
5960
RawArgs []string `toml:"-"`
61+
62+
// RawEnv stores unexpanded environment variables used for cross-server filtering decisions.
63+
RawEnv map[string]string `toml:"-"`
64+
65+
// RawVolumes stores unexpanded volume mappings used for cross-server filtering decisions.
66+
RawVolumes VolumeExecutionContext `toml:"-"`
6067
}
6168

69+
// VolumeExecutionContext maps volume names to their host paths or named volumes.
70+
// e.g., {"workspace": "/Users/foo/repos/mcpd", "gdrive": "mcp-gdrive"}
71+
type VolumeExecutionContext map[string]string
72+
6273
// Load loads an execution context configuration from the specified path.
6374
func (d *DefaultLoader) Load(path string) (Modifier, error) {
6475
path = strings.TrimSpace(path)
@@ -88,11 +99,13 @@ func (c *ExecutionContextConfig) Get(name string) (ServerExecutionContext, bool)
8899

89100
if srv, ok := c.Servers[name]; ok {
90101
return ServerExecutionContext{
91-
Name: name,
92-
Args: slices.Clone(srv.Args),
93-
Env: maps.Clone(srv.Env),
94-
RawEnv: maps.Clone(srv.RawEnv),
95-
RawArgs: slices.Clone(srv.RawArgs),
102+
Name: name,
103+
Args: slices.Clone(srv.Args),
104+
Env: maps.Clone(srv.Env),
105+
Volumes: maps.Clone(srv.Volumes),
106+
RawArgs: slices.Clone(srv.RawArgs),
107+
RawEnv: maps.Clone(srv.RawEnv),
108+
RawVolumes: maps.Clone(srv.RawVolumes),
96109
}, true
97110
}
98111

@@ -179,20 +192,28 @@ func (s *ServerExecutionContext) Equals(b ServerExecutionContext) bool {
179192
return false
180193
}
181194

182-
if len(s.RawEnv) != len(b.RawEnv) || !maps.Equal(s.RawEnv, b.RawEnv) {
195+
if !maps.Equal(s.Volumes, b.Volumes) {
183196
return false
184197
}
185198

186199
if !equalSlices(s.RawArgs, b.RawArgs) {
187200
return false
188201
}
189202

203+
if len(s.RawEnv) != len(b.RawEnv) || !maps.Equal(s.RawEnv, b.RawEnv) {
204+
return false
205+
}
206+
207+
if !maps.Equal(s.RawVolumes, b.RawVolumes) {
208+
return false
209+
}
210+
190211
return true
191212
}
192213

193-
// IsEmpty returns true if the ServerExecutionContext has no args or env vars.
214+
// IsEmpty returns true if the ServerExecutionContext has no args, env vars, or volumes.
194215
func (s *ServerExecutionContext) IsEmpty() bool {
195-
return len(s.Args) == 0 && len(s.Env) == 0
216+
return len(s.Args) == 0 && len(s.Env) == 0 && len(s.Volumes) == 0
196217
}
197218

198219
// AppDirName returns the name of the application directory for use in user-specific operations where data is being written.
@@ -312,11 +333,14 @@ func loadExecutionContextConfig(path string) (*ExecutionContextConfig, error) {
312333
for name, server := range cfg.Servers {
313334
server.Name = name
314335

336+
// Store raw args before expansion for filtering decisions.
337+
server.RawArgs = slices.Clone(server.Args)
338+
315339
// Store raw env vars before expansion for filtering decisions.
316340
server.RawEnv = maps.Clone(server.Env)
317341

318-
// Store raw args before expansion for filtering decisions.
319-
server.RawArgs = slices.Clone(server.Args)
342+
// Store raw volumes before expansion for filtering decisions.
343+
server.RawVolumes = maps.Clone(server.Volumes)
320344

321345
// Expand args.
322346
for i, arg := range server.Args {
@@ -328,6 +352,11 @@ func loadExecutionContextConfig(path string) (*ExecutionContextConfig, error) {
328352
server.Env[k] = os.ExpandEnv(v)
329353
}
330354

355+
// Expand volume paths.
356+
for k, v := range server.Volumes {
357+
server.Volumes[k] = os.ExpandEnv(v)
358+
}
359+
331360
cfg.Servers[name] = server
332361
}
333362

internal/daemon/daemon.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,14 @@ func (d *Daemon) startMCPServer(ctx context.Context, server runtime.Server) erro
191191
// Format the Docker package and version
192192
packageNameAndVersion := fmt.Sprintf("%s:%s", pkg, ver)
193193

194-
// Docker requires special handling for environment variables
194+
// Docker requires special handling for volumes and environment variables
195195
args = []string{"run", "-i", "--rm", "--network", "host"}
196196

197+
// Add volumes before environment variables.
198+
for _, vol := range server.SafeVolumes() {
199+
args = append(args, "--volume", vol.String())
200+
}
201+
197202
// Docker supplies the environment variables via args (-e), so
198203
// we don't use the server.SafeEnv() as this ensures least privilege.
199204
for _, env := range server.SafeEnvIsolated() {
@@ -256,7 +261,7 @@ func (d *Daemon) startMCPServer(ctx context.Context, server runtime.Server) erro
256261
initializeCtx, cancel := context.WithTimeout(ctx, d.clientInitTimeout)
257262
defer cancel()
258263

259-
// 'Initialize'
264+
// 'Initialize' the MCP server.
260265
initResult, err := stdioClient.Initialize(
261266
initializeCtx,
262267
mcp.InitializeRequest{
@@ -271,14 +276,10 @@ func (d *Daemon) startMCPServer(ctx context.Context, server runtime.Server) erro
271276

272277
logger.Info(
273278
"Initialized",
274-
"package",
275-
pkg,
276-
"version",
277-
ver,
278-
"server-name",
279-
initResult.ServerInfo.Name,
280-
"server-version",
281-
initResult.ServerInfo.Version,
279+
"package", pkg,
280+
"version", ver,
281+
"server-name", initResult.ServerInfo.Name,
282+
"server-version", initResult.ServerInfo.Version,
282283
)
283284

284285
// Store and track the client.

internal/daemon/daemon_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,3 +1118,124 @@ func TestDaemon_DockerRuntimeSupport(t *testing.T) {
11181118
// Verify Docker is in the daemon's supported runtimes
11191119
require.Contains(t, daemon.supportedRuntimes, runtime.Docker, "Daemon should support Docker runtime")
11201120
}
1121+
1122+
func TestDaemon_DockerVolumeArguments(t *testing.T) {
1123+
t.Parallel()
1124+
1125+
tests := []struct {
1126+
name string
1127+
volumes map[string]config.VolumeEntry
1128+
volumeContext map[string]string
1129+
expectedVolumeFlags []string
1130+
}{
1131+
{
1132+
name: "no volumes",
1133+
volumes: map[string]config.VolumeEntry{},
1134+
volumeContext: map[string]string{},
1135+
expectedVolumeFlags: []string{},
1136+
},
1137+
{
1138+
name: "single required volume",
1139+
volumes: map[string]config.VolumeEntry{
1140+
"workspace": {
1141+
Path: "/workspace",
1142+
Required: true,
1143+
},
1144+
},
1145+
volumeContext: map[string]string{
1146+
"workspace": "/Users/foo/repos/mcpd",
1147+
},
1148+
expectedVolumeFlags: []string{
1149+
"--volume", "/Users/foo/repos/mcpd:/workspace",
1150+
},
1151+
},
1152+
{
1153+
name: "multiple volumes",
1154+
volumes: map[string]config.VolumeEntry{
1155+
"workspace": {
1156+
Path: "/workspace",
1157+
Required: true,
1158+
},
1159+
"kubeconfig": {
1160+
Path: "/home/nonroot/.kube/config",
1161+
Required: true,
1162+
},
1163+
},
1164+
volumeContext: map[string]string{
1165+
"workspace": "/Users/foo/repos",
1166+
"kubeconfig": "~/.kube/config",
1167+
},
1168+
expectedVolumeFlags: []string{
1169+
"--volume", "/Users/foo/repos:/workspace",
1170+
"--volume", "~/.kube/config:/home/nonroot/.kube/config",
1171+
},
1172+
},
1173+
{
1174+
name: "named docker volume",
1175+
volumes: map[string]config.VolumeEntry{
1176+
"data": {
1177+
Path: "/data",
1178+
Required: true,
1179+
},
1180+
},
1181+
volumeContext: map[string]string{
1182+
"data": "mcp-data",
1183+
},
1184+
expectedVolumeFlags: []string{
1185+
"--volume", "mcp-data:/data",
1186+
},
1187+
},
1188+
{
1189+
name: "optional volume not configured",
1190+
volumes: map[string]config.VolumeEntry{
1191+
"workspace": {
1192+
Path: "/workspace",
1193+
Required: true,
1194+
},
1195+
"cache": {
1196+
Path: "/cache",
1197+
Required: false,
1198+
},
1199+
},
1200+
volumeContext: map[string]string{
1201+
"workspace": "/Users/foo/repos",
1202+
},
1203+
expectedVolumeFlags: []string{
1204+
"--volume", "/Users/foo/repos:/workspace",
1205+
},
1206+
},
1207+
}
1208+
1209+
for _, tc := range tests {
1210+
t.Run(tc.name, func(t *testing.T) {
1211+
t.Parallel()
1212+
1213+
// Manually construct Volume structs to test the Docker argument formatting.
1214+
// This simulates what would be produced after AggregateConfigs/computeVolumes.
1215+
var volumes []runtime.Volume
1216+
for name, entry := range tc.volumes {
1217+
from, exists := tc.volumeContext[name]
1218+
// Skip optional volumes without runtime configuration.
1219+
if !entry.Required && !exists {
1220+
continue
1221+
}
1222+
volumes = append(volumes, runtime.Volume{
1223+
Name: name,
1224+
VolumeEntry: entry,
1225+
From: from,
1226+
})
1227+
}
1228+
1229+
// Build actual volume arguments as the daemon would.
1230+
var actualVolumeFlags []string
1231+
for _, vol := range volumes {
1232+
actualVolumeFlags = append(actualVolumeFlags, "--volume", vol.String())
1233+
}
1234+
1235+
// Verify the volume flags match expectations.
1236+
// Note: We use ElementsMatch instead of Equal because Go map iteration order is not guaranteed.
1237+
require.ElementsMatch(t, tc.expectedVolumeFlags, actualVolumeFlags,
1238+
"Docker volume flags should match expected format")
1239+
})
1240+
}
1241+
}

internal/packages/argument.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const (
2020

2121
// VariableTypeArgPositional represents a positional command line argument.
2222
VariableTypeArgPositional VariableType = "argument_positional"
23+
24+
// VariableTypeVolume represents a Docker volume mount.
25+
VariableTypeVolume VariableType = "volume"
2326
)
2427

2528
// EnvVarPlaceholderRegex is used to find environment variable placeholders like ${VAR_NAME}.
@@ -37,7 +40,7 @@ type ArgumentMetadata struct {
3740
// Name is the reference for the argument.
3841
Name string `json:"name"`
3942

40-
// VariableType represents the type of argument this is (env var, value flag, bool flag, positional arg).
43+
// VariableType represents the type of argument this is (env var, value flag, bool flag, positional arg, volume).
4144
VariableType VariableType `json:"type"`
4245

4346
// Description provides a human-readable explanation of the argument's purpose.
@@ -52,6 +55,14 @@ type ArgumentMetadata struct {
5255
// Position specifies the position for positional arguments (1-based index).
5356
// Only relevant when Type is ArgumentPositional.
5457
Position *int `json:"position,omitempty"`
58+
59+
// Path is the container mount path for volumes.
60+
// Only relevant when VariableType is VariableTypeVolume.
61+
Path string `json:"path,omitempty"`
62+
63+
// From is the host path or named volume for volumes.
64+
// Only relevant when VariableType is VariableTypeVolume.
65+
From string `json:"from,omitempty"`
5566
}
5667

5768
// FilterBy allows filtering of Arguments using predicates.
@@ -165,3 +176,8 @@ func NonPositionalArgument(s string, data ArgumentMetadata) bool {
165176
func ValueAcceptingArgument(_ string, data ArgumentMetadata) bool {
166177
return data.VariableType == VariableTypeArgPositional || data.VariableType == VariableTypeArg
167178
}
179+
180+
// Volume is a predicate that requires the argument is a container volume mount.
181+
func Volume(_ string, data ArgumentMetadata) bool {
182+
return data.VariableType == VariableTypeVolume
183+
}

internal/provider/mcpm/registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ func NewRegistry(logger hclog.Logger, url string, opt ...runtime.Option) (*Regis
8383
}
8484

8585
// registrySupportedRuntimes declares the runtimes that this registry supports.
86+
// Docker runtime is excluded because MCPM packages don't provide rich enough Docker image metadata for volumes.
8687
func registrySupportedRuntimes() []runtime.Runtime {
8788
return []runtime.Runtime{
8889
runtime.NPX,
8990
runtime.UVX,
90-
runtime.Docker,
9191
}
9292
}
9393

0 commit comments

Comments
 (0)