diff --git a/forge/ee/lib/mcp/tools/platform.js b/forge/ee/lib/mcp/tools/platform.js index 19bf419365..901f2dd8d6 100644 --- a/forge/ee/lib/mcp/tools/platform.js +++ b/forge/ee/lib/mcp/tools/platform.js @@ -1,5 +1,7 @@ const { z } = require('zod') +const { basePagination, basePaginationKeys, searchQuery, searchQueryKeys, appendQuery } = require('../schemas') + function getProperty (properties, key) { let value = properties for (const part of key.split('.')) { @@ -98,5 +100,64 @@ module.exports = [ const response = await inject({ method: 'GET', url: '/api/v1/flow-blueprints' }) return response } + }, + { + name: 'platform_get_template', + title: 'Get Template', + description: 'Get a single template by id. Env values are blanked in the response.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + templateId: z.string().describe('Template hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/templates/${args.templateId}` }) + return response + } + }, + { + name: 'platform_get_blueprint', + title: 'Get Blueprint', + description: 'Get a single flow blueprint by id.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + flowBlueprintId: z.string().describe('Flow blueprint hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/flow-blueprints/${args.flowBlueprintId}` }) + return response + } + }, + { + name: 'platform_list_team_types', + title: 'List Team Types', + description: `FlowFuse platform automation tool: + Lists the team types (tiers/plans) available on the platform, with name search, active-state filtering and pagination. + Use this to see what team types exist before creating a team or to look up a team's current type.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + ...basePagination, + ...searchQuery, + filter: z.enum(['all', 'active', 'inactive']).optional().describe('Which team types to include by active state (default active only)') + }, + handler: async (args, { inject }) => { + const url = appendQuery('/api/v1/team-types', args, [...basePaginationKeys, ...searchQueryKeys, 'filter']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_team_type', + title: 'Get Team Type', + description: `FlowFuse platform automation tool: + Gets the details of a single team type by its hashid. + Use this to inspect the tier/plan a team is on, or to check a team type before assigning it to a new team.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamTypeId: z.string().describe('Team type hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/team-types/${args.teamTypeId}` }) + return response + } } ] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index c1740abaa9..3a2185b69f 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -81,8 +81,12 @@ const IMPLICIT_TOKEN_SCOPES = { // platform 'stack:list', 'flow-blueprint:list', + 'flow-blueprint:read', 'project:status', - 'template:list' + 'template:list', + 'template:read', + 'team-type:list', // list team types + 'team-type:read' // get team type ] } diff --git a/test/unit/forge/ee/lib/mcp/tools/platform_spec.js b/test/unit/forge/ee/lib/mcp/tools/platform_spec.js new file mode 100644 index 0000000000..4ad43cc2a9 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/platform_spec.js @@ -0,0 +1,204 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const tools = require('../../../../../../../forge/ee/lib/mcp/tools/platform') + +function getTool (name) { + return tools.find(tool => tool.name === name) +} + +describe('MCP Platform Catalog Tools', function () { + let inject + + beforeEach(function () { + inject = sinon.stub() + }) + + describe('platform_list_hosted_instance_types', function () { + const tool = getTool('platform_list_hosted_instance_types') + + it('decorates each type with availability, creatable flags and its stacks', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 200, + json: () => ({ + properties: { instances: { type1: { active: true } } }, + type: { properties: {} }, + instanceCountByType: {} + }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/project-types' }).resolves({ + statusCode: 200, + json: () => ({ types: [{ id: 'type1', name: 'small' }] }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/stacks?projectType=type1' }).resolves({ + statusCode: 200, + json: () => ({ stacks: [{ id: 'stack1', name: 'v3' }] }) + }) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + response.should.eql({ + types: [ + { + id: 'type1', + name: 'small', + available: true, + creatable: true, + stacks: [{ id: 'stack1', name: 'v3' }] + } + ] + }) + }) + + it('includes non-creatable types when creatableOnly is false', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 200, + json: () => ({ properties: {}, type: { properties: {} }, instanceCountByType: {} }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/project-types' }).resolves({ + statusCode: 200, + json: () => ({ types: [{ id: 'type1', name: 'small' }] }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/stacks?projectType=type1' }).resolves({ + statusCode: 200, + json: () => ({ stacks: [] }) + }) + + const response = await tool.handler({ teamId: 'team1', creatableOnly: false }, { inject }) + + response.types.should.have.length(1) + response.types[0].available.should.be.false() + response.types[0].creatable.should.be.false() + }) + + it('excludes non-creatable types by default', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 200, + json: () => ({ properties: {}, type: { properties: {} }, instanceCountByType: {} }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/project-types' }).resolves({ + statusCode: 200, + json: () => ({ types: [{ id: 'type1', name: 'small' }] }) + }) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + response.types.should.have.length(0) + }) + + it('narrows to a single type when projectType is set', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 200, + json: () => ({ + properties: { instances: { type1: { active: true }, type2: { active: true } } }, + type: { properties: {} }, + instanceCountByType: {} + }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/project-types' }).resolves({ + statusCode: 200, + json: () => ({ types: [{ id: 'type1', name: 'small' }, { id: 'type2', name: 'large' }] }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/stacks?projectType=type1' }).resolves({ + statusCode: 200, + json: () => ({ stacks: [] }) + }) + + const response = await tool.handler({ teamId: 'team1', projectType: 'type1' }, { inject }) + + response.types.should.have.length(1) + response.types[0].id.should.equal('type1') + }) + + it('returns an error object when the team fetch fails', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 404, + json: () => ({ code: 'not_found' }) + }) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + response.should.eql({ content: { code: 'not_found' }, code: 404, isError: true }) + }) + + it('returns an error object when the project-types fetch fails', async function () { + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1' }).resolves({ + statusCode: 200, + json: () => ({ properties: {}, type: { properties: {} }, instanceCountByType: {} }) + }) + inject.withArgs({ method: 'GET', url: '/api/v1/project-types' }).resolves({ + statusCode: 500, + json: () => ({ code: 'unexpected_error' }) + }) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + response.should.eql({ content: { code: 'unexpected_error' }, code: 500, isError: true }) + }) + }) + + describe('platform_list_team_types', function () { + const tool = getTool('platform_list_team_types') + + it('serialises pagination, search and filter onto the team-types route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ teamTypes: [] }) } + inject.withArgs({ + method: 'GET', + url: '/api/v1/team-types?cursor=c1&limit=20&query=ent&filter=active' + }).resolves(routeResponse) + + const response = await tool.handler({ + cursor: 'c1', + limit: 20, + query: 'ent', + filter: 'active' + }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 500, json: () => ({ code: 'unexpected_error' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ limit: 10 }, { inject }) + + response.should.equal(errorResponse) + }) + }) + + // Simple GET readers: each injects one URL and returns the response verbatim. + const passthroughGetTools = [ + { name: 'platform_list_templates', args: {}, url: '/api/v1/templates' }, + { name: 'platform_list_blueprints', args: {}, url: '/api/v1/flow-blueprints' }, + { name: 'platform_get_template', args: { templateId: 'tmpl1' }, url: '/api/v1/templates/tmpl1' }, + { name: 'platform_get_blueprint', args: { flowBlueprintId: 'bp1' }, url: '/api/v1/flow-blueprints/bp1' }, + { name: 'platform_get_team_type', args: { teamTypeId: 'tt1' }, url: '/api/v1/team-types/tt1' } + ] + + passthroughGetTools.forEach(({ name, args, url }) => { + describe(name, function () { + const tool = getTool(name) + + it(`injects GET ${url} and returns the response`, async function () { + const routeResponse = { statusCode: 200, json: () => ({ ok: true }) } + inject.withArgs({ method: 'GET', url }).resolves(routeResponse) + + const response = await tool.handler(args, { 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(args, { inject }) + + response.should.equal(errorResponse) + }) + }) + }) +})