feat: add workflow export MCP tool (#2796)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -499,14 +499,16 @@ http://127.0.0.1:<backend-port>/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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user