fix(mcp): expose accepted workspace mode values (#3667)

This commit is contained in:
José Almeida
2026-09-14 18:17:38 +02:00
committed by GitHub
parent 01b0dedd6c
commit afeebea2da
10 changed files with 523 additions and 5 deletions
@@ -87,6 +87,31 @@ func TestExternalMCPTaskModesReachPersistenceAndManagement(t *testing.T) {
httpServer := httptest.NewServer(router)
t.Cleanup(httpServer.Close)
// @covers AC-TASKS-MCP-WORKSPACE-MODE-004.3
t.Run("inheritance requires parent through MCP", func(t *testing.T) {
before, err := harness.taskSvc.ListTasks(ctx, kanbanWorkflows[0].ID)
require.NoError(t, err)
response := postExternalWorkspaceModeMCPRequest(t, httpServer.URL+"/mcp", 50, "create_task_kandev", map[string]any{
"title": "Invalid root inheritance",
"workspace_id": kanbanWorkspace.ID,
"workflow_id": kanbanWorkflows[0].ID,
"workspace_mode": "inherit_parent",
"agent_profile_id": "profile-1",
"start_agent": false,
})
require.Equal(t, http.StatusOK, response.StatusCode)
message := decodeExternalMCPResponse(t, response.Body)
require.NotContains(t, message, "error")
result, ok := message["result"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, result["isError"])
require.Contains(t, response.Body, "workspace_mode=inherit_parent requires parent_id")
require.NotContains(t, response.Body, "keyword: enum")
after, err := harness.taskSvc.ListTasks(ctx, kanbanWorkflows[0].ID)
require.NoError(t, err)
require.Equal(t, before, after)
})
created := make(map[string]string, 2)
for _, tc := range []struct {
name, workspaceID, workflowID, title, externalID string
@@ -0,0 +1,41 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// @covers AC-TASKS-MCP-WORKSPACE-MODE-004.3
func TestResolveMCPWorkspacePolicyCompatibility(t *testing.T) {
h := &Handlers{}
for _, tc := range []struct {
name, parent, mode, want, wantError string
}{
{name: "root omission"},
{name: "parent omission", parent: "parent", want: "inherit_parent"},
{name: "root blank", mode: " \t"},
{name: "parent blank", parent: "parent", mode: " \t", want: "inherit_parent"},
{name: "inherit", parent: "parent", mode: "inherit_parent", want: "inherit_parent"},
{name: "inherit needs parent", mode: "inherit_parent", wantError: "requires parent_id"},
{name: "new root", mode: "new_workspace", want: "new_workspace"},
{name: "new child", parent: "parent", mode: "new_workspace", want: "new_workspace"},
// Direct backend policy handling retains trimming; MCP schema validation
// rejects padded input before it reaches this resolver.
{name: "trim inheritance", parent: "parent", mode: " inherit_parent ", want: "inherit_parent"},
{name: "trim new", mode: " new_workspace ", want: "new_workspace"},
{name: "shared", mode: "shared", wantError: "invalid workspace_mode"},
{name: "shared group", mode: "shared_group", wantError: "invalid workspace_mode"},
} {
t.Run(tc.name, func(t *testing.T) {
policy, err := h.resolveMCPWorkspacePolicy(tc.parent, tc.mode)
if tc.wantError != "" {
require.ErrorContains(t, err, tc.wantError)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, policy.Mode)
})
}
}
@@ -0,0 +1,99 @@
package mcp
import (
"encoding/json"
"testing"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// @covers AC-TASKS-MCP-WORKSPACE-MODE-004.1, AC-TASKS-MCP-WORKSPACE-MODE-004.2
func TestCreateTaskWorkspaceModeSchema(t *testing.T) {
for _, mode := range []string{ModeTask, ModeExternal} {
t.Run(mode, func(t *testing.T) {
s := New(&testBackend{}, "", "", 10005, newTestLogger(t), "", false, mode)
tool, ok := s.mcpServer.ListTools()["create_task_kandev"]
require.True(t, ok)
catalog, err := json.Marshal(tool.Tool.InputSchema)
require.NoError(t, err)
for name, raw := range map[string][]byte{
"catalog": catalog, "attachment": mcpToolInputSchema(tool.Tool),
} {
t.Run(name, func(t *testing.T) {
var schema struct {
Properties map[string]map[string]any
Required []string
}
require.NoError(t, json.Unmarshal(raw, &schema))
property := schema.Properties["workspace_mode"]
require.NotNil(t, property)
assert.Equal(t, "string", property["type"])
assert.Equal(t, []any{"inherit_parent", "new_workspace"}, property["enum"])
assert.NotContains(t, schema.Required, "workspace_mode")
assert.NotContains(t, property, "default")
description, ok := property["description"].(string)
require.True(t, ok)
for _, phrase := range []string{
"Omit for subtasks", "inherit_parent requires parent_id",
"reuses the parent's materialized workspace/worktree",
"new_workspace requests a separate workspace/worktree",
} {
assert.Contains(t, description, phrase)
}
})
}
})
}
}
// @covers AC-TASKS-MCP-WORKSPACE-MODE-004.3, AC-TASKS-MCP-WORKSPACE-MODE-004.4
func TestCreateTaskWorkspaceModeArguments(t *testing.T) {
for _, mode := range []string{ModeTask, ModeExternal} {
t.Run(mode, func(t *testing.T) {
for _, tc := range []struct {
name string
value string
omit bool
valid bool
}{
{name: "omitted", omit: true, valid: true},
{name: "inherit", value: "inherit_parent", valid: true},
{name: "new", value: "new_workspace", valid: true},
{name: "empty"},
{name: "whitespace", value: " \t"},
{name: "padded", value: " new_workspace "},
{name: "shared", value: "shared"},
{name: "shared group", value: "shared_group"},
{name: "unknown", value: "unknown"},
} {
t.Run(tc.name, func(t *testing.T) {
backend := &testBackend{response: map[string]any{"id": "child"}}
s := New(backend, "", "", 10005, newTestLogger(t), "", false, mode)
args := map[string]any{"title": "Child task", "parent_id": "parent", "start_agent": false}
if !tc.omit {
args["workspace_mode"] = tc.value
}
result := callTool(t, s, "create_task_kandev", args)
if !tc.valid {
require.True(t, result.IsError)
assert.Empty(t, backend.lastAction)
require.NotEmpty(t, result.Content)
content, ok := result.Content[0].(mcp.TextContent)
require.True(t, ok)
assert.Contains(t, content.Text, "/workspace_mode")
assert.Contains(t, content.Text, "keyword: enum")
return
}
require.False(t, result.IsError)
assert.Equal(t, ws.ActionMCPCreateTask, backend.lastAction)
payload, ok := backend.lastPayload.(map[string]any)
require.True(t, ok)
assert.Equal(t, tc.value, payload["workspace_mode"])
})
}
})
}
}
+1 -1
View File
@@ -1477,7 +1477,7 @@ func (s *Server) registerCreateTaskTool() {
mcp.WithString("workspace_id", mcp.Description("The workspace ID. Auto-resolved if only one workspace exists. Defaulted from parent for subtasks when omitted.")),
mcp.WithString("workflow_id", mcp.Description("The workflow ID. Auto-resolved if the workspace has only one workflow. Defaulted from parent for subtasks when workspace_id is also omitted; if supplied, it must belong to the effective workspace_id.")),
mcp.WithString("workflow_step_id", mcp.Description("The workflow step ID (optional, auto-resolved if omitted; for subtasks, pass only with an explicit workflow_id)")),
mcp.WithString("workspace_mode", mcp.Description("Subtask materialized-workspace mode: inherit_parent reuses the parent's worktree/materialized workspace (default for subtasks); new_workspace launches the subtask in its own workspace/worktree.")),
mcp.WithString("workspace_mode", mcp.Enum("inherit_parent", "new_workspace"), mcp.Description("Optional materialized-workspace mode. Omit for subtasks to inherit the parent's workspace/worktree. inherit_parent requires parent_id and reuses the parent's materialized workspace/worktree; new_workspace requests a separate workspace/worktree.")),
mcp.WithString("title", mcp.Required(), mcp.MaxLength(service.TaskTitleMaxLength), mcp.Description("A concise, few-word task title (maximum 60 characters).")),
mcp.WithString("prompt", mcp.Description("The initial prompt for the task agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. For auto-started subtasks, provide a specific and detailed prompt; omitting it starts the task agent without task-specific context.")),
mcp.WithBoolean("autopilot", mcp.Description("Start this task in autopilot mode. Default: false. The value is fixed at creation and is not inherited by subtasks. The agent does not ask the user directly; it asks its direct parent only for critical decisions.")),