Skip to content

Commit 15945f4

Browse files
authored
Merge pull request #2460 from simonferquel-clanker/feat/toolset-working-dir
feat: add optional working_dir to MCP and LSP toolset configs
2 parents f131051 + c6bc325 commit 15945f4

16 files changed

Lines changed: 622 additions & 4 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. Only valid for subprocess-based MCP types (command or ref); not supported for remote MCP toolsets."
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', and not supported for remote MCP toolsets (no local subprocess)."
11441152
}
11451153
},
11461154
"additionalProperties": false,

docs/configuration/tools/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ Browse available tools at the [Docker MCP Catalog](https://hub.docker.com/search
6565
| `tools` | array | Optional: only expose these tools |
6666
| `instruction` | string | Custom instructions injected into the agent's context |
6767
| `config` | any | MCP server-specific configuration (passed during initialization) |
68+
| `working_dir` | string | Working directory for the MCP gateway subprocess. Only applies when the catalog entry runs as a local process (not remote). Relative paths are resolved against the agent's working directory. |
6869

6970
### Local MCP (stdio)
7071

@@ -86,6 +87,7 @@ toolsets:
8687
| `args` | array | Command arguments |
8788
| `tools` | array | Optional: only expose these tools |
8889
| `env` | object | Environment variables (key-value pairs) |
90+
| `working_dir` | string | Working directory for the MCP server process. Relative paths are resolved against the agent's working directory. Defaults to the agent's working directory when omitted. |
8991
| `instruction` | string | Custom instructions injected into the agent's context |
9092
| `version` | string | Package reference for [auto-installing](#auto-installing-tools) the command binary |
9193

docs/tools/lsp/index.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ agents:
6565
| `args` | array | ✗ | Command-line arguments for the LSP server |
6666
| `env` | object | ✗ | Environment variables for the LSP process |
6767
| `file_types` | array | ✗ | File extensions this LSP handles (e.g., `[".go", ".mod"]`) |
68+
| `working_dir` | string | ✗ | Working directory for the LSP server process. Relative paths are resolved against the agent's working directory. Defaults to the agent's working directory when omitted. |
6869
| `version` | string | ✗ | Package reference for [auto-installing]({{ '/configuration/tools/#auto-installing-tools' | relative_url }}) the command binary |
6970

7071
## Common LSP Servers
@@ -81,6 +82,16 @@ toolsets:
8182
file_types: [".go"]
8283
```
8384

85+
If your Go module lives in a subdirectory (e.g. a monorepo where `go.mod` is under `./backend`), set `working_dir` so `gopls` is started from the module root:
86+
87+
```yaml
88+
toolsets:
89+
- type: lsp
90+
command: gopls
91+
file_types: [".go"]
92+
working_dir: ./backend # gopls must be started from the module root
93+
```
94+
8495
### TypeScript/JavaScript (typescript-language-server)
8596

8697
```yaml

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-5-mini
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ 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+
}
120+
// working_dir requires a local subprocess; it is meaningless for remote MCP toolsets.
121+
if t.WorkingDir != "" && t.Type == "mcp" && t.Remote.URL != "" {
122+
return errors.New("working_dir is not valid for remote MCP toolsets (no local subprocess)")
123+
}
117124

118125
switch t.Type {
119126
case "shell":

pkg/config/latest/validate_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,106 @@ 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+
},
184+
{
185+
name: "working_dir on remote mcp is rejected",
186+
config: `
187+
version: "8"
188+
agents:
189+
root:
190+
model: "openai/gpt-4"
191+
toolsets:
192+
- type: mcp
193+
remote:
194+
url: https://mcp.example.com/sse
195+
working_dir: ./tools
196+
`,
197+
wantErr: "working_dir is not valid for remote MCP toolsets",
198+
wantValue: "",
199+
},
100200
}
101201

102202
for _, tt := range tests {
@@ -111,6 +211,7 @@ agents:
111211
require.Contains(t, err.Error(), tt.wantErr)
112212
} else {
113213
require.NoError(t, err)
214+
require.Equal(t, tt.wantValue, cfg.Agents.First().Toolsets[0].WorkingDir)
114215
}
115216
})
116217
}

pkg/config/mcps.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ func applyMCPDefaults(ts, def *latest.Toolset) {
8686
if ts.Defer.IsEmpty() {
8787
ts.Defer = def.Defer
8888
}
89+
if ts.WorkingDir == "" {
90+
// An empty working_dir in the referencing toolset is treated as "unset":
91+
// inherit the definition's value. This matches the semantics of all other
92+
// string fields in this function. An explicit `working_dir: ""` in YAML
93+
// is indistinguishable from omission and will therefore be overridden.
94+
ts.WorkingDir = def.WorkingDir
95+
}
8996
if len(def.Env) > 0 {
9097
merged := make(map[string]string, len(def.Env)+len(ts.Env))
9198
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

0 commit comments

Comments
 (0)