Skip to content

Commit f462226

Browse files
author
andypalmi
committed
feat(mcp): add device group read tools
Add read-only MCP tools to list application and team device groups, with unit and equivalence tests. Query-string building uses the shared appendQuery helper. Allow-list their scopes for the expert-mcp platform token.
1 parent 103f9ae commit f462226

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
const { z } = require('zod')
2+
3+
const { appendQuery, applicationId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, teamId } = require('../schemas')
4+
5+
module.exports = [
6+
{
7+
name: 'platform_list_application_device_groups',
8+
title: 'List Application Device Groups',
9+
description: `FlowFuse platform automation tool:
10+
Lists the device groups belonging to an application.
11+
Device groups are used to organize remote instances (devices) for fleet-style deployments,
12+
so multiple devices can share the same target snapshot and settings.
13+
Requires the deviceGroups feature to be enabled for the owning team; if it is not enabled,
14+
this returns a descriptive "device groups not enabled for this team" error.
15+
Use platform_get_application_device_group to fetch the full detail of a single group.`,
16+
annotations: { readOnlyHint: true, destructiveHint: false },
17+
inputSchema: {
18+
applicationId,
19+
...basePagination,
20+
...searchQuery
21+
},
22+
handler: async (args, { inject }) => {
23+
const url = appendQuery(`/api/v1/applications/${args.applicationId}/device-groups`, args, [...basePaginationKeys, ...searchQueryKeys])
24+
const response = await inject({ method: 'GET', url })
25+
return response
26+
}
27+
},
28+
{
29+
name: 'platform_get_application_device_group',
30+
title: 'Get Application Device Group',
31+
description: `FlowFuse platform automation tool:
32+
Fetches a single device group in an application, including its members and target snapshot.
33+
Requires the deviceGroups feature to be enabled for the owning team; if it is not enabled,
34+
this returns a descriptive "device groups not enabled for this team" error.
35+
If you need to find the group ID first, call platform_list_application_device_groups.`,
36+
annotations: { readOnlyHint: true, destructiveHint: false },
37+
inputSchema: {
38+
applicationId,
39+
groupId: z.string().describe('Device group hashid to fetch')
40+
},
41+
handler: async (args, { inject }) => {
42+
const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/device-groups/${args.groupId}` })
43+
return response
44+
}
45+
},
46+
{
47+
name: 'platform_list_team_device_groups',
48+
title: 'List Team Device Groups',
49+
description: `FlowFuse platform automation tool:
50+
Lists the device groups across all applications in a team.
51+
Requires the deviceGroups feature to be enabled for the team; if it is not enabled,
52+
this returns a descriptive "device groups not enabled for this team" error.
53+
Results are filtered to the applications you can access for non-admins, so a scoped
54+
token sees only its in-scope subset rather than an error.`,
55+
annotations: { readOnlyHint: true, destructiveHint: false },
56+
inputSchema: {
57+
teamId,
58+
...basePagination,
59+
...searchQuery
60+
},
61+
handler: async (args, { inject }) => {
62+
const url = appendQuery(`/api/v1/teams/${args.teamId}/device-groups`, args, [...basePaginationKeys, ...searchQueryKeys])
63+
const response = await inject({ method: 'GET', url })
64+
return response
65+
}
66+
}
67+
]

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+
// device groups
57+
'application:device-group:list', // list application device groups
58+
'application:device-group:read', // get application device group
59+
'team:device-group:list' // list team device groups
5660
]
5761
}
5862

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
const should = require('should') // eslint-disable-line no-unused-vars
2+
3+
const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence')
4+
5+
const FF_UTIL = require('flowforge-test-utils')
6+
7+
const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/deviceGroups')
8+
9+
const findTool = toolFinder(tools)
10+
11+
describe('MCP Tools: deviceGroups', function () {
12+
describe('platform_list_application_device_groups', function () {
13+
const tool = findTool('platform_list_application_device_groups')
14+
15+
it('should be registered', function () {
16+
should.exist(tool)
17+
})
18+
19+
it('should be read-only and non-destructive', function () {
20+
tool.annotations.should.have.property('readOnlyHint', true)
21+
tool.annotations.should.have.property('destructiveHint', false)
22+
})
23+
24+
it('should declare the expected input schema keys', function () {
25+
Object.keys(tool.inputSchema).should.containDeep(['applicationId', 'cursor', 'limit', 'query'])
26+
})
27+
28+
it('should GET the application device-groups endpoint', async function () {
29+
const { calls, inject } = recordingInject()
30+
await tool.handler({ applicationId: 'app123' }, { inject })
31+
calls.should.have.length(1)
32+
calls[0].should.have.property('method', 'GET')
33+
calls[0].should.have.property('url', '/api/v1/applications/app123/device-groups')
34+
})
35+
36+
it('should serialise the pagination query params onto the url', async function () {
37+
const { calls, inject } = recordingInject()
38+
await tool.handler({
39+
applicationId: 'app123',
40+
cursor: 'abc',
41+
limit: 10,
42+
query: 'foo bar'
43+
}, { inject })
44+
calls[0].url.should.equal('/api/v1/applications/app123/device-groups?cursor=abc&limit=10&query=foo+bar')
45+
})
46+
})
47+
48+
describe('platform_get_application_device_group', function () {
49+
const tool = findTool('platform_get_application_device_group')
50+
51+
it('should be registered', function () {
52+
should.exist(tool)
53+
})
54+
55+
it('should be read-only and non-destructive', function () {
56+
tool.annotations.should.have.property('readOnlyHint', true)
57+
tool.annotations.should.have.property('destructiveHint', false)
58+
})
59+
60+
it('should declare the expected input schema keys', function () {
61+
Object.keys(tool.inputSchema).should.containDeep(['applicationId', 'groupId'])
62+
})
63+
64+
it('should GET the single device-group endpoint', async function () {
65+
const { calls, inject } = recordingInject()
66+
await tool.handler({ applicationId: 'app123', groupId: 'group456' }, { inject })
67+
calls.should.have.length(1)
68+
calls[0].should.have.property('method', 'GET')
69+
calls[0].should.have.property('url', '/api/v1/applications/app123/device-groups/group456')
70+
})
71+
})
72+
73+
describe('platform_list_team_device_groups', function () {
74+
const tool = findTool('platform_list_team_device_groups')
75+
76+
it('should be registered', function () {
77+
should.exist(tool)
78+
})
79+
80+
it('should be read-only and non-destructive', function () {
81+
tool.annotations.should.have.property('readOnlyHint', true)
82+
tool.annotations.should.have.property('destructiveHint', false)
83+
})
84+
85+
it('should declare the expected input schema keys', function () {
86+
Object.keys(tool.inputSchema).should.containDeep(['teamId', 'cursor', 'limit', 'query'])
87+
})
88+
89+
it('should GET the team device-groups endpoint', async function () {
90+
const { calls, inject } = recordingInject()
91+
await tool.handler({ teamId: 'team789' }, { inject })
92+
calls.should.have.length(1)
93+
calls[0].should.have.property('method', 'GET')
94+
calls[0].should.have.property('url', '/api/v1/teams/team789/device-groups')
95+
})
96+
97+
it('should serialise the pagination query params onto the url', async function () {
98+
const { calls, inject } = recordingInject()
99+
await tool.handler({ teamId: 'team789', limit: 5, query: 'fleet' }, { inject })
100+
calls[0].url.should.equal('/api/v1/teams/team789/device-groups?limit=5&query=fleet')
101+
})
102+
})
103+
104+
describe('integration smoke', function () {
105+
const setup = require('../../../setup')
106+
107+
let app
108+
let token
109+
110+
before(async function () {
111+
app = await setup({ ai: { enabled: true }, expert: { enabled: true } })
112+
113+
// Device groups are off by default; enable the feature flag so the smoke test hits the real route
114+
const defaultTeamTypeProperties = app.defaultTeamType.properties
115+
defaultTeamTypeProperties.features.deviceGroups = true
116+
app.defaultTeamType.properties = defaultTeamTypeProperties
117+
await app.defaultTeamType.save()
118+
119+
token = await createExpertMcpToken(app)
120+
})
121+
122+
after(async function () {
123+
await app.close()
124+
})
125+
126+
it('should list an application\'s device groups through the real route', async function () {
127+
const inject = (options) => app.inject({
128+
...options,
129+
headers: { ...(options.headers || {}), authorization: `Bearer ${token}` }
130+
})
131+
132+
const tool = findTool('platform_list_application_device_groups')
133+
const response = await tool.handler({ applicationId: app.application.hashid }, { inject })
134+
135+
response.statusCode.should.equal(200)
136+
const body = response.json()
137+
body.should.have.property('count', 0)
138+
body.should.have.property('groups')
139+
body.groups.should.be.an.Array()
140+
body.groups.should.have.length(0)
141+
})
142+
143+
it('should return the same response as the application device-groups route for a populated application', async function () {
144+
const inject = (options) => app.inject({
145+
...options,
146+
headers: { ...(options.headers || {}), authorization: `Bearer ${token}` }
147+
})
148+
149+
const application = await app.factory.createApplication({ name: 'mcp-tool-app-device-groups' }, app.team)
150+
await app.factory.createApplicationDeviceGroup({ name: 'mcp-tool-group-1', description: 'group one' }, application)
151+
await app.factory.createApplicationDeviceGroup({ name: 'mcp-tool-group-2', description: 'group two' }, application)
152+
153+
const tool = findTool('platform_list_application_device_groups')
154+
155+
const { routeResponse } = await expectToolMatchesRoute(
156+
inject,
157+
tool,
158+
{ applicationId: application.hashid },
159+
{ method: 'GET', url: `/api/v1/applications/${application.hashid}/device-groups` }
160+
)
161+
162+
const body = routeResponse.json()
163+
body.should.have.property('count', 2)
164+
body.groups.should.have.length(2)
165+
})
166+
167+
it('should return the same response as the team device-groups route for a populated team', async function () {
168+
const inject = (options) => app.inject({
169+
...options,
170+
headers: { ...(options.headers || {}), authorization: `Bearer ${token}` }
171+
})
172+
173+
const application = await app.factory.createApplication({ name: 'mcp-tool-team-device-groups' }, app.team)
174+
await app.factory.createApplicationDeviceGroup({ name: 'mcp-tool-team-group-1', description: 'team group one' }, application)
175+
176+
const tool = findTool('platform_list_team_device_groups')
177+
178+
const { routeResponse } = await expectToolMatchesRoute(
179+
inject,
180+
tool,
181+
{ teamId: app.team.hashid },
182+
{ method: 'GET', url: `/api/v1/teams/${app.team.hashid}/device-groups` }
183+
)
184+
185+
const body = routeResponse.json()
186+
body.should.have.property('count')
187+
body.count.should.be.greaterThanOrEqual(1)
188+
body.groups.should.matchAny(g => g.name === 'mcp-tool-team-group-1')
189+
})
190+
})
191+
})

0 commit comments

Comments
 (0)