Skip to content

Commit 3ec5627

Browse files
feat: add optional working_dir to MCP and LSP toolset configs
- Add WorkingDir field to Toolset struct (pkg/config/latest/types.go) - Validate that working_dir is only used with type 'mcp' or 'lsp' - Resolve working_dir relative to agent's working directory at process start - Propagate working_dir from top-level mcps: definitions to agent toolsets - Add resolveToolsetWorkingDir helper in registry.go - Add tests: validate_test.go, mcps_test.go, registry_test.go - Add example: examples/toolset-working-dir.yaml - Update agent-schema.json for Toolset and MCPToolset Closes #2459 Assisted-By: docker-agent
1 parent f131051 commit 3ec5627

10 files changed

Lines changed: 254 additions & 2 deletions

File tree

agent-schema.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,10 @@
901901
}
902902
}
903903
]
904+
},
905+
"working_dir": {
906+
"type": "string",
907+
"description": "Optional working directory for the MCP server process. Relative paths are resolved relative to the agent's working directory."
904908
}
905909
},
906910
"anyOf": [
@@ -1141,6 +1145,10 @@
11411145
"version": {
11421146
"type": "string",
11431147
"description": "Package reference for auto-installation of MCP/LSP tool binaries. Format: 'owner/repo' or 'owner/repo@version'. Set to 'false' to disable auto-install for this toolset."
1148+
},
1149+
"working_dir": {
1150+
"type": "string",
1151+
"description": "Optional working directory for MCP/LSP toolset processes. Relative paths are resolved relative to the agent's working directory. Only valid for type 'mcp' or 'lsp'."
11441152
}
11451153
},
11461154
"additionalProperties": false,

examples/toolset-working-dir.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env docker agent run
2+
3+
# Example: using working_dir for MCP and LSP toolsets
4+
#
5+
# Some language servers and MCP tools must be started from a specific directory.
6+
# For example, gopls must be started from the Go module root. Use `working_dir`
7+
# to configure the launch directory for any MCP or LSP toolset.
8+
#
9+
# `working_dir` is:
10+
# - Optional (defaults to the agent's working directory when omitted)
11+
# - Resolved relative to the agent's working directory if it is a relative path
12+
13+
agents:
14+
root:
15+
model: openai/gpt-4o
16+
description: Example agent demonstrating working_dir for MCP and LSP toolsets
17+
instruction: |
18+
You are a helpful coding assistant with access to language server and MCP tools
19+
launched from their respective project directories.
20+
toolsets:
21+
# LSP server started from a subdirectory (e.g. a Go module in ./backend)
22+
- type: lsp
23+
command: gopls
24+
working_dir: ./backend
25+
26+
# MCP server started from a specific tools directory
27+
- type: mcp
28+
command: my-mcp-server
29+
working_dir: ./tools/mcp

pkg/config/latest/types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,11 @@ type Toolset struct {
741741

742742
// For the `model_picker` tool
743743
Models []string `json:"models,omitempty"`
744+
745+
// For `mcp` and `lsp` tools - optional working directory override.
746+
// When set, the toolset process is started from this directory.
747+
// Relative paths are resolved relative to the agent's working directory.
748+
WorkingDir string `json:"working_dir,omitempty"`
744749
}
745750

746751
func (t *Toolset) UnmarshalYAML(unmarshal func(any) error) error {

pkg/config/latest/validate.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ func (t *Toolset) validate() error {
114114
if t.RAGConfig != nil && t.Type != "rag" {
115115
return errors.New("rag_config can only be used with type 'rag'")
116116
}
117+
if t.WorkingDir != "" && t.Type != "mcp" && t.Type != "lsp" {
118+
return errors.New("working_dir can only be used with type 'mcp' or 'lsp'")
119+
}
117120

118121
switch t.Type {
119122
case "shell":

pkg/config/latest/validate_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,90 @@ agents:
9797
`,
9898
wantErr: "file_types can only be used with type 'lsp'",
9999
},
100+
{
101+
name: "lsp with working_dir",
102+
config: `
103+
version: "8"
104+
agents:
105+
root:
106+
model: "openai/gpt-4"
107+
toolsets:
108+
- type: lsp
109+
command: gopls
110+
working_dir: ./backend
111+
`,
112+
wantErr: "",
113+
},
114+
{
115+
name: "working_dir on non-mcp-lsp toolset is rejected",
116+
config: `
117+
version: "8"
118+
agents:
119+
root:
120+
model: "openai/gpt-4"
121+
toolsets:
122+
- type: shell
123+
working_dir: ./backend
124+
`,
125+
wantErr: "working_dir can only be used with type 'mcp' or 'lsp'",
126+
},
127+
}
128+
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
t.Parallel()
132+
133+
var cfg Config
134+
err := yaml.Unmarshal([]byte(tt.config), &cfg)
135+
136+
if tt.wantErr != "" {
137+
require.Error(t, err)
138+
require.Contains(t, err.Error(), tt.wantErr)
139+
} else {
140+
require.NoError(t, err)
141+
}
142+
})
143+
}
144+
}
145+
146+
func TestToolset_Validate_MCP_WorkingDir(t *testing.T) {
147+
t.Parallel()
148+
149+
tests := []struct {
150+
name string
151+
config string
152+
wantErr string
153+
wantValue string
154+
}{
155+
{
156+
name: "mcp with working_dir",
157+
config: `
158+
version: "8"
159+
agents:
160+
root:
161+
model: "openai/gpt-4"
162+
toolsets:
163+
- type: mcp
164+
command: my-mcp-server
165+
working_dir: ./tools/mcp
166+
`,
167+
wantErr: "",
168+
wantValue: "./tools/mcp",
169+
},
170+
{
171+
name: "mcp without working_dir defaults to empty",
172+
config: `
173+
version: "8"
174+
agents:
175+
root:
176+
model: "openai/gpt-4"
177+
toolsets:
178+
- type: mcp
179+
command: my-mcp-server
180+
`,
181+
wantErr: "",
182+
wantValue: "",
183+
},
100184
}
101185

102186
for _, tt := range tests {
@@ -111,6 +195,7 @@ agents:
111195
require.Contains(t, err.Error(), tt.wantErr)
112196
} else {
113197
require.NoError(t, err)
198+
require.Equal(t, tt.wantValue, cfg.Agents.First().Toolsets[0].WorkingDir)
114199
}
115200
})
116201
}

pkg/config/mcps.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ func applyMCPDefaults(ts, def *latest.Toolset) {
8686
if ts.Defer.IsEmpty() {
8787
ts.Defer = def.Defer
8888
}
89+
if ts.WorkingDir == "" {
90+
ts.WorkingDir = def.WorkingDir
91+
}
8992
if len(def.Env) > 0 {
9093
merged := make(map[string]string, len(def.Env)+len(ts.Env))
9194
maps.Copy(merged, def.Env)

pkg/config/mcps_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,25 @@ func TestMCPDefinitions_EnvMerge(t *testing.T) {
136136
// Toolset-only key is preserved
137137
assert.Equal(t, "from_toolset", ts.Env["EXTRA"])
138138
}
139+
140+
func TestMCPDefinitions_WorkingDir(t *testing.T) {
141+
t.Parallel()
142+
143+
cfg, err := Load(t.Context(), NewFileSource("testdata/mcp_definitions_working_dir.yaml"))
144+
require.NoError(t, err)
145+
146+
// WorkingDir from the definition is inherited by the referencing toolset.
147+
root, ok := cfg.Agents.Lookup("root")
148+
require.True(t, ok)
149+
require.Len(t, root.Toolsets, 1)
150+
ts := root.Toolsets[0]
151+
assert.Equal(t, "my-mcp-server", ts.Command)
152+
assert.Equal(t, "./tools/mcp", ts.WorkingDir)
153+
154+
// A toolset-level working_dir overrides the definition's value.
155+
override, ok := cfg.Agents.Lookup("override")
156+
require.True(t, ok)
157+
require.Len(t, override.Toolsets, 1)
158+
tsOverride := override.Toolsets[0]
159+
assert.Equal(t, "./override/path", tsOverride.WorkingDir)
160+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
version: "8"
2+
models:
3+
model:
4+
provider: openai
5+
model: gpt-4o
6+
7+
mcps:
8+
custom_mcp_with_dir:
9+
command: my-mcp-server
10+
working_dir: ./tools/mcp
11+
12+
agents:
13+
root:
14+
model: model
15+
toolsets:
16+
- type: mcp
17+
ref: custom_mcp_with_dir
18+
override:
19+
model: model
20+
toolsets:
21+
- type: mcp
22+
ref: custom_mcp_with_dir
23+
working_dir: ./override/path

pkg/teamloader/registry.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry {
8686
return r
8787
}
8888

89+
// resolveToolsetWorkingDir returns the effective working directory for a toolset process.
90+
// If toolsetWorkingDir is set, it is resolved relative to agentWorkingDir (when relative).
91+
// If toolsetWorkingDir is empty, agentWorkingDir is returned unchanged.
92+
func resolveToolsetWorkingDir(toolsetWorkingDir, agentWorkingDir string) string {
93+
if toolsetWorkingDir == "" {
94+
return agentWorkingDir
95+
}
96+
if filepath.IsAbs(toolsetWorkingDir) {
97+
return toolsetWorkingDir
98+
}
99+
if agentWorkingDir != "" {
100+
return filepath.Join(agentWorkingDir, toolsetWorkingDir)
101+
}
102+
return toolsetWorkingDir
103+
}
104+
89105
// resolveToolsetPath expands shell patterns (~, env vars) in the given path,
90106
// then validates it relative to the working directory or parent directory.
91107
func resolveToolsetPath(toolsetPath, parentDir string, runConfig *config.RuntimeConfig) (string, error) {
@@ -289,7 +305,8 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
289305
// Prepend tools bin dir to PATH so child processes can find installed tools
290306
env = toolinstall.PrependBinDirToEnv(env)
291307

292-
return mcp.NewToolsetCommand(toolset.Name, resolvedCommand, toolset.Args, env, runConfig.WorkingDir), nil
308+
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)
309+
return mcp.NewToolsetCommand(toolset.Name, resolvedCommand, toolset.Args, env, cwd), nil
293310

294311
// Remote MCP Server
295312
case toolset.Remote.URL != "":
@@ -329,7 +346,8 @@ func createLSPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
329346
// Prepend tools bin dir to PATH so child processes can find installed tools
330347
env = toolinstall.PrependBinDirToEnv(env)
331348

332-
tool := builtin.NewLSPTool(resolvedCommand, toolset.Args, env, runConfig.WorkingDir)
349+
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)
350+
tool := builtin.NewLSPTool(resolvedCommand, toolset.Args, env, cwd)
333351
if len(toolset.FileTypes) > 0 {
334352
tool.SetFileTypes(toolset.FileTypes)
335353
}

pkg/teamloader/registry_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,59 @@ func TestCreateMCPTool_BareCommandNotFound_CreatesToolsetAnyway(t *testing.T) {
7070
require.NotNil(t, tool)
7171
assert.Equal(t, "mcp(stdio cmd=some-nonexistent-mcp-binary)", tools.DescribeToolSet(tool))
7272
}
73+
74+
func TestResolveToolsetWorkingDir(t *testing.T) {
75+
t.Parallel()
76+
77+
tests := []struct {
78+
name string
79+
toolsetWorkingDir string
80+
agentWorkingDir string
81+
want string
82+
}{
83+
{
84+
name: "empty toolset dir returns agent dir",
85+
toolsetWorkingDir: "",
86+
agentWorkingDir: "/workspace",
87+
want: "/workspace",
88+
},
89+
{
90+
name: "absolute toolset dir is returned as-is",
91+
toolsetWorkingDir: "/tmp/mcp",
92+
agentWorkingDir: "/workspace",
93+
want: "/tmp/mcp",
94+
},
95+
{
96+
name: "relative toolset dir is joined with agent dir",
97+
toolsetWorkingDir: "./backend",
98+
agentWorkingDir: "/workspace",
99+
want: "/workspace/backend",
100+
},
101+
{
102+
name: "relative toolset dir without leading dot is joined with agent dir",
103+
toolsetWorkingDir: "tools/mcp",
104+
agentWorkingDir: "/workspace",
105+
want: "/workspace/tools/mcp",
106+
},
107+
{
108+
name: "relative toolset dir with empty agent dir returns toolset dir unchanged",
109+
toolsetWorkingDir: "./backend",
110+
agentWorkingDir: "",
111+
want: "./backend",
112+
},
113+
{
114+
name: "both empty returns empty",
115+
toolsetWorkingDir: "",
116+
agentWorkingDir: "",
117+
want: "",
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
t.Parallel()
124+
got := resolveToolsetWorkingDir(tt.toolsetWorkingDir, tt.agentWorkingDir)
125+
assert.Equal(t, tt.want, got)
126+
})
127+
}
128+
}

0 commit comments

Comments
 (0)