Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,10 @@
}
}
]
},
"working_dir": {
"type": "string",
"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."
}
},
"anyOf": [
Expand Down Expand Up @@ -1141,6 +1145,10 @@
"version": {
"type": "string",
"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."
},
"working_dir": {
"type": "string",
"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)."
}
},
"additionalProperties": false,
Expand Down
29 changes: 29 additions & 0 deletions examples/toolset-working-dir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env docker agent run

# Example: using working_dir for MCP and LSP toolsets
#
# Some language servers and MCP tools must be started from a specific directory.
# For example, gopls must be started from the Go module root. Use `working_dir`
# to configure the launch directory for any MCP or LSP toolset.
#
# `working_dir` is:
# - Optional (defaults to the agent's working directory when omitted)
# - Resolved relative to the agent's working directory if it is a relative path

agents:
root:
model: openai/gpt-5-mini
description: Example agent demonstrating working_dir for MCP and LSP toolsets
instruction: |
You are a helpful coding assistant with access to language server and MCP tools
launched from their respective project directories.
toolsets:
# LSP server started from a subdirectory (e.g. a Go module in ./backend)
- type: lsp
command: gopls
working_dir: ./backend

# MCP server started from a specific tools directory
- type: mcp
command: my-mcp-server
working_dir: ./tools/mcp
5 changes: 5 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,11 @@ type Toolset struct {

// For the `model_picker` tool
Models []string `json:"models,omitempty"`

// For `mcp` and `lsp` tools - optional working directory override.
// When set, the toolset process is started from this directory.
// Relative paths are resolved relative to the agent's working directory.
WorkingDir string `json:"working_dir,omitempty"`
}

func (t *Toolset) UnmarshalYAML(unmarshal func(any) error) error {
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ func (t *Toolset) validate() error {
if t.RAGConfig != nil && t.Type != "rag" {
return errors.New("rag_config can only be used with type 'rag'")
}
if t.WorkingDir != "" && t.Type != "mcp" && t.Type != "lsp" {
return errors.New("working_dir can only be used with type 'mcp' or 'lsp'")
}
// working_dir requires a local subprocess; it is meaningless for remote MCP toolsets.
if t.WorkingDir != "" && t.Type == "mcp" && t.Remote.URL != "" {
return errors.New("working_dir is not valid for remote MCP toolsets (no local subprocess)")
}

switch t.Type {
case "shell":
Expand Down
101 changes: 101 additions & 0 deletions pkg/config/latest/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,106 @@ agents:
`,
wantErr: "file_types can only be used with type 'lsp'",
},
{
name: "lsp with working_dir",
config: `
version: "8"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: lsp
command: gopls
working_dir: ./backend
`,
wantErr: "",
},
{
name: "working_dir on non-mcp-lsp toolset is rejected",
config: `
version: "8"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: shell
working_dir: ./backend
`,
wantErr: "working_dir can only be used with type 'mcp' or 'lsp'",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var cfg Config
err := yaml.Unmarshal([]byte(tt.config), &cfg)

if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}

func TestToolset_Validate_MCP_WorkingDir(t *testing.T) {
t.Parallel()

tests := []struct {
name string
config string
wantErr string
wantValue string
}{
{
name: "mcp with working_dir",
config: `
version: "8"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: mcp
command: my-mcp-server
working_dir: ./tools/mcp
`,
wantErr: "",
wantValue: "./tools/mcp",
},
{
name: "mcp without working_dir defaults to empty",
config: `
version: "8"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: mcp
command: my-mcp-server
`,
wantErr: "",
wantValue: "",
},
{
name: "working_dir on remote mcp is rejected",
config: `
version: "8"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: mcp
remote:
url: https://mcp.example.com/sse
working_dir: ./tools
`,
wantErr: "working_dir is not valid for remote MCP toolsets",
wantValue: "",
},
}

for _, tt := range tests {
Expand All @@ -111,6 +211,7 @@ agents:
require.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
require.Equal(t, tt.wantValue, cfg.Agents.First().Toolsets[0].WorkingDir)
}
})
}
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/mcps.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ func applyMCPDefaults(ts, def *latest.Toolset) {
if ts.Defer.IsEmpty() {
ts.Defer = def.Defer
}
if ts.WorkingDir == "" {
// An empty working_dir in the referencing toolset is treated as "unset":
// inherit the definition's value. This matches the semantics of all other
// string fields in this function. An explicit `working_dir: ""` in YAML
// is indistinguishable from omission and will therefore be overridden.
ts.WorkingDir = def.WorkingDir
}
if len(def.Env) > 0 {
merged := make(map[string]string, len(def.Env)+len(ts.Env))
maps.Copy(merged, def.Env)
Expand Down
22 changes: 22 additions & 0 deletions pkg/config/mcps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,25 @@ func TestMCPDefinitions_EnvMerge(t *testing.T) {
// Toolset-only key is preserved
assert.Equal(t, "from_toolset", ts.Env["EXTRA"])
}

func TestMCPDefinitions_WorkingDir(t *testing.T) {
t.Parallel()

cfg, err := Load(t.Context(), NewFileSource("testdata/mcp_definitions_working_dir.yaml"))
require.NoError(t, err)

// WorkingDir from the definition is inherited by the referencing toolset.
root, ok := cfg.Agents.Lookup("root")
require.True(t, ok)
require.Len(t, root.Toolsets, 1)
ts := root.Toolsets[0]
assert.Equal(t, "my-mcp-server", ts.Command)
assert.Equal(t, "./tools/mcp", ts.WorkingDir)

// A toolset-level working_dir overrides the definition's value.
override, ok := cfg.Agents.Lookup("override")
require.True(t, ok)
require.Len(t, override.Toolsets, 1)
tsOverride := override.Toolsets[0]
assert.Equal(t, "./override/path", tsOverride.WorkingDir)
}
23 changes: 23 additions & 0 deletions pkg/config/testdata/mcp_definitions_working_dir.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: "8"
models:
model:
provider: openai
model: gpt-4o

mcps:
custom_mcp_with_dir:
command: my-mcp-server
working_dir: ./tools/mcp

agents:
root:
model: model
toolsets:
- type: mcp
ref: custom_mcp_with_dir
override:
model: model
toolsets:
- type: mcp
ref: custom_mcp_with_dir
working_dir: ./override/path
88 changes: 84 additions & 4 deletions pkg/teamloader/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,62 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry {
return r
}

// checkDirExists returns an error if the given directory does not exist or is
// not a directory. toolsetType is used only in the error message.
func checkDirExists(dir, toolsetType string) error {
info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("working_dir %q for %s toolset does not exist", dir, toolsetType)
}
return fmt.Errorf("working_dir %q for %s toolset: %w", dir, toolsetType, err)
}
if !info.IsDir() {
return fmt.Errorf("working_dir %q for %s toolset is not a directory", dir, toolsetType)
}
return nil
}

// resolveToolsetWorkingDir returns the effective working directory for a toolset process.
//
// Resolution rules:
// - If toolsetWorkingDir is empty, agentWorkingDir is returned unchanged.
// - Shell patterns (~ and ${VAR}/$VAR) are expanded before any further processing.
// - If the expanded path is absolute, it is returned as-is.
// - If the expanded path is relative and agentWorkingDir is non-empty,
// it is joined with agentWorkingDir and made absolute via filepath.Abs.
// - If the expanded path is relative and agentWorkingDir is empty,
// the relative path is returned unchanged (caller will inherit the process cwd).
//
// Note: unlike resolveToolsetPath, this helper does not enforce containment
// within the agent working directory. working_dir is treated like command/args —
// a trusted, operator-authored value where cross-tree references (e.g. a sibling
// module root in a monorepo) are intentional and must not be silently blocked.
func resolveToolsetWorkingDir(toolsetWorkingDir, agentWorkingDir string) string {
if toolsetWorkingDir == "" {
return agentWorkingDir
}
// Expand ~ and environment variables before path operations.
toolsetWorkingDir = path.ExpandPath(toolsetWorkingDir)
if filepath.IsAbs(toolsetWorkingDir) {
return toolsetWorkingDir
}
if agentWorkingDir != "" {
// filepath.Abs cleans the result and anchors the URI correctly
// (avoids file://./backend-style LSP root URIs when the agent dir
// is itself absolute, which is the normal case).
abs, err := filepath.Abs(filepath.Join(agentWorkingDir, toolsetWorkingDir))
if err == nil {
return abs
}
// Fallback: return the joined path without Abs (should not happen in practice).
return filepath.Join(agentWorkingDir, toolsetWorkingDir)
}
// agentWorkingDir is empty and path is relative: return as-is.
// The child process will inherit the OS working directory.
return toolsetWorkingDir
}

// resolveToolsetPath expands shell patterns (~, env vars) in the given path,
// then validates it relative to the working directory or parent directory.
func resolveToolsetPath(toolsetPath, parentDir string, runConfig *config.RuntimeConfig) (string, error) {
Expand Down Expand Up @@ -241,6 +297,19 @@ func createFetchTool(_ context.Context, toolset latest.Toolset, _ string, _ *con
func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) {
envProvider := runConfig.EnvProvider()

// Resolve the working directory once; used for all subprocess-based branches.
// The remote branch never reaches here because working_dir is rejected by
// validation for toolsets with a remote.url.
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)

// S1: validate the resolved directory exists (if one was specified) so we
// surface a clear error now rather than a cryptic exec failure later.
if toolset.WorkingDir != "" {
if err := checkDirExists(cwd, "mcp"); err != nil {
return nil, err
}
}

switch {
// MCP Server from the MCP Catalog, running with the MCP Gateway
case toolset.Ref != "":
Expand All @@ -265,7 +334,8 @@ func createMCPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
envProvider,
)

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

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

return mcp.NewToolsetCommand(toolset.Name, resolvedCommand, toolset.Args, env, runConfig.WorkingDir), nil
return mcp.NewToolsetCommand(toolset.Name, resolvedCommand, toolset.Args, env, cwd), nil

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

Expand Down Expand Up @@ -329,7 +399,17 @@ func createLSPTool(ctx context.Context, toolset latest.Toolset, _ string, runCon
// Prepend tools bin dir to PATH so child processes can find installed tools
env = toolinstall.PrependBinDirToEnv(env)

tool := builtin.NewLSPTool(resolvedCommand, toolset.Args, env, runConfig.WorkingDir)
cwd := resolveToolsetWorkingDir(toolset.WorkingDir, runConfig.WorkingDir)

// S1: validate the resolved directory exists (if one was specified) so we
// surface a clear error now rather than a cryptic exec failure later.
if toolset.WorkingDir != "" {
if err := checkDirExists(cwd, "lsp"); err != nil {
return nil, err
}
}

tool := builtin.NewLSPTool(resolvedCommand, toolset.Args, env, cwd)
if len(toolset.FileTypes) > 0 {
tool.SetFileTypes(toolset.FileTypes)
}
Expand Down
Loading
Loading