FlowFuse / FlowFuse/flowfuse

5.5-b Write tools, non-destructive (phase 2)

Open
#7,697 0 comments 0 reactions 0 assignees View on GitHub
story
Dominant language
JavaScript
Stars
400
Forks
89
Avg merge
1d 21h
Merged PRs (30d)
146

Description

**Parent:** #7674 (5.5 Pipelines (DevOps))
**Tool file:** new `forge/ee/lib/mcp/tools/pipelines.js`

`readOnlyHint: false`, `destructiveHint: false`. Deploy is the CI/CD action; it changes state but does not overwrite or destroy existing content, so it lives with the ordinary writes.

| Tool | Endpoint | Scope | Annotation |
|---|---|---|---|
| `platform_create_pipeline` | `POST /pipelines` | `pipeline:create` | write |
| `platform_update_pipeline` | `PUT /pipelines/:pipelineId` | `pipeline:edit` | write |
| `platform_add_pipeline_stage` | `POST /pipelines/:pipelineId/stages` | `pipeline:edit` | write |
| `platform_update_pipeline_stage` | `PUT /pipelines/:pipelineId/stages/:stageId` | `pipeline:edit` | write |
| `platform_deploy_pipeline_stage` | `PUT /pipelines/:pipelineId/stages/:stageId/deploy` | `pipeline:edit` | write |

**Design notes:**
- **`deploy` has a state-dependent Owner gate:** if the target instance has `KEY_PROTECTED` enabled and the caller is not Owner, the route returns 403 `protected_instance`. Surface this as an actionable message ("only a team Owner can deploy to a protected instance").
- Deploy is a no-op 200 when the stage action is `NONE` and there is no git repo; deploy behaviour branches by target type (instance/device/deviceGroup/gitRepo) with no further permission differences.
- Stage creation accepts action/git/device stage types under one `pipeline:edit` scope. The stage `action` enum values are `create_snapshot`, `use_active_snapshot`, `use_latest_snapshot`, `prompt`, `none` (from `PipelineStage.SNAPSHOT_ACTIONS`).
- **`update_pipeline` body shaping:** the tool exposes a flat `name`; its handler wraps it into the `{ pipeline: { name } }` shape the route actually reads. The route's declared schema is a flat `{ name }`, but the handler reads `request.body.pipeline.name`, so the nested shape is what works in practice (source inconsistency worth a follow-up fix on the route). Keep this translation in the handler so the agent-facing input stays flat.

**Tool definitions (description + zod inputSchema):**
```js
platform_create_pipeline: {
description: 'Create a new pipeline in an application.',
inputSchema: z.object({
applicationId: z.string().describe('Application hashid the pipeline is created in'),
name: z.string().describe('Name for the new pipeline (required)')
})
}
platform_update_pipeline: {
description: 'Rename a pipeline.',
inputSchema: z.object({
pipelineId: z.string().describe('Pipeline hashid to update'),
name: z.string().describe('New pipeline name')
})
}
platform_add_pipeline_stage: {
description: 'Add a stage to a pipeline. Accepts action, git, and device stage types; supply only the fields relevant to the chosen stage type.',
inputSchema: z.object({
pipelineId: z.string().describe('Pipeline hashid to add the stage to'),
name: z.string().optional().describe('Stage name'),
instanceId: z.string().optional().describe('Hosted instance hashid bound to this stage'),
deviceId: z.string().optional().describe('Remote instance/device hashid bound to this stage'),
deviceGroupId: z.string().optional().describe('Device group hashid bound to this stage'),
deployToDevices: z.boolean().optional().describe('Whether deploying the stage also pushes to its bound devices'),
action: z.enum(['create_snapshot','use_active_snapshot','use_latest_snapshot','prompt','none']).optional().describe('Snapshot action for the stage (from PipelineStage.SNAPSHOT_ACTIONS)'),
gitTokenId: z.string().optional().describe('Git token hashid for a git-repo stage'),
url: z.string().optional().describe('Git repository URL for a git-repo stage'),
branch: z.string().optional().describe('Git branch to push to'),
pullBranch: z.string().optional().describe('Git branch to pull from'),
pushPath: z.string().optional().describe('Repository path to push to'),
pullPath: z.string().optional().describe('Repository path to pull from'),
credentialSecret: z.string().optional().describe('Secret used to encrypt stored git credentials'),
source: z.string().optional().describe('Stage hashid this stage sources its snapshot from')
})
}
platform_update_pipeline_stage: {
description: 'Update a pipeline stage. Accepts action, git, and device stage types; supply only the fields relevant to the chosen stage type.',
inputSchema: z.object({
pipelineId: z.string().describe('Pipeline hashid the stage belongs to'),
stageId: z.string().describe('Pipeline stage hashid to update'),
name: z.string().optional().describe('Stage name'),
instanceId: z.string().optional().describe('Hosted instance hashid bound to this stage'),
deviceId: z.string().optional().describe('Remote instance/device hashid bound to this stage'),
deviceGroupId: z.string().optional().describe('Device group hashid bound to this stage'),
deployToDevices: z.boolean().optional().describe('Whether deploying the stage also pushes to its bound devices'),
action: z.enum(['create_snapshot','use_active_snapshot','use_latest_snapshot','prompt','none']).optional().describe('Snapshot action for the stage (from PipelineStage.SNAPSHOT_ACTIONS)'),
gitTokenId: z.string().optional().describe('Git token hashid for a git-repo stage'),
url: z.string().optional().describe('Git repository URL for a git-repo stage'),
branch: z.string().optional().describe('Git branch to push to'),
pullBranch: z.string().optional().describe('Git branch to pull from'),
pushPath: z.string().optional().describe('Repository path to push to'),
pullPath: z.string().optional().describe('Repository path to pull from'),
credentialSecret: z.string().optional().describe('Secret used to encrypt stored git credentials'),
source: z.string().optional().describe('Stage hashid this stage sources its snapshot from')
})
}
platform_deploy_pipeline_stage: {
description: 'Deploy a source stage to the next stage in the pipeline. If the target instance has KEY_PROTECTED enabled and the caller is not a team Owner, the deploy is rejected with 403 protected_instance (surface as "only a team Owner can deploy to a protected instance"). Deploy is a no-op 200 when the stage action is NONE and there is no git repo; behaviour otherwise branches by target type (instance/device/deviceGroup/gitRepo) with no further permission differences. The request body is optional/nullable; only send sourceSnapshotId when the stage action is prompt.',
inputSchema: z.object({
pipelineId: z.string().describe('Pipeline hashid the stage belongs to'),
stageId: z.string().describe('Source pipeline stage hashid to deploy to the next stage'),
sourceSnapshotId: z.string().optional().describe('Snapshot hashid to deploy for a prompt-action stage')
})
}
```

**Tests:**
- Write and deploy tools rejected for read-only PAT.
- Deploy to a `KEY_PROTECTED` instance as a Member returns the descriptive 403.
- Deploy no-op path returns 200 without error.

---

Contributor guide

Open the contributing guide

Research direction

Start with the new forge/ee/lib/mcp/tools/pipelines.js entry point and the pipeline routes described in the issue, then trace how existing MCP tools shape requests and surface errors. Done means all five tools expose the listed inputs and annotations, preserve the flat pipeline name input, handle the protected-instance and no-op deploy cases, and pass the write/deploy authorization and deployment tests.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
ci-cd, devops
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.