fix(mcp): expose accepted workspace mode values (#3667)
This commit is contained in:
@@ -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"])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.")),
|
||||
|
||||
Reference in New Issue
Block a user