Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions forge/ee/lib/mcp/tools/bom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const { teamId, applicationId } = require('../schemas')

module.exports = [
{
name: 'platform_get_team_bom',
title: 'Get Team Bill of Materials',
description: `FlowFuse platform automation tool:
Reads the bill of materials for a team: the applications, instances, and their
dependencies across the team. This is plan-gated on the bom feature, which defaults
to disabled; if disabled for the team, the tool reports that the bill of materials
is not enabled for this team rather than the raw platform error.
Results are filtered to the applications the calling token can access,
so a scoped token sees only its in-scope subset instead of an error.`,
annotations: { readOnlyHint: true, destructiveHint: false },
inputSchema: {
teamId
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/bom` })
return response
}
},
{
name: 'platform_get_application_bom',
title: 'Get Application Bill of Materials',
description: `FlowFuse platform automation tool:
Reads the bill of materials for a single application: its instances and their dependencies.
This is plan-gated on the bom feature, which defaults to disabled; if the team's plan
has this feature disabled, the tool reports that the bill of materials is not enabled
for this team rather than the raw platform error.`,
annotations: { readOnlyHint: true, destructiveHint: false },
inputSchema: {
applicationId
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/bom` })
return response
}
}
]
5 changes: 4 additions & 1 deletion forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ const IMPLICIT_TOKEN_SCOPES = {
'stack:list',
'flow-blueprint:list',
'project:status',
'template:list'
'template:list',
// bill of materials
'team:bom', // get team bill of materials
'application:bom' // get application bill of materials
]
}

Expand Down
60 changes: 60 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/bom_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const should = require('should') // eslint-disable-line no-unused-vars
const sinon = require('sinon')

const tools = require('../../../../../../../forge/ee/lib/mcp/tools/bom')

function getTool (name) {
return tools.find(tool => tool.name === name)
}

describe('MCP Bill of Materials Tools', function () {
let inject

beforeEach(function () {
inject = sinon.stub()
})

describe('platform_get_team_bom', function () {
const tool = getTool('platform_get_team_bom')

it('injects the team bom route and returns the response', async function () {
const routeResponse = { statusCode: 200, json: () => ([]) }
inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/bom' }).resolves(routeResponse)

const response = await tool.handler({ teamId: 'team1' }, { 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({ teamId: 'team1' }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_get_application_bom', function () {
const tool = getTool('platform_get_application_bom')

it('injects the application bom route and returns the response', async function () {
const routeResponse = { statusCode: 200, json: () => ({}) }
inject.withArgs({ method: 'GET', url: '/api/v1/applications/app1/bom' }).resolves(routeResponse)

const response = await tool.handler({ applicationId: 'app1' }, { 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({ applicationId: 'app1' }, { inject })
response.should.equal(errorResponse)
})
})
})
Loading