diff --git a/forge/ee/lib/mcp/tools/platform.js b/forge/ee/lib/mcp/tools/platform.js index b5d2e20fe7..23b72172a6 100644 --- a/forge/ee/lib/mcp/tools/platform.js +++ b/forge/ee/lib/mcp/tools/platform.js @@ -1,45 +1,155 @@ +const { z } = require('zod') + +const { basePagination, basePaginationKeys, searchQuery, searchQueryKeys, appendQuery } = require('../schemas') + module.exports = [ { name: 'platform_list_hosted_instance_types', title: 'List Hosted Instance Types', - description: 'FlowFuse platform automation tool: List all available hosted instance types. Use this to find valid projectType values when creating a hosted instance.', + description: 'FlowFuse platform automation tool: List all available hosted instance types. Use this to find valid projectType values when creating a hosted instance. Supports name search, active-state filtering and pagination.', annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, + inputSchema: { + ...basePagination, + ...searchQuery, + filter: z.enum(['all', 'active', 'inactive']).optional().describe('Which types to include by active state (default active only)') + }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/project-types' }) + const url = appendQuery('/api/v1/project-types', args, [...basePaginationKeys, 'query', 'filter']) + const response = await inject({ method: 'GET', url }) return response } }, { name: 'platform_list_stacks', title: 'List Stacks', - description: 'FlowFuse platform automation tool: List all available stacks (Node-RED versions). Use this to find valid stack values when creating a hosted instance.', + description: 'FlowFuse platform automation tool: List all available stacks (Node-RED versions). Use this to find valid stack values when creating a hosted instance. Supports name search, filtering and pagination.', annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, + inputSchema: { + ...basePagination, + ...searchQuery, + filter: z.string().optional().describe('Filter by active state ("all", "active", "inactive") or by replacement ("replacedBy:")'), + projectType: z.string().optional().describe('Only return stacks for this hosted instance type (project-type hashid)') + }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/stacks' }) + const url = appendQuery('/api/v1/stacks', args, [...basePaginationKeys, 'query', 'filter', 'projectType']) + const response = await inject({ method: 'GET', url }) return response } }, { name: 'platform_list_templates', title: 'List Templates', - description: 'FlowFuse platform automation tool: List all available templates. When creating a hosted instance, if only one template exists, use it automatically. If multiple templates exist, ask the user which one to use.', + description: 'FlowFuse platform automation tool: List all available templates. When creating a hosted instance, if only one template exists, use it automatically. If multiple templates exist, ask the user which one to use. Supports name search and pagination.', annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, + inputSchema: { + ...basePagination, + ...searchQuery + }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/templates' }) + const url = appendQuery('/api/v1/templates', args, [...basePaginationKeys, 'query']) + const response = await inject({ method: 'GET', url }) return response } }, { name: 'platform_list_blueprints', title: 'List Blueprints', - description: 'FlowFuse platform automation tool: List all available flow blueprints. Blueprints provide starter flows that can be used when creating a new hosted instance.', + description: 'FlowFuse platform automation tool: List all available flow blueprints. Blueprints provide starter flows that can be used when creating a new hosted instance. Supports name search, active-state filtering and pagination.', annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, + inputSchema: { + ...basePagination, + ...searchQuery, + filter: z.enum(['all', 'active', 'inactive']).optional().describe('Which blueprints to include by active state (default active only)'), + team: z.string().optional().describe('Team hashid to also include blueprints specific to that team') + }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/flow-blueprints' }) + const url = appendQuery('/api/v1/flow-blueprints', args, [...basePaginationKeys, 'query', 'filter', 'team']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_hosted_instance_type', + title: 'Get Hosted Instance Type', + description: 'Get a single hosted instance type (project type) by id.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceTypeId: z.string().describe('Project-type hashid identifying the hosted instance type') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/project-types/${args.instanceTypeId}` }) + return response + } + }, + { + name: 'platform_get_stack', + title: 'Get Stack', + description: 'Get a single stack by id.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + stackId: z.string().describe('Stack hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/stacks/${args.stackId}` }) + 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 15a713be0c..b0ba1121a5 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -50,9 +50,14 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:read', // get team details // platform 'stack:list', + 'stack:read', '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..3695d3374a --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/platform_spec.js @@ -0,0 +1,311 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence') +const setup = require('../../../setup') + +const FF_UTIL = require('flowforge-test-utils') + +const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/platform') +const findTool = toolFinder(tools) + +describe('MCP Platform Tools - Catalog', function () { + describe('platform_list_hosted_instance_types', function () { + const tool = findTool('platform_list_hosted_instance_types') + + it('exposes pagination, search and filter params', function () { + Object.keys(tool.inputSchema).should.deepEqual(['cursor', 'limit', 'query', 'filter']) + }) + + it('serialises the supported params onto the project-types endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'small', filter: 'active', limit: 5 }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/project-types?limit=5&query=small&filter=active') + }) + }) + + describe('platform_list_stacks', function () { + const tool = findTool('platform_list_stacks') + + it('exposes pagination, search, filter and projectType params', function () { + Object.keys(tool.inputSchema).should.deepEqual(['cursor', 'limit', 'query', 'filter', 'projectType']) + }) + + it('serialises the supported params onto the stacks endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'v3', projectType: 'pt1', filter: 'active' }, { inject }) + calls[0].url.should.equal('/api/v1/stacks?query=v3&filter=active&projectType=pt1') + }) + }) + + describe('platform_list_templates', function () { + const tool = findTool('platform_list_templates') + + it('exposes pagination and search params', function () { + Object.keys(tool.inputSchema).should.deepEqual(['cursor', 'limit', 'query']) + }) + + it('serialises the supported params onto the templates endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'default', limit: 2 }, { inject }) + calls[0].url.should.equal('/api/v1/templates?limit=2&query=default') + }) + }) + + describe('platform_list_blueprints', function () { + const tool = findTool('platform_list_blueprints') + + it('exposes pagination, search, filter and team params', function () { + Object.keys(tool.inputSchema).should.deepEqual(['cursor', 'limit', 'query', 'filter', 'team']) + }) + + it('serialises the supported params onto the flow-blueprints endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'starter', filter: 'active', team: 'team1' }, { inject }) + calls[0].url.should.equal('/api/v1/flow-blueprints?query=starter&filter=active&team=team1') + }) + }) + + describe('platform_get_hosted_instance_type', function () { + const tool = findTool('platform_get_hosted_instance_type') + + it('should be registered with readOnly annotations', function () { + should.exist(tool) + tool.annotations.readOnlyHint.should.equal(true) + tool.annotations.destructiveHint.should.equal(false) + }) + + it('should declare an instanceTypeId input', function () { + Object.keys(tool.inputSchema).should.deepEqual(['instanceTypeId']) + }) + + it('should call the project-types get endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ instanceTypeId: 'ptid123' }, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/project-types/ptid123') + }) + }) + + describe('platform_get_stack', function () { + const tool = findTool('platform_get_stack') + + it('should be registered with readOnly annotations', function () { + should.exist(tool) + tool.annotations.readOnlyHint.should.equal(true) + tool.annotations.destructiveHint.should.equal(false) + }) + + it('should declare a stackId input', function () { + Object.keys(tool.inputSchema).should.deepEqual(['stackId']) + }) + + it('should call the stacks get endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ stackId: 'stackid123' }, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/stacks/stackid123') + }) + }) + + describe('platform_get_template', function () { + const tool = findTool('platform_get_template') + + it('should be registered with readOnly annotations', function () { + should.exist(tool) + tool.annotations.readOnlyHint.should.equal(true) + tool.annotations.destructiveHint.should.equal(false) + }) + + it('should declare a templateId input', function () { + Object.keys(tool.inputSchema).should.deepEqual(['templateId']) + }) + + it('should call the templates get endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ templateId: 'templateid123' }, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/templates/templateid123') + }) + }) + + describe('platform_get_blueprint', function () { + const tool = findTool('platform_get_blueprint') + + it('should be registered with readOnly annotations', function () { + should.exist(tool) + tool.annotations.readOnlyHint.should.equal(true) + tool.annotations.destructiveHint.should.equal(false) + }) + + it('should declare a flowBlueprintId input', function () { + Object.keys(tool.inputSchema).should.deepEqual(['flowBlueprintId']) + }) + + it('should call the flow-blueprints get endpoint', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ flowBlueprintId: 'bpid123' }, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/flow-blueprints/bpid123') + }) + }) + + describe('platform_list_team_types', function () { + const tool = findTool('platform_list_team_types') + + it('has pagination plus search and filter params', function () { + Object.keys(tool.inputSchema).should.eql(['cursor', 'limit', 'query', 'filter']) + }) + + it('calls GET /api/v1/team-types', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ cursor: 'abc' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/team-types?cursor=abc') + }) + + it('serialises search and filter params', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'starter', filter: 'active' }, { inject }) + calls[0].url.should.equal('/api/v1/team-types?query=starter&filter=active') + }) + }) + + describe('platform_get_team_type', function () { + const tool = findTool('platform_get_team_type') + + it('has the teamTypeId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamTypeId']) + }) + + it('calls GET /api/v1/team-types/:teamTypeId', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamTypeId: 'tt1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/team-types/tt1') + }) + }) + + describe('Integration smoke test', function () { + let app + let token + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true } + }) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + it('should get a stack by id via the platform_get_stack handler', async function () { + const tool = findTool('platform_get_stack') + const response = await tool.handler({ stackId: app.stack.hashid }, { inject }) + response.statusCode.should.equal(200) + const body = response.json() + body.should.have.property('id', app.stack.hashid) + }) + }) + + describe('Tool vs route equivalence', function () { + let app + let token + let blueprint + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true } + }) + token = await createExpertMcpToken(app) + blueprint = await app.factory.createBlueprint({ + name: 'equivalence-test-blueprint', + description: 'a blueprint used to test the platform_get_blueprint tool', + active: true, + category: 'starter', + flows: { flows: [] }, + modules: {} + }) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + it('platform_get_stack returns exactly what GET /api/v1/stacks/:stackId returns', async function () { + const tool = findTool('platform_get_stack') + await expectToolMatchesRoute(inject, tool, { stackId: app.stack.hashid }, { + method: 'GET', + url: `/api/v1/stacks/${app.stack.hashid}` + }) + }) + + it('platform_get_template returns exactly what GET /api/v1/templates/:templateId returns', async function () { + const tool = findTool('platform_get_template') + await expectToolMatchesRoute(inject, tool, { templateId: app.template.hashid }, { + method: 'GET', + url: `/api/v1/templates/${app.template.hashid}` + }) + }) + + it('platform_get_hosted_instance_type returns exactly what GET /api/v1/project-types/:instanceTypeId returns', async function () { + const tool = findTool('platform_get_hosted_instance_type') + await expectToolMatchesRoute(inject, tool, { instanceTypeId: app.projectType.hashid }, { + method: 'GET', + url: `/api/v1/project-types/${app.projectType.hashid}` + }) + }) + + it('platform_get_blueprint returns exactly what GET /api/v1/flow-blueprints/:flowBlueprintId returns', async function () { + const tool = findTool('platform_get_blueprint') + await expectToolMatchesRoute(inject, tool, { flowBlueprintId: blueprint.hashid }, { + method: 'GET', + url: `/api/v1/flow-blueprints/${blueprint.hashid}` + }) + }) + + it('platform_list_team_types matches GET /api/v1/team-types', async function () { + const tool = findTool('platform_list_team_types') + await expectToolMatchesRoute(inject, tool, {}, { + method: 'GET', + url: '/api/v1/team-types' + }) + }) + + it('platform_get_team_type matches GET /api/v1/team-types/:teamTypeId', async function () { + const tool = findTool('platform_get_team_type') + await expectToolMatchesRoute(inject, tool, { teamTypeId: app.defaultTeamType.hashid }, { + method: 'GET', + url: `/api/v1/team-types/${app.defaultTeamType.hashid}` + }) + }) + }) +})