fix: allow office mcp mode for existing tasks (#1315)

This commit is contained in:
Carlos Florêncio
2026-06-08 21:22:30 +01:00
committed by GitHub
parent e806464117
commit 3e92d3abc6
11 changed files with 118 additions and 21 deletions
@@ -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 —
@@ -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
@@ -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
@@ -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
@@ -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())
}
}
@@ -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)
@@ -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.
@@ -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
@@ -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
@@ -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)
@@ -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