diff --git a/forge/ee/lib/mcp/tools/bom.js b/forge/ee/lib/mcp/tools/bom.js new file mode 100644 index 0000000000..1999157b13 --- /dev/null +++ b/forge/ee/lib/mcp/tools/bom.js @@ -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 + } + } +] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index c1740abaa9..02cf9a89f6 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -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 ] } diff --git a/test/unit/forge/ee/lib/mcp/tools/bom_spec.js b/test/unit/forge/ee/lib/mcp/tools/bom_spec.js new file mode 100644 index 0000000000..a834e5041c --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/bom_spec.js @@ -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) + }) + }) +})