Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions forge/ee/lib/mcp/tools/broker.js
Original file line number Diff line number Diff line change
@@ -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
}
}
]
6 changes: 5 additions & 1 deletion forge/routes/auth/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
}

Expand Down
164 changes: 164 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/broker_spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading