From 67d1491b6bba092f267223362c627d492eec1b50 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 14 Sep 2026 17:08:43 +0300 Subject: [PATCH 1/3] [7697] Add MCP tools for pipeline writes and stage deploys --- forge/ee/lib/mcp/tools/pipelines.js | 137 +++++++++++++++ .../forge/ee/lib/mcp/tools/pipelines_spec.js | 158 ++++++++++++++++++ 2 files changed, 295 insertions(+) diff --git a/forge/ee/lib/mcp/tools/pipelines.js b/forge/ee/lib/mcp/tools/pipelines.js index a1c4f9a0c1..a5d48b04ea 100644 --- a/forge/ee/lib/mcp/tools/pipelines.js +++ b/forge/ee/lib/mcp/tools/pipelines.js @@ -48,5 +48,142 @@ 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. Every stage points at exactly one deploy target: pass exactly one of instanceId (hosted instance), deviceId (remote instance), deviceGroupId, or gitTokenId (git repository, together with url and the other git fields). The target must belong to the pipeline's application (git tokens to its team). + Stage ordering is a linked list: pass source as the id of the stage this new stage comes after. Omit source only for the pipeline's first stage - a stage added without source is not linked into the chain. + 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. + action controls how the stage obtains the snapshot it deploys onwards and defaults to create_snapshot; it is not meaningful for git-repo stages.`, + 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'), + gitTokenId: z.string().optional().describe('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'), + branch: z.string().optional().describe('Git branch to push to (git-repo stage)'), + pullBranch: z.string().optional().describe('Git branch to pull from (git-repo stage)'), + pushPath: z.string().optional().describe('Repository path to push to (git-repo stage)'), + pullPath: z.string().optional().describe('Repository path to pull from (git-repo stage)'), + credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the git repository'), + 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'), + action: 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 (default), use_active_snapshot / use_latest_snapshot reuse existing ones, prompt requires a sourceSnapshotId at deploy time, none makes deploys from this stage a no-op'), + source: z.string().optional().describe('Hashid of the existing stage this new stage comes after. Required for every stage except the first') + }, + handler: async (args, { inject }) => { + const payload = {} + for (const key of ['name', 'instanceId', 'deviceId', 'deviceGroupId', 'gitTokenId', 'url', 'branch', 'pullBranch', 'pushPath', 'pullPath', 'credentialSecret', 'deployToDevices', 'action', '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. name, action and deployToDevices are partial updates: omitted fields keep their values. + To change what the stage deploys to, pass exactly one of instanceId, deviceId, deviceGroupId or gitTokenId; the previous target binding is replaced. The same ordering rules as adding apply (no device group first, no instance/device after a device group). + For git-repo stages, the git settings are only applied together with gitTokenId, and they are applied as a set: always resend gitTokenId, url, branch, pullBranch, pushPath and pullPath on every git update, because omitted git fields are reset to empty. credentialSecret is the exception - it keeps its stored value when omitted.`, + 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'), + gitTokenId: z.string().optional().describe('Hashid of a team git token. Required on EVERY update of a git-repo stage, together with the full git settings'), + url: z.string().optional().describe('Git repository URL. Resent on every git update; reset to empty when omitted'), + branch: z.string().optional().describe('Git branch to push to. Resent on every git update; reset to empty when omitted'), + pullBranch: z.string().optional().describe('Git branch to pull from. Resent on every git update; reset to empty when omitted'), + pushPath: z.string().optional().describe('Repository path to push to. Resent on every git update; reset to empty when omitted'), + pullPath: z.string().optional().describe('Repository path to pull from. Resent on every git update; reset to empty when omitted'), + credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the repository. Keeps its stored value when omitted'), + 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'), + action: 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') + }, + handler: async (args, { inject }) => { + const payload = {} + for (const key of ['name', 'instanceId', 'deviceId', 'deviceGroupId', 'gitTokenId', 'url', 'branch', 'pullBranch', 'pushPath', 'pullPath', 'credentialSecret', 'deployToDevices', 'action']) { + 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 changes what is running on the target. Confirm with the user before deploying.`, + annotations: { readOnlyHint: false, destructiveHint: false, 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 + } } ] diff --git a/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js b/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js index 98af0d9258..6940aa9b99 100644 --- a/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js @@ -83,4 +83,162 @@ describe('MCP Pipelines Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_create_pipeline', function () { + const tool = getTool('platform_create_pipeline') + + it('posts the pipeline payload and returns the response', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'pipe1' }) } + inject.withArgs({ method: 'POST', url: '/api/v1/pipelines', payload: { applicationId: 'app1', name: 'Production' } }).resolves(routeResponse) + + const response = await tool.handler({ applicationId: 'app1', name: 'Production' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_name' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ applicationId: 'app1', name: '' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_update_pipeline', function () { + const tool = getTool('platform_update_pipeline') + + it('wraps the flat name into the nested pipeline shape the route reads', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'pipe1' }) } + inject.withArgs({ method: 'PUT', url: '/api/v1/pipelines/pipe1', payload: { pipeline: { name: 'Staging' } } }).resolves(routeResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', name: 'Staging' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', name: 'Staging' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_add_pipeline_stage', function () { + const tool = getTool('platform_add_pipeline_stage') + + it('posts an instance stage with only the provided fields', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'stage2' }) } + inject.withArgs({ + method: 'POST', + url: '/api/v1/pipelines/pipe1/stages', + payload: { name: 'Deploy to prod', instanceId: '11111111-1111-1111-1111-111111111111', action: 'prompt', source: 'stage1' } + }).resolves(routeResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', name: 'Deploy to prod', instanceId: '11111111-1111-1111-1111-111111111111', action: 'prompt', source: 'stage1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('posts a git-repo stage with the git settings', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'stage2' }) }) + + await tool.handler({ pipelineId: 'pipe1', name: 'Push to repo', gitTokenId: 'token1', url: 'https://github.com/org/repo', branch: 'main' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ name: 'Push to repo', gitTokenId: 'token1', url: 'https://github.com/org/repo', branch: 'main' }) + }) + + it('rejects an action outside the snapshot action set', function () { + tool.inputSchema.action.safeParse('bogus').success.should.be.false() + tool.inputSchema.action.safeParse('use_latest_snapshot').success.should.be.true() + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_input' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', name: 'stage' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_update_pipeline_stage', function () { + const tool = getTool('platform_update_pipeline_stage') + + it('puts only the provided fields onto the stage route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'stage1' }) } + inject.withArgs({ + method: 'PUT', + url: '/api/v1/pipelines/pipe1/stages/stage1', + payload: { name: 'Renamed stage', action: 'create_snapshot' } + }).resolves(routeResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1', name: 'Renamed stage', action: 'create_snapshot' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('forwards a rebinding to a device', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'stage1' }) }) + + await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1', deviceId: 'device1' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ deviceId: 'device1' }) + }) + + it('triggers the rebind branch when only git fields are provided', async function () { + // The route only applies git settings inside its rebinding branch, which is + // triggered by instanceId/deviceId/deviceGroupId being present - so the handler + // adds a blank deviceGroupId when a git token is sent without one of those. + inject.resolves({ statusCode: 200, json: () => ({ id: 'stage1' }) }) + + await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1', gitTokenId: 'token1', url: 'https://github.com/org/repo', branch: 'main' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ gitTokenId: 'token1', url: 'https://github.com/org/repo', branch: 'main', deviceGroupId: '' }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_input' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1', instanceId: 'a', deviceId: 'b' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_deploy_pipeline_stage', function () { + const tool = getTool('platform_deploy_pipeline_stage') + + it('puts to the deploy route with an empty payload by default', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'importing' }) } + inject.withArgs({ method: 'PUT', url: '/api/v1/pipelines/pipe1/stages/stage1/deploy', payload: {} }).resolves(routeResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('sends sourceSnapshotId for prompt-action stages', async function () { + inject.resolves({ statusCode: 200, json: () => ({ status: 'importing' }) }) + + await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1', sourceSnapshotId: 'snapshot1' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ sourceSnapshotId: 'snapshot1' }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 403, json: () => ({ code: 'protected_instance' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1' }, { inject }) + response.should.equal(errorResponse) + }) + }) }) From 9415a58fa10b72758565959fbccc1cdc9d487f92 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 21 Sep 2026 16:38:11 +0300 Subject: [PATCH 2/3] Mark stage deploys destructive and warn what a missing source does Deploying replaces the flows running on the next stage's target rather than adding to them, which is the same reasoning that puts platform_set_instance_device_target behind destructive access, so this belongs there too. "A stage added without source is not linked into the chain" undersold it. Adding a source-less stage to a pipeline that already has stages leaves two unlinked heads, and the pipeline then lists only the new one while the existing stages stop appearing, so a single missing argument reads as if it wiped the pipeline (#8582). Say that, on the description and on the field. --- forge/ee/lib/mcp/tools/pipelines.js | 11 +++++++---- test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/forge/ee/lib/mcp/tools/pipelines.js b/forge/ee/lib/mcp/tools/pipelines.js index a5d48b04ea..f39bde7a12 100644 --- a/forge/ee/lib/mcp/tools/pipelines.js +++ b/forge/ee/lib/mcp/tools/pipelines.js @@ -87,7 +87,7 @@ module.exports = [ title: 'Add Pipeline Stage', description: `FlowFuse platform automation tool: Adds a stage to a pipeline. Every stage points at exactly one deploy target: pass exactly one of instanceId (hosted instance), deviceId (remote instance), deviceGroupId, or gitTokenId (git repository, together with url and the other git fields). The target must belong to the pipeline's application (git tokens to its team). - Stage ordering is a linked list: pass source as the id of the stage this new stage comes after. Omit source only for the pipeline's first stage - a stage added without source is not linked into the chain. + Stage ordering is a linked list: pass source as the id of the stage this new stage comes after. Omit source ONLY for a pipeline's very first stage. Adding a source-less stage to a pipeline that already has stages does not append it: 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. 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. action controls how the stage obtains the snapshot it deploys onwards and defaults to create_snapshot; it is not meaningful for git-repo stages.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, @@ -106,7 +106,7 @@ module.exports = [ credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the git repository'), 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'), action: 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 (default), use_active_snapshot / use_latest_snapshot reuse existing ones, prompt requires a sourceSnapshotId at deploy time, none makes deploys from this stage a no-op'), - source: z.string().optional().describe('Hashid of the existing stage this new stage comes after. Required for every stage except the first') + source: z.string().optional().describe('Hashid of the existing stage this new stage comes after. Required for every stage except a pipeline\'s first; omitting it on a pipeline that already has stages hides those stages from the pipeline listing') }, handler: async (args, { inject }) => { const payload = {} @@ -170,8 +170,11 @@ module.exports = [ 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 changes what is running on the target. Confirm with the user before deploying.`, - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + 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'), diff --git a/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js b/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js index 6940aa9b99..34e22f80a8 100644 --- a/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/pipelines_spec.js @@ -215,6 +215,10 @@ describe('MCP Pipelines Tools', function () { describe('platform_deploy_pipeline_stage', function () { const tool = getTool('platform_deploy_pipeline_stage') + it('is marked destructive, since it overwrites what the target is running', function () { + tool.annotations.destructiveHint.should.be.true() + }) + it('puts to the deploy route with an empty payload by default', async function () { const routeResponse = { statusCode: 200, json: () => ({ status: 'importing' }) } inject.withArgs({ method: 'PUT', url: '/api/v1/pipelines/pipe1/stages/stage1/deploy', payload: {} }).resolves(routeResponse) From 44304c5ea7643e1785b747bfed4bd42f207bbabc Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 22 Sep 2026 12:08:19 +0300 Subject: [PATCH 3/3] Share the pipeline stage git fields and stop restating args in descriptions The add and update stage tools carried the same seven git fields twice, so they move to a gitStageFields fragment in schemas.js alongside the other shared pieces. The one real difference between the two, that an update applies the git settings as a set and empties anything omitted while credentialSecret keeps its value, is what the forUpdate flag carries, so the caveat still lives on the arguments that own it rather than in prose. action and deployToDevices were duplicated too and are now declared once, and both handlers build their payload from one shared key list. The descriptions kept restating logic the arguments already carry: the action modes, the git resend rule, the source linked-list rule, and the partial-update note. Those move onto their arguments, leaving the descriptions to say what each tool does plus the ordering rules, which are cross-field and belong to neither. No behaviour change. The published JSON Schema for both tools is identical before and after, same keys in the same order, same types, same required sets; only the descriptions differ. --- forge/ee/lib/mcp/schemas.js | 24 ++++++++++++++ forge/ee/lib/mcp/tools/pipelines.js | 49 ++++++++++++----------------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/forge/ee/lib/mcp/schemas.js b/forge/ee/lib/mcp/schemas.js index f6fcb12903..c254f187b4 100644 --- a/forge/ee/lib/mcp/schemas.js +++ b/forge/ee/lib/mcp/schemas.js @@ -7,6 +7,28 @@ 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()) + // Query fragments composed per tool by spreading only the ones the backing // route's finder actually honors. Not the same as the route's declared query // schema: most list routes reuse a generic PaginationParams that advertises @@ -85,6 +107,8 @@ module.exports = { hostedInstanceId, remoteInstanceId, snapshotId, + gitStageFields, + gitStageFieldKeys, cursorParam, limitParam, basePagination, diff --git a/forge/ee/lib/mcp/tools/pipelines.js b/forge/ee/lib/mcp/tools/pipelines.js index f39bde7a12..88bd9e6fd6 100644 --- a/forge/ee/lib/mcp/tools/pipelines.js +++ b/forge/ee/lib/mcp/tools/pipelines.js @@ -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 = [ { @@ -86,10 +92,8 @@ module.exports = [ name: 'platform_add_pipeline_stage', title: 'Add Pipeline Stage', description: `FlowFuse platform automation tool: - Adds a stage to a pipeline. Every stage points at exactly one deploy target: pass exactly one of instanceId (hosted instance), deviceId (remote instance), deviceGroupId, or gitTokenId (git repository, together with url and the other git fields). The target must belong to the pipeline's application (git tokens to its team). - Stage ordering is a linked list: pass source as the id of the stage this new stage comes after. Omit source ONLY for a pipeline's very first stage. Adding a source-less stage to a pipeline that already has stages does not append it: 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. - 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. - action controls how the stage obtains the snapshot it deploys onwards and defaults to create_snapshot; it is not meaningful for git-repo stages.`, + 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'), @@ -97,20 +101,14 @@ module.exports = [ 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'), - gitTokenId: z.string().optional().describe('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'), - branch: z.string().optional().describe('Git branch to push to (git-repo stage)'), - pullBranch: z.string().optional().describe('Git branch to pull from (git-repo stage)'), - pushPath: z.string().optional().describe('Repository path to push to (git-repo stage)'), - pullPath: z.string().optional().describe('Repository path to pull from (git-repo stage)'), - credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the git repository'), - 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'), - action: 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 (default), use_active_snapshot / use_latest_snapshot reuse existing ones, prompt requires a sourceSnapshotId at deploy time, none makes deploys from this stage a no-op'), - source: z.string().optional().describe('Hashid of the existing stage this new stage comes after. Required for every stage except a pipeline\'s first; omitting it on a pipeline that already has stages hides those stages from the pipeline listing') + ...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 ['name', 'instanceId', 'deviceId', 'deviceGroupId', 'gitTokenId', 'url', 'branch', 'pullBranch', 'pushPath', 'pullPath', 'credentialSecret', 'deployToDevices', 'action', 'source']) { + for (const key of [...stagePayloadKeys, 'source']) { if (args[key] !== undefined) { payload[key] = args[key] } @@ -123,9 +121,8 @@ module.exports = [ name: 'platform_update_pipeline_stage', title: 'Update Pipeline Stage', description: `FlowFuse platform automation tool: - Updates a pipeline stage. name, action and deployToDevices are partial updates: omitted fields keep their values. - To change what the stage deploys to, pass exactly one of instanceId, deviceId, deviceGroupId or gitTokenId; the previous target binding is replaced. The same ordering rules as adding apply (no device group first, no instance/device after a device group). - For git-repo stages, the git settings are only applied together with gitTokenId, and they are applied as a set: always resend gitTokenId, url, branch, pullBranch, pushPath and pullPath on every git update, because omitted git fields are reset to empty. credentialSecret is the exception - it keeps its stored value when omitted.`, + 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'), @@ -134,19 +131,13 @@ module.exports = [ 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'), - gitTokenId: z.string().optional().describe('Hashid of a team git token. Required on EVERY update of a git-repo stage, together with the full git settings'), - url: z.string().optional().describe('Git repository URL. Resent on every git update; reset to empty when omitted'), - branch: z.string().optional().describe('Git branch to push to. Resent on every git update; reset to empty when omitted'), - pullBranch: z.string().optional().describe('Git branch to pull from. Resent on every git update; reset to empty when omitted'), - pushPath: z.string().optional().describe('Repository path to push to. Resent on every git update; reset to empty when omitted'), - pullPath: z.string().optional().describe('Repository path to pull from. Resent on every git update; reset to empty when omitted'), - credentialSecret: z.string().optional().describe('Secret used to encrypt flow credentials pushed to the repository. Keeps its stored value when omitted'), - 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'), - action: 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') + ...gitStageFields({ forUpdate: true }), + deployToDevices, + action: stageAction }, handler: async (args, { inject }) => { const payload = {} - for (const key of ['name', 'instanceId', 'deviceId', 'deviceGroupId', 'gitTokenId', 'url', 'branch', 'pullBranch', 'pushPath', 'pullPath', 'credentialSecret', 'deployToDevices', 'action']) { + for (const key of stagePayloadKeys) { if (args[key] !== undefined) { payload[key] = args[key] }