feat: add workflow export MCP tool (#2796)

This commit is contained in:
Carlos Florêncio
2026-08-19 10:32:40 +01:00
committed by GitHub
parent 957e6bbf7d
commit d976aec8ff
17 changed files with 555 additions and 9 deletions
@@ -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",
+1
View File
@@ -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"
+117
View File
@@ -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.
+5 -3
View File
@@ -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.
+1
View File
@@ -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",
+48 -2
View File
@@ -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).