Skip to content

Commit 0438f64

Browse files
author
andypalmi
committed
feat(mcp): add notification and self-service read tools
Add read-only MCP tools for the user profile, notifications and own invitations, with unit and equivalence tests. Allow-list their scopes for the expert-mcp platform token.
1 parent 103f9ae commit 0438f64

3 files changed

Lines changed: 236 additions & 1 deletion

File tree

forge/ee/lib/mcp/tools/user.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const { basePagination, basePaginationKeys, appendQuery } = require('../schemas')
2+
3+
module.exports = [
4+
{
5+
name: 'platform_get_current_user',
6+
title: 'Get Current User',
7+
description: `FlowFuse platform automation tool:
8+
Gets the profile of the authenticated user: name, username, email, and default team.
9+
Use this to find out who the current user is or what their default team is.
10+
No parameters or body required.`,
11+
annotations: { readOnlyHint: true, destructiveHint: false },
12+
inputSchema: {},
13+
handler: async (args, { inject }) => {
14+
const response = await inject({ method: 'GET', url: '/api/v1/user' })
15+
return response
16+
}
17+
},
18+
{
19+
name: 'platform_list_notifications',
20+
title: 'List Notifications',
21+
description: `FlowFuse platform automation tool:
22+
Lists the authenticated user's own notifications, with pagination.
23+
Use this to check for unread alerts or recent activity addressed to the current user.`,
24+
annotations: { readOnlyHint: true, destructiveHint: false },
25+
inputSchema: { ...basePagination },
26+
handler: async (args, { inject }) => {
27+
const url = appendQuery('/api/v1/user/notifications', args, basePaginationKeys)
28+
const response = await inject({ method: 'GET', url })
29+
return response
30+
}
31+
},
32+
{
33+
name: 'platform_list_own_invitations',
34+
title: 'List Own Team Invitations',
35+
description: `FlowFuse platform automation tool:
36+
Lists the team invitations the authenticated user has received but not yet accepted or rejected.
37+
Use this to check whether the current user has any pending invitations to join a team.
38+
No parameters or body required.`,
39+
annotations: { readOnlyHint: true, destructiveHint: false },
40+
inputSchema: {},
41+
handler: async (args, { inject }) => {
42+
const response = await inject({ method: 'GET', url: '/api/v1/user/invitations' })
43+
return response
44+
}
45+
}
46+
]

forge/routes/auth/permissions.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ const IMPLICIT_TOKEN_SCOPES = {
5252
'stack:list',
5353
'flow-blueprint:list',
5454
'project:status',
55-
'template:list'
55+
'template:list',
56+
// user self-service
57+
'user:read', // get current user profile
58+
'user:edit' // list own notifications, list own team invitations
5659
]
5760
}
5861

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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/user')
8+
const findTool = toolFinder(tools)
9+
10+
describe('MCP Platform Tools - User', function () {
11+
describe('platform_get_current_user', function () {
12+
const tool = findTool('platform_get_current_user')
13+
14+
it('is defined', function () {
15+
should.exist(tool)
16+
})
17+
18+
it('is read-only and non-destructive', function () {
19+
tool.annotations.should.have.property('readOnlyHint', true)
20+
tool.annotations.should.have.property('destructiveHint', false)
21+
})
22+
23+
it('has no input schema parameters', function () {
24+
Object.keys(tool.inputSchema).should.eql([])
25+
})
26+
27+
it('calls GET /api/v1/user', async function () {
28+
const { calls, inject } = recordingInject()
29+
await tool.handler({}, { inject })
30+
calls.should.have.length(1)
31+
calls[0].should.have.property('method', 'GET')
32+
calls[0].should.have.property('url', '/api/v1/user')
33+
})
34+
})
35+
36+
describe('platform_list_notifications', function () {
37+
const tool = findTool('platform_list_notifications')
38+
39+
it('is defined', function () {
40+
should.exist(tool)
41+
})
42+
43+
it('is read-only and non-destructive', function () {
44+
tool.annotations.should.have.property('readOnlyHint', true)
45+
tool.annotations.should.have.property('destructiveHint', false)
46+
})
47+
48+
it('exposes the base pagination parameters', function () {
49+
Object.keys(tool.inputSchema).should.eql(['cursor', 'limit'])
50+
})
51+
52+
it('calls GET /api/v1/user/notifications with no query when no args are given', async function () {
53+
const { calls, inject } = recordingInject()
54+
await tool.handler({}, { inject })
55+
calls.should.have.length(1)
56+
calls[0].should.have.property('method', 'GET')
57+
calls[0].should.have.property('url', '/api/v1/user/notifications')
58+
})
59+
60+
it('serialises the pagination args onto the query string', async function () {
61+
const { calls, inject } = recordingInject()
62+
await tool.handler({ cursor: 'abc123', limit: 10 }, { inject })
63+
calls.should.have.length(1)
64+
calls[0].should.have.property('method', 'GET')
65+
calls[0].should.have.property('url', '/api/v1/user/notifications?cursor=abc123&limit=10')
66+
})
67+
})
68+
69+
describe('platform_list_own_invitations', function () {
70+
const tool = findTool('platform_list_own_invitations')
71+
72+
it('is defined', function () {
73+
should.exist(tool)
74+
})
75+
76+
it('is read-only and non-destructive', function () {
77+
tool.annotations.should.have.property('readOnlyHint', true)
78+
tool.annotations.should.have.property('destructiveHint', false)
79+
})
80+
81+
it('has no input schema parameters', function () {
82+
Object.keys(tool.inputSchema).should.eql([])
83+
})
84+
85+
it('calls GET /api/v1/user/invitations', async function () {
86+
const { calls, inject } = recordingInject()
87+
await tool.handler({}, { inject })
88+
calls.should.have.length(1)
89+
calls[0].should.have.property('method', 'GET')
90+
calls[0].should.have.property('url', '/api/v1/user/invitations')
91+
})
92+
})
93+
94+
describe('Integration: list notifications', function () {
95+
const setup = require('../../../setup')
96+
let app
97+
let token
98+
99+
before(async function () {
100+
app = await setup({ ai: { enabled: true }, expert: { enabled: true } })
101+
token = await createExpertMcpToken(app)
102+
})
103+
104+
after(async function () {
105+
await app.close()
106+
})
107+
108+
function inject (options) {
109+
return app.inject({
110+
...options,
111+
headers: {
112+
...(options.headers || {}),
113+
authorization: `Bearer ${token}`
114+
}
115+
})
116+
}
117+
118+
it('lists notifications for the authenticated user', async function () {
119+
const response = await inject({ method: 'GET', url: '/api/v1/user/notifications' })
120+
response.statusCode.should.equal(200)
121+
const body = response.json()
122+
body.should.have.property('notifications')
123+
body.notifications.should.be.an.Array()
124+
body.should.have.property('count', body.notifications.length)
125+
})
126+
})
127+
128+
describe('Integration: read tools match their backing routes', function () {
129+
const setup = require('../../../setup')
130+
let app
131+
let token
132+
133+
before(async function () {
134+
app = await setup({ ai: { enabled: true }, expert: { enabled: true } })
135+
token = await createExpertMcpToken(app)
136+
137+
// Creating the invitation also fires a 'team-invite' notification, so this one seed covers both tools.
138+
const invitor = await app.factory.createUser({
139+
username: 'ivy',
140+
name: 'Ivy Invitor',
141+
email: 'ivy@example.com',
142+
password: 'iiPassword'
143+
})
144+
const otherTeam = await app.factory.createTeam({ name: 'Team-Invitor' })
145+
await app.factory.createInvitation(otherTeam, invitor, app.user)
146+
})
147+
148+
after(async function () {
149+
await app.close()
150+
})
151+
152+
function inject (options) {
153+
return app.inject({
154+
...options,
155+
headers: {
156+
...(options.headers || {}),
157+
authorization: `Bearer ${token}`
158+
}
159+
})
160+
}
161+
162+
it('platform_get_current_user matches GET /api/v1/user', async function () {
163+
await expectToolMatchesRoute(inject, findTool('platform_get_current_user'), {}, {
164+
method: 'GET',
165+
url: '/api/v1/user'
166+
})
167+
})
168+
169+
it('platform_list_notifications matches GET /api/v1/user/notifications', async function () {
170+
const { routeResponse } = await expectToolMatchesRoute(inject, findTool('platform_list_notifications'), {}, {
171+
method: 'GET',
172+
url: '/api/v1/user/notifications'
173+
})
174+
// Guard against a false pass from two empty responses.
175+
routeResponse.json().notifications.length.should.be.above(0)
176+
})
177+
178+
it('platform_list_own_invitations matches GET /api/v1/user/invitations', async function () {
179+
const { routeResponse } = await expectToolMatchesRoute(inject, findTool('platform_list_own_invitations'), {}, {
180+
method: 'GET',
181+
url: '/api/v1/user/invitations'
182+
})
183+
routeResponse.json().invitations.length.should.be.above(0)
184+
})
185+
})
186+
})

0 commit comments

Comments
 (0)