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..989276c372 --- /dev/null +++ b/forge/ee/routes/mcp/server.js @@ -0,0 +1,34 @@ +/** + * MCP Platform Tools Server + * + * Exposes FlowFuse platform management capabilities as MCP tools. + * + * @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 handler will be implemented in #7429 + app.post('/', async (request, reply) => { + reply.code(501).send({ code: 'not_implemented', error: 'MCP endpoint not yet implemented' }) + }) + + // 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/package-lock.json b/package-lock.json index 3675e3fb50..c83a79f9ee 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": { @@ -3989,6 +3991,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", @@ -25424,9 +25435,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" @@ -27869,6 +27880,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": { @@ -42591,9 +42609,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 2fe109b0a0..96bd73e8c9 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..cc2282dcb5 --- /dev/null +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -0,0 +1,172 @@ +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() + }) + + it('should register the expertPlatformAutomation feature flag', async function () { + app.config.features.enabled('expertPlatformAutomation').should.equal(true) + }) + + it('should return 501 for POST /api/v1/mcp with valid PAT (endpoint shell)', 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(501) + }) + + it('should return 401 for POST /api/v1/mcp 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 for POST /api/v1/mcp 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) + }) + + it('should return 405 for GET /api/v1/mcp', 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 /api/v1/mcp', async function () { + const response = await app.inject({ + method: 'DELETE', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + } + }) + response.statusCode.should.equal(405) + }) + + 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) + + // Verify listing also works + 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) + }) + }) +})