Skip to content

Commit 428ed67

Browse files
authored
Merge pull request #6436 from FlowFuse/6249-expert-mcp-feature-branch
Expert MCP feature branch
2 parents 7e305ad + d6f98b0 commit 428ed67

39 files changed

+2215
-258
lines changed

config/webpack.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ module.exports = function (env, argv) {
164164
port: 3000,
165165
historyApiFallback: true
166166
},
167+
watchOptions: {
168+
poll: 1000
169+
},
167170
resolve: {
168171
alias: {
169172
// Use vue with the runtime compiler (needed for template strings)

forge/ee/db/models/MCPRegistration.js

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Stores MCP endpoints for a Team
33
*/
4-
const { DataTypes } = require('sequelize')
4+
const { DataTypes, literal } = require('sequelize')
55

66
module.exports = {
77
name: 'MCPRegistration',
@@ -21,16 +21,64 @@ module.exports = {
2121
],
2222
associations: function (M) {
2323
this.belongsTo(M.Team, { foreignKey: { allowNull: false } })
24+
this.belongsTo(M.Project, { foreignKey: 'targetId', constraints: false }) // allow byTeam to include instance info
25+
this.belongsTo(M.Device, { foreignKey: 'targetId', constraints: false }) // allow byTeam to include instance info
2426
},
2527
finders: function (M, app) {
28+
const isPG = this.sequelize.getDialect() === 'postgres'
29+
const instanceOwnershipJoin = literal(`
30+
"MCPRegistration"."targetType" = 'instance'
31+
AND (
32+
CASE
33+
WHEN "MCPRegistration"."targetType" = 'instance'
34+
THEN "MCPRegistration"."targetId"${isPG ? '::uuid' : ''}
35+
END
36+
) = "Project"."id"
37+
`)
38+
const deviceOwnershipJoin = literal(`
39+
"MCPRegistration"."targetType" = 'device'
40+
AND (
41+
CASE
42+
WHEN "MCPRegistration"."targetType" = 'device'
43+
THEN "MCPRegistration"."targetId"${isPG ? '::int' : ''}
44+
END
45+
) = "Device"."id"
46+
`)
2647
return {
2748
static: {
28-
byTeam: async (teamId) => {
49+
byTeam: async (teamIdOrHash, {
50+
includeTeam = false,
51+
includeInstance = false
52+
} = {}) => {
53+
let teamId = teamIdOrHash
2954
if (typeof teamId === 'string') {
3055
teamId = M.Team.decodeHashid(teamId)
3156
}
57+
const include = []
58+
if (includeTeam) {
59+
include.push({ model: M.Team, include: { model: M.TeamType } })
60+
}
61+
if (includeInstance) {
62+
include.push({
63+
model: M.Project,
64+
attributes: ['hashid', 'id', 'name', 'slug', 'links', 'url', 'ApplicationId'],
65+
required: false,
66+
on: instanceOwnershipJoin
67+
})
68+
include.push({
69+
model: M.Device,
70+
attributes: ['hashid', 'id', 'name', 'type', 'ApplicationId'],
71+
required: false,
72+
on: deviceOwnershipJoin,
73+
include: {
74+
model: M.Project,
75+
attributes: ['ApplicationId']
76+
}
77+
})
78+
}
3279
return this.findAll({
33-
where: { TeamId: teamId }
80+
where: { TeamId: teamId },
81+
include
3482
})
3583
},
3684
byTypeAndIDs: async (targetType, targetId, nodeId) => {

forge/routes/api/expert.js

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ module.exports = async function (app) {
1717

1818
app.addHook('preHandler', app.verifySession)
1919
app.addHook('preHandler', async (request, reply) => {
20-
if (!serviceEnabled) {
20+
if (!serviceEnabled || !expertUrl) {
2121
return reply.code(501).send({
2222
code: 'service_disabled',
2323
error: 'Expert service is not enabled'
@@ -38,11 +38,24 @@ module.exports = async function (app) {
3838
// Get the user object
3939
request.user = await app.db.models.User.byId(request.session.User.id)
4040
if (!request.user) {
41-
reply.code(401).send({
41+
return reply.code(401).send({
4242
code: 'unauthorized',
4343
error: 'unauthorized'
4444
})
4545
}
46+
// Ensure users team access is valid
47+
const teamId = request.body.context?.teamId // `context.teamId` is the hash provided in the body context by the client
48+
if (!teamId) {
49+
return reply.status(404).send({ code: 'not_found', error: 'Not Found' })
50+
}
51+
const existingRole = await request.user.getTeamMembership(teamId)
52+
if (!existingRole) {
53+
return reply.status(404).send({ code: 'not_found', error: 'Not Found' })
54+
}
55+
request.team = await app.db.models.Team.byId(teamId)
56+
if (!request.team) {
57+
return reply.status(404).send({ code: 'not_found', error: 'Not Found' })
58+
}
4659
}
4760
})
4861

@@ -104,9 +117,164 @@ module.exports = async function (app) {
104117
throw new Error('Transaction ID mismatch')
105118
}
106119

120+
reply.send(response.data)
121+
} catch (error) {
122+
reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message })
123+
}
124+
})
125+
/**
126+
* an endpoint to retrieve MCP features (prompts/resources/tools) for the users team
127+
*/
128+
app.post('/mcp/features', {
129+
schema: {
130+
hide: true, // dont show in swagger
131+
headers: {
132+
type: 'object',
133+
properties: {
134+
'x-chat-transaction-id': { type: 'string', minLength: 1 }
135+
},
136+
required: ['x-chat-transaction-id']
137+
},
138+
body: {
139+
type: 'object',
140+
properties: {
141+
context: {
142+
type: 'object',
143+
properties: {
144+
teamId: { type: 'string', minLength: 10 }
145+
},
146+
required: ['teamId'],
147+
additionalProperties: true
148+
}
149+
},
150+
required: ['context']
151+
},
152+
response: {
153+
200: {
154+
type: 'object',
155+
properties: {
156+
transactionId: { type: 'string' },
157+
servers: {
158+
type: 'array',
159+
items: {
160+
type: 'object',
161+
properties: {
162+
team: { type: 'string' },
163+
instance: { type: 'string' },
164+
instanceType: { type: 'string', enum: ['instance', 'device'] },
165+
instanceName: { type: 'string' },
166+
mcpServerName: { type: 'string' },
167+
prompts: { type: 'array', items: { type: 'object', additionalProperties: true } },
168+
resources: { type: 'array', items: { type: 'object', additionalProperties: true } },
169+
resourceTemplates: { type: 'array', items: { type: 'object', additionalProperties: true } },
170+
tools: { type: 'array', items: { type: 'object', additionalProperties: true } },
171+
mcpProtocol: { type: 'string', enum: ['http', 'sse'] },
172+
mcpServerUrl: { type: 'string' },
173+
title: { type: 'string' },
174+
version: { type: 'string' },
175+
description: { type: 'string' }
176+
},
177+
required: ['instance', 'instanceType', 'instanceName', 'mcpServerName', 'prompts', 'resources', 'resourceTemplates', 'tools', 'mcpProtocol'],
178+
additionalProperties: false
179+
}
180+
}
181+
},
182+
additionalProperties: false
183+
},
184+
'4xx': {
185+
$ref: 'APIError'
186+
}
187+
}
188+
}
189+
},
190+
/**
191+
* Get MCP capabilities for the user's team
192+
* @param {import('fastify').FastifyRequest} request
193+
* @param {import('fastify').FastifyReply} reply
194+
*/
195+
async (request, reply) => {
196+
try {
197+
/** @type {MCPServerItem[]} */
198+
const runningInstancesWithMCPServer = []
199+
const transactionId = request.headers['x-chat-transaction-id']
200+
const mcpCapabilitiesUrl = `${expertUrl.split('/').slice(0, -1).join('/')}/mcp/features`
201+
const mcpServers = await app.db.models.MCPRegistration.byTeam(request.team.id, { includeInstance: true }) || []
202+
203+
for (const server of mcpServers) {
204+
const { name, protocol, endpointRoute, TeamId, Project, Device, title, version, description } = server
205+
if (TeamId !== request.team.id) {
206+
// shouldn't happen due to byTeam filter, but just in case
207+
continue
208+
}
209+
let owner, ownerId, ownerType
210+
if (Device) {
211+
ownerType = 'device'
212+
owner = Device
213+
ownerId = Device.hashid
214+
} else if (Project) {
215+
ownerType = 'instance'
216+
owner = Project
217+
ownerId = Project.id
218+
} else {
219+
continue
220+
}
221+
222+
const liveState = await owner.liveState({ omitStorageFlows: true })
223+
if (liveState?.meta?.state !== 'running') {
224+
continue
225+
}
226+
227+
runningInstancesWithMCPServer.push({
228+
team: request.team.hashid,
229+
instance: ownerId,
230+
instanceType: ownerType,
231+
instanceName: owner.name,
232+
instanceUrl: owner.url,
233+
mcpServerName: name,
234+
mcpEndpoint: endpointRoute,
235+
mcpProtocol: protocol,
236+
title,
237+
version,
238+
description
239+
})
240+
}
241+
if (runningInstancesWithMCPServer.length === 0) {
242+
return reply.send({ servers: [], transactionId })
243+
}
244+
const response = await axios.post(mcpCapabilitiesUrl, {
245+
teamId: request.team.hashid,
246+
servers: runningInstancesWithMCPServer
247+
}, {
248+
headers: {
249+
Origin: request.headers.origin,
250+
'X-Chat-Transaction-ID': transactionId,
251+
...(serviceToken ? { Authorization: `Bearer ${serviceToken}` } : {})
252+
},
253+
timeout: requestTimeout
254+
})
255+
256+
if (response.data.transactionId !== transactionId) {
257+
throw new Error('Transaction ID mismatch')
258+
}
259+
107260
reply.send(response.data)
108261
} catch (error) {
109262
reply.code(error.response?.status || 500).send({ code: error.response?.data?.code || 'unexpected_error', error: error.response?.data?.error || error.message })
110263
}
111264
})
112265
}
266+
267+
/**
268+
* @typedef {Object} MCPServerItem MCP server info for a team
269+
* @property {string} team
270+
* @property {string} instance
271+
* @property {string} instanceType
272+
* @property {string} instanceName
273+
* @property {string} instanceUrl
274+
* @property {string} mcpServerName
275+
* @property {string} mcpEndpoint
276+
* @property {string} mcpProtocol
277+
* @property {string} [title]
278+
* @property {string} [version]
279+
* @property {string} [description]
280+
*/

frontend/src/App.vue

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,7 @@ export default {
142142
this.$store.dispatch('product/checkFlags')
143143
},
144144
methods: {
145-
// todo this should be switched to a dedicated context store
146-
...mapActions('product/expert', ['updateRoute'])
145+
...mapActions('context', ['updateRoute'])
147146
}
148147
}
149148
</script>

frontend/src/api/expert.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ const chat = async ({
3131
})
3232
}
3333

34+
const getCapabilities = async (payload) => {
35+
const transactionId = uuidv4()
36+
return client.post('/api/v1/expert/mcp/features', payload, {
37+
headers: {
38+
'X-Chat-Transaction-ID': transactionId
39+
}
40+
}).then(res => {
41+
if (res.data.transactionId !== transactionId) {
42+
// ignore transaction ID mismatch for this endpoint for now
43+
console.warn('Transaction ID mismatch - response may be from a different request')
44+
return {}
45+
}
46+
return res.data
47+
})
48+
}
49+
3450
export default {
35-
chat
51+
chat,
52+
getCapabilities
3653
}

frontend/src/components/drawers/RightDrawer.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ export default {
374374
375375
<style scoped lang="scss">
376376
#right-drawer {
377-
position: absolute;
377+
position: fixed;
378378
border-left: 1px solid $ff-grey-300;
379379
background: $ff-grey-50;
380380
height: calc(100% - 60px);

0 commit comments

Comments
 (0)