From d976aec8ffe0426f4ee720ed94b245e5488a8d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 19 Aug 2026 10:32:40 +0100 Subject: [PATCH] feat: add workflow export MCP tool (#2796) --- apps/backend/config/prompts/config-context.md | 1 + .../mcp/handlers/config_workflow_handlers.go | 17 +++ .../handlers/config_workflow_handlers_test.go | 91 ++++++++++++++ .../backend/internal/mcp/handlers/handlers.go | 1 + .../internal/mcp/server/config_handlers.go | 26 ++++ .../mcp/server/config_handlers_test.go | 1 + .../mcp/server/config_workflow_export_test.go | 78 ++++++++++++ .../internal/mcp/server/server_test.go | 10 +- .../mcp/server/server_workflow_export_test.go | 26 ++++ .../internal/sysprompt/sysprompt_test.go | 1 + apps/backend/pkg/websocket/actions.go | 1 + docs/plans/workflow-mcp-export/plan.md | 117 ++++++++++++++++++ .../task-01-expose-workflow-export.md | 73 +++++++++++ .../task-02-document-workflow-export.md | 62 ++++++++++ docs/public/automation-and-mcp.md | 8 +- docs/public/coverage.json | 1 + docs/specs/integrations/external-mcp.md | 50 +++++++- 17 files changed, 555 insertions(+), 9 deletions(-) create mode 100644 apps/backend/internal/mcp/server/config_workflow_export_test.go create mode 100644 apps/backend/internal/mcp/server/server_workflow_export_test.go create mode 100644 docs/plans/workflow-mcp-export/plan.md create mode 100644 docs/plans/workflow-mcp-export/task-01-expose-workflow-export.md create mode 100644 docs/plans/workflow-mcp-export/task-02-document-workflow-export.md diff --git a/apps/backend/config/prompts/config-context.md b/apps/backend/config/prompts/config-context.md index b259125012..c6524edf9d 100644 --- a/apps/backend/config/prompts/config-context.md +++ b/apps/backend/config/prompts/config-context.md @@ -11,6 +11,7 @@ WORKFLOW TOOLS: - update_workflow_kandev: Update a workflow. Required: workflow_id. Optional: name, description. - delete_workflow_kandev: Delete a workflow and all its steps (destructive). Required: workflow_id. - import_workflow_kandev: Import one or more workflows into a workspace from a portable document (the kandev_workflow YAML/JSON export envelope). Workflows whose name already exists are skipped. Required: workspace_id, document. Returns the created and skipped workflow names. +- export_workflow_kandev: Export one workflow as a portable version 1 kandev_workflow JSON document. Required: workflow_id. Pass the returned JSON unchanged as document to import_workflow_kandev. - list_workflow_steps_kandev: List workflow steps (columns) in a workflow. Required: workflow_id. - create_workflow_step_kandev: Create a new workflow step. Required: workflow_id, name. Optional: position, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, auto_advance_requires_signal, cancel_triggers_turn_complete, events. - update_workflow_step_kandev: Update a workflow step. Required: step_id. Optional: name, color, prompt, is_start_step, allow_manual_move, show_in_command_panel, auto_archive_after_hours, auto_advance_requires_signal, cancel_triggers_turn_complete, events. diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go index c3073de2fa..afd9a45a31 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers.go @@ -82,6 +82,23 @@ func (h *Handlers) handleDeleteWorkflow(ctx context.Context, msg *ws.Message) (* return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) } +func (h *Handlers) handleExportWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + workflowID, err := unmarshalStringField(msg.Payload, "workflow_id") + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if workflowID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_id is required", nil) + } + + export, err := h.workflowSvc.ExportWorkflow(ctx, workflowID) + if err != nil { + h.logger.Error("failed to export workflow", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to export workflow", nil) + } + return ws.NewResponse(msg.ID, msg.Action, export) +} + // handleImportWorkflow imports one or more workflows into a workspace from a // portable document. The document is the same YAML/JSON envelope produced by // the export endpoint; YAML parsing accepts JSON too, matching the HTTP diff --git a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go index 76751be814..498472e3d1 100644 --- a/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go @@ -433,6 +433,97 @@ workflows: assert.Equal(t, "Done", steps[1].Name) } +func TestHandleExportWorkflow_ReturnsPortableWorkflow(t *testing.T) { + h, provider, repo := setupImportHandlers(t) + ctx := context.Background() + provider.workflows = append(provider.workflows, &taskmodels.Workflow{ + ID: "wf-export", WorkspaceID: "ws-source", Name: "Portable Board", + Description: "A portable workflow", Prompt: "Keep the board moving", + }) + require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{ + ID: "step-todo", WorkflowID: "wf-export", Name: "Todo", Position: 0, + Color: "#3b82f6", IsStartStep: true, ShowInCommandPanel: true, + })) + require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{ + ID: "step-done", WorkflowID: "wf-export", Name: "Done", Position: 1, + Color: "#22c55e", + })) + + resp, err := h.handleExportWorkflow(ctx, makeWSMessage(t, ws.ActionMCPExportWorkflow, map[string]string{ + "workflow_id": "wf-export", + })) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, resp.Type) + + var exported wfmodels.WorkflowExport + require.NoError(t, json.Unmarshal(resp.Payload, &exported)) + assert.Equal(t, wfmodels.ExportVersion, exported.Version) + assert.Equal(t, wfmodels.ExportType, exported.Type) + require.Len(t, exported.Workflows, 1) + assert.Equal(t, "Portable Board", exported.Workflows[0].Name) + assert.Equal(t, "A portable workflow", exported.Workflows[0].Description) + require.Len(t, exported.Workflows[0].Steps, 2) + assert.Equal(t, "Todo", exported.Workflows[0].Steps[0].Name) + assert.Equal(t, "Done", exported.Workflows[0].Steps[1].Name) + assert.NotContains(t, string(resp.Payload), "wf-export") + assert.NotContains(t, string(resp.Payload), "step-todo") +} + +func TestHandleExportWorkflow_ResultCanBeImportedUnchanged(t *testing.T) { + h, provider, repo := setupImportHandlers(t) + ctx := context.Background() + provider.workflows = append(provider.workflows, &taskmodels.Workflow{ + ID: "wf-round-trip", WorkspaceID: "ws-source", Name: "Round Trip", + }) + require.NoError(t, repo.CreateStep(ctx, &wfmodels.WorkflowStep{ + ID: "step-round-trip", WorkflowID: "wf-round-trip", Name: "Review", Position: 0, + Color: "purple", IsStartStep: true, + })) + + exportResp, err := h.handleExportWorkflow(ctx, makeWSMessage(t, ws.ActionMCPExportWorkflow, map[string]string{ + "workflow_id": "wf-round-trip", + })) + require.NoError(t, err) + + importResp, err := h.handleImportWorkflow(ctx, makeWSMessage(t, ws.ActionMCPImportWorkflow, map[string]string{ + "workspace_id": "ws-target", + "document": string(exportResp.Payload), + })) + require.NoError(t, err) + require.Equal(t, ws.MessageTypeResponse, importResp.Type) + + var result workflowsvc.ImportResult + require.NoError(t, json.Unmarshal(importResp.Payload, &result)) + assert.Equal(t, []string{"Round Trip"}, result.Created) + assert.Empty(t, result.Skipped) + require.Len(t, provider.workflows, 2) + imported := provider.workflows[1] + assert.Equal(t, "ws-target", imported.WorkspaceID) + steps, err := repo.ListStepsByWorkflow(ctx, imported.ID) + require.NoError(t, err) + require.Len(t, steps, 1) + assert.Equal(t, "Review", steps[0].Name) +} + +func TestHandleExportWorkflow_UnknownWorkflowReturnsGenericError(t *testing.T) { + h, _, _ := setupImportHandlers(t) + + resp, err := h.handleExportWorkflow(context.Background(), makeWSMessage(t, ws.ActionMCPExportWorkflow, map[string]string{ + "workflow_id": "missing-workflow", + })) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeInternalError) + assert.NotContains(t, string(resp.Payload), "workflows") +} + +func TestHandleExportWorkflow_MissingWorkflowID(t *testing.T) { + h := &Handlers{} + + resp, err := h.handleExportWorkflow(context.Background(), makeWSMessage(t, ws.ActionMCPExportWorkflow, map[string]string{})) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + func TestHandleImportWorkflow_SkipsDuplicateName(t *testing.T) { h, provider, _ := setupImportHandlers(t) provider.workflows = append(provider.workflows, &taskmodels.Workflow{ diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 4b0e01191b..800cab4ec8 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -403,6 +403,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPUpdateWorkflow, h.handleUpdateWorkflow) d.RegisterFunc(ws.ActionMCPDeleteWorkflow, h.handleDeleteWorkflow) d.RegisterFunc(ws.ActionMCPImportWorkflow, h.handleImportWorkflow) + d.RegisterFunc(ws.ActionMCPExportWorkflow, h.handleExportWorkflow) d.RegisterFunc(ws.ActionMCPCreateWorkflowStep, h.handleCreateWorkflowStep) d.RegisterFunc(ws.ActionMCPUpdateWorkflowStep, h.handleUpdateWorkflowStep) d.RegisterFunc(ws.ActionMCPDeleteWorkflowStep, h.handleDeleteWorkflowStep) diff --git a/apps/backend/internal/mcp/server/config_handlers.go b/apps/backend/internal/mcp/server/config_handlers.go index c601a6a8ec..816b8cbfe8 100644 --- a/apps/backend/internal/mcp/server/config_handlers.go +++ b/apps/backend/internal/mcp/server/config_handlers.go @@ -3,6 +3,7 @@ package mcp import ( "context" "encoding/json" + "strings" ws "github.com/kandev/kandev/pkg/websocket" "github.com/mark3labs/mcp-go/mcp" @@ -68,6 +69,17 @@ func (s *Server) registerConfigWorkflowTools() { ), s.wrapHandler("import_workflow_kandev", s.importWorkflowHandler()), ) + s.mcpServer.AddTool( + mcp.NewTool("export_workflow_kandev", + mcp.WithDescription("Export one workflow as a portable version 1 kandev_workflow JSON document. The result contains one workflow and its steps without instance IDs or timestamps. Pass the JSON text unchanged as document to import_workflow_kandev (the import document limit is 1 MiB)."), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID to export")), + ), + s.wrapHandler("export_workflow_kandev", s.exportWorkflowHandler()), + ) s.registerConfigWorkflowStepTools() } @@ -395,6 +407,20 @@ func (s *Server) importWorkflowHandler() server.ToolHandlerFunc { } } +func (s *Server) exportWorkflowHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + workflowID, err := req.RequireString("workflow_id") + if err != nil { + return mcp.NewToolResultError("workflow_id is required"), nil + } + workflowID = strings.TrimSpace(workflowID) + if workflowID == "" { + return mcp.NewToolResultError("workflow_id is required"), nil + } + return s.forwardToBackend(ctx, ws.ActionMCPExportWorkflow, map[string]string{"workflow_id": workflowID}) + } +} + func (s *Server) createWorkflowStepHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { workflowID, err := req.RequireString("workflow_id") diff --git a/apps/backend/internal/mcp/server/config_handlers_test.go b/apps/backend/internal/mcp/server/config_handlers_test.go index 36e517ea12..7bf98d319d 100644 --- a/apps/backend/internal/mcp/server/config_handlers_test.go +++ b/apps/backend/internal/mcp/server/config_handlers_test.go @@ -85,6 +85,7 @@ func TestActionConstants_MatchWebSocketActions(t *testing.T) { assert.Equal(t, "mcp.update_workflow", ws.ActionMCPUpdateWorkflow) assert.Equal(t, "mcp.delete_workflow", ws.ActionMCPDeleteWorkflow) assert.Equal(t, "mcp.import_workflow", ws.ActionMCPImportWorkflow) + assert.Equal(t, "mcp.export_workflow", ws.ActionMCPExportWorkflow) assert.Equal(t, "mcp.create_workflow_step", ws.ActionMCPCreateWorkflowStep) assert.Equal(t, "mcp.update_workflow_step", ws.ActionMCPUpdateWorkflowStep) assert.Equal(t, "mcp.delete_workflow_step", ws.ActionMCPDeleteWorkflowStep) diff --git a/apps/backend/internal/mcp/server/config_workflow_export_test.go b/apps/backend/internal/mcp/server/config_workflow_export_test.go new file mode 100644 index 0000000000..489578c073 --- /dev/null +++ b/apps/backend/internal/mcp/server/config_workflow_export_test.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "encoding/json" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExportWorkflowTool_AdvertisesReadOnlyRisk(t *testing.T) { + s := newTestServer(t, &testBackend{}) + tool, ok := s.mcpServer.ListTools()["export_workflow_kandev"] + require.True(t, ok) + annotations := tool.Tool.Annotations + require.NotNil(t, annotations.ReadOnlyHint) + require.NotNil(t, annotations.DestructiveHint) + require.NotNil(t, annotations.IdempotentHint) + require.NotNil(t, annotations.OpenWorldHint) + assert.True(t, *annotations.ReadOnlyHint) + assert.False(t, *annotations.DestructiveHint) + assert.True(t, *annotations.IdempotentHint) + assert.False(t, *annotations.OpenWorldHint) + assert.Contains(t, tool.Tool.Description, "1 MiB") +} + +func TestExportWorkflowHandler_ForwardsWorkflowIDAndReturnsJSONText(t *testing.T) { + backend := &testBackend{ + response: map[string]interface{}{ + "version": 1, + "type": "kandev_workflow", + "workflows": []interface{}{ + map[string]interface{}{"name": "Sprint Board", "steps": []interface{}{}}, + }, + }, + } + s := newTestServer(t, backend) + + result := callTool(t, s, "export_workflow_kandev", map[string]interface{}{ + "workflow_id": "wf-123", + }) + + assert.False(t, result.IsError) + assert.Equal(t, "mcp.export_workflow", backend.lastAction) + payload, ok := backend.lastPayload.(map[string]string) + require.True(t, ok) + assert.Equal(t, "wf-123", payload["workflow_id"]) + require.Len(t, result.Content, 1) + content, ok := result.Content[0].(mcplib.TextContent) + require.True(t, ok) + var exported map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(content.Text), &exported)) + assert.Equal(t, float64(1), exported["version"]) + assert.Equal(t, "kandev_workflow", exported["type"]) +} + +func TestExportWorkflowHandler_MissingWorkflowID(t *testing.T) { + backend := &testBackend{} + s := newTestServer(t, backend) + + result := callTool(t, s, "export_workflow_kandev", map[string]interface{}{}) + + assert.True(t, result.IsError) + assert.Empty(t, backend.lastAction) +} + +func TestExportWorkflowHandler_RejectsWhitespaceWorkflowID(t *testing.T) { + backend := &testBackend{} + s := newTestServer(t, backend) + + result := callTool(t, s, "export_workflow_kandev", map[string]interface{}{ + "workflow_id": " \t", + }) + + assert.True(t, result.IsError) + assert.Empty(t, backend.lastAction) +} diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index dfe8006a02..54edaab392 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -535,6 +535,7 @@ func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { assert.Contains(t, tools, "update_workflow_kandev") assert.Contains(t, tools, "delete_workflow_kandev") assert.Contains(t, tools, "import_workflow_kandev") + assert.Contains(t, tools, "export_workflow_kandev") assert.Contains(t, tools, "list_workflow_steps_kandev") assert.Contains(t, tools, "create_workflow_step_kandev") assert.Contains(t, tools, "update_workflow_step_kandev") @@ -920,9 +921,9 @@ func TestServerModeConfig_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeConfig) tools := getRegisteredToolNames(s) - // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 33 + // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 interaction = 34 assert.NotContains(t, tools, "step_complete_kandev", "step_complete_kandev requires a live task session; must NOT register in config mode") - assert.Equal(t, 33, len(tools)) + assert.Equal(t, 34, len(tools)) } func TestServerModeConfig_ToolDescriptions(t *testing.T) { @@ -1039,6 +1040,7 @@ func TestServerModeExternal_RegistersCorrectTools(t *testing.T) { assert.Contains(t, tools, "list_workspaces_kandev") assert.Contains(t, tools, "list_repositories_kandev") assert.Contains(t, tools, "create_workflow_kandev") + assert.Contains(t, tools, "export_workflow_kandev") assert.Contains(t, tools, "list_agents_kandev") assert.Contains(t, tools, "get_mcp_config_kandev") assert.Contains(t, tools, "list_executors_kandev") @@ -1080,9 +1082,9 @@ func TestServerModeExternal_ToolCount(t *testing.T) { s := New(backend, "", "", 0, log, "", true, ModeExternal) tools := getRegisteredToolNames(s) - // 12 workflow (incl. list_repositories + import_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency = 35. + // 13 workflow (incl. list_repositories + import_workflow + export_workflow) + 4 agent + 4 mcp + 5 executor + 7 task (incl. list_task_sessions) + 1 create_task + 2 task-dependency = 36. // add_branch_to_task_kandev is task-mode only — external coding agents have no live session to attach a worktree to. - assert.Equal(t, 35, len(tools)) + assert.Equal(t, 36, len(tools)) assert.NotContains(t, tools, "add_branch_to_task_kandev") } diff --git a/apps/backend/internal/mcp/server/server_workflow_export_test.go b/apps/backend/internal/mcp/server/server_workflow_export_test.go new file mode 100644 index 0000000000..4920d773d9 --- /dev/null +++ b/apps/backend/internal/mcp/server/server_workflow_export_test.go @@ -0,0 +1,26 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkflowExportTool_RegisteredOnlyOnConfigurationSurfaces(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + for _, mode := range []string{ModeConfig, ModeExternal} { + t.Run(mode, func(t *testing.T) { + s := New(backend, "test-session", "test-task", 10005, log, "", true, mode) + assert.Contains(t, s.mcpServer.ListTools(), "export_workflow_kandev") + }) + } + for _, mode := range []string{ModeTask, ModeOffice} { + t.Run(mode, func(t *testing.T) { + s := New(backend, "test-session", "test-task", 10005, log, "", true, mode) + assert.NotContains(t, s.mcpServer.ListTools(), "export_workflow_kandev") + }) + } +} diff --git a/apps/backend/internal/sysprompt/sysprompt_test.go b/apps/backend/internal/sysprompt/sysprompt_test.go index 8fdd625034..5d0bafd3b5 100644 --- a/apps/backend/internal/sysprompt/sysprompt_test.go +++ b/apps/backend/internal/sysprompt/sysprompt_test.go @@ -24,6 +24,7 @@ func TestConfigContext_ContainsAllTools(t *testing.T) { "create_workflow_kandev", "update_workflow_kandev", "delete_workflow_kandev", + "export_workflow_kandev", "list_workflow_steps_kandev", "create_workflow_step_kandev", "update_workflow_step_kandev", diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 778fbacb62..334cb39141 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -433,6 +433,7 @@ const ( ActionMCPUpdateWorkflow = "mcp.update_workflow" ActionMCPDeleteWorkflow = "mcp.delete_workflow" ActionMCPImportWorkflow = "mcp.import_workflow" + ActionMCPExportWorkflow = "mcp.export_workflow" ActionMCPCreateWorkflowStep = "mcp.create_workflow_step" ActionMCPUpdateWorkflowStep = "mcp.update_workflow_step" diff --git a/docs/plans/workflow-mcp-export/plan.md b/docs/plans/workflow-mcp-export/plan.md new file mode 100644 index 0000000000..f25d5f8026 --- /dev/null +++ b/docs/plans/workflow-mcp-export/plan.md @@ -0,0 +1,117 @@ +--- +spec: docs/specs/integrations/external-mcp.md +created: 2026-08-19 +status: done +--- + +# Implementation Plan: Workflow MCP Export + +## Overview + +Add one read-only MCP tool for a single workflow. Reuse the existing workflow export service and the current MCP JSON-text bridge. + +The change adds no new export format or persistence path. A second task updates the public MCP reference after the contract is complete. + +## Confirmed implementation path + +`workflow.Service.ExportWorkflow` already builds the complete `WorkflowExport` document. The backend wires its provider to the scoped task service. + +The MCP server already converts backend response objects to indented JSON text. `import_workflow_kandev` accepts that JSON text as its `document` argument. + +This path gives a direct round trip for documents within the existing 1 MiB import limit. No new serializer is necessary. + +## Backend + +### WebSocket action and dispatcher + +- `apps/backend/pkg/websocket/actions.go`: add `ActionMCPExportWorkflow = "mcp.export_workflow"` beside the workflow configuration actions. +- `apps/backend/internal/mcp/handlers/handlers.go`: register the action when the workflow service is available. + +### Backend export handler + +- `apps/backend/internal/mcp/handlers/config_workflow_handlers.go`: add `handleExportWorkflow`. +- Decode and require `workflow_id`. +- Call `h.workflowSvc.ExportWorkflow(ctx, workflowID)`. +- Return the `WorkflowExport` object through `ws.NewResponse`. +- Return a generic tool error for service errors. Do not include a partial document or internal error details. + +### MCP tool and bridge + +- `apps/backend/internal/mcp/server/config_handlers.go`: register `export_workflow_kandev` in the workflow configuration group. +- Define one required string argument named `workflow_id`. +- Add `exportWorkflowHandler` and forward the request through `ActionMCPExportWorkflow`. +- Keep the shared `forwardToBackend` conversion. It returns the export object as indented JSON MCP text. + +### Agent configuration context + +- `apps/backend/config/prompts/config-context.md`: list the export tool, its required argument, and its JSON result. +- State that the result can be the `document` input for `import_workflow_kandev`. + +## Frontend + +No frontend code changes are in scope. The existing static tool preview is not the live MCP contract. + +## Public documentation + +- `docs/public/automation-and-mcp.md`: add workflow export to the external MCP group and update the live tool count from 35 to 36. +- Keep the static-preview caveat. Add `export_workflow_kandev` to its list of omitted tools. +- `docs/public/coverage.json`: add `export_workflow_kandev` to the workflow MCP tool coverage list. + +Primary content type: reference. + +## Tests + +- **What:** The tool is registered on configuration and external surfaces, but not task or Office surfaces. + **File:** `apps/backend/internal/mcp/server/server_test.go`. + **How:** Inspect the registered tool names and update the exact surface counts. +- **What:** The server requires `workflow_id`, sends `mcp.export_workflow`, and returns the backend envelope as JSON text. + **File:** `apps/backend/internal/mcp/server/config_handlers_test.go`. + **How:** Call the registered tool against the existing backend stub. Decode its MCP text as `WorkflowExport`. +- **What:** The backend exports one complete portable workflow and does not change source state. + **File:** `apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go`. + **How:** Seed a workflow and steps through the existing test service. Call the backend handler and compare the portable fields. +- **What:** The exported MCP response can enter the existing import path unchanged. + **File:** `apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go`. + **How:** Export one workflow, use its JSON payload as the import document for another workspace, and compare imported workflow behavior. +- **What:** Missing identifiers and export service errors return tool errors without documents. + **Files:** `apps/backend/internal/mcp/server/config_handlers_test.go` and `apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go`. + **How:** Use missing arguments and an unknown workflow ID. +- **What:** The configuration context references a registered tool. + **File:** `apps/backend/internal/mcp/server/sysprompt_sync_test.go`. + **How:** Run the existing exact-name synchronization test after the context update. +- **What:** Public documentation remains structurally valid. + **Files:** `docs/public/automation-and-mcp.md` and `docs/public/coverage.json`. + **How:** Run both public-document validation scripts. + +No browser E2E test is necessary. The feature changes an MCP contract and agent context, not rendered UI behavior. + +## Verification Results + +- `go test ./internal/mcp/handlers ./internal/mcp/server ./pkg/websocket`: passed, 696 tests across 3 packages. +- PR fixup: the annotation and whitespace-ID regression tests failed before the fix and passed after it. +- `go test ./internal/mcp/handlers ./internal/mcp/server ./pkg/websocket`: passed, 698 tests across 3 packages after fixup. +- `go test ./internal/sysprompt`: passed. +- `make -C apps/backend lint`: passed with 0 issues after fixup. +- `node --test scripts/validate-public-docs.test.mjs`: passed 61 tests with 0 failures. +- `node scripts/validate-public-docs.mjs`: validated 41 published docs pages. +- `git diff --check`: passed. + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [Task 01: Expose workflow export through MCP](task-01-expose-workflow-export.md) - done. + +Wave 2: + +- [x] [Task 02: Document MCP workflow export](task-02-document-workflow-export.md) - done. + +Execution is sequential in the primary conversation. No subagents are authorized. + +## Risks and out of scope + +- The import handler rejects documents larger than 1 MiB. This plan does not change that limit. +- The existing exporter owns field fidelity. The MCP handler must not duplicate export assembly or add a second serializer. +- The existing workflow provider owns authorization. The MCP handler must pass the scoped request context unchanged. +- A workspace-level batch export tool is out of scope. +- The static External MCP settings preview remains incomplete and non-authoritative. diff --git a/docs/plans/workflow-mcp-export/task-01-expose-workflow-export.md b/docs/plans/workflow-mcp-export/task-01-expose-workflow-export.md new file mode 100644 index 0000000000..b313a23e4b --- /dev/null +++ b/docs/plans/workflow-mcp-export/task-01-expose-workflow-export.md @@ -0,0 +1,73 @@ +--- +id: "01-expose-workflow-export" +title: "Expose workflow export through MCP" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/integrations/external-mcp.md" +--- + +# Task 01: Expose Workflow Export Through MCP + +## Intent + +Add `export_workflow_kandev` to the configuration and external MCP surfaces. Reuse the existing single-workflow export service. + +## Acceptance + +- Configuration and external clients receive the new tool with one required `workflow_id` argument. Task and Office clients do not receive it. +- A successful call returns one version 1 `kandev_workflow` JSON document. The import handler accepts the text unchanged within its size limit. +- Missing, unknown, or inaccessible workflow IDs return tool errors without a partial document. + +## Verification + +```bash +cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./pkg/websocket +``` + +## Files likely touched + +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/config_workflow_handlers.go` +- `apps/backend/internal/mcp/handlers/config_workflow_handlers_test.go` +- `apps/backend/internal/mcp/server/config_handlers.go` +- `apps/backend/internal/mcp/server/config_handlers_test.go` +- `apps/backend/internal/mcp/server/server_test.go` +- `apps/backend/config/prompts/config-context.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. This task owns the MCP action, handler, registration, prompt contract, and focused backend tests. + +## Inputs + +- `docs/specs/integrations/external-mcp.md`, workflow export sections and scenarios. +- `docs/plans/workflow-mcp-export/plan.md`, Backend and Tests sections. +- The `import_workflow_kandev` action, handler, server bridge, and tests as the nearest pattern. +- `workflow.Service.ExportWorkflow` as the only workflow export assembly path. + +## Risks + +- Do not bypass the scoped request context. +- Do not create a second portable format or a new size limit. +- Keep internal service errors out of the MCP result. + +## Output contract + +Report the files changed, the red and green test results, remaining risks, and task status. Update this task and `plan.md` in the same conversation. + +## Results + +- Added `export_workflow_kandev` to the configuration and external MCP surfaces. +- Reused `workflow.Service.ExportWorkflow` and the existing JSON-text bridge. +- Added coverage for registration, portable output, unchanged import round trips, missing IDs, and service errors. +- Verification: `cd apps/backend && go test ./internal/mcp/handlers ./internal/mcp/server ./pkg/websocket` passed with 696 tests. +- PR fixup added read-only, non-destructive, idempotent, closed-world tool annotations; documented the 1 MiB import limit in the tool description; and rejected empty or whitespace-only IDs before forwarding. +- Fixup regression verification: the new annotation and whitespace-ID tests failed before the patch, then passed after it. +- Fixup verification: `go test ./internal/mcp/handlers ./internal/mcp/server ./pkg/websocket` passed with 698 tests and `make -C apps/backend lint` reported 0 issues. diff --git a/docs/plans/workflow-mcp-export/task-02-document-workflow-export.md b/docs/plans/workflow-mcp-export/task-02-document-workflow-export.md new file mode 100644 index 0000000000..70af929cfe --- /dev/null +++ b/docs/plans/workflow-mcp-export/task-02-document-workflow-export.md @@ -0,0 +1,62 @@ +--- +id: "02-document-workflow-export" +title: "Document MCP workflow export" +status: done +wave: 2 +depends_on: ["01-expose-workflow-export"] +plan: "plan.md" +spec: "../../specs/integrations/external-mcp.md" +--- + +# Task 02: Document MCP Workflow Export + +## Intent + +Update the public External MCP reference and coverage metadata for `export_workflow_kandev`. + +## Acceptance + +- The public reference lists workflow export and states the correct live external tool count. +- The static-preview caveat identifies the new tool as omitted and non-authoritative. +- Public coverage metadata includes `export_workflow_kandev`, and both document validation commands pass. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `docs/public/automation-and-mcp.md` +- `docs/public/coverage.json` + +## Dependencies + +Task 01 must fix the final tool name, surface, and result contract. + +## Parallelism + +Sequential. This task follows the backend contract and changes only public documentation. + +## Inputs + +- `docs/specs/integrations/external-mcp.md`, API surface and scenarios. +- `docs/plans/workflow-mcp-export/plan.md`, Public documentation section. +- The final registered tool counts from Task 01. + +## Risks + +- Do not present the static settings preview as the live tool contract. +- Do not document a workspace-level batch tool. + +## Output contract + +Report the files changed, exact document validation results, remaining risks, and task status. Update this task and `plan.md` in the same conversation. + +## Results + +- Updated `docs/public/automation-and-mcp.md` with the live external MCP count of 36, the workflow export contract, and the non-authoritative static-preview omission list. +- Added `export_workflow_kandev` to `docs/public/coverage.json`. +- Verification: `node --test scripts/validate-public-docs.test.mjs` passed 61 tests with 0 failures. +- Verification: `node scripts/validate-public-docs.mjs` validated 41 published docs pages. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 0b16dde005..2e7b536127 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -499,14 +499,16 @@ http://127.0.0.1:/mcp SSE compatibility uses `/mcp/sse` with messages sent to `/mcp/message`. A reverse proxy must support long-lived streaming connections. -External MCP exposes 33 tools in these groups: +External MCP exposes 36 tools in these groups: -- workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, or import workflows; create, update, delete, or reorder steps; +- workspace/workflow configuration: list workspaces, workflows, repositories, and workflow steps; create, update, delete, import, or export workflows; create, update, delete, or reorder steps; - agents and profiles: list/update agents; create/delete profiles; list/update profiles; get/update profile MCP configuration; - executors: list executors and profiles; create, update, or delete executor profiles; - tasks: list, create, move, delete, archive, or update task state; list a task's sessions; and read task conversation. -The settings page's static **Available tools** preview currently counts 30 and omits `list_repositories_kandev`, `import_workflow_kandev`, and `get_task_conversation_kandev`. Treat the client's live `tools/list` response from the endpoint, not that preview, as authoritative. +`export_workflow_kandev` takes `workflow_id` and returns one version 1 `kandev_workflow` JSON document. It omits instance IDs and timestamps. Pass its JSON text unchanged as `document` to `import_workflow_kandev` when it is within the existing 1 MiB import limit. + +The settings page's static **Available tools** preview currently counts 30 and omits `list_repositories_kandev`, `import_workflow_kandev`, `export_workflow_kandev`, and `get_task_conversation_kandev`. Treat the client's live `tools/list` response from the endpoint, not that preview, as authoritative. In external mode, `create_task_kandev` has no current task and does not accept the `parent_id: "self"` shorthand. Its registered top-level contract asks for a repository ID, repository URL (including a supported GitHub pull request or GitLab merge request URL), or local path; workspace and workflow resolve automatically only when unambiguous. The current handler can nevertheless accept an omitted repository and create repo-less work, which is a contract/implementation mismatch rather than a supported equivalent of the regular UI's **None** option. Supply an explicit repository locator for portable clients. A resolvable agent profile is required even with `start_agent: false`; otherwise `start_agent` defaults to true. To create a subtask, pass the full ID of an existing parent. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 94ea6b3d7c..f464434561 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -71,6 +71,7 @@ "create_workflow_step_kandev", "delete_workflow_kandev", "delete_workflow_step_kandev", + "export_workflow_kandev", "import_workflow_kandev", "list_workflow_steps_kandev", "list_workflows_kandev", diff --git a/docs/specs/integrations/external-mcp.md b/docs/specs/integrations/external-mcp.md index acc6bab78f..fa3fb24a98 100644 --- a/docs/specs/integrations/external-mcp.md +++ b/docs/specs/integrations/external-mcp.md @@ -17,9 +17,44 @@ Users want to operate on Kandev workspaces, workflows, agents, and tasks from co - Streamable HTTP: `http://localhost:38429/mcp` - SSE: `http://localhost:38429/mcp/sse` - The external endpoint exposes the **config-mode tool surface** plus `create_task_kandev` (no plan tools, no `ask_user_question_kandev`). -- The endpoint requires no authentication, matching the current Kandev app surface. +- The endpoint requires a personal access token when Kandev authentication is enabled. It remains open when authentication is disabled. - The Settings UI shows the URL and ready-to-paste config snippets for popular agents. - The existing per-`agentctl` MCP behavior is unchanged — the same tool definitions back both endpoints. +- The configuration and external tool surfaces expose `export_workflow_kandev`. +- `export_workflow_kandev` requires one `workflow_id` and returns a JSON `WorkflowExport` document as MCP text content. +- The document uses `version: 1` and `type: kandev_workflow`. It contains exactly one portable workflow and all of its steps. +- The document omits instance IDs and timestamps. It keeps portable profile data and position-based step references from the existing export service. +- A caller can pass the returned text unchanged as `document` to `import_workflow_kandev` when the document is within the import limit. +- Workflow export is read-only. It does not change the workflow, its steps, or its workspace. + +## API surface + +### `export_workflow_kandev` + +Available surfaces: configuration MCP and external MCP. + +Input: + +```json +{"workflow_id":"workflow-uuid"} +``` + +Success result: MCP text content that contains an indented JSON `WorkflowExport` document. + +The output uses the existing portable workflow contract. The tool does not define a second export format. + +## Permissions + +The tool uses the caller identity that Kandev attaches to the MCP request. The existing workflow service authorizes the workflow through its workspace. + +The tool does not return a workflow from another user's workspace. Authentication-disabled installations keep their current unscoped, single-user behavior. + +## Failure modes + +- A missing or empty `workflow_id` returns a tool error before backend export work starts. +- An unknown or inaccessible workflow returns a tool error and no partial document. +- A workflow or step read error returns a tool error and no partial document. +- `import_workflow_kandev` keeps its existing 1 MiB document limit. This feature does not add an export limit or change that import limit. ## Scenarios @@ -27,10 +62,21 @@ Users want to operate on Kandev workspaces, workflows, agents, and tasks from co - **GIVEN** the user is in a Cursor session outside Kandev, **WHEN** they ask Cursor to "create a task to fix the login bug in workspace X", **THEN** the task appears in the Kandev kanban board. - **GIVEN** the user opens Kandev Settings → External MCP, **WHEN** they click "Copy Claude Code config", **THEN** they receive a JSON snippet they can paste into `~/.claude.json`. - **GIVEN** an external agent calls a session-scoped tool that does not exist on this endpoint, **THEN** the call returns a tool-not-found error and the rest of the session is unaffected. +- **GIVEN** a caller can read a workflow, **WHEN** it calls `export_workflow_kandev` with that workflow ID, **THEN** it receives one valid portable workflow document with all workflow steps. +- **GIVEN** an exported document is within 1 MiB, **WHEN** the caller passes the result unchanged to `import_workflow_kandev`, **THEN** import accepts the document and applies existing deduplication rules. +- **GIVEN** a caller cannot read a workflow, **WHEN** it calls `export_workflow_kandev` with that workflow ID, **THEN** the call returns an error without workflow data. +- **GIVEN** a task or Office MCP surface, **WHEN** the client lists tools, **THEN** `export_workflow_kandev` is absent. ## Out of scope -- Authentication / bearer tokens / OAuth. +- New authentication methods or changes to personal access tokens. - Session-scoped tools (`create_task_plan_kandev`, `ask_user_question_kandev`, plan get/update/delete). - Per-workspace scoping of the endpoint. - Exposing `agentctl`'s per-session MCP externally — architectural mismatch (per-session, ephemeral, no auth boundary). +- A workspace-level `export_workflows_kandev` batch tool. +- Changes to the portable workflow format or the 1 MiB import limit. +- Changes to the existing HTTP export endpoints or workflow export UI. + +## Implementation plan + +See [Workflow MCP export](../../plans/workflow-mcp-export/plan.md).