Skip to content

Commit 67baf03

Browse files
fix: address reviewer feedback on working_dir feature
B1: pass resolved CWD to NewGatewayToolset; gateway-based MCPs now honour working_dir. Remote MCP toolsets reject working_dir at validation time (no local subprocess). B2: call path.ExpandPath in resolveToolsetWorkingDir so ~ and ${VAR}/$VAR are expanded before path operations. Use filepath.Abs when joining a relative path with the agent dir (fixes file://./backend LSP root URI). S1: add checkDirExists helper; createMCPTool and createLSPTool now surface a clear error if working_dir does not exist at tool-creation time. S2: doc comment on resolveToolsetWorkingDir explains no-containment- check posture (working_dir is treated like command/args). S3: filepath.Abs on relative+non-empty-agentDir path ensures LSP rootURI is always absolute. S4: validation rejects working_dir on remote MCP toolsets. S5: comment in applyMCPDefaults explains empty-string inheritance semantics. S6: doc comment covers the empty-agent-dir+relative edge case. Nits: example model → gpt-5-mini; test name → 'bare relative dir'; schema descriptions aligned; import groups tidied; hard-coded non-existent path in test → t.TempDir()+'/missing'. N5: integration tests added: - TestCreateMCPTool_WorkingDir_ReachesSubprocess - TestCreateMCPTool_RelativeWorkingDir_ResolvedAgainstAgentDir - TestCreateMCPTool_NonexistentWorkingDir_ReturnsError - TestCreateLSPTool_WorkingDir_ReachesHandler Also add WorkingDir() accessors on *mcp.Toolset and *builtin.LSPTool to support the above integration tests. Assisted-By: docker-agent
1 parent 3ec5627 commit 67baf03

9 files changed

Lines changed: 258 additions & 9 deletions

File tree

agent-schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,7 @@
904904
},
905905
"working_dir": {
906906
"type": "string",
907-
"description": "Optional working directory for the MCP server process. Relative paths are resolved relative to the agent's working directory."
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."
908908
}
909909
},
910910
"anyOf": [
@@ -1148,7 +1148,7 @@
11481148
},
11491149
"working_dir": {
11501150
"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'."
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)."
11521152
}
11531153
},
11541154
"additionalProperties": false,

examples/toolset-working-dir.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
agents:
1414
root:
15-
model: openai/gpt-4o
15+
model: openai/gpt-5-mini
1616
description: Example agent demonstrating working_dir for MCP and LSP toolsets
1717
instruction: |
1818
You are a helpful coding assistant with access to language server and MCP tools

pkg/config/latest/validate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ func (t *Toolset) validate() error {
117117
if t.WorkingDir != "" && t.Type != "mcp" && t.Type != "lsp" {
118118
return errors.New("working_dir can only be used with type 'mcp' or 'lsp'")
119119
}
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+
}
120124

121125
switch t.Type {
122126
case "shell":

pkg/config/latest/validate_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ agents:
181181
wantErr: "",
182182
wantValue: "",
183183
},
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+
},
184200
}
185201

186202
for _, tt := range tests {

pkg/config/mcps.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ func applyMCPDefaults(ts, def *latest.Toolset) {
8787
ts.Defer = def.Defer
8888
}
8989
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.
9094
ts.WorkingDir = def.WorkingDir
9195
}
9296
if len(def.Env) > 0 {

pkg/teamloader/registry.go

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,59 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry {
8686
return r
8787
}
8888

89+
// checkDirExists returns an error if the given directory does not exist or is
90+
// not a directory. toolsetType is used only in the error message.
91+
func checkDirExists(dir, toolsetType string) error {
92+
info, err := os.Stat(dir)
93+
if err != nil {
94+
if os.IsNotExist(err) {
95+
return fmt.Errorf("working_dir %q for %s toolset does not exist", dir, toolsetType)
96+
}
97+
return fmt.Errorf("working_dir %q for %s toolset: %w", dir, toolsetType, err)
98+
}
99+
if !info.IsDir() {
100+
return fmt.Errorf("working_dir %q for %s toolset is not a directory", dir, toolsetType)
101+
}
102+
return nil
103+
}
104+
89105
// 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.
106+
//
107+
// Resolution rules:
108+
// - If toolsetWorkingDir is empty, agentWorkingDir is returned unchanged.
109+
// - Shell patterns (~ and ${VAR}/$VAR) are expanded before any further processing.
110+
// - If the expanded path is absolute, it is returned as-is.
111+
// - If the expanded path is relative and agentWorkingDir is non-empty,
112+
// it is joined with agentWorkingDir and made absolute via filepath.Abs.
113+
// - If the expanded path is relative and agentWorkingDir is empty,
114+
// the relative path is returned unchanged (caller will inherit the process cwd).
115+
//
116+
// Note: unlike resolveToolsetPath, this helper does not enforce containment
117+
// within the agent working directory. working_dir is treated like command/args —
118+
// a trusted, operator-authored value where cross-tree references (e.g. a sibling
119+
// module root in a monorepo) are intentional and must not be silently blocked.
92120
func resolveToolsetWorkingDir(toolsetWorkingDir, agentWorkingDir string) string {
93121
if toolsetWorkingDir == "" {
94122
return agentWorkingDir
95123
}
124+
// Expand ~ and environment variables before path operations.
125+
toolsetWorkingDir = path.ExpandPath(toolsetWorkingDir)
96126
if filepath.IsAbs(toolsetWorkingDir) {
97127
return toolsetWorkingDir
98128
}
99129
if agentWorkingDir != "" {
130+
// filepath.Abs cleans the result and anchors the URI correctly
131+
// (avoids file://./backend-style LSP root URIs when the agent dir
132+
// is itself absolute, which is the normal case).
133+
abs, err := filepath.Abs(filepath.Join(agentWorkingDir, toolsetWorkingDir))
134+
if err == nil {
135+
return abs
136+
}
137+
// Fallback: return the joined path without Abs (should not happen in practice).
100138
return filepath.Join(agentWorkingDir, toolsetWorkingDir)
101139
}
140+
// agentWorkingDir is empty and path is relative: return as-is.
141+
// The child process will inherit the OS working directory.
102142
return toolsetWorkingDir
103143
}
104144

@@ -257,6 +297,19 @@ func createFetchTool(_ context.Context, toolset latest.Toolset, _ string, _ *con
257297
func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) {
258298
envProvider := runConfig.EnvProvider()
259299

300+
// Resolve the working directory once; used for all subprocess-based branches.
301+
// The remote branch never reaches here because working_dir is rejected by
302+
// validation for toolsets with a remote.url.
303+
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)
304+
305+
// S1: validate the resolved directory exists (if one was specified) so we
306+
// surface a clear error now rather than a cryptic exec failure later.
307+
if toolset.WorkingDir != "" {
308+
if err := checkDirExists(cwd, "mcp"); err != nil {
309+
return nil, err
310+
}
311+
}
312+
260313
switch {
261314
// MCP Server from the MCP Catalog, running with the MCP Gateway
262315
case toolset.Ref != "":
@@ -281,7 +334,8 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
281334
envProvider,
282335
)
283336

284-
return mcp.NewGatewayToolset(ctx, toolset.Name, mcpServerName, serverSpec.Secrets, toolset.Config, envProvider, runConfig.WorkingDir)
337+
// Pass the resolved cwd so gateway-based MCPs also honour working_dir.
338+
return mcp.NewGatewayToolset(ctx, toolset.Name, mcpServerName, serverSpec.Secrets, toolset.Config, envProvider, cwd)
285339

286340
// STDIO MCP Server from shell command
287341
case toolset.Command != "":
@@ -305,10 +359,9 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
305359
// Prepend tools bin dir to PATH so child processes can find installed tools
306360
env = toolinstall.PrependBinDirToEnv(env)
307361

308-
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)
309362
return mcp.NewToolsetCommand(toolset.Name, resolvedCommand, toolset.Args, env, cwd), nil
310363

311-
// Remote MCP Server
364+
// Remote MCP Server — working_dir is rejected at validation time for this branch.
312365
case toolset.Remote.URL != "":
313366
expander := js.NewJsExpander(envProvider)
314367

@@ -347,6 +400,15 @@ func createLSPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
347400
env = toolinstall.PrependBinDirToEnv(env)
348401

349402
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)
403+
404+
// S1: validate the resolved directory exists (if one was specified) so we
405+
// surface a clear error now rather than a cryptic exec failure later.
406+
if toolset.WorkingDir != "" {
407+
if err := checkDirExists(cwd, "lsp"); err != nil {
408+
return nil, err
409+
}
410+
}
411+
350412
tool := builtin.NewLSPTool(resolvedCommand, toolset.Args, env, cwd)
351413
if len(toolset.FileTypes) > 0 {
352414
tool.SetFileTypes(toolset.FileTypes)

pkg/teamloader/registry_test.go

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package teamloader
22

33
import (
4+
"os"
5+
"path/filepath"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
@@ -10,6 +12,8 @@ import (
1012
"github.com/docker/docker-agent/pkg/config/latest"
1113
"github.com/docker/docker-agent/pkg/environment"
1214
"github.com/docker/docker-agent/pkg/tools"
15+
"github.com/docker/docker-agent/pkg/tools/builtin"
16+
mcptool "github.com/docker/docker-agent/pkg/tools/mcp"
1317
)
1418

1519
func TestCreateShellTool(t *testing.T) {
@@ -74,6 +78,9 @@ func TestCreateMCPTool_BareCommandNotFound_CreatesToolsetAnyway(t *testing.T) {
7478
func TestResolveToolsetWorkingDir(t *testing.T) {
7579
t.Parallel()
7680

81+
home, err := os.UserHomeDir()
82+
require.NoError(t, err)
83+
7784
tests := []struct {
7885
name string
7986
toolsetWorkingDir string
@@ -99,7 +106,7 @@ func TestResolveToolsetWorkingDir(t *testing.T) {
99106
want: "/workspace/backend",
100107
},
101108
{
102-
name: "relative toolset dir without leading dot is joined with agent dir",
109+
name: "bare relative dir joined with agent dir",
103110
toolsetWorkingDir: "tools/mcp",
104111
agentWorkingDir: "/workspace",
105112
want: "/workspace/tools/mcp",
@@ -116,6 +123,19 @@ func TestResolveToolsetWorkingDir(t *testing.T) {
116123
agentWorkingDir: "",
117124
want: "",
118125
},
126+
// Tilde expansion tests (B2)
127+
{
128+
name: "tilde expands to home dir",
129+
toolsetWorkingDir: "~/projects/app",
130+
agentWorkingDir: "/workspace",
131+
want: filepath.Join(home, "projects/app"),
132+
},
133+
{
134+
name: "bare tilde expands to home dir",
135+
toolsetWorkingDir: "~",
136+
agentWorkingDir: "/workspace",
137+
want: home,
138+
},
119139
}
120140

121141
for _, tt := range tests {
@@ -126,3 +146,130 @@ func TestResolveToolsetWorkingDir(t *testing.T) {
126146
})
127147
}
128148
}
149+
150+
// TestResolveToolsetWorkingDir_EnvVarExpansion tests env-var expansion separately
151+
// because t.Setenv is incompatible with t.Parallel on the parent test.
152+
func TestResolveToolsetWorkingDir_EnvVarExpansion(t *testing.T) {
153+
t.Setenv("TEST_REGISTRY_CWD_VAR", "/custom/path")
154+
155+
got := resolveToolsetWorkingDir("${TEST_REGISTRY_CWD_VAR}/app", "/workspace")
156+
assert.Equal(t, "/custom/path/app", got)
157+
}
158+
159+
// TestCreateMCPTool_WorkingDir_ReachesSubprocess verifies that working_dir is
160+
// wired all the way through createMCPTool to the underlying stdio command (N5).
161+
func TestCreateMCPTool_WorkingDir_ReachesSubprocess(t *testing.T) {
162+
t.Setenv("DOCKER_AGENT_TOOLS_DIR", t.TempDir())
163+
164+
// Create a real temporary directory so the existence check passes.
165+
customDir := t.TempDir()
166+
agentDir := t.TempDir()
167+
168+
toolset := latest.Toolset{
169+
Type: "mcp",
170+
Command: "some-nonexistent-mcp-binary",
171+
WorkingDir: customDir,
172+
}
173+
174+
registry := NewDefaultToolsetRegistry()
175+
runConfig := &config.RuntimeConfig{
176+
Config: config.Config{WorkingDir: agentDir},
177+
EnvProviderForTests: environment.NewOsEnvProvider(),
178+
}
179+
180+
rawTool, err := registry.CreateTool(t.Context(), toolset, ".", runConfig, "test-agent")
181+
require.NoError(t, err)
182+
require.NotNil(t, rawTool)
183+
184+
// Assert the CWD reached the inner stdio command.
185+
ts, ok := rawTool.(*mcptool.Toolset)
186+
require.True(t, ok, "expected *mcp.Toolset")
187+
assert.Equal(t, customDir, ts.WorkingDir())
188+
}
189+
190+
// TestCreateMCPTool_RelativeWorkingDir_ResolvedAgainstAgentDir verifies that a
191+
// relative working_dir is resolved against the agent's working directory.
192+
func TestCreateMCPTool_RelativeWorkingDir_ResolvedAgainstAgentDir(t *testing.T) {
193+
t.Setenv("DOCKER_AGENT_TOOLS_DIR", t.TempDir())
194+
195+
agentDir := t.TempDir()
196+
// Create the subdirectory so the existence check passes.
197+
subDir := filepath.Join(agentDir, "tools", "mcp")
198+
require.NoError(t, os.MkdirAll(subDir, 0o700))
199+
200+
toolset := latest.Toolset{
201+
Type: "mcp",
202+
Command: "some-nonexistent-mcp-binary",
203+
WorkingDir: "tools/mcp",
204+
}
205+
206+
registry := NewDefaultToolsetRegistry()
207+
runConfig := &config.RuntimeConfig{
208+
Config: config.Config{WorkingDir: agentDir},
209+
EnvProviderForTests: environment.NewOsEnvProvider(),
210+
}
211+
212+
rawTool, err := registry.CreateTool(t.Context(), toolset, ".", runConfig, "test-agent")
213+
require.NoError(t, err)
214+
require.NotNil(t, rawTool)
215+
216+
ts, ok := rawTool.(*mcptool.Toolset)
217+
require.True(t, ok, "expected *mcp.Toolset")
218+
assert.Equal(t, subDir, ts.WorkingDir())
219+
}
220+
221+
// TestCreateMCPTool_NonexistentWorkingDir_ReturnsError verifies that a
222+
// non-existent working_dir surfaces a clear error at tool-creation time (S1).
223+
func TestCreateMCPTool_NonexistentWorkingDir_ReturnsError(t *testing.T) {
224+
t.Setenv("DOCKER_AGENT_TOOLS_DIR", t.TempDir())
225+
226+
// Use a path that is guaranteed not to exist by creating a tempdir and
227+
// appending a non-existent subdir (avoids flakes on hosts where a
228+
// hard-coded path might coincidentally exist).
229+
nonExistent := filepath.Join(t.TempDir(), "missing")
230+
231+
toolset := latest.Toolset{
232+
Type: "mcp",
233+
Command: "some-nonexistent-mcp-binary",
234+
WorkingDir: nonExistent,
235+
}
236+
237+
registry := NewDefaultToolsetRegistry()
238+
runConfig := &config.RuntimeConfig{
239+
Config: config.Config{WorkingDir: t.TempDir()},
240+
EnvProviderForTests: environment.NewOsEnvProvider(),
241+
}
242+
243+
_, err := registry.CreateTool(t.Context(), toolset, ".", runConfig, "test-agent")
244+
require.Error(t, err)
245+
assert.Contains(t, err.Error(), "working_dir")
246+
assert.Contains(t, err.Error(), "does not exist")
247+
}
248+
249+
// TestCreateLSPTool_WorkingDir_ReachesHandler verifies that working_dir is
250+
// wired all the way through createLSPTool to the LSP handler (N5).
251+
func TestCreateLSPTool_WorkingDir_ReachesHandler(t *testing.T) {
252+
// Create a real temporary directory so the existence check passes.
253+
customDir := t.TempDir()
254+
agentDir := t.TempDir()
255+
256+
toolset := latest.Toolset{
257+
Type: "lsp",
258+
Command: "gopls",
259+
WorkingDir: customDir,
260+
}
261+
262+
registry := NewDefaultToolsetRegistry()
263+
runConfig := &config.RuntimeConfig{
264+
Config: config.Config{WorkingDir: agentDir},
265+
EnvProviderForTests: environment.NewOsEnvProvider(),
266+
}
267+
268+
rawTool, err := registry.CreateTool(t.Context(), toolset, ".", runConfig, "test-agent")
269+
require.NoError(t, err)
270+
require.NotNil(t, rawTool)
271+
272+
lsp, ok := rawTool.(*builtin.LSPTool)
273+
require.True(t, ok, "expected *builtin.LSPTool")
274+
assert.Equal(t, customDir, lsp.WorkingDir())
275+
}

pkg/tools/builtin/lsp.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,12 @@ func (t *LSPTool) SetFileTypes(fileTypes []string) {
346346
t.handler.fileTypes = fileTypes
347347
}
348348

349+
// WorkingDir returns the working directory of the LSP server process.
350+
// This is intended for testing only.
351+
func (t *LSPTool) WorkingDir() string {
352+
return t.handler.workingDir
353+
}
354+
349355
// HandlesFile checks if this LSP handles the given file based on its extension.
350356
func (t *LSPTool) HandlesFile(path string) bool {
351357
return t.handler.handlesFile(path)

0 commit comments

Comments
 (0)