diff --git a/forge/ee/lib/mcp/tools/broker.js b/forge/ee/lib/mcp/tools/broker.js new file mode 100644 index 0000000000..21c86f8172 --- /dev/null +++ b/forge/ee/lib/mcp/tools/broker.js @@ -0,0 +1,113 @@ +const { z } = require('zod') + +const { teamId, basePagination, basePaginationKeys, searchQuery, appendQuery } = require('../schemas') + +module.exports = [ + { + name: 'platform_list_broker_clients', + title: 'List Broker Clients', + description: `FlowFuse platform automation tool: + Lists the MQTT clients registered on the team broker (the built-in MQTT broker that ships with the platform). + Each entry identifies the client username and, where known, the hosted instance or remote instance it belongs to. This does not include MQTT credentials. + Supports username search and pagination. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + ...basePagination, + ...searchQuery + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/broker/clients`, args, [...basePaginationKeys, 'query']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_broker_client', + title: 'Get Broker Client', + description: `FlowFuse platform automation tool: + Gets a single MQTT client registered on the team broker, identified by its username. This does not include MQTT credentials. + Use this after platform_list_broker_clients to inspect one client in detail. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + username: z.string().describe('Username of the broker client to fetch') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/broker/client/${args.username}` }) + return response + } + }, + { + name: 'platform_list_brokers', + title: 'List Brokers', + description: `FlowFuse platform automation tool: + Lists the brokers configured for a team: the built-in team broker plus any 3rd-party MQTT brokers that have been linked to the team. This does not include MQTT credentials. + Use this to find a broker's ID before calling platform_get_broker, platform_list_broker_topics, or platform_get_broker_schema. + Supports pagination. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + ...basePagination + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/brokers`, args, basePaginationKeys) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_broker', + title: 'Get Broker', + description: `FlowFuse platform automation tool: + Gets the details and status of a single broker: the built-in team broker or a linked 3rd-party MQTT broker. This does not include MQTT credentials. + Use this after platform_list_brokers to inspect one broker in detail. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid") + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/brokers/${args.brokerId}` }) + return response + } + }, + { + name: 'platform_list_broker_topics', + title: 'List Broker Topics', + description: `FlowFuse platform automation tool: + Lists the MQTT topics that have been observed on a broker, along with any recorded metadata and inferred payload schema for each topic. + Use this to understand what data is flowing through a broker before wiring up new flows that publish or subscribe to it. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid") + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/brokers/${args.brokerId}/topics` }) + return response + } + }, + { + name: 'platform_get_broker_schema', + title: 'Get Broker Schema', + description: `FlowFuse platform automation tool: + Gets the auto-generated AsyncAPI topic schema for a broker, built from the topics observed on it. + Use this when the user wants a documented overview of a broker's topic structure and message shapes, for example to share with another team or to generate integration code. + This tool requires the enterprise license tier and the team broker feature enabled for the team; if the team does not have it enabled, the request returns a not found response.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid") + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/broker/${args.brokerId}/schema` }) + return response + } + } +] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index c1740abaa9..ee75a6c5a5 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -82,7 +82,11 @@ const IMPLICIT_TOKEN_SCOPES = { 'stack:list', 'flow-blueprint:list', 'project:status', - 'template:list' + 'template:list', + // broker + 'broker:clients:list', // list/get team broker clients + 'broker:credentials:list', // list/get brokers + 'broker:topics:list' // list broker topics, get broker schema ] } diff --git a/test/unit/forge/ee/lib/mcp/tools/broker_spec.js b/test/unit/forge/ee/lib/mcp/tools/broker_spec.js new file mode 100644 index 0000000000..cb3c860697 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/broker_spec.js @@ -0,0 +1,164 @@ +const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') + +const tools = require('../../../../../../../forge/ee/lib/mcp/tools/broker') + +function getTool (name) { + return tools.find(tool => tool.name === name) +} + +describe('MCP Broker Tools', function () { + let inject + + beforeEach(function () { + inject = sinon.stub() + }) + + describe('platform_list_broker_clients', function () { + const tool = getTool('platform_list_broker_clients') + + it('injects the broker clients list route and returns the response', async function () { + const routeResponse = { statusCode: 200, json: () => ({ clients: [], count: 0 }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/broker/clients' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('serialises pagination and search params', async function () { + inject.resolves({ statusCode: 200, json: () => ({ clients: [] }) }) + + await tool.handler({ teamId: 'team1', cursor: 'abc', limit: 20, query: 'sensor' }, { inject }) + + inject.firstCall.args[0].url.should.equal('/api/v1/teams/team1/broker/clients?cursor=abc&limit=20&query=sensor') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_get_broker_client', function () { + const tool = getTool('platform_get_broker_client') + + it('injects the broker client route for the given username', async function () { + const routeResponse = { statusCode: 200, json: () => ({ username: 'client1' }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/broker/client/client1' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1', username: 'client1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1', username: 'client1' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_list_brokers', function () { + const tool = getTool('platform_list_brokers') + + it('injects the brokers list route and returns the response', async function () { + const routeResponse = { statusCode: 200, json: () => ({ brokers: [] }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/brokers' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('serialises pagination params', async function () { + inject.resolves({ statusCode: 200, json: () => ({ brokers: [] }) }) + + await tool.handler({ teamId: 'team1', cursor: 'abc', limit: 5 }, { inject }) + + inject.firstCall.args[0].url.should.equal('/api/v1/teams/team1/brokers?cursor=abc&limit=5') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_get_broker', function () { + const tool = getTool('platform_get_broker') + + it('injects the broker detail route for the given broker', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'team-broker' }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/brokers/team-broker' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_list_broker_topics', function () { + const tool = getTool('platform_list_broker_topics') + + it('injects the broker topics route for the given broker', async function () { + const routeResponse = { statusCode: 200, json: () => ({ topics: [] }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/brokers/team-broker/topics' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_get_broker_schema', function () { + const tool = getTool('platform_get_broker_schema') + + it('injects the broker schema route for the given broker', async function () { + const routeResponse = { statusCode: 200, json: () => ({ channels: {} }) } + inject.withArgs({ method: 'GET', url: '/api/v1/teams/team1/broker/team-broker/schema' }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1', brokerId: 'team-broker' }, { inject }) + response.should.equal(errorResponse) + }) + }) +})