Skip to content

Commit 8396005

Browse files
author
andypalmi
committed
feat(mcp): add pipeline read tools
Add read-only MCP tools to list team and application pipelines and get a pipeline stage, with unit and equivalence tests. Allow-list their scopes for the expert-mcp platform token.
1 parent 913be23 commit 8396005

3 files changed

Lines changed: 269 additions & 1 deletion

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const { z } = require('zod')
2+
3+
const { teamId, applicationId } = require('../schemas')
4+
5+
module.exports = [
6+
{
7+
name: 'platform_list_team_pipelines',
8+
title: 'List Team Pipelines',
9+
description: `FlowFuse platform automation tool:
10+
Lists the DevOps pipelines that exist for a team.
11+
Results are filtered to the applications you can access, so a scoped access token
12+
sees only its in-scope subset of pipelines rather than an error.
13+
Use this to discover which pipelines exist before inspecting a specific pipeline's stages.`,
14+
annotations: { readOnlyHint: true, destructiveHint: false },
15+
inputSchema: {
16+
teamId
17+
},
18+
handler: async (args, { inject }) => {
19+
const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/pipelines` })
20+
return response
21+
}
22+
},
23+
{
24+
name: 'platform_list_application_pipelines',
25+
title: 'List Application Pipelines',
26+
description: `FlowFuse platform automation tool:
27+
Lists the DevOps pipelines belonging to a specific application.
28+
Use this when you already know the application and want its pipelines,
29+
rather than filtering the full team pipeline list.`,
30+
annotations: { readOnlyHint: true, destructiveHint: false },
31+
inputSchema: {
32+
applicationId
33+
},
34+
handler: async (args, { inject }) => {
35+
const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/pipelines` })
36+
return response
37+
}
38+
},
39+
{
40+
name: 'platform_get_pipeline_stage',
41+
title: 'Get Pipeline Stage',
42+
description: `FlowFuse platform automation tool:
43+
Fetches the full details of a single stage within a pipeline, including what it
44+
targets (hosted instance, remote instance/device, device group, or git repository)
45+
and its snapshot action.
46+
Use this once you have a pipeline ID and a stage ID and need to inspect that stage.`,
47+
annotations: { readOnlyHint: true, destructiveHint: false },
48+
inputSchema: {
49+
pipelineId: z.string().describe('Pipeline hashid the stage belongs to'),
50+
stageId: z.string().describe('Pipeline stage hashid to fetch')
51+
},
52+
handler: async (args, { inject }) => {
53+
const response = await inject({ method: 'GET', url: `/api/v1/pipelines/${args.pipelineId}/stages/${args.stageId}` })
54+
return response
55+
}
56+
}
57+
]

forge/routes/auth/permissions.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ const IMPLICIT_TOKEN_SCOPES = {
5252
'stack:list',
5353
'flow-blueprint:list',
5454
'project:status',
55-
'template:list'
55+
'template:list',
56+
// pipelines
57+
'team:pipeline:list', // list team pipelines
58+
'application:pipeline:list', // list application pipelines
59+
'pipeline:read' // get pipeline stage details
5660
]
5761
}
5862

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
const should = require('should') // eslint-disable-line no-unused-vars
2+
3+
const TestModelFactory = require('../../../../../../lib/TestModelFactory')
4+
const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence')
5+
6+
const FF_UTIL = require('flowforge-test-utils')
7+
8+
const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/pipelines')
9+
10+
const findTool = toolFinder(tools)
11+
12+
describe('MCP Platform Tools - Pipelines', function () {
13+
describe('platform_list_team_pipelines', function () {
14+
const tool = findTool('platform_list_team_pipelines')
15+
16+
it('is defined with read-only, non-destructive annotations', function () {
17+
should(tool).be.an.Object()
18+
tool.annotations.readOnlyHint.should.equal(true)
19+
tool.annotations.destructiveHint.should.equal(false)
20+
})
21+
22+
it('accepts a teamId input', function () {
23+
Object.keys(tool.inputSchema).should.eql(['teamId'])
24+
})
25+
26+
it('calls GET /api/v1/teams/:teamId/pipelines', async function () {
27+
const { calls, inject } = recordingInject()
28+
await tool.handler({ teamId: 'team1' }, { inject })
29+
calls.should.have.length(1)
30+
calls[0].method.should.equal('GET')
31+
calls[0].url.should.equal('/api/v1/teams/team1/pipelines')
32+
})
33+
})
34+
35+
describe('platform_list_application_pipelines', function () {
36+
const tool = findTool('platform_list_application_pipelines')
37+
38+
it('is defined with read-only, non-destructive annotations', function () {
39+
should(tool).be.an.Object()
40+
tool.annotations.readOnlyHint.should.equal(true)
41+
tool.annotations.destructiveHint.should.equal(false)
42+
})
43+
44+
it('accepts an applicationId input', function () {
45+
Object.keys(tool.inputSchema).should.eql(['applicationId'])
46+
})
47+
48+
it('calls GET /api/v1/applications/:applicationId/pipelines', async function () {
49+
const { calls, inject } = recordingInject()
50+
await tool.handler({ applicationId: 'app1' }, { inject })
51+
calls.should.have.length(1)
52+
calls[0].method.should.equal('GET')
53+
calls[0].url.should.equal('/api/v1/applications/app1/pipelines')
54+
})
55+
})
56+
57+
describe('platform_get_pipeline_stage', function () {
58+
const tool = findTool('platform_get_pipeline_stage')
59+
60+
it('is defined with read-only, non-destructive annotations', function () {
61+
should(tool).be.an.Object()
62+
tool.annotations.readOnlyHint.should.equal(true)
63+
tool.annotations.destructiveHint.should.equal(false)
64+
})
65+
66+
it('accepts pipelineId and stageId inputs', function () {
67+
Object.keys(tool.inputSchema).should.eql(['pipelineId', 'stageId'])
68+
})
69+
70+
it('calls GET /api/v1/pipelines/:pipelineId/stages/:stageId', async function () {
71+
const { calls, inject } = recordingInject()
72+
await tool.handler({ pipelineId: 'pipe1', stageId: 'stage1' }, { inject })
73+
calls.should.have.length(1)
74+
calls[0].method.should.equal('GET')
75+
calls[0].url.should.equal('/api/v1/pipelines/pipe1/stages/stage1')
76+
})
77+
})
78+
79+
describe('integration smoke', function () {
80+
const setup = require('../../../setup')
81+
82+
let app
83+
let token
84+
85+
before(async function () {
86+
app = await setup({ ai: { enabled: true }, expert: { enabled: true } })
87+
token = await createExpertMcpToken(app)
88+
})
89+
90+
after(async function () {
91+
await app.close()
92+
})
93+
94+
function inject (o) {
95+
return app.inject({ ...o, headers: { ...(o.headers || {}), authorization: `Bearer ${token}` } })
96+
}
97+
98+
it('lists pipelines for a team with none configured', async function () {
99+
const response = await inject({ method: 'GET', url: `/api/v1/teams/${app.team.hashid}/pipelines` })
100+
response.statusCode.should.equal(200)
101+
const body = response.json()
102+
body.should.have.property('count', 0)
103+
body.should.have.property('pipelines')
104+
body.pipelines.should.be.an.Array()
105+
body.pipelines.should.have.length(0)
106+
})
107+
})
108+
109+
describe('integration equivalence (tool vs route)', function () {
110+
const setup = require('../../../setup')
111+
const { createSnapshot } = FF_UTIL.require('forge/services/snapshots')
112+
113+
let app
114+
let token
115+
let pipeline
116+
let stageOne
117+
let stageTwo
118+
119+
before(async function () {
120+
app = await setup({ ai: { enabled: true }, expert: { enabled: true } })
121+
token = await createExpertMcpToken(app)
122+
123+
const factory = new TestModelFactory(app)
124+
125+
const deviceGroup = await factory.createApplicationDeviceGroup({ name: 'device-group-a' }, app.application)
126+
127+
pipeline = await factory.createPipeline({ name: 'equivalence-pipeline' }, app.application)
128+
stageOne = await factory.createPipelineStage({ name: 'stage-one', instanceId: app.instance.id }, pipeline)
129+
stageTwo = await factory.createPipelineStage({
130+
name: 'stage-two',
131+
deviceGroupId: deviceGroup.id,
132+
source: stageOne.hashid,
133+
action: 'use_latest_snapshot'
134+
}, pipeline)
135+
136+
await createSnapshot(app, app.instance, app.user, {
137+
name: 'equivalence-snapshot',
138+
description: 'snapshot seeded for tool/route equivalence checks',
139+
setAsTarget: false
140+
})
141+
})
142+
143+
after(async function () {
144+
await app.close()
145+
})
146+
147+
function inject (o) {
148+
return app.inject({ ...o, headers: { ...(o.headers || {}), authorization: `Bearer ${token}` } })
149+
}
150+
151+
it('platform_list_team_pipelines matches GET /api/v1/teams/:teamId/pipelines', async function () {
152+
const tool = findTool('platform_list_team_pipelines')
153+
const { routeResponse } = await expectToolMatchesRoute(
154+
inject,
155+
tool,
156+
{ teamId: app.team.hashid },
157+
{ method: 'GET', url: `/api/v1/teams/${app.team.hashid}/pipelines` }
158+
)
159+
routeResponse.statusCode.should.equal(200)
160+
const body = routeResponse.json()
161+
body.count.should.equal(1)
162+
body.pipelines.should.have.length(1)
163+
})
164+
165+
it('platform_list_application_pipelines matches GET /api/v1/applications/:applicationId/pipelines', async function () {
166+
const tool = findTool('platform_list_application_pipelines')
167+
const { routeResponse } = await expectToolMatchesRoute(
168+
inject,
169+
tool,
170+
{ applicationId: app.application.hashid },
171+
{ method: 'GET', url: `/api/v1/applications/${app.application.hashid}/pipelines` }
172+
)
173+
routeResponse.statusCode.should.equal(200)
174+
const body = routeResponse.json()
175+
body.count.should.equal(1)
176+
body.pipelines.should.have.length(1)
177+
})
178+
179+
it('platform_get_pipeline_stage matches GET /api/v1/pipelines/:pipelineId/stages/:stageId (instance stage)', async function () {
180+
const tool = findTool('platform_get_pipeline_stage')
181+
const { routeResponse } = await expectToolMatchesRoute(
182+
inject,
183+
tool,
184+
{ pipelineId: pipeline.hashid, stageId: stageOne.hashid },
185+
{ method: 'GET', url: `/api/v1/pipelines/${pipeline.hashid}/stages/${stageOne.hashid}` }
186+
)
187+
routeResponse.statusCode.should.equal(200)
188+
const body = routeResponse.json()
189+
body.should.have.property('id', stageOne.hashid)
190+
body.should.have.property('instances')
191+
})
192+
193+
it('platform_get_pipeline_stage matches GET /api/v1/pipelines/:pipelineId/stages/:stageId (device group stage)', async function () {
194+
const tool = findTool('platform_get_pipeline_stage')
195+
const { routeResponse } = await expectToolMatchesRoute(
196+
inject,
197+
tool,
198+
{ pipelineId: pipeline.hashid, stageId: stageTwo.hashid },
199+
{ method: 'GET', url: `/api/v1/pipelines/${pipeline.hashid}/stages/${stageTwo.hashid}` }
200+
)
201+
routeResponse.statusCode.should.equal(200)
202+
const body = routeResponse.json()
203+
body.should.have.property('id', stageTwo.hashid)
204+
body.should.have.property('deviceGroups')
205+
})
206+
})
207+
})

0 commit comments

Comments
 (0)