diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 8b979786d9..65abf24ef8 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -52,6 +52,9 @@ module.exports = fp(async function (app, opts) { // Set the expert assistant Feature Flag app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // Set the expert platform automation Feature Flag (MCP platform tools server) + app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // temporary until FF Expert Insights can be enabled on Self Hosted EE instance const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled app.config.features.register('expertInsights', isInsightsEnabled ?? false, false) diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index ba9f1b6f98..14ef4c5b03 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -39,7 +39,7 @@ module.exports = async function (app) { if (app.config.tables?.enabled) { await app.register(require('./tables'), { prefix: '/api/v1/teams/:teamId/databases', logLevel: app.config.logging.http }) } - await app.register(require('./mcp'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) + await app.register(require('./mcp'), { logLevel: app.config.logging.http }) await app.register(require('./autoUpdateStacks'), { prefix: '/api/v1/projects/:projectId/autoUpdateStack', logLevel: app.config.logging.http }) await app.register(require('./expert'), { prefix: '/api/v1/expert', logLevel: app.config.logging.http }) diff --git a/forge/ee/routes/mcp/index.js b/forge/ee/routes/mcp/index.js index ed8da3b08e..1b692df3dd 100644 --- a/forge/ee/routes/mcp/index.js +++ b/forge/ee/routes/mcp/index.js @@ -1,198 +1,12 @@ +/** + * MCP routes + * + * - registrations: NR instance/device MCP server registration and discovery + * - server: Platform MCP server endpoint for external AI agents + * + * @param {import('../../../forge').ForgeApplication} app + */ module.exports = async function (app) { - app.addHook('preHandler', async (request, reply) => { - if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { - if (!request.team) { - // For a :teamId route, we can now lookup the full team object - request.team = await app.db.models.Team.byId(request.params.teamId) - if (!request.team) { - reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } - } - } - if (request.session.User) { - request.sessionUser = true - request.instanceTokenReq = false - if (!request.teamMembership) { - request.teamMembership = await request.session.User.getTeamMembership(request.team.id) - } - } else if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // this is a request from a project or device - request.sessionUserReq = false - request.instanceTokenReq = true - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - throw new Error('Unauthorized') - } - }) - - /** - * Get the MCP servers for a team - * @name /api/v1/teams/:teamId/mcp - * @static - * @memberof forge.routes.api.team.mcp - */ - app.get('/', { - preHandler: app.needsPermission('team:mcp:list'), - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - properties: { - count: { type: 'number' }, - servers: { $ref: 'MCPRegistrationSummaryList' } - } - }, - '4xx': { - $ref: 'APIError' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - const mcpServers = await app.db.models.MCPRegistration.byTeam(request.params.teamId) - const mcpServersView = app.db.views.MCPRegistrations.MCPRegistrationSummaryList(mcpServers) - reply.send({ count: mcpServers.length, servers: mcpServersView }) - } catch (err) { - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to find mcp entries for team' }) - } - }) - - app.post('/:type/:typeId/:nodeId', { - preHandler: async (request, reply) => { - if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // all good - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - } - }, - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' }, - type: { type: 'string' }, - typeId: { type: 'string' }, - nodeId: { type: 'string' } - } - }, - body: { - type: 'object', - properties: { - name: { type: 'string' }, - endpointRoute: { type: 'string' }, - protocol: { type: 'string' }, - title: { type: 'string' }, - version: { type: 'string' }, - description: { type: 'string' } - } - }, - response: { - 200: { - type: 'object' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - let typeId = request.params.typeId - if (request.params.type === 'device') { - const device = await app.db.models.Device.byId(request.params.typeId) - if (!device) { - throw new Error(`Device '${request.params.typeId}' not found`) - } - typeId = device.id - } else if (request.params.type === 'instance') { - const project = await app.db.models.Project.byId(request.params.typeId) - if (!project) { - throw new Error(`Instance '${request.params.typeId}' not found`) - } - } else { - throw new Error(`Unknown MCP target type '${request.params.type}'`) - } - - await app.db.models.MCPRegistration.upsert({ - targetType: request.params.type, - targetId: typeId, - nodeId: request.params.nodeId, - title: request.body.title, - version: request.body.version, - description: request.body.description, - name: request.body.name, - endpointRoute: request.body.endpointRoute, - protocol: request.body.protocol, - TeamId: request.team.id - }, { - fields: ['name', 'endpointRoute', 'title', 'version', 'description'], - conflictFields: ['TeamId', 'targetType', 'nodeId', 'targetId'] - }) - } catch (err) { - app.log.error(`register MCP Server ${err.toString()}`) - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create mcp entry' }) - return - } - reply.send({}) - }) - - app.delete('/:type/:typeId/:nodeId', { - preHandler: async (request, reply) => { - if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // all good - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - } - }, - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' }, - type: { type: 'string' }, - typeId: { type: 'string' }, - nodeId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object' - }, - '4xx': { - $ref: 'APIError' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) - if (mcpServer) { - await mcpServer.destroy() - reply.send({}) - } else { - reply.status(404).send({ code: 'not_found', error: 'MCP server not found' }) - } - } catch (err) { - app.log.error(`delete MCP Server ${err.toString()}`) - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to delete mcp entry' }) - } - }) + await app.register(require('./registrations'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) + await app.register(require('./server'), { prefix: '/api/v1/mcp', logLevel: app.config.logging.http }) } diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js new file mode 100644 index 0000000000..ed8da3b08e --- /dev/null +++ b/forge/ee/routes/mcp/registrations.js @@ -0,0 +1,198 @@ +module.exports = async function (app) { + app.addHook('preHandler', async (request, reply) => { + if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { + if (!request.team) { + // For a :teamId route, we can now lookup the full team object + request.team = await app.db.models.Team.byId(request.params.teamId) + if (!request.team) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + } + if (request.session.User) { + request.sessionUser = true + request.instanceTokenReq = false + if (!request.teamMembership) { + request.teamMembership = await request.session.User.getTeamMembership(request.team.id) + } + } else if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // this is a request from a project or device + request.sessionUserReq = false + request.instanceTokenReq = true + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + throw new Error('Unauthorized') + } + }) + + /** + * Get the MCP servers for a team + * @name /api/v1/teams/:teamId/mcp + * @static + * @memberof forge.routes.api.team.mcp + */ + app.get('/', { + preHandler: app.needsPermission('team:mcp:list'), + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + count: { type: 'number' }, + servers: { $ref: 'MCPRegistrationSummaryList' } + } + }, + '4xx': { + $ref: 'APIError' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const mcpServers = await app.db.models.MCPRegistration.byTeam(request.params.teamId) + const mcpServersView = app.db.views.MCPRegistrations.MCPRegistrationSummaryList(mcpServers) + reply.send({ count: mcpServers.length, servers: mcpServersView }) + } catch (err) { + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to find mcp entries for team' }) + } + }) + + app.post('/:type/:typeId/:nodeId', { + preHandler: async (request, reply) => { + if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // all good + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + } + }, + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' }, + type: { type: 'string' }, + typeId: { type: 'string' }, + nodeId: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + endpointRoute: { type: 'string' }, + protocol: { type: 'string' }, + title: { type: 'string' }, + version: { type: 'string' }, + description: { type: 'string' } + } + }, + response: { + 200: { + type: 'object' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + let typeId = request.params.typeId + if (request.params.type === 'device') { + const device = await app.db.models.Device.byId(request.params.typeId) + if (!device) { + throw new Error(`Device '${request.params.typeId}' not found`) + } + typeId = device.id + } else if (request.params.type === 'instance') { + const project = await app.db.models.Project.byId(request.params.typeId) + if (!project) { + throw new Error(`Instance '${request.params.typeId}' not found`) + } + } else { + throw new Error(`Unknown MCP target type '${request.params.type}'`) + } + + await app.db.models.MCPRegistration.upsert({ + targetType: request.params.type, + targetId: typeId, + nodeId: request.params.nodeId, + title: request.body.title, + version: request.body.version, + description: request.body.description, + name: request.body.name, + endpointRoute: request.body.endpointRoute, + protocol: request.body.protocol, + TeamId: request.team.id + }, { + fields: ['name', 'endpointRoute', 'title', 'version', 'description'], + conflictFields: ['TeamId', 'targetType', 'nodeId', 'targetId'] + }) + } catch (err) { + app.log.error(`register MCP Server ${err.toString()}`) + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create mcp entry' }) + return + } + reply.send({}) + }) + + app.delete('/:type/:typeId/:nodeId', { + preHandler: async (request, reply) => { + if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // all good + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + } + }, + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' }, + type: { type: 'string' }, + typeId: { type: 'string' }, + nodeId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object' + }, + '4xx': { + $ref: 'APIError' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) + if (mcpServer) { + await mcpServer.destroy() + reply.send({}) + } else { + reply.status(404).send({ code: 'not_found', error: 'MCP server not found' }) + } + } catch (err) { + app.log.error(`delete MCP Server ${err.toString()}`) + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to delete mcp entry' }) + } + }) +} diff --git a/forge/ee/routes/mcp/server.js b/forge/ee/routes/mcp/server.js new file mode 100644 index 0000000000..5038af9494 --- /dev/null +++ b/forge/ee/routes/mcp/server.js @@ -0,0 +1,100 @@ +const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js') +const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js') + +const { loadToolDefinitions, registerTools } = require('./toolLoader') + +// Load tool definitions once at startup +const toolDefinitions = loadToolDefinitions() + +/** + * MCP Platform Tools Server + * + * Exposes FlowFuse platform management capabilities as MCP tools. + * Stateless Streamable HTTP: each POST creates a fresh McpServer and transport. + * Auth via Bearer token (PAT), forwarded through app.inject() to existing routes. + * + * @param {import('../../../forge').ForgeApplication} app + */ +module.exports = async function (app) { + app.addHook('preHandler', async (request, reply) => { + // Gate on feature flag + if (!app.config.features.enabled('expertPlatformAutomation')) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return + } + // Require a user-owned PAT (not device/project/broker tokens) + if (!request.session?.User) { + reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) + } + }) + + /** + * POST / - MCP protocol endpoint (Streamable HTTP) + * + * Each request creates a fresh McpServer instance with a stateless transport. + * The auth token is forwarded to all internal route calls via app.inject(). + */ + app.post('/', async (request, reply) => { + const server = new McpServer( + { name: 'FlowFuse Platform', version: '1.0.0' }, + { capabilities: { tools: {} } } + ) + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined // stateless, no server-side sessions + }) + + // Bind inject to this request's auth token + const inject = (opts) => { + return app.inject({ + ...opts, + headers: { + ...opts.headers, + authorization: request.headers.authorization + } + }) + } + + // Stub scope check: will enforce PAT scopes once scoped PATs (#7411) land. + // When implemented, this will check tool.annotations against the PAT's + // readOnly flag and team scope restrictions. + const checkScope = (_tool) => { + return null // no restriction for now + } + + registerTools(server, toolDefinitions, inject, checkScope) + + await server.connect(transport) + + // Hand off response handling to the MCP transport. + // 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 + // a drain timeout that calls socket.destroySoon(). Fastify's app.inject() + // creates mock sockets that lack this method, so we polyfill it. + const socket = request.raw.socket + if (socket && !socket.destroySoon) { + socket.destroySoon = () => socket.destroy?.() + } + + await transport.handleRequest(request.raw, reply.raw, request.body) + await server.close() + }) + + // GET and DELETE are not supported in stateless mode + app.get('/', async (request, reply) => { + reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Use POST for MCP requests.' }) + }) + + app.delete('/', async (request, reply) => { + reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Stateless mode, no sessions to terminate.' }) + }) +} diff --git a/forge/ee/routes/mcp/toolLoader.js b/forge/ee/routes/mcp/toolLoader.js new file mode 100644 index 0000000000..f8655ed360 --- /dev/null +++ b/forge/ee/routes/mcp/toolLoader.js @@ -0,0 +1,69 @@ +const fs = require('fs') +const path = require('path') + +const toolsDir = path.join(__dirname, 'tools') + +/** + * Loads all tool definition files from the tools/ directory. + * Each file should export an array of tool definitions with: + * { name, description, inputSchema, annotations, handler } + * + * Definitions are loaded once at startup and reused across requests. + */ +function loadToolDefinitions () { + const files = fs.readdirSync(toolsDir).filter(f => f.endsWith('.js')) + const allTools = [] + for (const file of files) { + const tools = require(path.join(toolsDir, file)) + allTools.push(...tools) + } + return allTools +} + +/** + * Registers all tool definitions on a McpServer instance. + * Called once per request since the server is stateless (fresh per request). + * + * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server + * @param {Array} toolDefinitions - loaded tool definitions + * @param {Function} inject - app.inject helper bound to the request's auth token + * @param {Function} checkScope - scope check function (stub for now) + */ +function registerTools (server, toolDefinitions, inject, checkScope) { + for (const tool of toolDefinitions) { + const config = { + description: tool.description, + annotations: tool.annotations + } + if (tool.inputSchema && Object.keys(tool.inputSchema).length > 0) { + config.inputSchema = tool.inputSchema + } + + server.registerTool(tool.name, config, async (args) => { + const scopeError = checkScope(tool) + if (scopeError) { + return scopeError + } + const response = await tool.handler(args, { inject }) + return formatResponse(response) + }) + } +} + +/** + * Formats an app.inject() response into an MCP CallToolResult. + */ +function formatResponse (response) { + const body = response.json() + if (response.statusCode >= 400) { + return { + content: [{ type: 'text', text: JSON.stringify(body) }], + isError: true + } + } + return { + content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] + } +} + +module.exports = { loadToolDefinitions, registerTools } 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 new file mode 100644 index 0000000000..291f0e7847 --- /dev/null +++ b/forge/ee/routes/mcp/tools/teams.js @@ -0,0 +1,26 @@ +const { z } = require('zod') + +module.exports = [ + { + 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: {}, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: '/api/v1/user/teams' }) + return response + } + }, + { + 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: { + 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}` }) + return response + } + } +] diff --git a/package-lock.json b/package-lock.json index c38358d7fb..41de50e556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", @@ -78,6 +79,7 @@ "vue-shepherd": "^3.0.0", "vue3-google-login": "^2.0.33", "yaml": "^2.3.1", + "zod": "^4.4.3", "zxcvbn": "^4.4.2" }, "bin": { @@ -4836,6 +4838,15 @@ "node": ">=16.x" } }, + "node_modules/@flowfuse/nr-assistant/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@flowfuse/nr-file-nodes": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/@flowfuse/nr-file-nodes/-/nr-file-nodes-0.0.10.tgz", @@ -27234,9 +27245,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -30052,6 +30063,13 @@ "onnxruntime-web": "^1.22.0", "semver": "^7.7.2", "zod": "^3.25.76" + }, + "dependencies": { + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + } } }, "@flowfuse/nr-file-nodes": { @@ -45316,9 +45334,9 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, "zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" }, "zod-to-json-schema": { "version": "3.25.2", diff --git a/package.json b/package.json index 0b1719c256..fb1a9b8e3b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", @@ -125,6 +126,7 @@ "vue-shepherd": "^3.0.0", "vue3-google-login": "^2.0.33", "yaml": "^2.3.1", + "zod": "^4.4.3", "zxcvbn": "^4.4.2" }, "devDependencies": { diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js new file mode 100644 index 0000000000..a458766626 --- /dev/null +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -0,0 +1,394 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const setup = require('../../setup') + +describe('MCP Platform Tools Server', function () { + describe('Feature flag enabled (default)', function () { + let app + const TestObjects = {} + + before(async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w', + ai: { enabled: true }, + expert: { enabled: true } + }) + + TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( + app.user, + '', + null, + 'alice-pat' + ) + }) + + after(async function () { + await app.close() + }) + + /** + * Parses an SSE response from the MCP transport. + * Extracts JSON-RPC messages from `data:` lines. + */ + function parseSSEResponse (response) { + const body = response.body + if (response.headers['content-type']?.includes('application/json')) { + return { statusCode: response.statusCode, result: JSON.parse(body) } + } + const messages = [] + const lines = body.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + messages.push(JSON.parse(line.slice(6))) + } catch (e) { + // skip non-JSON data lines + } + } + } + if (messages.length === 1) { + return { statusCode: response.statusCode, result: messages[0] } + } + return { statusCode: response.statusCode, messages } + } + + describe('Feature flag', function () { + it('should register the expertPlatformAutomation feature flag', async function () { + app.config.features.enabled('expertPlatformAutomation').should.equal(true) + }) + }) + + describe('Authentication', function () { + it('should return 401 without auth', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(401) + }) + + it('should return 401 with invalid token', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: 'Bearer invalid-token' + }, + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(401) + }) + }) + + describe('Transport', function () { + it('should return 405 for GET', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + } + }) + response.statusCode.should.equal(405) + }) + + it('should return 405 for DELETE', async function () { + const response = await app.inject({ + method: 'DELETE', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + } + }) + response.statusCode.should.equal(405) + }) + }) + + describe('Initialize', function () { + it('should respond with server info and capabilities', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + } + }) + response.statusCode.should.equal(200) + const { result } = parseSSEResponse(response) + result.should.have.property('result') + result.result.should.have.property('serverInfo') + result.result.serverInfo.name.should.equal('FlowFuse Platform') + result.result.serverInfo.version.should.equal('1.0.0') + result.result.should.have.property('capabilities') + result.result.capabilities.should.have.property('tools') + }) + }) + + describe('Tool listing', function () { + it('should list registered tools with annotations', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: [ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'tools/list', id: 2 } + ] + }) + response.statusCode.should.equal(200) + const parsed = parseSSEResponse(response) + const messages = parsed.messages || [parsed.result] + const toolsResponse = messages.find(m => m.id === 2) + toolsResponse.should.have.property('result') + toolsResponse.result.should.have.property('tools') + toolsResponse.result.tools.should.be.an.Array() + toolsResponse.result.tools.length.should.equal(22) + + 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 === 'platform.get-team') + getTeam.should.be.an.Object() + getTeam.should.have.property('inputSchema') + }) + }) + + describe('Tool execution', function () { + it('list-teams should return teams for the authenticated user', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: [ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.list-teams', arguments: {} } } + ] + }) + const parsed = parseSSEResponse(response) + const messages = parsed.messages || [parsed.result] + const toolResult = messages.find(m => m.id === 2) + toolResult.should.have.property('result') + toolResult.result.should.have.property('content') + toolResult.result.content[0].type.should.equal('text') + + const data = JSON.parse(toolResult.result.content[0].text) + data.should.have.property('teams') + data.teams.should.be.an.Array() + data.teams.length.should.be.greaterThan(0) + data.teams[0].should.have.property('name', 'ATeam') + }) + + it('get-team should return team details by ID', async function () { + const teamId = app.team.hashid + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: [ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.get-team', arguments: { teamId } } } + ] + }) + const parsed = parseSSEResponse(response) + const messages = parsed.messages || [parsed.result] + const toolResult = messages.find(m => m.id === 2) + toolResult.should.have.property('result') + + const data = JSON.parse(toolResult.result.content[0].text) + data.should.have.property('name', 'ATeam') + }) + + it('get-team should return error for non-existent team', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: [ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', method: 'tools/call', id: 2, params: { name: 'platform.get-team', arguments: { teamId: 'nonexistent' } } } + ] + }) + const parsed = parseSSEResponse(response) + const messages = parsed.messages || [parsed.result] + const toolResult = messages.find(m => m.id === 2) + toolResult.should.have.property('result') + toolResult.result.isError.should.equal(true) + }) + }) + + describe('Stateless behavior', function () { + it('should not leak state between sequential requests', async function () { + // First request: initialize + const res1 = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + } + }) + res1.statusCode.should.equal(200) + + // Second request: independent initialize (no session carry-over) + const res2 = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + payload: { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client-2', version: '2.0.0' } + } + } + }) + res2.statusCode.should.equal(200) + + // Both should have succeeded independently + const parsed1 = parseSSEResponse(res1) + const parsed2 = parseSSEResponse(res2) + parsed1.result.result.serverInfo.name.should.equal('FlowFuse Platform') + parsed2.result.result.serverInfo.name.should.equal('FlowFuse Platform') + + // No Mcp-Session-Id header (stateless) + should(res1.headers['mcp-session-id']).be.undefined() + should(res2.headers['mcp-session-id']).be.undefined() + }) + }) + + describe('Existing registration routes', function () { + it('should not break existing registration routes', async function () { + const { token } = await app.instance.refreshAuthTokens() + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/mcp/instance/${app.instance.id}/test-node`, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + payload: { + name: 'test-server', + protocol: 'http', + endpointRoute: '/mcp', + title: 'Test MCP', + version: '1.0.0', + description: 'test' + } + }) + response.statusCode.should.equal(200) + + await login(app) + const listResponse = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/mcp`, + cookies: { sid: TestObjects.aliceSid } + }) + listResponse.statusCode.should.equal(200) + const body = listResponse.json() + body.should.have.property('servers') + body.servers.should.be.an.Array() + }) + + async function login (app) { + if (TestObjects.aliceSid) { + return + } + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username: 'alice', password: 'aaPassword', remember: false } + }) + TestObjects.aliceSid = response.cookies[0].value + } + }) + }) + + describe('Feature flag disabled', function () { + let app + const TestObjects = {} + + before(async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w', + ai: { enabled: false } + }) + + TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( + app.user, + '', + null, + 'alice-pat' + ) + }) + + after(async function () { + await app.close() + }) + + it('should not register the expertPlatformAutomation feature flag when AI is disabled', async function () { + should(app.config.features.enabled('expertPlatformAutomation')).not.equal(true) + }) + + it('should return 404 for POST /api/v1/mcp when feature is disabled', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + }, + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(404) + }) + }) +})