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
9 changes: 9 additions & 0 deletions api/v1alpha1/agentconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ type MCPServerSpec struct {
// for overlapping keys.
// +optional
EnvFrom *SecretValuesSource `json:"envFrom,omitempty"`

// WorkingDir is the working directory in which a stdio MCP server
// command is spawned. Only used when type is "stdio". Useful for
// servers that must run from a specific project root (for example,
// "php artisan boost:mcp" requires the Laravel project root as cwd).
// Relative paths are resolved by the agent against its own current
// working directory; absolute paths are recommended.
// +optional
WorkingDir string `json:"cwd,omitempty"`
}

// SecretValuesSource selects a Secret to populate values from.
Expand Down
1 change: 1 addition & 0 deletions codex/kelos_entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ for (const [name, s] of Object.entries(servers)) {
toml += `[mcp_servers.${JSON.stringify(name)}]\n`;
if (s.command) toml += `command = ${JSON.stringify(s.command)}\n`;
if (s.args && s.args.length) toml += `args = ${JSON.stringify(s.args)}\n`;
if (s.cwd) toml += `cwd = ${JSON.stringify(s.cwd)}\n`;
if (s.url) toml += `url = ${JSON.stringify(s.url)}\n`;
if (s.headers) {
const h = Object.entries(s.headers).map(([k,v]) => `${JSON.stringify(k)} = ${JSON.stringify(v)}`).join(", ");
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ GitHub Apps are preferred over PATs for production use because they offer fine-g
| `spec.mcpServers[].url` | Server endpoint (http/sse only) | No |
| `spec.mcpServers[].headers` | HTTP headers (http/sse only) | No |
| `spec.mcpServers[].env` | Environment variables for server process (stdio only) | No |
| `spec.mcpServers[].cwd` | Working directory in which the stdio command is spawned (stdio only); useful for servers that must run from a specific project root (e.g., `php artisan boost:mcp` from a Laravel root) | No |

## TaskSpawner

Expand Down
2 changes: 2 additions & 0 deletions internal/controller/job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ type mcpServerJSON struct {
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Env map[string]string `json:"env,omitempty"`
Cwd string `json:"cwd,omitempty"`
}

// buildMCPServersJSON converts MCPServerSpec entries into a JSON string
Expand All @@ -840,6 +841,7 @@ func buildMCPServersJSON(servers []kelosv1alpha1.MCPServerSpec) (string, error)
URL: s.URL,
Headers: s.Headers,
Env: s.Env,
Cwd: s.WorkingDir,
}
mcpMap[s.Name] = entry
}
Expand Down
115 changes: 115 additions & 0 deletions internal/controller/job_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3179,6 +3179,121 @@ func TestBuildJob_AgentConfigMCPServers(t *testing.T) {
}
}

func TestBuildJob_AgentConfigMCPServersStdioCwd(t *testing.T) {
builder := NewJobBuilder()
task := &kelosv1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{
Name: "test-mcp-cwd",
Namespace: "default",
},
Spec: kelosv1alpha1.TaskSpec{
Type: AgentTypeClaudeCode,
Prompt: "Fix issue",
Credentials: kelosv1alpha1.Credentials{
Type: kelosv1alpha1.CredentialTypeAPIKey,
SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"},
},
},
}

agentConfig := &kelosv1alpha1.AgentConfigSpec{
MCPServers: []kelosv1alpha1.MCPServerSpec{
{
Name: "laravel-boost",
Type: "stdio",
Command: "php",
Args: []string{"artisan", "boost:mcp"},
WorkingDir: "/workspace/repo",
},
},
}

job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt)
if err != nil {
t.Fatalf("Build() returned error: %v", err)
}

container := job.Spec.Template.Spec.Containers[0]
var mcpJSON string
for _, env := range container.Env {
if env.Name == "KELOS_MCP_SERVERS" {
mcpJSON = env.Value
}
}
if mcpJSON == "" {
t.Fatal("Expected KELOS_MCP_SERVERS env var to be set")
}

var parsed struct {
MCPServers map[string]struct {
Type string `json:"type"`
Command string `json:"command"`
Args []string `json:"args"`
Cwd string `json:"cwd"`
} `json:"mcpServers"`
}
if err := json.Unmarshal([]byte(mcpJSON), &parsed); err != nil {
t.Fatalf("Failed to parse KELOS_MCP_SERVERS JSON: %v", err)
}

boost, ok := parsed.MCPServers["laravel-boost"]
if !ok {
t.Fatal("Expected 'laravel-boost' MCP server entry")
}
if boost.Cwd != "/workspace/repo" {
t.Errorf("Expected cwd '/workspace/repo', got %q", boost.Cwd)
}
}

func TestBuildJob_AgentConfigMCPServersCwdOmittedWhenUnset(t *testing.T) {
builder := NewJobBuilder()
task := &kelosv1alpha1.Task{
ObjectMeta: metav1.ObjectMeta{
Name: "test-mcp-no-cwd",
Namespace: "default",
},
Spec: kelosv1alpha1.TaskSpec{
Type: AgentTypeClaudeCode,
Prompt: "Fix issue",
Credentials: kelosv1alpha1.Credentials{
Type: kelosv1alpha1.CredentialTypeAPIKey,
SecretRef: &kelosv1alpha1.SecretReference{Name: "my-secret"},
},
},
}

agentConfig := &kelosv1alpha1.AgentConfigSpec{
MCPServers: []kelosv1alpha1.MCPServerSpec{
{
Name: "no-cwd",
Type: "stdio",
Command: "echo",
Args: []string{"hello"},
},
},
}

job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt)
if err != nil {
t.Fatalf("Build() returned error: %v", err)
}

container := job.Spec.Template.Spec.Containers[0]
var mcpJSON string
for _, env := range container.Env {
if env.Name == "KELOS_MCP_SERVERS" {
mcpJSON = env.Value
}
}
if mcpJSON == "" {
t.Fatal("Expected KELOS_MCP_SERVERS env var to be set")
}

if strings.Contains(mcpJSON, `"cwd"`) {
t.Errorf("Expected cwd key to be omitted when WorkingDir is unset, got: %s", mcpJSON)
}
}

func TestBuildJob_AgentConfigMCPServersWithHTTPHeaders(t *testing.T) {
builder := NewJobBuilder()
task := &kelosv1alpha1.Task{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ spec:
Command is the executable to run for stdio transport.
Required when type is "stdio".
type: string
cwd:
description: |-
WorkingDir is the working directory in which a stdio MCP server
command is spawned. Only used when type is "stdio". Useful for
servers that must run from a specific project root (for example,
"php artisan boost:mcp" requires the Laravel project root as cwd).
Relative paths are resolved by the agent against its own current
working directory; absolute paths are recommended.
type: string
env:
additionalProperties:
type: string
Expand Down
9 changes: 9 additions & 0 deletions internal/manifests/install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ spec:
Command is the executable to run for stdio transport.
Required when type is "stdio".
type: string
cwd:
description: |-
WorkingDir is the working directory in which a stdio MCP server
command is spawned. Only used when type is "stdio". Useful for
servers that must run from a specific project root (for example,
"php artisan boost:mcp" requires the Laravel project root as cwd).
Relative paths are resolved by the agent against its own current
working directory; absolute paths are recommended.
type: string
env:
additionalProperties:
type: string
Expand Down
Loading