From 487826dca996d7d8bb05231e1fd9281422cb4703 Mon Sep 17 00:00:00 2001 From: Ben Hardill Date: Tue, 21 Jul 2026 11:24:53 +0100 Subject: [PATCH 1/7] Prevent cross team MCP registraytion fixes FlowFuse/security#125 --- forge/ee/routes/mcp/registrations.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index ed8da3b08e..5f4c903090 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -117,11 +117,19 @@ module.exports = async function (app) { throw new Error(`Device '${request.params.typeId}' not found`) } typeId = device.id + if (device.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } } 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`) } + if (project.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } } else { throw new Error(`Unknown MCP target type '${request.params.type}'`) } @@ -182,6 +190,21 @@ module.exports = async function (app) { } } }, async (request, reply) => { + if (request.params.type === 'device') { + const device = await app.db.models.Device.byId(request.params.typeId) + if (device.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } + } else if (request.params.type === 'instance') { + const project = await app.db.models.Project.byId(request.params.typeId) + if (project.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } + } else { + throw new Error(`Unknown MCP target type '${request.params.type}'`) + } try { const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) if (mcpServer) { From 0f2550bb065e32e0b42f7852c263172b31a6f84f Mon Sep 17 00:00:00 2001 From: Ben Hardill Date: Tue, 21 Jul 2026 17:00:53 +0100 Subject: [PATCH 2/7] Add tests and fix actual problem --- forge/ee/routes/mcp/registrations.js | 5 ++++ test/unit/forge/ee/routes/mcp/index_spec.js | 33 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index 5f4c903090..9270a3afbf 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -134,6 +134,11 @@ module.exports = async function (app) { throw new Error(`Unknown MCP target type '${request.params.type}'`) } + if (request.params.typeId !== request.session.ownerId) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } + await app.db.models.MCPRegistration.upsert({ targetType: request.params.type, targetId: typeId, diff --git a/test/unit/forge/ee/routes/mcp/index_spec.js b/test/unit/forge/ee/routes/mcp/index_spec.js index acd2c9c2ad..dc36340254 100644 --- a/test/unit/forge/ee/routes/mcp/index_spec.js +++ b/test/unit/forge/ee/routes/mcp/index_spec.js @@ -233,4 +233,37 @@ describe('MCP Server Registration', function () { const errMsg = appLogStub.getCall(0).args[0] errMsg.should.match(/Unknown MCP target type 'blah'/) }) + it('should not crate mcp endpoint for non team asset', async function () { + // create second team + const bTeam = await app.factory.createTeam({ name: 'BTeam' }) + const bApplicaiton = await app.factory.createApplication({ name: 'application-2' }, bTeam) + // create instance in BTeam + const bInstance = await app.factory.createInstance( + { name: 'projectb' }, + bApplicaiton, + app.stack, + app.template, + app.projetType, + { start: false } + ) + const { token } = await bInstance.refreshAuthTokens() + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/mcp/instance/${app.instance.id}/abcde`, + headers: { + Authorization: `Bearer ${token}`, // Use token from instance in team b + 'Content-Type': 'application/json' + }, + body: { + name: 'foo', + protocol: 'http', + endpointRoute: '/mcp', + title: 'FlowFuse MCP', + version: '1.2.3', + description: 'Test MCP registration entry' + } + }) + response.statusCode.should.equal(403) + }) }) From b16bc3332d7042ac93288d195409ae10815545c1 Mon Sep 17 00:00:00 2001 From: Ben Hardill Date: Tue, 21 Jul 2026 17:25:15 +0100 Subject: [PATCH 3/7] Ensure only Instances in Team can create/remove MCP registration fixes FlowFuse/security#125 See FlowFuse/security#123 for details --- forge/ee/routes/mcp/registrations.js | 12 ++++++ test/unit/forge/ee/routes/mcp/index_spec.js | 45 ++++++++++++++++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index 9270a3afbf..4e08e71183 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -201,15 +201,27 @@ module.exports = async function (app) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return } + if (device.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } } else if (request.params.type === 'instance') { const project = await app.db.models.Project.byId(request.params.typeId) if (project.Team.id !== request.team.id) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return } + if (project.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } } else { throw new Error(`Unknown MCP target type '${request.params.type}'`) } + if (request.params.typeId !== request.session.ownerId) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return + } try { const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) if (mcpServer) { diff --git a/test/unit/forge/ee/routes/mcp/index_spec.js b/test/unit/forge/ee/routes/mcp/index_spec.js index dc36340254..03d4eb6f0b 100644 --- a/test/unit/forge/ee/routes/mcp/index_spec.js +++ b/test/unit/forge/ee/routes/mcp/index_spec.js @@ -233,7 +233,7 @@ describe('MCP Server Registration', function () { const errMsg = appLogStub.getCall(0).args[0] errMsg.should.match(/Unknown MCP target type 'blah'/) }) - it('should not crate mcp endpoint for non team asset', async function () { + it('should not create mcp endpoint for non team asset', async function () { // create second team const bTeam = await app.factory.createTeam({ name: 'BTeam' }) const bApplicaiton = await app.factory.createApplication({ name: 'application-2' }, bTeam) @@ -266,4 +266,47 @@ describe('MCP Server Registration', function () { }) response.statusCode.should.equal(403) }) + it('should delete MCP entry', 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}/abcde`, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: { + name: 'foo', + protocol: 'http', + endpointRoute: '/mcp', + title: 'FlowFuse MCP', + version: '1.2.3', + description: 'Test MCP registration entry' + } + }) + response.statusCode.should.equal(200) + + // create second team + const cTeam = await app.factory.createTeam({ name: 'CTeam' }) + const cApplicaiton = await app.factory.createApplication({ name: 'application-2' }, cTeam) + // create instance in BTeam + const cInstance = await app.factory.createInstance( + { name: 'projectc' }, + cApplicaiton, + app.stack, + app.template, + app.projetType, + { start: false } + ) + const cToken = (await cInstance.refreshAuthTokens()).token + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/teams/${app.team.hashid}/mcp/instance/${app.instance.id}/abcde`, + headers: { + Authorization: `Bearer ${cToken}` + } + }) + deleteResponse.statusCode.should.equal(403) + }) }) From 4558d25062bd05d5eb9eee6f0b80930501165861 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 22 Jul 2026 12:07:09 +0200 Subject: [PATCH 4/7] fix(mcp): validate resolved target id and team on registration routes The owner check compared the URL :typeId against the token ownerId directly. For device tokens the URL carries the hashid while ownerId is the numeric id, so a device could never register or delete its own MCP entry. Compare the resolved target id instead. Also tidy the delete handler: drop the duplicated team check, guard for a missing device or instance with a 404, return 400 for an unknown target type, and scope the registration lookup to the resolved id and team. --- forge/ee/db/models/MCPRegistration.js | 18 +++-- forge/ee/routes/mcp/registrations.js | 21 ++++-- test/unit/forge/ee/routes/mcp/index_spec.js | 82 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/forge/ee/db/models/MCPRegistration.js b/forge/ee/db/models/MCPRegistration.js index 96e96463db..402d700414 100644 --- a/forge/ee/db/models/MCPRegistration.js +++ b/forge/ee/db/models/MCPRegistration.js @@ -91,14 +91,16 @@ module.exports = { include }) }, - byTypeAndIDs: async (targetType, targetId, nodeId) => { - return this.findOne({ - where: { - targetType, - targetId, - nodeId - } - }) + byTypeAndIDs: async (targetType, targetId, nodeId, teamId) => { + const where = { + targetType, + targetId, + nodeId + } + if (teamId !== undefined) { + where.TeamId = teamId + } + return this.findOne({ where }) }, byId: async (idOrHash, { includeAssociations = false } = {}) => { let id = idOrHash diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index 4e08e71183..2eeb69107e 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -134,7 +134,7 @@ module.exports = async function (app) { throw new Error(`Unknown MCP target type '${request.params.type}'`) } - if (request.params.typeId !== request.session.ownerId) { + if (String(typeId) !== String(request.session.ownerId)) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return } @@ -195,20 +195,22 @@ module.exports = async function (app) { } } }, async (request, reply) => { + let typeId = request.params.typeId if (request.params.type === 'device') { const device = await app.db.models.Device.byId(request.params.typeId) - if (device.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + if (!device) { + reply.code(404).send({ code: 'not_found', error: 'Device not found' }) return } + typeId = device.id if (device.Team.id !== request.team.id) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return } } else if (request.params.type === 'instance') { const project = await app.db.models.Project.byId(request.params.typeId) - if (project.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + if (!project) { + reply.code(404).send({ code: 'not_found', error: 'Instance not found' }) return } if (project.Team.id !== request.team.id) { @@ -216,14 +218,17 @@ module.exports = async function (app) { return } } else { - throw new Error(`Unknown MCP target type '${request.params.type}'`) + reply.code(400).send({ code: 'invalid_request', error: `Unknown MCP target type '${request.params.type}'` }) + return } - if (request.params.typeId !== request.session.ownerId) { + + if (String(typeId) !== String(request.session.ownerId)) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return } + try { - const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) + const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, typeId, request.params.nodeId, request.team.id) if (mcpServer) { await mcpServer.destroy() reply.send({}) diff --git a/test/unit/forge/ee/routes/mcp/index_spec.js b/test/unit/forge/ee/routes/mcp/index_spec.js index 03d4eb6f0b..395177dd34 100644 --- a/test/unit/forge/ee/routes/mcp/index_spec.js +++ b/test/unit/forge/ee/routes/mcp/index_spec.js @@ -309,4 +309,86 @@ describe('MCP Server Registration', function () { }) deleteResponse.statusCode.should.equal(403) }) + it('should let a device register and delete its own MCP entry', async function () { + const created = await app.factory.createDevice({ name: 'mcp-device' }, app.team) + // reload so the Team association is hydrated for token generation + const device = await app.db.models.Device.byId(created.id) + const { token } = await device.refreshAuthTokens() + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/mcp/device/${device.hashid}/nodeD`, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: { + name: 'device-mcp', + protocol: 'http', + endpointRoute: '/mcp' + } + }) + response.statusCode.should.equal(200) + // stored against the numeric device id, not the hashid + const stored = await app.db.models.MCPRegistration.byTypeAndIDs('device', device.id, 'nodeD') + should.exist(stored) + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/teams/${app.team.hashid}/mcp/device/${device.hashid}/nodeD`, + headers: { + Authorization: `Bearer ${token}` + } + }) + deleteResponse.statusCode.should.equal(200) + const afterDelete = await app.db.models.MCPRegistration.byTypeAndIDs('device', device.id, 'nodeD') + should.not.exist(afterDelete) + }) + it('should not create MCP entry when teamId does not match the asset team', async function () { + // second team, used only for the URL teamId + const bTeam = await app.factory.createTeam({ name: 'BTeamMismatch' }) + const { token } = await app.instance.refreshAuthTokens() + + // caller's own token and own instance id, but a foreign team in the URL + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${bTeam.hashid}/mcp/instance/${app.instance.id}/abcde`, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: { + name: 'foo', + protocol: 'http', + endpointRoute: '/mcp' + } + }) + response.statusCode.should.equal(403) + }) + it('should not delete MCP entry when teamId does not match the asset team', async function () { + const bTeam = await app.factory.createTeam({ name: 'BTeamMismatchDelete' }) + const { token } = await app.instance.refreshAuthTokens() + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/teams/${bTeam.hashid}/mcp/instance/${app.instance.id}/abcde`, + headers: { + Authorization: `Bearer ${token}` + } + }) + deleteResponse.statusCode.should.equal(403) + }) + it('should return 404 when deleting MCP entry for a missing instance', async function () { + const { token } = await app.instance.refreshAuthTokens() + const randomId = uuidv4() + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/v1/teams/${app.team.hashid}/mcp/instance/${randomId}/abcde`, + headers: { + Authorization: `Bearer ${token}` + } + }) + deleteResponse.statusCode.should.equal(404) + }) }) From b9dad04c8bf224f6a1b7405523327fb32ff3bcaf Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 22 Jul 2026 12:41:49 +0200 Subject: [PATCH 5/7] fix(mcp): compare MCP target id as a string The device branch resolved the target id to the numeric device id, which Postgres rejects when compared against the varchar targetId column. Use the string form so the lookup stays a varchar comparison. --- forge/ee/routes/mcp/registrations.js | 4 ++-- test/unit/forge/ee/routes/mcp/index_spec.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index 2eeb69107e..9607f8802a 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -116,7 +116,7 @@ module.exports = async function (app) { if (!device) { throw new Error(`Device '${request.params.typeId}' not found`) } - typeId = device.id + typeId = '' + device.id if (device.Team.id !== request.team.id) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return @@ -202,7 +202,7 @@ module.exports = async function (app) { reply.code(404).send({ code: 'not_found', error: 'Device not found' }) return } - typeId = device.id + typeId = '' + device.id if (device.Team.id !== request.team.id) { reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) return diff --git a/test/unit/forge/ee/routes/mcp/index_spec.js b/test/unit/forge/ee/routes/mcp/index_spec.js index 395177dd34..6148cc1cad 100644 --- a/test/unit/forge/ee/routes/mcp/index_spec.js +++ b/test/unit/forge/ee/routes/mcp/index_spec.js @@ -330,7 +330,7 @@ describe('MCP Server Registration', function () { }) response.statusCode.should.equal(200) // stored against the numeric device id, not the hashid - const stored = await app.db.models.MCPRegistration.byTypeAndIDs('device', device.id, 'nodeD') + const stored = await app.db.models.MCPRegistration.byTypeAndIDs('device', '' + device.id, 'nodeD') should.exist(stored) const deleteResponse = await app.inject({ @@ -341,7 +341,7 @@ describe('MCP Server Registration', function () { } }) deleteResponse.statusCode.should.equal(200) - const afterDelete = await app.db.models.MCPRegistration.byTypeAndIDs('device', device.id, 'nodeD') + const afterDelete = await app.db.models.MCPRegistration.byTypeAndIDs('device', '' + device.id, 'nodeD') should.not.exist(afterDelete) }) it('should not create MCP entry when teamId does not match the asset team', async function () { From d8940e552d25c73fc6bee6b7de80d078ec97c884 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 22 Jul 2026 19:10:49 +0200 Subject: [PATCH 6/7] fix(mcp): align POST/DELETE registration error handling Extract the device/instance resolution, team ownership, and owner match checks into a shared function used by both routes, so unknown device/instance/type now return 404/400 on POST instead of a generic 500 from the broad try/catch, matching what DELETE already returned. --- forge/ee/routes/mcp/registrations.js | 109 +++++++++----------- test/unit/forge/ee/routes/mcp/index_spec.js | 39 +++---- 2 files changed, 63 insertions(+), 85 deletions(-) diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js index 9607f8802a..74f4811a04 100644 --- a/forge/ee/routes/mcp/registrations.js +++ b/forge/ee/routes/mcp/registrations.js @@ -1,3 +1,44 @@ +// Resolves the device/instance named by request.params.type/typeId, checks it +// belongs to request.team, and that it matches the session's own owner id. +// Replies with the appropriate status code and returns null on any failure, +// otherwise returns the resolved (string) typeId - shared by POST and DELETE +// so both report the same status code for the same failure. +async function resolveTarget (app, request, reply) { + let typeId = request.params.typeId + if (request.params.type === 'device') { + const device = await app.db.models.Device.byId(request.params.typeId) + if (!device) { + reply.code(404).send({ code: 'not_found', error: 'Device not found' }) + return null + } + typeId = '' + device.id + if (device.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return null + } + } else if (request.params.type === 'instance') { + const project = await app.db.models.Project.byId(request.params.typeId) + if (!project) { + reply.code(404).send({ code: 'not_found', error: 'Instance not found' }) + return null + } + if (project.Team.id !== request.team.id) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return null + } + } else { + reply.code(400).send({ code: 'invalid_request', error: `Unknown MCP target type '${request.params.type}'` }) + return null + } + + if (String(typeId) !== String(request.session.ownerId)) { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + return null + } + + return typeId +} + module.exports = async function (app) { app.addHook('preHandler', async (request, reply) => { if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { @@ -103,42 +144,21 @@ module.exports = async function (app) { 200: { type: 'object' }, + '4xx': { + $ref: 'APIError' + }, 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 - if (device.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - return - } - } 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`) - } - if (project.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - return - } - } else { - throw new Error(`Unknown MCP target type '${request.params.type}'`) - } - - if (String(typeId) !== String(request.session.ownerId)) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - return - } + const typeId = await resolveTarget(app, request, reply) + if (typeId === null) { + return + } + try { await app.db.models.MCPRegistration.upsert({ targetType: request.params.type, targetId: typeId, @@ -195,35 +215,8 @@ module.exports = async function (app) { } } }, async (request, reply) => { - let typeId = request.params.typeId - if (request.params.type === 'device') { - const device = await app.db.models.Device.byId(request.params.typeId) - if (!device) { - reply.code(404).send({ code: 'not_found', error: 'Device not found' }) - return - } - typeId = '' + device.id - if (device.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - return - } - } else if (request.params.type === 'instance') { - const project = await app.db.models.Project.byId(request.params.typeId) - if (!project) { - reply.code(404).send({ code: 'not_found', error: 'Instance not found' }) - return - } - if (project.Team.id !== request.team.id) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - return - } - } else { - reply.code(400).send({ code: 'invalid_request', error: `Unknown MCP target type '${request.params.type}'` }) - return - } - - if (String(typeId) !== String(request.session.ownerId)) { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + const typeId = await resolveTarget(app, request, reply) + if (typeId === null) { return } diff --git a/test/unit/forge/ee/routes/mcp/index_spec.js b/test/unit/forge/ee/routes/mcp/index_spec.js index 6148cc1cad..40b8f305f7 100644 --- a/test/unit/forge/ee/routes/mcp/index_spec.js +++ b/test/unit/forge/ee/routes/mcp/index_spec.js @@ -143,10 +143,8 @@ describe('MCP Server Registration', function () { const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs('instance', app.instance.id, 'abcde') should.not.exist(mcpServer) }) - it('should return 500 and log error for unknown device', async function () { + it('should return 404 for unknown device', async function () { const { token } = await app.instance.refreshAuthTokens() - // stub app.log to capture error message - const appLogStub = sinon.stub(app.log, 'error') const response = await app.inject({ method: 'POST', @@ -164,19 +162,14 @@ describe('MCP Server Registration', function () { description: 'Test MCP registration entry' } }) - response.statusCode.should.equal(500) + response.statusCode.should.equal(404) const result = response.json() result.should.be.an.Object() - result.should.have.property('code', 'unexpected_error') - result.should.have.property('error', 'Failed to create mcp entry') - appLogStub.calledOnce.should.be.true() - const errMsg = appLogStub.getCall(0).args[0] - errMsg.should.match(/Device '99999' not found/) + result.should.have.property('code', 'not_found') + result.should.have.property('error', 'Device not found') }) - it('should return 500 and log error for unknown instance', async function () { + it('should return 404 for unknown instance', async function () { const { token } = await app.instance.refreshAuthTokens() - // stub app.log to capture error message - const appLogStub = sinon.stub(app.log, 'error') const randomId = uuidv4() const response = await app.inject({ method: 'POST', @@ -194,19 +187,14 @@ describe('MCP Server Registration', function () { description: 'Test MCP registration entry' } }) - response.statusCode.should.equal(500) + response.statusCode.should.equal(404) const result = response.json() result.should.be.an.Object() - result.should.have.property('code', 'unexpected_error') - result.should.have.property('error', 'Failed to create mcp entry') - appLogStub.calledOnce.should.be.true() - const errMsg = appLogStub.getCall(0).args[0] - errMsg.should.match(new RegExp(`Instance '${randomId}' not found`)) + result.should.have.property('code', 'not_found') + result.should.have.property('error', 'Instance not found') }) - it('should return 500 and log error for unknown type', async function () { + it('should return 400 for unknown type', async function () { const { token } = await app.instance.refreshAuthTokens() - // stub app.log to capture error message - const appLogStub = sinon.stub(app.log, 'error') const response = await app.inject({ method: 'POST', @@ -224,14 +212,11 @@ describe('MCP Server Registration', function () { description: 'Test MCP registration entry' } }) - response.statusCode.should.equal(500) + response.statusCode.should.equal(400) const result = response.json() result.should.be.an.Object() - result.should.have.property('code', 'unexpected_error') - result.should.have.property('error', 'Failed to create mcp entry') - appLogStub.calledOnce.should.be.true() - const errMsg = appLogStub.getCall(0).args[0] - errMsg.should.match(/Unknown MCP target type 'blah'/) + result.should.have.property('code', 'invalid_request') + result.should.have.property('error', "Unknown MCP target type 'blah'") }) it('should not create mcp endpoint for non team asset', async function () { // create second team From bd2b824926aa84a7211ff5e46fa778fae8ee19e3 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 22 Jul 2026 19:16:08 +0200 Subject: [PATCH 7/7] chore: regenerate OpenAPI types for MCP registration 4xx response --- frontend/src/types/generated.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/types/generated.ts b/frontend/src/types/generated.ts index 14fdab1d89..bea90fcf74 100644 --- a/frontend/src/types/generated.ts +++ b/frontend/src/types/generated.ts @@ -10041,6 +10041,15 @@ export interface paths { "application/json": components["schemas"]["APIError"]; }; }; + /** @description Default Response */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["APIError"]; + }; + }; }; }; delete: {