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
61 changes: 61 additions & 0 deletions forge/ee/lib/mcp/tools/platform.js
Original file line number Diff line number Diff line change
@@ -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('.')) {
Expand Down Expand Up @@ -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
}
}
]
6 changes: 5 additions & 1 deletion forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}

Expand Down
204 changes: 204 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/platform_spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
Loading