Skip to content
Merged
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
4 changes: 2 additions & 2 deletions forge/comms/platformAutomation.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ class PlatformAutomationHandler {
})
}

const { formatResponse } = require('../ee/lib/mcp/toolLoader')
const { formatResponse, invokeTool } = require('../ee/lib/mcp/toolLoader')
const args = applyInputDefaults(tool, data?.input || {})
const response = await tool.handler(args, { inject, app: this.app, user, mcpSessionId, scope: callerScope })
const response = await invokeTool(tool, args, { inject, app: this.app, user, mcpSessionId, scope: callerScope })
result = formatResponse(response)
break
}
Expand Down
17 changes: 12 additions & 5 deletions forge/ee/lib/mcp/schemas.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
const { z } = require('zod')

// Ids are interpolated into the URL path handed to app.inject(), which resolves dot segments, so
// an id must be one plain path segment: hashid, UUID, or the 'team-broker' literal.
const PATH_ID_PATTERN = /^[A-Za-z0-9_-]+$/
const pathId = z.string().regex(PATH_ID_PATTERN, 'must contain only letters, digits, hyphen and underscore')

// Hosted instances are Projects (UUID primary key); the other entities use hashids.
const teamId = z.string().describe('The hashid of the team')
const applicationId = z.string().describe('The hashid of the application')
const teamId = pathId.describe('The hashid of the team')
const applicationId = pathId.describe('The hashid of the application')
const hostedInstanceId = z.string().uuid().describe('The id (UUID) of the hosted instance')
const remoteInstanceId = z.string().describe('The hashid of the remote instance')
const snapshotId = z.string().describe('The hashid of the snapshot')
const remoteInstanceId = pathId.describe('The hashid of the remote instance')
const snapshotId = pathId.describe('The hashid of the snapshot')

// Shared by the pipeline stage add and update tools: both routes take the same
// git-repo fields. forUpdate carries the one real difference between them, that
Expand All @@ -14,7 +19,7 @@ const snapshotId = z.string().describe('The hashid of the snapshot')
const gitStageFields = ({ forUpdate = false } = {}) => {
const resendNote = forUpdate ? '. Resent on every git update; reset to empty when omitted' : ''
return {
gitTokenId: z.string().optional().describe(forUpdate
gitTokenId: pathId.optional().describe(forUpdate
? 'Hashid of a team git token. Required on every update of a git-repo stage, together with the full git settings, since the git settings are only applied when it is present and are applied as a set'
: 'Hashid of a team git token, making this a git-repository stage. Pass exactly one target, and include url'),
url: z.string().optional().describe(`Git repository URL for a git-repo stage${resendNote}`),
Expand Down Expand Up @@ -111,6 +116,8 @@ function appendQuery (url, args, keys) {
}

module.exports = {
PATH_ID_PATTERN,
pathId,
teamId,
applicationId,
hostedInstanceId,
Expand Down
6 changes: 4 additions & 2 deletions forge/ee/lib/mcp/tool-schemas/tables.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const { z } = require('zod')

const { pathId } = require('../schemas')

// Shared field schemas reused across the FlowFuse Tables tools, since the same
// database/table identifiers appear in both the input and output schemas.
const teamIdSchema = z.string().describe('The hashid of the team')
const databaseIdSchema = z.string().describe('The hashid of the FlowFuse Tables database')
const teamIdSchema = pathId.describe('The hashid of the team')
const databaseIdSchema = pathId.describe('The hashid of the FlowFuse Tables database')
const tableNameSchema = z.string().describe('Name of the database table')
const schemaNameSchema = z.string().describe('Schema the table lives in, as returned by platform_list_database_tables')
const databaseSchema = z.object({
Expand Down
17 changes: 16 additions & 1 deletion forge/ee/lib/mcp/toolLoader.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const fs = require('fs')
const path = require('path')

const { validateIdArgs, guardInject } = require('./utils')

const toolsDir = path.join(__dirname, 'tools')

/**
Expand Down Expand Up @@ -39,4 +41,17 @@ function formatResponse (response) {
return body
}

module.exports = { formatResponse, loadToolDefinitions }
/**
* Runs a tool handler behind the shared path guard: rejects id arguments that are not a plain
* path segment, and hands the handler an inject that refuses dot-segment paths. Every dispatch
* of a tool must go through here so individual tools never have to remember the checks.
*/
async function invokeTool (tool, args, context) {
const invalid = validateIdArgs(args)
if (invalid) {
return invalid
}
return tool.handler(args, { ...context, inject: guardInject(context.inject) })
}

module.exports = { formatResponse, loadToolDefinitions, invokeTool }
22 changes: 11 additions & 11 deletions forge/ee/lib/mcp/tools/broker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { z } = require('zod')

const { teamId, basePagination, basePaginationKeys, searchQuery, appendQuery, toolError } = require('../schemas')
const { pathId, teamId, basePagination, basePaginationKeys, searchQuery, appendQuery, toolError } = require('../schemas')

const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id)

Expand Down Expand Up @@ -38,7 +38,7 @@ module.exports = [
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}` })
const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/broker/client/${encodeURIComponent(args.username)}` })
return response
}
},
Expand Down Expand Up @@ -76,7 +76,7 @@ module.exports = [
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid")
brokerId: pathId.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}` })
Expand All @@ -95,7 +95,7 @@ module.exports = [
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid")
brokerId: pathId.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` })
Expand All @@ -115,7 +115,7 @@ module.exports = [
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid")
brokerId: pathId.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` })
Expand All @@ -136,7 +136,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
brokerId: pathId.describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
action: z.enum(['start', 'stop', 'suspend']).describe('Lifecycle transition to apply to the broker topic-collection agent')
},
handler: async (args, { inject }) => {
Expand All @@ -154,7 +154,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
brokerId: pathId.describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
topics: z.array(z.object({
topic: z.string().describe('MQTT topic string, e.g. "factory/line1/temperature"'),
metadata: z.record(z.string(), z.any()).optional().describe('Arbitrary topic metadata, e.g. { description: "..." }')
Expand All @@ -176,8 +176,8 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
topicId: z.string().describe('The hashid of the topic to update, as returned by platform_list_broker_topics'),
brokerId: pathId.describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
topicId: pathId.describe('The hashid of the topic to update, as returned by platform_list_broker_topics'),
metadata: z.record(z.string(), z.any()).describe('Replacement metadata object for the topic, e.g. { description: "..." }')
},
handler: async (args, { inject }) => {
Expand All @@ -199,8 +199,8 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
brokerId: z.string().describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
topicId: z.string().describe('The hashid of the topic to delete, as returned by platform_list_broker_topics')
brokerId: pathId.describe("broker id: either the literal 'team-broker' or a 3rd-party broker hashid"),
topicId: pathId.describe('The hashid of the topic to delete, as returned by platform_list_broker_topics')
},
handler: async (args, { inject }) => {
// The ids go into the URL path and inject resolves dot segments, so anything but a plain
Expand Down
14 changes: 7 additions & 7 deletions forge/ee/lib/mcp/tools/deviceGroups.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { z } = require('zod')

const { appendQuery, applicationId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, teamId, toolError } = require('../schemas')
const { pathId, appendQuery, applicationId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, teamId, toolError } = require('../schemas')

module.exports = [
{
Expand Down Expand Up @@ -59,7 +59,7 @@ module.exports = [
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
applicationId,
groupId: z.string().describe('Device group hashid to fetch')
groupId: pathId.describe('Device group hashid to fetch')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'GET', url: `/api/v1/applications/${args.applicationId}/device-groups/${args.groupId}` })
Expand Down Expand Up @@ -104,10 +104,10 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
applicationId: applicationId.describe('The hashid of the application the group belongs to'),
groupId: z.string().describe('The hashid of the device group to update'),
groupId: pathId.describe('The hashid of the device group to update'),
name: z.string().min(1).optional().describe('New name for the group'),
description: z.string().optional().describe('New description for the group'),
targetSnapshotId: z.string().nullable().optional().describe('The hashid of the snapshot to pin as the group target (deployed to all member devices immediately), or null to clear the pin. The snapshot must belong to the same application')
targetSnapshotId: pathId.nullable().optional().describe('The hashid of the snapshot to pin as the group target (deployed to all member devices immediately), or null to clear the pin. The snapshot must belong to the same application')
},
handler: async (args, { inject }) => {
const payload = {}
Expand Down Expand Up @@ -139,7 +139,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
applicationId: applicationId.describe('The hashid of the application the group belongs to'),
groupId: z.string().describe('The hashid of the device group whose membership to change'),
groupId: pathId.describe('The hashid of the device group whose membership to change'),
add: z.array(z.string()).optional().describe('Hashids of devices to add to the group'),
remove: z.array(z.string()).optional().describe('Hashids of devices to remove from the group'),
set: z.array(z.string()).optional().describe('Hashids of devices to set as the exact membership, replacing the current list. Overrides add/remove')
Expand Down Expand Up @@ -172,7 +172,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
applicationId: applicationId.describe('The hashid of the application the group belongs to'),
groupId: z.string().describe('The hashid of the device group whose settings to update'),
groupId: pathId.describe('The hashid of the device group whose settings to update'),
env: z.array(z.object({
name: z.string().describe('Environment variable name: a letter or underscore followed by letters, digits or underscores'),
value: z.string().describe('Environment variable value. For an existing hidden variable, pass an empty string (with hidden true) to keep the stored value'),
Expand All @@ -199,7 +199,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
applicationId: applicationId.describe('The hashid of the application the group belongs to'),
groupId: z.string().describe('The hashid of the device group to delete')
groupId: pathId.describe('The hashid of the device group to delete')
},
handler: async (args, { inject }) => {
// Both ids go into the URL path and inject resolves dot segments, so anything but a plain
Expand Down
6 changes: 3 additions & 3 deletions forge/ee/lib/mcp/tools/instances.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const { randomUUID } = require('crypto')

const { z } = require('zod')

const { teamId, applicationId, hostedInstanceId, searchQuery, sortParams, limitParam, pageParam, toolError } = require('../schemas')
const { pathId, teamId, applicationId, hostedInstanceId, searchQuery, sortParams, limitParam, pageParam, toolError } = require('../schemas')
const { emptySuccessAsOkay } = require('../utils')

// Mirrors the runningStates/errorStates/stoppedStates groups in frontend/src/composables/InstanceStates.js,
Expand Down Expand Up @@ -201,7 +201,7 @@ module.exports = [
projectType: z.string().describe('The ID of the hosted instance type (use platform_list_hosted_instance_types to find valid values)'),
stack: z.string().describe('The ID of the stack (use platform_list_hosted_instance_types to find valid values)'),
template: z.string().describe('The ID of the template (use platform_list_templates to find valid values)'),
flowBlueprintId: z.string().optional().describe('Optional blueprint ID to initialize the hosted instance with starter flows (use platform_list_blueprints to find valid values)')
flowBlueprintId: pathId.optional().describe('Optional blueprint ID to initialize the hosted instance with starter flows (use platform_list_blueprints to find valid values)')
},
handler: async (args, { inject }) => {
const payload = {
Expand Down Expand Up @@ -639,7 +639,7 @@ module.exports = [
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
inputSchema: {
instanceId: hostedInstanceId.describe('The id (UUID) of the hosted instance to roll back'),
snapshotId: z.string().describe('The hashid of the snapshot to roll back to. Must be a snapshot of this hosted instance')
snapshotId: pathId.describe('The hashid of the snapshot to roll back to. Must be a snapshot of this hosted instance')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.instanceId}/actions/rollback`, payload: { snapshot: args.snapshotId } })
Expand Down
Loading
Loading