From 265129ce8cac1af997b5f793ee5511fb631e4834 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 7 Jul 2026 11:54:43 +0000 Subject: [PATCH] feat(task T03): implement via codex --- docs-web/architecture/mcp-server.md | 9 + .../content/docs/architecture-mcp-server.mdx | 9 + .../docs/developer-management-actions.mdx | 2 +- docs-web/content/docs/developer-mcp-tools.mdx | 18 +- .../docs/developer-settings-reference.mdx | 1 + docs-web/content/docs/registry.ts | 4 +- docs-web/content/docs/user-mcp-clients.mdx | 1 + docs-web/developer/management-actions.md | 2 +- docs-web/developer/mcp-tools.md | 18 +- docs-web/developer/settings-reference.md | 1 + docs-web/user/mcp-clients.md | 1 + docs/mcp/runtime-and-dispatch.md | 13 +- docs/mcp/tools-and-contracts.md | 37 +- src/api/mcp/tool-registry.ts | 3 +- .../dependency-factory/dashboard-factory.ts | 1 + src/app/dependency-factory/mcp-factory.ts | 1 + src/contracts/internal-management-types.ts | 22 ++ src/contracts/mcp-tool-definitions.ts | 38 ++ src/mcp/management-tool-handler.ts | 20 + src/mcp/management/agent-actions.ts | 24 +- src/mcp/management/node-flow-actions.ts | 345 ++++++++++++++++++ src/mcp/management/payload-parsers.ts | 1 + src/server/mcp-request-router.ts | 1 + .../mcp/management-node-flow-actions.test.ts | 207 +++++++++++ tests/backend/mcp/mcp-management.test.ts | 87 +++++ .../backend/mcp/mcp-tool-availability.test.ts | 4 + tests/backend/mcp/tool-registry.test.ts | 44 +++ .../backend/services/agent-mcp-access.test.ts | 2 + 28 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 src/mcp/management/node-flow-actions.ts create mode 100644 tests/backend/mcp/management-node-flow-actions.test.ts diff --git a/docs-web/architecture/mcp-server.md b/docs-web/architecture/mcp-server.md index 6048df6bc3..e541acc031 100644 --- a/docs-web/architecture/mcp-server.md +++ b/docs-web/architecture/mcp-server.md @@ -95,6 +95,7 @@ router .register("manage_scheduler", h.handleManageScheduler) .register("scheduler_code_ux", h.handleScheduler) .register("manage_agents", h.handleManageAgents) + .register("manage_node_flows", h.handleManageNodeFlows) .register("manage_memory", h.handleManageMemory) .register("manage_skills", h.handleManageSkills) .register("manage_settings", h.handleManageSettings) @@ -157,6 +158,7 @@ Each tool has an entry in `settings.mcpTools` (`McpToolToggle[]`). Defaults: { "name": "manage_scheduler", "enabled": true, "isInternal": true }, { "name": "scheduler_code_ux", "enabled": true, "isInternal": true }, { "name": "manage_agents", "enabled": true, "isInternal": true }, + { "name": "manage_node_flows", "enabled": true, "isInternal": true }, { "name": "manage_memory", "enabled": true, "isInternal": true }, { "name": "manage_skills", "enabled": true, "isInternal": true }, { "name": "search_knowledge", "enabled": true, "isInternal": true }, @@ -197,6 +199,13 @@ skill markdown import/export, agent storage attachment management, and the autho `SkillActions`. `search_skills` is registered separately as a retrieval tool and returns concise ranked summaries with IDs and metadata. Full markdown retrieval stays behind `manage_skills`. +## Node flow dispatch + +`manage_node_flows` routes through `NodeFlowActions` and delegates to `NodeFlowService` for graph +validation, CRUD persistence, runtime execution, run inspection, and flow-backed agent skill +attachments. The MCP layer applies optional widget schemas into submitted graph specs and masks +secret-shaped graph/run fields in responses. + ## Connection registry The `ConnectionRegistry` tracks every MCP client that connects. Each entry records: diff --git a/docs-web/content/docs/architecture-mcp-server.mdx b/docs-web/content/docs/architecture-mcp-server.mdx index caf19f25f8..f8ba527302 100644 --- a/docs-web/content/docs/architecture-mcp-server.mdx +++ b/docs-web/content/docs/architecture-mcp-server.mdx @@ -95,6 +95,7 @@ router .register("manage_scheduler", h.handleManageScheduler) .register("scheduler_code_ux", h.handleScheduler) .register("manage_agents", h.handleManageAgents) + .register("manage_node_flows", h.handleManageNodeFlows) .register("manage_memory", h.handleManageMemory) .register("manage_skills", h.handleManageSkills) .register("manage_settings", h.handleManageSettings) @@ -157,6 +158,7 @@ Each tool has an entry in `settings.mcpTools` (`McpToolToggle[]`). Defaults: { "name": "manage_scheduler", "enabled": true, "isInternal": true }, { "name": "scheduler_code_ux", "enabled": true, "isInternal": true }, { "name": "manage_agents", "enabled": true, "isInternal": true }, + { "name": "manage_node_flows", "enabled": true, "isInternal": true }, { "name": "manage_memory", "enabled": true, "isInternal": true }, { "name": "manage_skills", "enabled": true, "isInternal": true }, { "name": "search_knowledge", "enabled": true, "isInternal": true }, @@ -197,6 +199,13 @@ skill markdown import/export, agent storage attachment management, and the autho `SkillActions`. `search_skills` is registered separately as a retrieval tool and returns concise ranked summaries with IDs and metadata. Full markdown retrieval stays behind `manage_skills`. +## Node flow dispatch + +`manage_node_flows` routes through `NodeFlowActions` and delegates to `NodeFlowService` for graph +validation, CRUD persistence, runtime execution, run inspection, and flow-backed agent skill +attachments. The MCP layer applies optional widget schemas into submitted graph specs and masks +secret-shaped graph/run fields in responses. + ## Connection registry The `ConnectionRegistry` tracks every MCP client that connects. Each entry records: diff --git a/docs-web/content/docs/developer-management-actions.mdx b/docs-web/content/docs/developer-management-actions.mdx index 6c0b6b93c7..71ce5e916e 100644 --- a/docs-web/content/docs/developer-management-actions.mdx +++ b/docs-web/content/docs/developer-management-actions.mdx @@ -1,7 +1,7 @@ # Management actions Code UX exposes **one MCP tool per management domain** — `manage_projects`, `manage_sprints`, -`manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_agents`, `manage_memory`, +`manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_agents`, `manage_node_flows`, `manage_memory`, `manage_settings`, `manage_preview`, `manage_chat_providers`, and `manage_telemetry` — each with a set of **actions**. This page is the complete matrix. (See [MCP tools](/docs/developer-mcp-tools) for the tool list and schemas.) diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index 75a15ea11b..39d3ec7565 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -2,7 +2,7 @@ Code UX is also an MCP server. When connected, it advertises a set of **management tools** that an MCP client (or another agent) can call to drive projects, sprints, tasks, agents, memory, persistent -skills, settings, previews, chat connectors, and telemetry. This page is the exact contract: the tool list, each +skills, node flows, settings, previews, chat connectors, and telemetry. This page is the exact contract: the tool list, each tool's `action` enum, input shape, approval rules, and the error model. > **Server identity:** the server identifies as `code-ux`, with the version matching the installed @@ -47,6 +47,7 @@ action-specific fields, and an optional `approval` object for destructive action | `manage_scheduler` | orchestration | Create and run scheduled sprints, quicksprints, and messages. | | `scheduler_code_ux` | orchestration | Agent-owned wakeups and task reruns with restricted list/schedule/cancel actions. | | `manage_agents` | agents & memory | Manage agent presets and sync them to project markdown. | +| `manage_node_flows` | agents & memory | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | agents & memory | Inspect, search, promote, and re-embed short/long-term memory. | | `manage_skills` | agents & memory | Manage persistent skill storages, skill markdown, and agent storage attachments. | | `search_knowledge` | agents & memory | Semantic search over the knowledge base subscribed to the caller. | @@ -69,6 +70,7 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_scheduler` | `list`, `create`, `update`, `delete`, `run_due`, `schedule_sprint`, `schedule_quicksprint`, `schedule_chat` | | `scheduler_code_ux` | `list`, `schedule_wakeup`, `schedule_task`, `cancel` | | `manage_agents` | `list`, `get`, `create`, `update`, `delete`, `sync` | +| `manage_node_flows` | `list`, `get`, `create`, `update`, `delete`, `validate`, `run`, `list_runs`, `get_run`, `attach_to_agent`, `detach_from_agent` | | `manage_memory` | `list`, `get`, `count`, `create`, `update`, `delete`, `search`, `promote`, `get_map`, `model_status`, `start_reembed` | | `manage_skills` | `authoring_prompt`, `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`, `reset_storage`, `list_agent_storages`, `attach_storage`, `detach_storage`, `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`, `import_markdown`, `export_markdown` | | `manage_settings` | `get_system`, `get_project_override`, `resolve_project_effective`, `get_sprint_override`, `resolve_sprint_effective`, `replace_system_settings`, `patch_system_setting`, `replace_project_settings`, `patch_project_setting`, `reset_project_settings`, `replace_sprint_settings`, `patch_sprint_setting`, `reset_sprint_settings`, `export_settings_bundle`, `apply_settings_bundle` | @@ -98,6 +100,20 @@ entries created through `manage_scheduler`, or entries created by another agent. does not expose `run_due`, arbitrary updates, recurrence editing, sprint or quicksprint scheduling, memory remediation, or global scheduler destructive controls. +## Node flows + +`manage_node_flows` exposes project node workflows through MCP. It supports graph validation, CRUD, +runtime execution, run inspection, and flow-backed agent skill attachments. + +Create and update calls validate the structured graph before repository writes. `run` delegates to the +node-flow runtime through `NodeFlowService.runFlow`, and `delete` requires the normal approval +handshake. Responses mask secret-shaped graph data, inputs, and outputs before returning them to MCP +clients. + +Agents should build Code UX-adapted node flows rather than cloning n8n workflows one-to-one. Graphs +should include dynamic widget schemas for editable graph inputs and node fields; callers can provide +`widgets` as a graph-level `{ fields: [...] }` schema or as node-id keys mapped to node widget schemas. + ## Approval handshake (destructive actions) Destructive and mutating actions require a two-step confirmation. The first call returns an approval diff --git a/docs-web/content/docs/developer-settings-reference.mdx b/docs-web/content/docs/developer-settings-reference.mdx index b53ea8ae4c..6d4b9cdf7a 100644 --- a/docs-web/content/docs/developer-settings-reference.mdx +++ b/docs-web/content/docs/developer-settings-reference.mdx @@ -292,6 +292,7 @@ Both reflection loops are disabled by default. When enabled, planning and QA str { "name": "manage_quicksprints", "enabled": true, "isInternal": true }, { "name": "manage_scheduler", "enabled": true, "isInternal": true }, { "name": "manage_agents", "enabled": true, "isInternal": true }, + { "name": "manage_node_flows", "enabled": true, "isInternal": true }, { "name": "manage_memory", "enabled": true, "isInternal": true }, { "name": "search_knowledge", "enabled": true, "isInternal": true }, { "name": "manage_settings", "enabled": true, "isInternal": true }, diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index 8127c785e3..95f5482e41 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -248,14 +248,14 @@ export const docsRegistry: Record = { path: '/docs/developer-mcp-tools', section: 'Developer Reference', title: "MCP tools", - description: "Code UX is also an MCP server. When connected, it advertises a set of management tools that an MCP client (or another agent) can call to drive projects, sprints, tasks, agents, memory, persistent skills, settings, pre...", + description: "Code UX is also an MCP server. When connected, it advertises a set of management tools that an MCP client (or another agent) can call to drive projects, sprints, tasks, agents, node flows, memory, persistent skills...", }, 'developer-management-actions': { id: 'developer-management-actions', path: '/docs/developer-management-actions', section: 'Developer Reference', title: "Management actions", - description: "Code UX exposes one MCP tool per management domain — manage_projects, manage_sprints, manage_tasks, manage_quicksprints, manage_scheduler, manage_agents, manage_memory, manage_settings, manage_preview, manage_chat_pro...", + description: "Code UX exposes one MCP tool per management domain — manage_projects, manage_sprints, manage_tasks, manage_quicksprints, manage_scheduler, manage_agents, manage_node_flows, manage_memory, manage_settings, manage_preview...", }, 'developer-http-api': { id: 'developer-http-api', diff --git a/docs-web/content/docs/user-mcp-clients.mdx b/docs-web/content/docs/user-mcp-clients.mdx index e55a9cb534..fa5c95c46d 100644 --- a/docs-web/content/docs/user-mcp-clients.mdx +++ b/docs-web/content/docs/user-mcp-clients.mdx @@ -125,6 +125,7 @@ domain**, plus `search_knowledge`: | `manage_quicksprints` | Manage quicksprint templates and execute them. | | `manage_scheduler` | Create and run scheduled sprints, quicksprints, and messages. | | `manage_agents` | Manage agent presets and sync them to project markdown. | +| `manage_node_flows` | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | Inspect, search, promote, and re-embed memory. | | `search_knowledge` | Semantic search over the caller's subscribed knowledge base. | | `manage_settings` | Get/resolve/patch/replace/reset system, project, and sprint settings. | diff --git a/docs-web/developer/management-actions.md b/docs-web/developer/management-actions.md index 1891f01354..6fe3f40921 100644 --- a/docs-web/developer/management-actions.md +++ b/docs-web/developer/management-actions.md @@ -1,7 +1,7 @@ # Management actions Code UX exposes **one MCP tool per management domain** — `manage_projects`, `manage_sprints`, -`manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_agents`, `manage_memory`, +`manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_agents`, `manage_node_flows`, `manage_memory`, `manage_settings`, `manage_preview`, `manage_chat_providers`, and `manage_telemetry` — each with a set of **actions**. This page is the complete matrix. (See [MCP tools](/docs/developer-mcp-tools) for the tool list and schemas.) diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index 2148ebc603..6e631d2b63 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -2,7 +2,7 @@ Code UX is also an MCP server. When connected, it advertises a set of **management tools** that an MCP client (or another agent) can call to drive projects, sprints, tasks, agents, memory, persistent -skills, settings, previews, chat connectors, and telemetry. This page is the exact contract: the tool list, each +skills, node flows, settings, previews, chat connectors, and telemetry. This page is the exact contract: the tool list, each tool's `action` enum, input shape, approval rules, and the error model. > **Server identity:** the server identifies as `code-ux`, with the version matching the installed @@ -47,6 +47,7 @@ action-specific fields, and an optional `approval` object for destructive action | `manage_scheduler` | orchestration | Create and run scheduled sprints, quicksprints, and messages. | | `scheduler_code_ux` | orchestration | Agent-owned wakeups and task reruns with restricted list/schedule/cancel actions. | | `manage_agents` | agents & memory | Manage agent presets and sync them to project markdown. | +| `manage_node_flows` | agents & memory | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | agents & memory | Inspect, search, promote, and re-embed short/long-term memory. | | `manage_skills` | agents & memory | Manage persistent skill storages, skill markdown, and agent storage attachments. | | `search_knowledge` | agents & memory | Semantic search over the knowledge base subscribed to the caller. | @@ -69,6 +70,7 @@ Every tool requires `runtimeRoles: ["project_manager"]` and is enabled by defaul | `manage_scheduler` | `list`, `create`, `update`, `delete`, `run_due`, `schedule_sprint`, `schedule_quicksprint`, `schedule_chat` | | `scheduler_code_ux` | `list`, `schedule_wakeup`, `schedule_task`, `cancel` | | `manage_agents` | `list`, `get`, `create`, `update`, `delete`, `sync` | +| `manage_node_flows` | `list`, `get`, `create`, `update`, `delete`, `validate`, `run`, `list_runs`, `get_run`, `attach_to_agent`, `detach_from_agent` | | `manage_memory` | `list`, `get`, `count`, `create`, `update`, `delete`, `search`, `promote`, `get_map`, `model_status`, `start_reembed` | | `manage_skills` | `authoring_prompt`, `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`, `reset_storage`, `list_agent_storages`, `attach_storage`, `detach_storage`, `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`, `import_markdown`, `export_markdown` | | `manage_settings` | `get_system`, `get_project_override`, `resolve_project_effective`, `get_sprint_override`, `resolve_sprint_effective`, `replace_system_settings`, `patch_system_setting`, `replace_project_settings`, `patch_project_setting`, `reset_project_settings`, `replace_sprint_settings`, `patch_sprint_setting`, `reset_sprint_settings`, `export_settings_bundle`, `apply_settings_bundle` | @@ -98,6 +100,20 @@ entries created through `manage_scheduler`, or entries created by another agent. does not expose `run_due`, arbitrary updates, recurrence editing, sprint or quicksprint scheduling, memory remediation, or global scheduler destructive controls. +## Node flows + +`manage_node_flows` exposes project node workflows through MCP. It supports graph validation, CRUD, +runtime execution, run inspection, and flow-backed agent skill attachments. + +Create and update calls validate the structured graph before repository writes. `run` delegates to the +node-flow runtime through `NodeFlowService.runFlow`, and `delete` requires the normal approval +handshake. Responses mask secret-shaped graph data, inputs, and outputs before returning them to MCP +clients. + +Agents should build Code UX-adapted node flows rather than cloning n8n workflows one-to-one. Graphs +should include dynamic widget schemas for editable graph inputs and node fields; callers can provide +`widgets` as a graph-level `{ fields: [...] }` schema or as node-id keys mapped to node widget schemas. + ## Approval handshake (destructive actions) Destructive and mutating actions require a two-step confirmation. The first call returns an approval diff --git a/docs-web/developer/settings-reference.md b/docs-web/developer/settings-reference.md index b53ea8ae4c..6d4b9cdf7a 100644 --- a/docs-web/developer/settings-reference.md +++ b/docs-web/developer/settings-reference.md @@ -292,6 +292,7 @@ Both reflection loops are disabled by default. When enabled, planning and QA str { "name": "manage_quicksprints", "enabled": true, "isInternal": true }, { "name": "manage_scheduler", "enabled": true, "isInternal": true }, { "name": "manage_agents", "enabled": true, "isInternal": true }, + { "name": "manage_node_flows", "enabled": true, "isInternal": true }, { "name": "manage_memory", "enabled": true, "isInternal": true }, { "name": "search_knowledge", "enabled": true, "isInternal": true }, { "name": "manage_settings", "enabled": true, "isInternal": true }, diff --git a/docs-web/user/mcp-clients.md b/docs-web/user/mcp-clients.md index 031baf488b..1825ec27db 100644 --- a/docs-web/user/mcp-clients.md +++ b/docs-web/user/mcp-clients.md @@ -125,6 +125,7 @@ domain**, plus `search_knowledge`: | `manage_quicksprints` | Manage quicksprint templates and execute them. | | `manage_scheduler` | Create and run scheduled sprints, quicksprints, and messages. | | `manage_agents` | Manage agent presets and sync them to project markdown. | +| `manage_node_flows` | Manage reusable node workflows, run them, and attach them as agent skills. | | `manage_memory` | Inspect, search, promote, and re-embed memory. | | `search_knowledge` | Semantic search over the caller's subscribed knowledge base. | | `manage_settings` | Get/resolve/patch/replace/reset system, project, and sprint settings. | diff --git a/docs/mcp/runtime-and-dispatch.md b/docs/mcp/runtime-and-dispatch.md index aa143a3697..e9fdcaf597 100644 --- a/docs/mcp/runtime-and-dispatch.md +++ b/docs/mcp/runtime-and-dispatch.md @@ -95,7 +95,7 @@ This allows all log lines emitted during a tool call to share a single `correlat - Defines strict argument interfaces for every MCP tool. - Provides `register` and `dispatch` APIs with compile-time tool/argument matching. - Management dispatch target: `ManagementToolHandler` - - Routes dedicated management tools such as `manage_projects`, `manage_memory`, and `manage_skills` to domain action classes. + - Routes dedicated management tools such as `manage_projects`, `manage_memory`, `manage_node_flows`, and `manage_skills` to domain action classes. - Routes retrieval tools such as `search_knowledge` and `search_skills` separately, so agents can receive retrieval without broader management authority. - Applies stateful approval fingerprints to destructive management actions before mutation. - Core dispatch target: `CoreToolHandler` @@ -115,6 +115,17 @@ Runtime behavior: - Search scoping is project-owned. `storageId` limits retrieval to one storage; otherwise `agentPresetId` limits retrieval to the agent's attached storages; otherwise all project storages are eligible. - Search results return ranked summaries with IDs and metadata. Full markdown retrieval remains behind `manage_skills` (`export_markdown` or `get_skill` with `includeContent: true`). +## Node Flow Tools + +`manage_node_flows` uses `NodeFlowService` as the MCP backend boundary. The action layer parses MCP payloads, applies optional widget schemas into the graph, masks secret-shaped response fields, and delegates graph validation, persistence, run inspection, runtime execution, and agent skill attachments to the service. + +Runtime behavior: + +- `create` and `update` validate graph specs before repository writes. +- `run` calls the configured node-flow runtime through `NodeFlowService.runFlow`. +- `delete` uses the same stateful approval handshake as other destructive management actions. +- `attach_to_agent` and `detach_from_agent` manage flow-backed skill attachments for agent presets; the agent still needs explicit MCP access if it should call `manage_node_flows` itself. + ## Custom MCP Defaults Dashboard settings include custom MCP servers that local CLI providers may receive at execution time. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 03ba55d266..b66e1f0e02 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -16,6 +16,7 @@ These cover: - `manage_scheduler` - `scheduler_code_ux` - `manage_agents` +- `manage_node_flows` - `manage_memory` - `manage_skills` - `search_knowledge` @@ -53,6 +54,7 @@ These cover: - `manage_scheduler` - `scheduler_code_ux` - `manage_agents` +- `manage_node_flows` - `manage_memory` - `manage_skills` - `search_knowledge` @@ -257,7 +259,40 @@ For payload normalization in management tools, Code UX centralizes parsing behav - **Validation Errors**: Parser failures throw `ManagementValidationError`, which the management tool handler serializes as the standardized `result.status: "error"` envelope with `errorType: "validation"` and `isError: true`. -The dedicated management tools (`manage_sprints`, `manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_settings`) share the same action handlers. +The dedicated management tools (`manage_sprints`, `manage_tasks`, `manage_quicksprints`, `manage_scheduler`, `manage_node_flows`, `manage_settings`) share the same action handlers. + +## Node Flow Tools + +`manage_node_flows` exposes project node workflows through the project-manager MCP surface. It supports `list`, `get`, `create`, `update`, `delete`, `validate`, `run`, `list_runs`, `get_run`, `attach_to_agent`, and `detach_from_agent`. + +Node-flow management always delegates graph validation and persistence to `NodeFlowService`; `run` delegates execution through the configured node-flow runtime service. Create and update calls reject malformed graph specs before repository writes. `delete` uses the standard stateful approval handshake. + +Agents should build Code UX-adapted flows from structured graph specs instead of cloning n8n workflows one-to-one. Graphs should include widget schemas for editable inputs and node fields; MCP callers can provide `widgets` as a graph-level `{ fields: [...] }` schema or as node-id keys mapped to each node's widget schema. Flow and run responses mask secret-shaped graph data, inputs, and outputs before returning them through MCP. + +Attach a flow as an agent skill: + +```json +{ + "action": "attach_to_agent", + "flowId": "flow-123", + "agentPresetId": "agent-123", + "skillAlias": "Review automation", + "description": "Runs the reusable review node flow." +} +``` + +Run a flow: + +```json +{ + "action": "run", + "projectId": "project-123", + "flowId": "flow-123", + "input": { + "prompt": "Review the current diff" + } +} +``` ### `manage_skills` persistent skill actions diff --git a/src/api/mcp/tool-registry.ts b/src/api/mcp/tool-registry.ts index 5440eda2f3..05f1c2e2ec 100644 --- a/src/api/mcp/tool-registry.ts +++ b/src/api/mcp/tool-registry.ts @@ -1,5 +1,5 @@ import type { ToolName as ContractToolName } from "../../contracts/mcp-tool-definitions.js"; -import type { ManageCodeUxArgs, ManageProjectsArgs, ManageSprintsArgs, ManageTasksArgs, ManageQuicksprintsArgs, ManageSchedulerArgs, SchedulerArgs, ManageAgentsArgs, ManageMemoryArgs, ManageSkillsArgs, ManageSettingsArgs, ManagePreviewArgs, ManageChatProvidersArgs, ManageTelemetryArgs, SearchKnowledgeArgs, SearchSkillsArgs } from "../../contracts/internal-management-types.js"; +import type { ManageCodeUxArgs, ManageProjectsArgs, ManageSprintsArgs, ManageTasksArgs, ManageQuicksprintsArgs, ManageSchedulerArgs, SchedulerArgs, ManageAgentsArgs, ManageNodeFlowsArgs, ManageMemoryArgs, ManageSkillsArgs, ManageSettingsArgs, ManagePreviewArgs, ManageChatProvidersArgs, ManageTelemetryArgs, SearchKnowledgeArgs, SearchSkillsArgs } from "../../contracts/internal-management-types.js"; import type { PullWorkerTaskDispatchArgs, RegisterExternalWorkerEndpointArgs, UpdateWorkerTaskDispatchArgs } from "../../services/worker-task-dispatch-service.js"; export interface McpToolArgsByName { @@ -11,6 +11,7 @@ export interface McpToolArgsByName { manage_scheduler: ManageSchedulerArgs; scheduler_code_ux: SchedulerArgs; manage_agents: ManageAgentsArgs; + manage_node_flows: ManageNodeFlowsArgs; manage_memory: ManageMemoryArgs; manage_skills: ManageSkillsArgs; manage_settings: ManageSettingsArgs; diff --git a/src/app/dependency-factory/dashboard-factory.ts b/src/app/dependency-factory/dashboard-factory.ts index a0637d95c9..db41e41755 100644 --- a/src/app/dependency-factory/dashboard-factory.ts +++ b/src/app/dependency-factory/dashboard-factory.ts @@ -105,6 +105,7 @@ export function createDashboardDependencies( memoryPromotionService: coreDeps.memoryPromotionService, embeddingModelManager: coreDeps.embeddingModelManager, skillService: coreDeps.skillService, + nodeFlowService: coreDeps.nodeFlowService, knowledgeService: coreDeps.knowledgeService, planningAgentService: planningAgentServiceRef, projectSetupService: projectSetupServiceRef, diff --git a/src/app/dependency-factory/mcp-factory.ts b/src/app/dependency-factory/mcp-factory.ts index 45b127fb20..a80c12b992 100644 --- a/src/app/dependency-factory/mcp-factory.ts +++ b/src/app/dependency-factory/mcp-factory.ts @@ -45,6 +45,7 @@ export function createMcpDependencies( memoryPromotionService: coreDeps.memoryPromotionService, embeddingModelManager: coreDeps.embeddingModelManager, skillService: coreDeps.skillService, + nodeFlowService: dashboardDeps.nodeFlowService, knowledgeService: coreDeps.knowledgeService, planningAgentService: dashboardDeps.planningAgentService, projectSetupService: dashboardDeps.projectSetupService, diff --git a/src/contracts/internal-management-types.ts b/src/contracts/internal-management-types.ts index 948479945d..7910edb9dd 100644 --- a/src/contracts/internal-management-types.ts +++ b/src/contracts/internal-management-types.ts @@ -1,4 +1,5 @@ import type { ProviderId } from "./app-types.js"; +import type { AgentMcpAccessConfig } from "./agent-preset-types.js"; import type { ChatProviderBridgeMode, ChatProviderConnectionStatus, @@ -9,6 +10,7 @@ import type { ChatProviderRoutingHints, ExternalChannelMetadata, } from "./chat-provider-types.js"; +import type { NodeFlowGraph, NodeFlowJsonObject, NodeWidgetSchema } from "./node-flow-types.js"; import type { CreateProjectInput } from "./project-management-types.js"; export interface ManagementApproval { @@ -164,14 +166,34 @@ export interface ManageAgentsArgs { projectId?: string; presetId?: string; name?: string; + description?: string; instructionMarkdown?: string; labels?: string[]; avatarConfig?: Record; + providerConfigId?: string | null; + model?: string | null; + memoryConfig?: Record; + mcpAccess?: AgentMcpAccessConfig; memoryTemplateOverrideEnabled?: boolean; memoryTemplateMarkdown?: string; approval?: ManagementApproval; } +export interface ManageNodeFlowsArgs { + action: "list" | "get" | "create" | "update" | "delete" | "validate" | "run" | "list_runs" | "get_run" | "attach_to_agent" | "detach_from_agent"; + projectId?: string; + flowId?: string; + runId?: string; + name?: string; + description?: string; + graph?: NodeFlowGraph; + widgets?: NodeWidgetSchema | Record; + input?: NodeFlowJsonObject; + agentPresetId?: string; + skillAlias?: string; + approval?: ManagementApproval; +} + export interface ManageMemoryArgs { action: "search" | "list" | "get" | "create" | "update" | "delete" | "promote" | "start_reembed" | "get_map" | "count" | "model_status" | "create_claim" | "list_claims" | "get_claim" | "update_claim" | "add_claim_evidence" | "deprecate_claim"; projectId?: string; diff --git a/src/contracts/mcp-tool-definitions.ts b/src/contracts/mcp-tool-definitions.ts index b649f6b494..52c84e768c 100644 --- a/src/contracts/mcp-tool-definitions.ts +++ b/src/contracts/mcp-tool-definitions.ts @@ -276,9 +276,18 @@ export const TOOL_DEFINITIONS = [ projectId: { type: "string", description: "Required for list, get, sync, create, update, delete." }, presetId: { type: "string", description: "Required for get, update, delete." }, name: { type: "string", description: "Required for create, optional for update." }, + description: { type: "string", description: "Optional for create, update." }, instructionMarkdown: { type: "string", description: "Optional for create, update." }, labels: { type: "array", items: { type: "string" }, description: "Optional for create, update." }, avatarConfig: { type: "object", additionalProperties: true, description: "Optional for create, update." }, + providerConfigId: { type: ["string", "null"], description: "Optional provider config id for create, update." }, + model: { type: ["string", "null"], description: "Optional model override for create, update." }, + memoryConfig: { type: "object", additionalProperties: true, description: "Optional memory injection config for create, update." }, + mcpAccess: { + type: "object", + additionalProperties: true, + description: "Optional per-agent MCP access config for create, update. Use codeUxEnabled plus codeUxToolToggles to grant tools such as manage_node_flows without replacing unrelated agent settings.", + }, memoryTemplateOverrideEnabled: { type: "boolean", description: "Optional for create, update." }, memoryTemplateMarkdown: { type: "string", description: "Optional for create, update." }, approval: { @@ -291,6 +300,35 @@ export const TOOL_DEFINITIONS = [ required: ["action"], }, }, + { + name: "manage_node_flows", + runtimeRoles: ["project_manager"], + category: "agents_memory", + description: "Manage Code UX node flows as reusable agent skills. Supports list, get, create, update, delete, validate, run, list_runs, get_run, attach_to_agent, and detach_from_agent. Build Code UX-adapted flows with typed graph nodes and dynamic widget schemas for editable fields; do not clone n8n workflows one-to-one. Deleting flows requires approval confirmation.", + inputSchema: { + type: "object", + properties: { + action: { type: "string", enum: ["list", "get", "create", "update", "delete", "validate", "run", "list_runs", "get_run", "attach_to_agent", "detach_from_agent"], description: "The node-flow action to perform." }, + projectId: { type: "string", description: "Required for list, create, run, and validation of new graph specs." }, + flowId: { type: "string", description: "Required for get, update, delete, run, list_runs, attach_to_agent, and detach_from_agent." }, + runId: { type: "string", description: "Required for get_run." }, + name: { type: "string", description: "Required for create. Optional title for update." }, + description: { type: "string", description: "Optional flow or attached skill description." }, + graph: { type: "object", additionalProperties: true, description: "Structured node-flow graph. Required for create and standalone validate. Optional for update and validate existing flow." }, + widgets: { type: "object", additionalProperties: true, description: "Optional dynamic widget schemas for editable graph inputs or node fields. Use a fields array for graph inputSchema or node-id keys for node widgetSchema entries." }, + input: { type: "object", additionalProperties: true, description: "Optional JSON object passed to run." }, + agentPresetId: { type: "string", description: "Required for attach_to_agent and detach_from_agent." }, + skillAlias: { type: "string", description: "Optional display name for the attached node-flow skill." }, + approval: { + type: "object", + properties: { + confirmed: { type: "boolean" }, + }, + }, + }, + required: ["action"], + }, + }, { name: "manage_memory", runtimeRoles: ["project_manager"], diff --git a/src/mcp/management-tool-handler.ts b/src/mcp/management-tool-handler.ts index 22492c6bbf..b32f6cf3c7 100644 --- a/src/mcp/management-tool-handler.ts +++ b/src/mcp/management-tool-handler.ts @@ -8,6 +8,7 @@ import type { ManageSchedulerArgs, SchedulerArgs, ManageAgentsArgs, + ManageNodeFlowsArgs, ManageMemoryArgs, ManageSkillsArgs, ManageSettingsArgs, @@ -38,6 +39,7 @@ import type { WorkerTaskDispatchService, } from "../services/worker-task-dispatch-service.js"; import type { SkillService } from "../services/skill-service.js"; +import type { NodeFlowService } from "../services/node-flow-service.js"; import type { PlanningAgentService } from "../services/planning-agent-service.js"; import type { ProjectSetupService } from "../services/project-setup-service.js"; @@ -58,6 +60,7 @@ import { SchedulerActions } from "./management/scheduler-actions.js"; import { AgentSchedulerActions } from "./management/agent-scheduler-actions.js"; import { SettingsActions } from "./management/settings-actions.js"; import { AgentActions } from "./management/agent-actions.js"; +import { NodeFlowActions } from "./management/node-flow-actions.js"; import { MemoryActions } from "./management/memory-actions.js"; import { SkillActions } from "./management/skill-actions.js"; import { ChatProviderActions } from "./management/chat-provider-actions.js"; @@ -78,6 +81,7 @@ export interface ManagementToolHandlerDeps { memoryPromotionService: MemoryPromotionService; embeddingModelManager: EmbeddingModelManager; skillService: SkillService; + nodeFlowService: NodeFlowService; knowledgeService: KnowledgeService; planningAgentService: LateBoundOrValue; projectSetupService?: LateBoundOrValue; @@ -96,6 +100,7 @@ export class ManagementToolHandler { private readonly pendingDestructiveApprovals = new Map(); private readonly settingsActions: SettingsActions; private readonly agentActions: AgentActions; + private readonly nodeFlowActions: NodeFlowActions; private readonly memoryActions: MemoryActions; private readonly skillActions: SkillActions; private readonly previewActions: PreviewActions; @@ -104,6 +109,7 @@ export class ManagementToolHandler { constructor(private readonly deps: ManagementToolHandlerDeps) { this.settingsActions = new SettingsActions(deps.settingsRepository); this.agentActions = new AgentActions(deps.agentPresetSyncService); + this.nodeFlowActions = new NodeFlowActions(deps.nodeFlowService); this.memoryActions = new MemoryActions(deps.memoryService, deps.memoryPromotionService, deps.embeddingModelManager); this.skillActions = new SkillActions(deps.skillService); this.previewActions = new PreviewActions(deps.sprintPreviewService); @@ -280,6 +286,8 @@ export class ManagementToolHandler { return this.settingsActions.handleSettingsAction(args); } else if (args.domain === "agents") { return this.agentActions.handleAgentAction(args); + } else if (args.domain === "node_flows") { + return this.nodeFlowActions.handleNodeFlowAction(args); } else if (args.domain === "memory") { return this.memoryActions.handleMemoryAction(args); } else if (args.domain === "skills") { @@ -410,6 +418,18 @@ export class ManagementToolHandler { } } + async handleManageNodeFlows(args: ManageNodeFlowsArgs): Promise<{ content: Array<{ type: string; text: string }> }> { + try { + const managementArgs = { domain: "node_flows", action: args.action, payload: args as unknown as Record, approval: args.approval }; + const dispatch = (approval = args.approval) => this.nodeFlowActions.handleNodeFlowAction({ ...managementArgs, approval }); + const approvalGate = await this.requireStatefulApproval(managementArgs, () => dispatch({ confirmed: false })); + const envelope = approvalGate ?? this.recordStatefulApprovalRequirement(managementArgs, await dispatch()); + return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] }; + } catch (error) { + return this.formatError("node_flows", args.action, error); + } + } + async handleManageMemory(args: ManageMemoryArgs): Promise<{ content: Array<{ type: string; text: string }> }> { try { const managementArgs = { domain: "memory", action: args.action, payload: args as unknown as Record, approval: args.approval }; diff --git a/src/mcp/management/agent-actions.ts b/src/mcp/management/agent-actions.ts index 352e401efa..e23027ef08 100644 --- a/src/mcp/management/agent-actions.ts +++ b/src/mcp/management/agent-actions.ts @@ -1,14 +1,19 @@ import type { ManageCodeUxArgs, ManagementResponseEnvelope } from "../../contracts/internal-management-types.js"; import type { AgentPresetSyncService } from "../../services/agent-preset-sync-service.js"; -import type { AgentAvatarConfig } from "../../contracts/agent-preset-types.js"; -import { parseRequiredString, parseOptionalString, parseOptionalStringArray, parseOptionalBoolean, parseOptionalObject } from "./payload-parsers.js"; +import type { AgentAvatarConfig, AgentMcpAccessConfig, AgentMemoryConfig } from "../../contracts/agent-preset-types.js"; +import { parseRequiredString, parseOptionalString, parseOptionalStringArray, parseOptionalBoolean, parseOptionalObject, parseOptionalNullableString } from "./payload-parsers.js"; interface UpdateAgentInput { name?: string; + description?: string; instructionMarkdown?: string; labels?: string[]; avatarConfig?: AgentAvatarConfig; + providerConfigId?: string | null; + model?: string | null; + memoryConfig?: AgentMemoryConfig; + mcpAccess?: AgentMcpAccessConfig; memoryTemplateOverrideEnabled?: boolean; memoryTemplateMarkdown?: string; } @@ -83,9 +88,14 @@ export class AgentActions { const agent = await this.agentPresetSyncService.createAgentPreset(projectId, { name, + description: parseOptionalString(payload, "description"), instructionMarkdown, labels: parseOptionalStringArray(payload, "labels") ?? [], avatarConfig, + providerConfigId: parseOptionalNullableString(payload, "providerConfigId"), + model: parseOptionalNullableString(payload, "model"), + memoryConfig: parseOptionalObject(payload, "memoryConfig"), + mcpAccess: parseOptionalObject(payload, "mcpAccess"), memoryTemplateOverrideEnabled: parseOptionalBoolean(payload, "memoryTemplateOverrideEnabled"), memoryTemplateMarkdown: parseOptionalString(payload, "memoryTemplateMarkdown"), }); @@ -100,12 +110,22 @@ export class AgentActions { const updateInput: UpdateAgentInput = {}; const name = parseOptionalString(payload, "name"); if (name !== undefined) updateInput.name = name; + const description = parseOptionalString(payload, "description"); + if (description !== undefined) updateInput.description = description; const instructionMarkdown = parseOptionalString(payload, "instructionMarkdown"); if (instructionMarkdown !== undefined) updateInput.instructionMarkdown = instructionMarkdown; const labels = parseOptionalStringArray(payload, "labels"); if (labels !== undefined) updateInput.labels = labels; const avatarConfig = parseOptionalObject(payload, "avatarConfig"); if (avatarConfig !== undefined) updateInput.avatarConfig = avatarConfig; + const providerConfigId = parseOptionalNullableString(payload, "providerConfigId"); + if (providerConfigId !== undefined) updateInput.providerConfigId = providerConfigId; + const model = parseOptionalNullableString(payload, "model"); + if (model !== undefined) updateInput.model = model; + const memoryConfig = parseOptionalObject(payload, "memoryConfig"); + if (memoryConfig !== undefined) updateInput.memoryConfig = memoryConfig; + const mcpAccess = parseOptionalObject(payload, "mcpAccess"); + if (mcpAccess !== undefined) updateInput.mcpAccess = mcpAccess; const memoryTemplateOverrideEnabled = parseOptionalBoolean(payload, "memoryTemplateOverrideEnabled"); if (memoryTemplateOverrideEnabled !== undefined) updateInput.memoryTemplateOverrideEnabled = memoryTemplateOverrideEnabled; const memoryTemplateMarkdown = parseOptionalString(payload, "memoryTemplateMarkdown"); diff --git a/src/mcp/management/node-flow-actions.ts b/src/mcp/management/node-flow-actions.ts new file mode 100644 index 0000000000..cdb6cd138e --- /dev/null +++ b/src/mcp/management/node-flow-actions.ts @@ -0,0 +1,345 @@ +import type { + ManageCodeUxArgs, + ManagementResponseEnvelope, +} from "../../contracts/internal-management-types.js"; +import type { + NodeFlowGraph, + NodeFlowJsonObject, + NodeFlowJsonValue, + NodeFlowNode, + NodeFlowRecord, + NodeFlowRunRecord, + NodeFlowRunSummaryResponse, + NodeWidgetSchema, +} from "../../contracts/node-flow-types.js"; +import type { NodeFlowService } from "../../services/node-flow-service.js"; +import { + managementValidationError, + parseOptionalObject, + parseOptionalString, + parseRequiredObject, + parseRequiredString, +} from "./payload-parsers.js"; + +const SECRET_KEY_PATTERN = /(api[_-]?key|authorization|cookie|password|secret|token)/i; + +export class NodeFlowActions { + constructor(private readonly nodeFlowService: NodeFlowService) {} + + async handleNodeFlowAction(args: ManageCodeUxArgs): Promise { + const payload = args.payload || {}; + + switch (args.action) { + case "list": + return this.listFlows(payload); + case "get": + return this.getFlow(payload); + case "create": + return this.createFlow(payload); + case "update": + return this.updateFlow(payload); + case "delete": + return this.deleteFlow(args, payload); + case "validate": + return this.validateFlow(payload); + case "run": + return await this.runFlow(payload); + case "list_runs": + return this.listRuns(payload); + case "get_run": + return this.getRun(payload); + case "attach_to_agent": + return this.attachToAgent(payload); + case "detach_from_agent": + return this.detachFromAgent(payload); + default: + throw new Error(`Unknown node flow action: ${args.action}`); + } + } + + private listFlows(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const flows = this.nodeFlowService.list(projectId).flows.map(formatFlowSummary); + return { result: { flows } }; + } + + private getFlow(payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + const flow = this.requireFlow(flowId); + this.assertProjectMatch(payload, flow); + return { + result: { + flow: formatFlow(flow), + agentSkills: this.nodeFlowService.listAgentSkills(flow.id), + }, + }; + } + + private createFlow(payload: Record): ManagementResponseEnvelope { + const projectId = parseRequiredString(payload, "projectId"); + const graph = this.parseGraphWithWidgets(payload, true); + if (!graph) { + throw managementValidationError("graph object is required", "graph"); + } + const validation = this.nodeFlowService.validate(graph); + if (!validation.valid || !validation.graph) { + throw validationToManagementError(validation.errors); + } + + const flow = this.nodeFlowService.create(projectId, { + title: parseRequiredString(payload, "name"), + description: parseOptionalText(payload, "description"), + graph: validation.graph, + }); + return { result: { flow: formatFlow(flow) } }; + } + + private updateFlow(payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + const graph = this.parseGraphWithWidgets(payload, false); + const validation = graph ? this.nodeFlowService.validate(graph) : null; + if (validation && (!validation.valid || !validation.graph)) { + throw validationToManagementError(validation.errors); + } + + const name = parseOptionalString(payload, "name"); + const description = parseOptionalText(payload, "description"); + const flow = this.nodeFlowService.update(flowId, { + ...(name !== undefined ? { title: name } : {}), + ...(description !== undefined ? { description } : {}), + ...(validation?.graph ? { graph: validation.graph } : {}), + }); + return { result: { flow: formatFlow(flow) } }; + } + + private deleteFlow(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Deleting node flow ${flowId} removes its versions, agent skill attachments, and run records. Call again with approval.confirmed true after human approval.`, + }; + } + + this.nodeFlowService.delete(flowId); + return { result: { success: true, deletedFlowId: flowId } }; + } + + private validateFlow(payload: Record): ManagementResponseEnvelope { + const graph = this.parseGraphWithWidgets(payload, false); + if (graph) { + return { result: this.nodeFlowService.validate(graph) }; + } + + const flowId = parseRequiredString(payload, "flowId", "flowId is required when graph is omitted"); + return { result: this.nodeFlowService.validateFlow(flowId) }; + } + + private async runFlow(payload: Record): Promise { + const projectId = parseRequiredString(payload, "projectId"); + const flowId = parseRequiredString(payload, "flowId"); + const input = parseOptionalObject(payload, "input") ?? {}; + const result = await this.nodeFlowService.runFlow(projectId, flowId, input, { + triggerType: "mcp_management", + }); + return { result: formatRunSummary(result) }; + } + + private listRuns(payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + const runs = this.nodeFlowService.listRuns(flowId).runs.map(formatRun); + return { result: { runs } }; + } + + private getRun(payload: Record): ManagementResponseEnvelope { + const runId = parseRequiredString(payload, "runId"); + const run = this.nodeFlowService.getRun(runId); + if (!run) { + throw new Error(`Node flow run not found: ${runId}`); + } + return { + result: { + run: formatRun(run), + nodeRuns: this.nodeFlowService.listNodeRuns(run.id).nodeRuns.map((nodeRun) => ({ + ...nodeRun, + input: maskJsonObject(nodeRun.input), + output: maskJsonObject(nodeRun.output), + })), + }, + }; + } + + private attachToAgent(payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + const attachment = this.nodeFlowService.attachToAgent(flowId, { + agentPresetId: parseRequiredString(payload, "agentPresetId"), + skillName: parseOptionalString(payload, "skillAlias"), + description: parseOptionalText(payload, "description"), + }); + return { result: { attachment } }; + } + + private detachFromAgent(payload: Record): ManagementResponseEnvelope { + const flowId = parseRequiredString(payload, "flowId"); + const agentPresetId = parseRequiredString(payload, "agentPresetId"); + this.nodeFlowService.detachFromAgent(flowId, agentPresetId); + return { result: { success: true, flowId, agentPresetId } }; + } + + private parseGraphWithWidgets(payload: Record, required: boolean): NodeFlowGraph | undefined { + const hasGraph = "graph" in payload && payload.graph !== undefined && payload.graph !== null; + if (!hasGraph && !required) { + return undefined; + } + const graph = required + ? parseRequiredObject(payload, "graph") + : parseOptionalObject(payload, "graph"); + if (!graph) { + return undefined; + } + return applyWidgetsToGraph(graph, parseOptionalObject>(payload, "widgets")); + } + + private requireFlow(flowId: string): NodeFlowRecord { + const flow = this.nodeFlowService.get(flowId); + if (!flow) { + throw new Error(`Node flow not found: ${flowId}`); + } + return flow; + } + + private assertProjectMatch(payload: Record, flow: NodeFlowRecord): void { + const projectId = parseOptionalString(payload, "projectId"); + if (projectId && projectId !== flow.projectId) { + throw managementValidationError("Node flow does not belong to the requested project.", "projectId"); + } + } +} + +function parseOptionalText(payload: Record, key: string): string | undefined { + if (!(key in payload)) { + return undefined; + } + const value = payload[key]; + if (value === undefined || value === null) { + return undefined; + } + return typeof value === "string" ? value.trim() : undefined; +} + +function validationToManagementError(errors: Array<{ field: string; message: string }>): Error { + const first = errors[0]; + if (!first) { + return managementValidationError("Node flow graph is invalid.", "graph"); + } + return managementValidationError(first.message, first.field || "graph"); +} + +function applyWidgetsToGraph( + graph: NodeFlowGraph, + widgets: NodeWidgetSchema | Record | undefined, +): NodeFlowGraph { + if (!widgets) { + return graph; + } + if (isWidgetSchema(widgets)) { + return { ...graph, inputSchema: widgets }; + } + + const widgetsByNode = widgets as Record; + return { + ...graph, + nodes: graph.nodes.map((node) => { + const widgetSchema = widgetsByNode[node.id]; + return widgetSchema ? { ...node, widgetSchema } : node; + }), + }; +} + +function isWidgetSchema(value: unknown): value is NodeWidgetSchema { + return Boolean(value) + && typeof value === "object" + && !Array.isArray(value) + && Array.isArray((value as NodeWidgetSchema).fields); +} + +function formatFlowSummary(flow: NodeFlowRecord): Record { + return { + id: flow.id, + projectId: flow.projectId, + name: flow.title, + description: flow.description, + version: flow.version, + nodeCount: flow.graph.nodes.length, + edgeCount: flow.graph.edges.length, + createdAt: flow.createdAt, + updatedAt: flow.updatedAt, + }; +} + +function formatFlow(flow: NodeFlowRecord): Record { + return { + ...formatFlowSummary(flow), + graph: maskGraph(flow.graph), + }; +} + +function formatRun(run: NodeFlowRunRecord): Record { + return { + ...run, + triggerPayload: maskJsonObject(run.triggerPayload), + input: maskJsonObject(run.input), + output: maskJsonObject(run.output), + }; +} + +function formatRunSummary(summary: NodeFlowRunSummaryResponse): Record { + return { + run: formatRun(summary.run), + nodeRuns: summary.nodeRuns.map((nodeRun) => ({ + ...nodeRun, + input: maskJsonObject(nodeRun.input), + output: maskJsonObject(nodeRun.output), + })), + output: maskJsonObject(summary.output), + }; +} + +function maskGraph(graph: NodeFlowGraph): NodeFlowGraph { + return { + ...graph, + metadata: maskJsonObject(graph.metadata) ?? undefined, + nodes: graph.nodes.map(maskNode), + }; +} + +function maskNode(node: NodeFlowNode): NodeFlowNode { + return { + ...node, + data: maskJsonObject(node.data) ?? undefined, + }; +} + +function maskJsonObject(value: NodeFlowJsonObject | null | undefined): NodeFlowJsonObject | null { + if (!value) { + return value ?? null; + } + return maskJsonValue(value) as NodeFlowJsonObject; +} + +function maskJsonValue(value: NodeFlowJsonValue, key = ""): NodeFlowJsonValue { + if (SECRET_KEY_PATTERN.test(key)) { + return "[REDACTED]"; + } + if (Array.isArray(value)) { + return value.map((entry) => maskJsonValue(entry)); + } + if (value && typeof value === "object") { + const masked: NodeFlowJsonObject = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + masked[entryKey] = maskJsonValue(entryValue, entryKey); + } + return masked; + } + return value; +} diff --git a/src/mcp/management/payload-parsers.ts b/src/mcp/management/payload-parsers.ts index 2b1b926f42..a49202a000 100644 --- a/src/mcp/management/payload-parsers.ts +++ b/src/mcp/management/payload-parsers.ts @@ -60,6 +60,7 @@ const APPROVAL_SCOPE_KEYS = [ "templateId", "sessionId", "entryId", + "flowId", "sprintRunId", "taskRunId", ]; diff --git a/src/server/mcp-request-router.ts b/src/server/mcp-request-router.ts index 14246d796c..2942c63a31 100644 --- a/src/server/mcp-request-router.ts +++ b/src/server/mcp-request-router.ts @@ -37,6 +37,7 @@ export const registerMcpRequestHandlers = (args: McpRequestRouterArgs): void => .register("manage_scheduler", async (input) => (await args.managementToolHandler.handleManageScheduler(input)) as McpToolResponse) .register("scheduler_code_ux", async (input) => (await args.managementToolHandler.handleScheduler(input)) as McpToolResponse) .register("manage_agents", async (input) => (await args.managementToolHandler.handleManageAgents(input)) as McpToolResponse) + .register("manage_node_flows", async (input) => (await args.managementToolHandler.handleManageNodeFlows(input)) as McpToolResponse) .register("manage_memory", async (input) => (await args.managementToolHandler.handleManageMemory(input)) as McpToolResponse) .register("manage_skills", async (input) => (await args.managementToolHandler.handleManageSkills(input)) as McpToolResponse) .register("manage_settings", async (input) => (await args.managementToolHandler.handleManageSettings(input)) as McpToolResponse) diff --git a/tests/backend/mcp/management-node-flow-actions.test.ts b/tests/backend/mcp/management-node-flow-actions.test.ts new file mode 100644 index 0000000000..54711b8284 --- /dev/null +++ b/tests/backend/mcp/management-node-flow-actions.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import { ManagementToolHandler } from "../../../src/mcp/management-tool-handler.js"; +import type { NodeFlowGraph, NodeFlowRecord } from "../../../src/contracts/node-flow-types.js"; + +const validGraph: NodeFlowGraph = { + nodes: [ + { id: "input", type: "input", title: "Input", data: { apiToken: "secret-token", visible: "ok" } }, + { id: "output", type: "output", title: "Output" }, + ], + edges: [{ fromNodeId: "input", toNodeId: "output" }], +}; + +const flowRecord = (overrides: Partial = {}): NodeFlowRecord => ({ + id: "flow-1", + projectId: "project-1", + title: "Flow", + description: "Flow description", + graph: validGraph, + version: 1, + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...overrides, +}); + +const createHandler = (nodeFlowService: Record): ManagementToolHandler => new ManagementToolHandler({ + projectManagementRepository: {}, + sprintPreviewService: {}, + executionRepository: {}, + getDashboardSettings: () => ({}), + executionControlService: {}, + taskRerunService: {}, + settingsRepository: {}, + chatProviderRepository: {}, + agentPresetSyncService: {}, + memoryService: {}, + memoryPromotionService: {}, + embeddingModelManager: {}, + skillService: {}, + nodeFlowService, + knowledgeService: {}, + planningAgentService: {}, + sprintIssueService: {}, +} as any); + +const parseResponse = (response: { content: Array<{ text: string }> }): Record => + JSON.parse(response.content[0].text) as Record; + +describe("manage_node_flows", () => { + it("returns validation failure envelopes before creating malformed graphs", async () => { + const nodeFlowService = { + validate: vi.fn(() => ({ + valid: false, + errors: [{ field: "nodes", code: "required", message: "Node flow graph requires at least one node." }], + })), + create: vi.fn(), + }; + const handler = createHandler(nodeFlowService); + + const response = await handler.handleManageNodeFlows({ + action: "create", + projectId: "project-1", + name: "Bad flow", + graph: { nodes: [], edges: [] }, + }); + const parsed = parseResponse(response); + + expect(response.isError).toBe(true); + expect(parsed.result).toMatchObject({ + status: "error", + domain: "node_flows", + action: "create", + message: "Node flow graph requires at least one node.", + errorType: "validation", + field: "nodes", + }); + expect(nodeFlowService.create).not.toHaveBeenCalled(); + }); + + it("delegates runs to the node-flow runtime service through NodeFlowService", async () => { + const nodeFlowService = { + runFlow: vi.fn(async () => ({ + run: { + id: "run-1", + flowId: "flow-1", + projectId: "project-1", + version: 1, + status: "succeeded", + executionInvocationId: "xi-flow", + triggerType: "mcp_management", + triggerPayload: null, + input: { prompt: "Ship", apiKey: "[REDACTED]" }, + output: { ok: true }, + errorMessage: null, + startedAt: "2026-07-07T00:00:00.000Z", + finishedAt: "2026-07-07T00:00:01.000Z", + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:01.000Z", + }, + nodeRuns: [], + output: { ok: true }, + })), + }; + const handler = createHandler(nodeFlowService); + + const response = await handler.handleManageNodeFlows({ + action: "run", + projectId: "project-1", + flowId: "flow-1", + input: { prompt: "Ship" }, + }); + const parsed = parseResponse(response); + + expect(nodeFlowService.runFlow).toHaveBeenCalledWith("project-1", "flow-1", { prompt: "Ship" }, { + triggerType: "mcp_management", + }); + expect(parsed.result.run.id).toBe("run-1"); + expect(parsed.result.output).toEqual({ ok: true }); + }); + + it("requires exact approval before deleting a flow", async () => { + const nodeFlowService = { + delete: vi.fn(), + }; + const handler = createHandler(nodeFlowService); + + let response = await handler.handleManageNodeFlows({ + action: "delete", + flowId: "flow-1", + approval: { confirmed: true }, + }); + let parsed = parseResponse(response); + expect(parsed.approvalRequired).toBe(true); + expect(nodeFlowService.delete).not.toHaveBeenCalled(); + + response = await handler.handleManageNodeFlows({ action: "delete", flowId: "flow-1" }); + parsed = parseResponse(response); + expect(parsed.approvalRequired).toBe(true); + + response = await handler.handleManageNodeFlows({ + action: "delete", + flowId: "flow-1", + approval: { confirmed: true }, + }); + parsed = parseResponse(response); + + expect(parsed.result).toEqual({ success: true, deletedFlowId: "flow-1" }); + expect(nodeFlowService.delete).toHaveBeenCalledWith("flow-1"); + }); + + it("attaches and detaches node-flow skills for agent presets", async () => { + const nodeFlowService = { + attachToAgent: vi.fn(() => ({ + flowId: "flow-1", + projectId: "project-1", + agentPresetId: "agent-1", + skillName: "Review flow", + description: "Runs review automation", + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + })), + detachFromAgent: vi.fn(), + }; + const handler = createHandler(nodeFlowService); + + const attachResponse = await handler.handleManageNodeFlows({ + action: "attach_to_agent", + flowId: "flow-1", + agentPresetId: "agent-1", + skillAlias: " Review flow ", + description: " Runs review automation ", + }); + const detachResponse = await handler.handleManageNodeFlows({ + action: "detach_from_agent", + flowId: "flow-1", + agentPresetId: "agent-1", + }); + + expect(nodeFlowService.attachToAgent).toHaveBeenCalledWith("flow-1", { + agentPresetId: "agent-1", + skillName: "Review flow", + description: "Runs review automation", + }); + expect(parseResponse(attachResponse).result.attachment.skillName).toBe("Review flow"); + expect(nodeFlowService.detachFromAgent).toHaveBeenCalledWith("flow-1", "agent-1"); + expect(parseResponse(detachResponse).result).toEqual({ + success: true, + flowId: "flow-1", + agentPresetId: "agent-1", + }); + }); + + it("masks secret-shaped graph data in MCP flow responses", async () => { + const nodeFlowService = { + get: vi.fn(() => flowRecord()), + listAgentSkills: vi.fn(() => []), + }; + const handler = createHandler(nodeFlowService); + + const response = await handler.handleManageNodeFlows({ action: "get", flowId: "flow-1" }); + const parsed = parseResponse(response); + + expect(parsed.result.flow.graph.nodes[0].data).toEqual({ + apiToken: "[REDACTED]", + visible: "ok", + }); + }); +}); diff --git a/tests/backend/mcp/mcp-management.test.ts b/tests/backend/mcp/mcp-management.test.ts index 4c103f231c..e44b86f95e 100644 --- a/tests/backend/mcp/mcp-management.test.ts +++ b/tests/backend/mcp/mcp-management.test.ts @@ -77,6 +77,7 @@ describe("ManagementToolHandler", () => { }, agentPresetSyncService: { syncPresets: vi.fn(), + updateAgentPreset: vi.fn(), }, memoryService: { searchMemory: vi.fn(), @@ -90,6 +91,9 @@ describe("ManagementToolHandler", () => { skillService: { listStorages: vi.fn(), }, + nodeFlowService: { + list: vi.fn(), + }, planningAgentService: { planSprint: vi.fn(), }, @@ -397,6 +401,89 @@ describe("ManagementToolHandler", () => { expect(parsed.result).toEqual({ entries: [], occurrences: [], from: "from", to: "to" }); }); + it("routes manage_node_flows through the node-flow action handler", async () => { + deps.nodeFlowService.list.mockReturnValue({ flows: [] }); + + const response = await handler.handleManageNodeFlows({ action: "list", projectId: "p1" }); + const parsed = JSON.parse(response.content[0].text); + + expect(deps.nodeFlowService.list).toHaveBeenCalledWith("p1"); + expect(parsed.result).toEqual({ flows: [] }); + }); + + it("updates agent MCP access without replacing unrelated agent fields", async () => { + deps.agentPresetSyncService.updateAgentPreset.mockResolvedValue({ + id: "agent-1", + projectId: "p1", + name: "Specialist", + labels: ["review"], + avatarConfig: { body: "bot" }, + providerConfigId: "codex-primary", + model: "gpt-5", + memoryConfig: { tier: "both", categories: [], minStrength: 0, minStrengthPerCategory: {}, maxShortTerm: 0, maxLongTerm: 0 }, + mcpAccess: { + codeUxEnabled: true, + codeUxToolToggles: [{ name: "manage_node_flows", enabled: true, isInternal: true }], + linkedServerIds: ["playwright"], + }, + }); + + const response = await handler.handleManageAgents({ + action: "update", + projectId: "p1", + presetId: "agent-1", + mcpAccess: { + codeUxEnabled: true, + codeUxToolToggles: [{ name: "manage_node_flows", enabled: true, isInternal: true }], + linkedServerIds: ["playwright"], + }, + }); + const parsed = JSON.parse(response.content[0].text); + + expect(deps.agentPresetSyncService.updateAgentPreset).toHaveBeenCalledWith("agent-1", { + mcpAccess: { + codeUxEnabled: true, + codeUxToolToggles: [{ name: "manage_node_flows", enabled: true, isInternal: true }], + linkedServerIds: ["playwright"], + }, + }); + expect(parsed.result.agent.mcpAccess.codeUxToolToggles).toEqual([ + { name: "manage_node_flows", enabled: true, isInternal: true }, + ]); + expect(parsed.result.agent.labels).toEqual(["review"]); + expect(parsed.result.agent.providerConfigId).toBe("codex-primary"); + expect(parsed.result.agent.model).toBe("gpt-5"); + }); + + it("exposes the manage_node_flows MCP schema", () => { + const tool = TOOL_DEFINITIONS.find((definition) => definition.name === "manage_node_flows"); + expect(tool).toBeDefined(); + + const schema = tool?.inputSchema as { properties: Record } | undefined; + const properties = schema?.properties ?? {}; + + expect(properties.action?.enum).toEqual([ + "list", + "get", + "create", + "update", + "delete", + "validate", + "run", + "list_runs", + "get_run", + "attach_to_agent", + "detach_from_agent", + ]); + expect(properties.graph).toMatchObject({ type: "object" }); + expect(properties.widgets).toMatchObject({ type: "object" }); + expect(properties.input).toMatchObject({ type: "object" }); + expect(properties.agentPresetId).toMatchObject({ type: "string" }); + expect(properties.skillAlias).toMatchObject({ type: "string" }); + expect(tool?.description).toContain("Code UX-adapted flows"); + expect(tool?.description).toContain("dynamic widget schemas"); + }); + it("exposes the expanded import_issues MCP schema on manage_sprints", () => { const tool = TOOL_DEFINITIONS.find((definition) => definition.name === "manage_sprints"); expect(tool).toBeDefined(); diff --git a/tests/backend/mcp/mcp-tool-availability.test.ts b/tests/backend/mcp/mcp-tool-availability.test.ts index c4a18dca88..42663e532c 100644 --- a/tests/backend/mcp/mcp-tool-availability.test.ts +++ b/tests/backend/mcp/mcp-tool-availability.test.ts @@ -14,6 +14,7 @@ describe("tool availability", () => { expect(projectManagerTools.some((tool) => tool.name === "manage_quicksprints")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "manage_scheduler")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "scheduler_code_ux")).toBe(true); + expect(projectManagerTools.some((tool) => tool.name === "manage_node_flows")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "manage_skills")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "search_skills")).toBe(true); expect(projectManagerTools.some((tool) => tool.name === "register_worker_endpoint")).toBe(true); @@ -25,6 +26,7 @@ describe("tool availability", () => { expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_quicksprints", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_scheduler", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "scheduler_code_ux", "project_manager")).toBe(true); + expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_node_flows", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "manage_skills", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "search_skills", "project_manager")).toBe(true); expect(isToolEnabled(DEFAULT_DASHBOARD_SETTINGS, "register_worker_endpoint", "project_manager")).toBe(true); @@ -92,11 +94,13 @@ describe("tool availability", () => { it("sanitizes toggles and ignores unknown tool names", () => { const sanitized = sanitizeMcpToolToggles([ { name: "manage_tasks", enabled: false }, + { name: "manage_node_flows", enabled: false }, { name: "unknown_tool", enabled: false }, { name: " ", enabled: true }, ]); expect(sanitized.find((tool) => tool.name === "manage_tasks")?.enabled).toBe(false); + expect(sanitized.find((tool) => tool.name === "manage_node_flows")?.enabled).toBe(false); expect(sanitized.find((tool) => tool.name === "manage_projects")?.enabled).toBe(true); expect(sanitized.some((tool) => tool.name === "unknown_tool")).toBe(false); }); diff --git a/tests/backend/mcp/tool-registry.test.ts b/tests/backend/mcp/tool-registry.test.ts index 1e8c6afd65..aa51cafed8 100644 --- a/tests/backend/mcp/tool-registry.test.ts +++ b/tests/backend/mcp/tool-registry.test.ts @@ -13,6 +13,7 @@ const createRouterHarness = (resolveAgentMcpToolAccess?: (agentId: string) => Ag const managementToolHandler = { handleManageProjects: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })), handleManageChatProviders: vi.fn(async () => ({ content: [{ type: "text", text: "chat-providers" }] })), + handleManageNodeFlows: vi.fn(async () => ({ content: [{ type: "text", text: "node-flows" }] })), handleScheduler: vi.fn(async () => ({ content: [{ type: "text", text: "scheduled" }] })), }; @@ -66,6 +67,14 @@ const callManageChatProviders = async (handlers: RouterHandlers): Promise => + handlers.callTool({ + params: { + name: "manage_node_flows", + arguments: { action: "list", projectId: "project-1" }, + }, + }); + describe("ToolRegistry", () => { it("dispatches a registered tool handler", async () => { const registry = new ToolRegistry(); @@ -169,6 +178,27 @@ describe("ToolRegistry", () => { providerKind: "slack", }); }); + + it("can register and dispatch manage_node_flows", async () => { + const registry = new ToolRegistry(); + const handler = vi.fn(async (args: McpToolArgsByName["manage_node_flows"]) => `manage_node_flows:${args.action}`); + + registry.register("manage_node_flows", handler); + + const result = await registry.dispatch("manage_node_flows", { + action: "run", + projectId: "proj-1", + flowId: "flow-1", + input: { prompt: "Ship" }, + }); + expect(result).toBe("manage_node_flows:run"); + expect(handler).toHaveBeenCalledWith({ + action: "run", + projectId: "proj-1", + flowId: "flow-1", + input: { prompt: "Ship" }, + }); + }); }); describe("MCP router per-agent Code UX access", () => { @@ -230,6 +260,17 @@ describe("MCP router per-agent Code UX access", () => { expect(managementToolHandler.handleManageChatProviders).toHaveBeenCalledTimes(1); }); + it("lists and dispatches manage_node_flows when enabled by tool availability", async () => { + const { handlers, managementToolHandler } = createRouterHarness(() => null); + + await runWithMcpAgentContext(null, async () => { + await expect(listToolNames(handlers)).resolves.toContain("manage_node_flows"); + await expect(callManageNodeFlows(handlers)).resolves.toEqual({ content: [{ type: "text", text: "node-flows" }] }); + }); + + expect(managementToolHandler.handleManageNodeFlows).toHaveBeenCalledTimes(1); + }); + it("rejects scheduler calls when the tool is disabled", async () => { const { handlers, managementToolHandler } = createRouterHarness((agentId) => agentId === "agent-no-scheduler" @@ -297,6 +338,9 @@ const compileTimeTypeChecks = (): void => { // @ts-expect-error manage_chat_providers requires a valid action value registry.dispatch("manage_chat_providers", { action: "route_inbound_message" }); + + // @ts-expect-error manage_node_flows requires a valid action value + registry.dispatch("manage_node_flows", { action: "execute" }); }; void compileTimeTypeChecks; diff --git a/tests/backend/services/agent-mcp-access.test.ts b/tests/backend/services/agent-mcp-access.test.ts index bfb4e3d5f8..42887731ac 100644 --- a/tests/backend/services/agent-mcp-access.test.ts +++ b/tests/backend/services/agent-mcp-access.test.ts @@ -36,6 +36,7 @@ describe("sanitizeAgentMcpAccess", () => { linkedServerIds: ["a", "a", " b ", "", "b"], codeUxToolToggles: [ { name: "manage_tasks", enabled: false }, + { name: "manage_node_flows", enabled: true }, { name: "bogus_tool", enabled: false }, { name: "manage_projects", enabled: true }, ], @@ -43,6 +44,7 @@ describe("sanitizeAgentMcpAccess", () => { expect(result.linkedServerIds).toEqual(["a", "b"]); expect(result.codeUxToolToggles).toEqual([ { name: "manage_tasks", enabled: false, isInternal: true }, + { name: "manage_node_flows", enabled: true, isInternal: true }, { name: "manage_projects", enabled: true, isInternal: true }, ]); });