diff --git a/forge/ee/routes/mcp/server.js b/forge/ee/routes/mcp/server.js index 3994dd465a..5038af9494 100644 --- a/forge/ee/routes/mcp/server.js +++ b/forge/ee/routes/mcp/server.js @@ -67,7 +67,14 @@ module.exports = async function (app) { await server.connect(transport) // Hand off response handling to the MCP transport. - // reply.hijack() tells Fastify we're managing the response directly. + // reply.hijack() tells Fastify we're managing the response directly, + // which means Fastify plugins (including CORS) won't set headers. + // Set CORS headers manually on the raw response before hijacking. + const origin = request.headers.origin + if (origin) { + reply.raw.setHeader('Access-Control-Allow-Origin', origin) + reply.raw.setHeader('Access-Control-Allow-Credentials', 'true') + } reply.hijack() // The MCP SDK's transport uses @hono/node-server internally, which sets diff --git a/forge/ee/routes/mcp/tools/applications.js b/forge/ee/routes/mcp/tools/applications.js new file mode 100644 index 0000000000..47d07d9175 --- /dev/null +++ b/forge/ee/routes/mcp/tools/applications.js @@ -0,0 +1,46 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform.list-applications', + description: 'List all applications in a team.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/applications` }) + return response + } + }, + { + name: 'platform.get-application', + description: 'Get details of a specific application, including its instances and devices.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}` }) + return response + } + }, + { + name: 'platform.create-application', + description: 'Create a new application in a team.', + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + name: z.string().describe('Name for the new application'), + teamId: z.string().describe('The ID or hashid of the team to create the application in'), + description: z.string().optional().describe('Optional description for the application') + }, + handler: async (args, { inject }) => { + const payload = { name: args.name, teamId: args.teamId } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: '/api/v1/applications', payload }) + return response + } + } +] diff --git a/forge/ee/routes/mcp/tools/catalog.js b/forge/ee/routes/mcp/tools/catalog.js new file mode 100644 index 0000000000..6d84eb9a1a --- /dev/null +++ b/forge/ee/routes/mcp/tools/catalog.js @@ -0,0 +1,32 @@ +module.exports = [ + { + name: 'platform.list-instance-types', + description: 'List all available instance types. Use this to find valid projectType values when creating an 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', + description: 'List all available stacks (Node-RED versions). Use this to find valid stack values when creating an instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/stacks' }) + return response + } + }, + { + name: 'platform.list-blueprints', + description: 'List all available flow blueprints. Blueprints provide starter flows that can be used when creating a new instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/flow-blueprints' }) + return response + } + } +] diff --git a/forge/ee/routes/mcp/tools/devices.js b/forge/ee/routes/mcp/tools/devices.js new file mode 100644 index 0000000000..9ffecec032 --- /dev/null +++ b/forge/ee/routes/mcp/tools/devices.js @@ -0,0 +1,61 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform.list-devices', + description: 'List all devices in a team.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId: z.string().describe('The ID or hashid of the team') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/devices` }) + return response + } + }, + { + name: 'platform.get-device', + description: 'Get details of a specific device, including its status, assigned application, and target snapshot.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + deviceId: z.string().describe('The ID or hashid of the device') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/devices/${args.deviceId}` }) + return response + } + }, + { + name: 'platform.list-device-snapshots', + description: 'List all snapshots for an application-owned device.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + deviceId: z.string().describe('The ID or hashid of the device') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/devices/${args.deviceId}/snapshots` }) + return response + } + }, + { + name: 'platform.create-device-snapshot', + description: 'Create a snapshot from an application-owned device, capturing its current state.', + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + deviceId: z.string().describe('The ID or hashid of the device'), + name: z.string().optional().describe('Name for the snapshot'), + description: z.string().optional().describe('Description of the snapshot') + }, + handler: async (args, { inject }) => { + const payload = {} + if (args.name) { + payload.name = args.name + } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: `/api/v1/devices/${args.deviceId}/snapshots`, payload }) + return response + } + } +] diff --git a/forge/ee/routes/mcp/tools/instances.js b/forge/ee/routes/mcp/tools/instances.js new file mode 100644 index 0000000000..1f26e9742c --- /dev/null +++ b/forge/ee/routes/mcp/tools/instances.js @@ -0,0 +1,104 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform.list-instances', + description: 'List all instances in an application.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + applicationId: z.string().describe('The ID or hashid of the application') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/instances` }) + return response + } + }, + { + name: 'platform.get-instance', + description: 'Get details of a specific instance, including its current state, URL, and settings.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.instanceId}` }) + return response + } + }, + { + name: 'platform.get-instance-status', + description: 'Get the live runtime status of an instance (running, stopped, suspended, starting, etc).', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.instanceId}/status` }) + return response + } + }, + { + name: 'platform.get-instance-logs', + description: 'Get runtime logs for an instance. Useful for debugging after restarts or failures.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance'), + limit: z.number().optional().describe('Number of log entries to return (default 30)'), + cursor: z.string().optional().describe('Cursor for pagination') + }, + handler: async (args, { inject }) => { + let url = `/api/v1/projects/${args.instanceId}/logs` + const params = [] + if (args.limit) { + params.push(`limit=${args.limit}`) + } + if (args.cursor) { + params.push(`cursor=${args.cursor}`) + } + if (params.length > 0) { + url += '?' + params.join('&') + } + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform.check-name-availability', + description: 'Check if an instance name is available.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + name: z.string().describe('The instance name to check') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: '/api/v1/projects/check-name', payload: { name: args.name } }) + return response + } + }, + { + name: 'platform.create-instance', + description: 'Create a new Node-RED instance in an application. The instance starts automatically after creation. Use platform.list-instance-types, platform.list-stacks, and platform.list-blueprints to discover valid values for the required parameters.', + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + name: z.string().describe('Name for the new instance'), + applicationId: z.string().describe('The ID or hashid of the application'), + projectType: z.string().describe('The ID of the instance type (use platform.list-instance-types to find valid values)'), + stack: z.string().describe('The ID of the stack (use platform.list-stacks to find valid values)'), + template: z.string().describe('The ID of the template'), + flowBlueprintId: z.string().optional().describe('Optional blueprint ID to initialize the instance with starter flows') + }, + handler: async (args, { inject }) => { + const payload = { + name: args.name, + applicationId: args.applicationId, + projectType: args.projectType, + stack: args.stack, + template: args.template + } + if (args.flowBlueprintId) { + payload.flowBlueprintId = args.flowBlueprintId + } + const response = await inject({ method: 'POST', url: '/api/v1/projects', payload }) + return response + } + } +] diff --git a/forge/ee/routes/mcp/tools/navigation.js b/forge/ee/routes/mcp/tools/navigation.js new file mode 100644 index 0000000000..4c66935a92 --- /dev/null +++ b/forge/ee/routes/mcp/tools/navigation.js @@ -0,0 +1,42 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform.open-editor', + description: 'Get the URL to open the Node-RED editor for an instance. Returns a URL the user can open in their browser.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.instanceId}` }) + if (response.statusCode >= 400) { + return response + } + const instance = response.json() + return { + statusCode: 200, + json: () => ({ url: `${instance.url}/editor`, name: instance.name }) + } + } + }, + { + name: 'platform.open-instance', + description: 'Get the URL to open the instance dashboard in the FlowFuse platform. Returns a URL the user can open in their browser.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.instanceId}` }) + if (response.statusCode >= 400) { + return response + } + const instance = response.json() + return { + statusCode: 200, + json: () => ({ url: instance.url, name: instance.name }) + } + } + } +] diff --git a/forge/ee/routes/mcp/tools/snapshots.js b/forge/ee/routes/mcp/tools/snapshots.js new file mode 100644 index 0000000000..2c8b7511c6 --- /dev/null +++ b/forge/ee/routes/mcp/tools/snapshots.js @@ -0,0 +1,37 @@ +const { z } = require('zod') + +module.exports = [ + { + name: 'platform.list-snapshots', + description: 'List all snapshots for an instance.', + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/projects/${args.instanceId}/snapshots` }) + return response + } + }, + { + name: 'platform.create-snapshot', + description: 'Create a snapshot of an instance, capturing its current flows, settings, and credentials.', + annotations: { readOnlyHint: false, destructiveHint: false }, + inputSchema: { + instanceId: z.string().describe('The ID or hashid of the instance'), + name: z.string().optional().describe('Name for the snapshot'), + description: z.string().optional().describe('Description of the snapshot') + }, + handler: async (args, { inject }) => { + const payload = {} + if (args.name) { + payload.name = args.name + } + if (args.description) { + payload.description = args.description + } + const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.instanceId}/snapshots`, payload }) + return response + } + } +] diff --git a/forge/ee/routes/mcp/tools/teams.js b/forge/ee/routes/mcp/tools/teams.js index 1efd178c91..291f0e7847 100644 --- a/forge/ee/routes/mcp/tools/teams.js +++ b/forge/ee/routes/mcp/tools/teams.js @@ -2,7 +2,7 @@ const { z } = require('zod') module.exports = [ { - name: 'list-teams', + name: 'platform.list-teams', description: 'List all teams the authenticated user belongs to. Returns team names, slugs, IDs, and membership roles.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: {}, @@ -12,7 +12,7 @@ module.exports = [ } }, { - name: 'get-team', + name: 'platform.get-team', description: 'Get details of a specific team by its ID, including team type, member count, and instance counts.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js index 5562b42798..a458766626 100644 --- a/test/unit/forge/ee/routes/mcp/server_spec.js +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -159,15 +159,15 @@ describe('MCP Platform Tools Server', function () { toolsResponse.should.have.property('result') toolsResponse.result.should.have.property('tools') toolsResponse.result.tools.should.be.an.Array() - toolsResponse.result.tools.length.should.be.greaterThan(0) + toolsResponse.result.tools.length.should.equal(22) - const listTeams = toolsResponse.result.tools.find(t => t.name === 'list-teams') + const listTeams = toolsResponse.result.tools.find(t => t.name === 'platform.list-teams') listTeams.should.be.an.Object() listTeams.should.have.property('description') listTeams.annotations.readOnlyHint.should.equal(true) listTeams.annotations.destructiveHint.should.equal(false) - const getTeam = toolsResponse.result.tools.find(t => t.name === 'get-team') + const getTeam = toolsResponse.result.tools.find(t => t.name === 'platform.get-team') getTeam.should.be.an.Object() getTeam.should.have.property('inputSchema') }) @@ -185,7 +185,7 @@ describe('MCP Platform Tools Server', function () { }, payload: [ { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'list-teams', arguments: {} } } + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.list-teams', arguments: {} } } ] }) const parsed = parseSSEResponse(response) @@ -214,7 +214,7 @@ describe('MCP Platform Tools Server', function () { }, payload: [ { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'get-team', arguments: { teamId } } } + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.get-team', arguments: { teamId } } } ] }) const parsed = parseSSEResponse(response) @@ -237,7 +237,7 @@ describe('MCP Platform Tools Server', function () { }, payload: [ { jsonrpc: '2.0', method: 'notifications/initialized' }, - { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'get-team', arguments: { teamId: 'nonexistent' } } } + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.get-team', arguments: { teamId: 'nonexistent' } } } ] }) const parsed = parseSSEResponse(response)