diff --git a/forge/ee/lib/mcp/tools/applications.js b/forge/ee/lib/mcp/tools/applications.js index 5d3be73a6b..3d49d17748 100644 --- a/forge/ee/lib/mcp/tools/applications.js +++ b/forge/ee/lib/mcp/tools/applications.js @@ -36,8 +36,8 @@ module.exports = [ description: `FlowFuse platform automation tool: Gets all the hosted instances that live inside an application. A hosted instance is a Node-RED that runs on the same environment as the FlowFuse platform. - Use this to see which hosted instances an application has. Each result includes the instance name, URL, and basic settings. - To get the full details of one specific hosted instance, call platform_get_hosted_instance with its ID. + Use this to see which hosted instances an application has. Each result includes the instance id, name, URL, and basic settings. + This list does not include an instance's specification (its instance type, stack, or template). To read an instance's specification, for example to duplicate it, call platform_get_hosted_instance with the instance id. To check if hosted instances are currently running or stopped, call platform_get_application_instances_status instead.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { diff --git a/forge/ee/lib/mcp/tools/instances.js b/forge/ee/lib/mcp/tools/instances.js index 26e498ec89..00e973b471 100644 --- a/forge/ee/lib/mcp/tools/instances.js +++ b/forge/ee/lib/mcp/tools/instances.js @@ -8,7 +8,8 @@ module.exports = [ Gets the full details of one specific hosted instance. A hosted instance is a Node-RED that runs on the same environment as the FlowFuse platform. Use this when you already have a hosted instance ID and need to know everything about it: - its name, URL, settings, what application and team it belongs to, and its current state. + its name, URL, settings, what application and team it belongs to, its current state, and its specification (the instance type, stack, and template it uses). + Read the specification from this tool whenever you need to know or compare what an existing instance is running, for example to duplicate it. If you need to list all hosted instances first, call platform_get_application_hosted_instances. To check the live running status, call platform_get_hosted_instance_status instead.`, annotations: { readOnlyHint: true, destructiveHint: false }, @@ -89,8 +90,8 @@ module.exports = [ description: `FlowFuse platform automation tool: Creates a new hosted Node-RED instance inside an application. The instance starts automatically after creation. Before calling this tool, gather the required parameters: - 1. Call platform_list_hosted_instance_types first to see what instance types are available on this platform, then ask the user which one they want. - 2. If they want a specific Node-RED version or stack, or just the latest. Call platform_list_stacks to get the options. If the user has no preference, use the latest available stack. + 1. Call platform_list_hosted_instance_types first to see what instance types (and their stacks) this team can create, then ask the user which one they want. + 2. If they want a specific Node-RED version or stack, or just the latest. Use platform_list_hosted_instance_types with the chosen projectType to see its stacks. If the user has no preference, use the instance type's defaultStack, which is the latest recommended version. 3. If they want to start from a blueprint (pre-built starter flows). Call platform_list_blueprints to show them what is available. This is optional. 4. Call platform_list_templates to get the template. If only one template exists, use it automatically. If there are multiple, ask the user which one to use. 5. Call platform_check_hosted_instance_name_availability to make sure the chosen name is not already taken. @@ -101,7 +102,7 @@ module.exports = [ name: z.string().regex(/^[a-zA-Z][a-zA-Z0-9-]*$/).describe('Name for the new hosted instance. When generating a name, always use hyphens to separate multiple words (e.g. "my-new-instance" not "my new instance").'), applicationId: z.string().describe('The ID or hashid of the application'), projectType: z.string().describe('The ID of the hosted instance type (use platform_list_hosted_instance_types to find valid values)'), - stack: z.string().describe('The ID of the stack (use platform_list_stacks to find valid values)'), + stack: z.string().describe('The ID of the stack (use platform_list_hosted_instance_types to find valid values)'), template: z.string().describe('The ID of the template (use platform_list_templates to find valid values)'), flowBlueprintId: z.string().optional().describe('Optional blueprint ID to initialize the hosted instance with starter flows (use platform_list_blueprints to find valid values)') }, diff --git a/forge/ee/lib/mcp/tools/platform.js b/forge/ee/lib/mcp/tools/platform.js index b5d2e20fe7..19bf419365 100644 --- a/forge/ee/lib/mcp/tools/platform.js +++ b/forge/ee/lib/mcp/tools/platform.js @@ -1,24 +1,80 @@ +const { z } = require('zod') + +function getProperty (properties, key) { + let value = properties + for (const part of key.split('.')) { + if (value === undefined || value === null || !Object.hasOwn(value, part)) { + return undefined + } + value = value[part] + } + return value +} + +function getTeamProperty (team, key, defaultValue) { + const teamValue = getProperty(team.properties, key) + if (teamValue !== undefined) { + return teamValue + } + const teamTypeValue = getProperty(team.type?.properties, key) + return teamTypeValue !== undefined ? teamTypeValue : defaultValue +} + 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.', - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/project-types' }) - 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: + Lists the hosted instance types available to a team, each with its stacks (Node-RED versions) nested inside. + Use this to find valid projectType and stack values when creating a hosted instance. + Each type is flagged available (allowed for the team's plan) and creatable (the team can create a new instance of this type right now, within its limits). + By default only creatable types are returned - pass creatableOnly: false to also see types the team cannot currently create. + Pass projectType to look up one specific instance type and only see its stacks. + Each type includes a defaultStack, which is the recommended (latest) stack for that type.`, annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: {}, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team to check instance type availability for'), + projectType: z.string().optional().describe('Optional ID of one hosted instance type to look up, to only return that type and its stacks'), + creatableOnly: z.boolean().default(true).optional().describe('Whether to only include instance types the team can currently create. Defaults to true.') + }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: '/api/v1/stacks' }) - return response + const teamResponse = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}` }) + if (teamResponse.statusCode >= 400) { + return { content: teamResponse.json(), code: teamResponse.statusCode, isError: true } + } + const team = teamResponse.json() + + const typesResponse = await inject({ method: 'GET', url: '/api/v1/project-types' }) + if (typesResponse.statusCode >= 400) { + return { content: typesResponse.json(), code: typesResponse.statusCode, isError: true } + } + + let types = typesResponse.json().types + if (args.projectType) { + types = types.filter(type => type.id === args.projectType) + } + + const creatableOnly = args.creatableOnly !== false + const decoratedTypes = [] + for (const type of types) { + const available = getTeamProperty(team, `instances.${type.id}.active`, false) + const creatableForTeam = getTeamProperty(team, `instances.${type.id}.creatable`, true) + const limit = getTeamProperty(team, `instances.${type.id}.limit`, null) + const existingCount = team.instanceCountByType?.[type.id] || 0 + const withinLimit = limit === null || limit === undefined || limit < 0 || existingCount < limit + const creatable = available && creatableForTeam && withinLimit + + if (creatableOnly && !creatable) { + continue + } + + const stacksResponse = await inject({ method: 'GET', url: `/api/v1/stacks?projectType=${type.id}` }) + const stacks = stacksResponse.statusCode < 400 ? stacksResponse.json().stacks : [] + + decoratedTypes.push({ ...type, available, creatable, stacks }) + } + + return { types: decoratedTypes } } }, {