Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions forge/ee/lib/mcp/schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ const hostedInstanceId = z.string().uuid().describe('The id (UUID) of the hosted
const remoteInstanceId = z.string().describe('The hashid of the remote instance')
const snapshotId = z.string().describe('The hashid of the snapshot')

// Shared by the pipeline stage add and update tools: both routes take the same
// git-repo fields. forUpdate carries the one real difference between them, that
// the update route applies the git settings as a set and empties anything omitted,
// with credentialSecret as the documented exception.
const gitStageFields = ({ forUpdate = false } = {}) => {
const resendNote = forUpdate ? '. Resent on every git update; reset to empty when omitted' : ''
return {
gitTokenId: z.string().optional().describe(forUpdate
? 'Hashid of a team git token. Required on every update of a git-repo stage, together with the full git settings, since the git settings are only applied when it is present and are applied as a set'
: 'Hashid of a team git token, making this a git-repository stage. Pass exactly one target, and include url'),
url: z.string().optional().describe(`Git repository URL for a git-repo stage${resendNote}`),
branch: z.string().optional().describe(`Git branch to push to (git-repo stage)${resendNote}`),
pullBranch: z.string().optional().describe(`Git branch to pull from (git-repo stage)${resendNote}`),
pushPath: z.string().optional().describe(`Repository path to push to (git-repo stage)${resendNote}`),
pullPath: z.string().optional().describe(`Repository path to pull from (git-repo stage)${resendNote}`),
credentialSecret: z.string().optional().describe(forUpdate
? 'Secret used to encrypt flow credentials pushed to the repository. Unlike the other git fields it keeps its stored value when omitted'
: 'Secret used to encrypt flow credentials pushed to the git repository')
}
}
const gitStageFieldKeys = Object.keys(gitStageFields())
// Shared by the snapshot export and import tools: both routes take the same
// component selection, and the controller applies the same defaults to each.
// Direction-specific cautions (an export exposing hidden values, for instance)
Expand Down Expand Up @@ -95,6 +116,8 @@ module.exports = {
hostedInstanceId,
remoteInstanceId,
snapshotId,
gitStageFields,
gitStageFieldKeys,
snapshotComponents,
cursorParam,
limitParam,
Expand Down
133 changes: 132 additions & 1 deletion forge/ee/lib/mcp/tools/pipelines.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
const { z } = require('zod')

const { teamId, applicationId, toolError } = require('../schemas')
const { teamId, applicationId, toolError, gitStageFields, gitStageFieldKeys } = require('../schemas')

const stageAction = z.enum(['create_snapshot', 'use_active_snapshot', 'use_latest_snapshot', 'prompt', 'none']).optional()
.describe('How the stage obtains the snapshot it passes on when deployed FROM: create_snapshot makes a new one (the default), use_active_snapshot and use_latest_snapshot reuse existing ones, prompt requires a sourceSnapshotId at deploy time, none makes deploys from this stage a no-op. Not meaningful for git-repo stages')
const deployToDevices = z.boolean().optional()
.describe('For a hosted-instance stage: also push to the devices assigned to that instance when this stage is deployed to')
const stagePayloadKeys = ['name', 'instanceId', 'deviceId', 'deviceGroupId', ...gitStageFieldKeys, 'deployToDevices', 'action']

module.exports = [
{
Expand Down Expand Up @@ -48,5 +54,130 @@ module.exports = [
const response = await inject({ method: 'GET', url: `/api/v1/pipelines/${args.pipelineId}/stages/${args.stageId}` })
return response
}
},
{
name: 'platform_create_pipeline',
title: 'Create Pipeline',
description: `FlowFuse platform automation tool:
Creates a new, empty DevOps pipeline in an application. Add stages with platform_add_pipeline_stage afterwards.
A pipeline is an ordered chain of stages (hosted instances, remote instances/devices, device groups, or git repositories) that flows are promoted through with platform_deploy_pipeline_stage.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: {
applicationId: applicationId.describe('The hashid of the application to create the pipeline in'),
name: z.string().min(1).describe('Name for the new pipeline')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'POST', url: '/api/v1/pipelines', payload: { applicationId: args.applicationId, name: args.name } })
return response
}
},
{
name: 'platform_update_pipeline',
title: 'Update Pipeline',
description: `FlowFuse platform automation tool:
Renames a pipeline. The name is the only property a pipeline has beyond its stages; use the stage tools to change stages.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
pipelineId: z.string().describe('The hashid of the pipeline to rename'),
name: z.string().min(1).describe('New name for the pipeline')
},
handler: async (args, { inject }) => {
// The route's handler reads request.body.pipeline.name (not the flat name its
// declared schema suggests), so wrap the flat input into the working shape.
const response = await inject({ method: 'PUT', url: `/api/v1/pipelines/${args.pipelineId}`, payload: { pipeline: { name: args.name } } })
return response
}
},
{
name: 'platform_add_pipeline_stage',
title: 'Add Pipeline Stage',
description: `FlowFuse platform automation tool:
Adds a stage to a pipeline. Stages form an ordered chain that flows are promoted through with platform_deploy_pipeline_stage, and each one points at a single deploy target that must belong to the pipeline's application (git tokens to its team).
Ordering rules enforced by the API: a device group cannot be the first stage, and an instance or device cannot be added after a device group.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: {
pipelineId: z.string().describe('The hashid of the pipeline to add the stage to'),
name: z.string().min(1).describe('Name for the stage'),
instanceId: z.string().uuid().optional().describe('UUID of the hosted instance this stage deploys to. Pass exactly one target'),
deviceId: z.string().optional().describe('Hashid of the remote instance (device) this stage deploys to. Pass exactly one target'),
deviceGroupId: z.string().optional().describe('Hashid of the device group this stage deploys to. Cannot be the first stage. Pass exactly one target'),
...gitStageFields(),
deployToDevices,
action: stageAction,
source: z.string().optional().describe('Stage ordering is a linked list: this is the hashid of the existing stage the new stage comes after. Required for every stage except a pipeline\'s very first. Omitting it on a pipeline that already has stages does not append the stage, it becomes a second unlinked head and the pipeline then lists only the new stage while the existing ones stop appearing, even though they still exist and can still be fetched by id')
},
handler: async (args, { inject }) => {
const payload = {}
for (const key of [...stagePayloadKeys, 'source']) {
if (args[key] !== undefined) {
payload[key] = args[key]
}
}
const response = await inject({ method: 'POST', url: `/api/v1/pipelines/${args.pipelineId}/stages`, payload })
return response
}
},
{
name: 'platform_update_pipeline_stage',
title: 'Update Pipeline Stage',
description: `FlowFuse platform automation tool:
Updates a pipeline stage: its name, what it deploys to, how it obtains the snapshot it passes on, and its git settings. Fields you omit keep their stored value, except the git settings, which are applied as a set.
Rebinding the target replaces the previous binding, and the same ordering rules as adding apply: a device group cannot be the first stage, and an instance or device cannot come after a device group.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
pipelineId: z.string().describe('The hashid of the pipeline the stage belongs to'),
stageId: z.string().describe('The hashid of the stage to update'),
name: z.string().min(1).optional().describe('New name for the stage'),
instanceId: z.string().uuid().optional().describe('UUID of the hosted instance to rebind this stage to. Pass at most one target'),
deviceId: z.string().optional().describe('Hashid of the remote instance (device) to rebind this stage to. Pass at most one target'),
deviceGroupId: z.string().optional().describe('Hashid of the device group to rebind this stage to. Pass at most one target'),
...gitStageFields({ forUpdate: true }),
deployToDevices,
action: stageAction
},
handler: async (args, { inject }) => {
const payload = {}
for (const key of stagePayloadKeys) {
if (args[key] !== undefined) {
payload[key] = args[key]
}
}
// The route only applies git settings inside its rebinding branch, and that
// branch is triggered by instanceId/deviceId/deviceGroupId being present in the
// body. Mirror the frontend, which sends a blank deviceGroupId alongside git
// updates, so a git-only update still lands.
if (payload.gitTokenId && payload.instanceId === undefined && payload.deviceId === undefined && payload.deviceGroupId === undefined) {
payload.deviceGroupId = ''
}
const response = await inject({ method: 'PUT', url: `/api/v1/pipelines/${args.pipelineId}/stages/${args.stageId}`, payload })
return response
}
},
{
name: 'platform_deploy_pipeline_stage',
title: 'Deploy Pipeline Stage',
description: `FlowFuse platform automation tool:
Deploys a pipeline stage to the NEXT stage in the pipeline: stageId is the SOURCE stage, and its snapshot (obtained per the stage's action) is pushed to whatever the following stage targets. Fails with not_found when the stage has no next stage.
Deploying to an instance, device or device group replies 200 { status: "importing" } as soon as the deploy has STARTED - it completes in the background, so check the target's status afterwards (for example platform_get_hosted_instance_status) to confirm it finished. A stage whose action is none (and that has no git repository) replies { status: "okay" } and does nothing.
Only a team Owner can deploy to a protected instance; other roles get a 403 protected_instance.
sourceSnapshotId is only used when the source stage's action is prompt, where it names the snapshot to deploy; other actions select the snapshot themselves.
This overwrites what is running on the target. Confirm with the user before deploying.`,
// destructiveHint: this replaces the flows running on the next stage's target
// rather than adding to them, the same reasoning as
// platform_set_instance_device_target, so it belongs behind destructive access.
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: {
pipelineId: z.string().describe('The hashid of the pipeline'),
stageId: z.string().describe('The hashid of the SOURCE stage to deploy from; the deploy lands on the stage after it'),
sourceSnapshotId: z.string().optional().describe('The hashid of the snapshot to deploy. Only used (and required) when the source stage action is prompt')
},
handler: async (args, { inject }) => {
const payload = {}
if (args.sourceSnapshotId !== undefined) {
payload.sourceSnapshotId = args.sourceSnapshotId
}
const response = await inject({ method: 'PUT', url: `/api/v1/pipelines/${args.pipelineId}/stages/${args.stageId}/deploy`, payload })
return response
}
}
]
Loading
Loading