diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 9360b9cc6d..13cdfae772 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -53,7 +53,7 @@ type CreateInstanceRequest struct { DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents) AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers - McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default) or "config" + McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "config", or "office" // RequiresProcessKill forces agentctl to kill the agent's process group // (not just close stdin) on shutdown. Required for agents whose runtime // keeps child processes (e.g. MCP servers) alive when stdin closes — diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 1573bf5158..ec165100ab 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -263,7 +263,7 @@ type ExecutorCreateRequest struct { McpServers []McpServerConfig AgentConfig agents.Agent // Agent type info needed by runtimes PreviousExecutionID string // Non-empty when reconnecting to a previous execution - McpMode string // MCP tool mode: "task" (default) or "config" + McpMode string // MCP tool mode: "task" (default), "config", or "office" AuthToken string // Previously handshaken agentctl token for reconnects BootstrapNonce string // Stored nonce for re-handshake after container restart diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index fb3838918f..c2aebbeaf1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -360,7 +360,7 @@ type LaunchRequest struct { ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) PreviousExecutionID string // Previous execution ID for runtime reconnect - McpMode string // MCP tool mode: "config" activates config-mode tools + McpMode string // MCP tool mode: "task" (default), "config", or "office" // Environment preparation SetupScript string // Setup script to run before agent starts diff --git a/apps/backend/internal/agent/runtime/runtime.go b/apps/backend/internal/agent/runtime/runtime.go index 8b7afba783..7ef4ad5910 100644 --- a/apps/backend/internal/agent/runtime/runtime.go +++ b/apps/backend/internal/agent/runtime/runtime.go @@ -76,7 +76,7 @@ type LaunchSpec struct { // PriorACPSession is the ACP session id to resume, if any. PriorACPSession string - // McpMode selects the MCP tool surface (e.g. "config" for config-mode tools). + // McpMode selects the MCP tool surface ("task", "config", or "office"). McpMode string // Metadata carries integration-specific fields. Phase 1 supports diff --git a/apps/backend/internal/agentctl/server/api/mcp_mode_test.go b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go new file mode 100644 index 0000000000..59d946329c --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/mcp_mode_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" + mcpserver "github.com/kandev/kandev/internal/mcp/server" +) + +func newTestServerWithMCP(t *testing.T) *Server { + t.Helper() + log := newTestLogger() + cfg := &config.InstanceConfig{ + Port: 0, + WorkDir: "/tmp/test", + } + procMgr := process.NewManager(cfg, log) + backend := mcpserver.NewChannelBackendClient(log) + t.Cleanup(backend.Close) + mcpServer := mcpserver.New(backend, "test-session", "test-task", 0, log, "", false, mcpserver.ModeTask) + return NewServer(cfg, procMgr, mcpServer, nil, log) +} + +func setMcpMode(t *testing.T, s *Server, mode string) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(map[string]string{"mode": mode}) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/api/v1/mcp/mode", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(rec, req) + return rec +} + +func TestHandleSetMcpMode_AcceptsSupportedModes(t *testing.T) { + s := newTestServerWithMCP(t) + + for _, mode := range []string{mcpserver.ModeTask, mcpserver.ModeConfig, mcpserver.ModeOffice} { + t.Run(mode, func(t *testing.T) { + rec := setMcpMode(t, s, mode) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var body struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if body.Mode != mode { + t.Fatalf("mode = %q, want %q", body.Mode, mode) + } + }) + } +} + +func TestHandleSetMcpMode_RejectsUnsupportedMode(t *testing.T) { + s := newTestServerWithMCP(t) + + rec := setMcpMode(t, s, mcpserver.ModeExternal) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 09b75c94e1..80e8b84b17 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -299,8 +299,8 @@ func (s *Server) handleSetMcpMode(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if req.Mode != "task" && req.Mode != "config" { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task' or 'config'"}) + if req.Mode != mcp.ModeTask && req.Mode != mcp.ModeConfig && req.Mode != mcp.ModeOffice { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid mode: must be 'task', 'config', or 'office'"}) return } s.mcpServer.SetMode(req.Mode) diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 7490034f1d..5b34745e3f 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -209,7 +209,7 @@ type InstanceConfig struct { AssumeMcpHttp bool // McpMode controls which MCP tools are registered for this instance. - // "task" (default) registers kanban/plan tools; "config" registers config tools. + // "task" (default), "config", and "office" select distinct tool surfaces. McpMode string // AuthToken is a shared secret for authenticating requests. diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index b3b2274132..44467b30f5 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -148,7 +148,7 @@ type CreateRequest struct { // AssumeMcpHttp overrides MCP capability filtering to assume HTTP support. AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` - // McpMode controls which MCP tools are registered: "task" (default) or "config". + // McpMode controls which MCP tools are registered: "task" (default), "config", or "office". McpMode string `json:"mcp_mode,omitempty"` // RequiresProcessKill forces the agent's process group to be killed on diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 1346b377c2..f1854cdb86 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -400,6 +400,9 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M if req.Title == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil) } + if req.AssigneeAgentProfileID != "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "assignee_agent_profile_id is office-only and cannot be set via create_task_kandev", nil) + } // Default start_agent to true for backward compatibility startAgent := req.StartAgent == nil || *req.StartAgent diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index d7731f8c81..50e3c0840e 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -142,6 +142,30 @@ func TestHandleCreateTask_MissingTitle(t *testing.T) { assertWSError(t, resp, ws.ErrorCodeValidation) } +func TestHandleCreateTask_RejectsAssigneeAgentProfileID(t *testing.T) { + svc, _ := newTestTaskService(t) + ctx := context.Background() + workspaces, err := svc.ListWorkspaces(ctx) + require.NoError(t, err) + require.Len(t, workspaces, 1) + workflows, err := svc.ListWorkflows(ctx, workspaces[0].ID, false) + require.NoError(t, err) + require.Len(t, workflows, 1) + + h := &Handlers{taskSvc: svc, logger: testLogger(t).WithFields()} + msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ + "workspace_id": workspaces[0].ID, + "workflow_id": workflows[0].ID, + "title": "Office child from kanban", + "assignee_agent_profile_id": "agent-inst-42", + "start_agent": false, + }) + + resp, err := h.handleCreateTask(ctx, msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + func TestSessionStateEventsIncludeUpdatedAt(t *testing.T) { tests := []struct { name string @@ -839,16 +863,15 @@ func TestHandleCreateTask_AutoResolveFailsWithMultipleWorkflows(t *testing.T) { } func TestHandleCreateTask_NewFields_Unmarshalled(t *testing.T) { - // Verify that execution_policy and assignee_agent_profile_id are accepted - // in the payload without triggering a parse error. The handler will fail - // at the workspace_id validation stage, not at unmarshal. + // Verify that extra fields are tolerated by JSON decoding. Office-only + // assignee_agent_profile_id is covered by + // TestHandleCreateTask_RejectsAssigneeAgentProfileID. h := &Handlers{} msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ - "title": "My task", - "workspace_id": "ws-1", - "workflow_id": "wf-1", - "execution_policy": `{"stages":[]}`, - "assignee_agent_profile_id": "agent-inst-42", + "title": "My task", + "workspace_id": "ws-1", + "workflow_id": "wf-1", + "execution_policy": `{"stages":[]}`, }) // taskSvc is nil so CreateTask will panic before we reach it; the payload @@ -859,9 +882,8 @@ func TestHandleCreateTask_NewFields_Unmarshalled(t *testing.T) { // a payload that fails a post-unmarshal check (missing workspace) which // exercised the request struct fields. msgMissingWs := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ - "title": "My task", - "execution_policy": `{"stages":[]}`, - "assignee_agent_profile_id": "agent-inst-42", + "title": "My task", + "execution_policy": `{"stages":[]}`, }) resp, err := h.handleCreateTask(context.Background(), msgMissingWs) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 4d635198f3..c0375804b0 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -269,7 +269,7 @@ type LaunchAgentRequest struct { ExecutorType string // Executor type (e.g., "local", "worktree", "local_docker") - determines runtime ExecutorConfig map[string]string // Executor config (docker_host, git_token, etc.) PreviousExecutionID string // Previous execution ID for runtime reconnect - McpMode string // MCP tool mode: "config" activates config-mode tools + McpMode string // MCP tool mode: "task" (default), "config", or "office" IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback) @@ -361,7 +361,7 @@ type LaunchOptions struct { Prompt string WorkflowStepID string StartAgent bool - McpMode string // MCP tool mode: McpModeConfig activates config-mode tools + McpMode string // MCP tool mode: empty task default, McpModeConfig, or McpModeOffice Attachments []v1.MessageAttachment Env map[string]string // RouteOverride carries a provider-routing override resolved by the