From 15194e38296ea1d4c04d4cff1f4182a6ac329e75 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 14 Sep 2026 16:58:12 +0300 Subject: [PATCH 1/7] [7688] Add MCP tools for snapshot update, export, import and device target --- forge/ee/lib/mcp/tools/snapshots.js | 120 +++++++++++++++ .../forge/ee/lib/mcp/tools/snapshots_spec.js | 138 ++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 280a26a1ee..da7b29c72b 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -99,6 +99,126 @@ module.exports = [ return { statusCode: response.statusCode, json: () => body } } }, + { + name: 'platform_update_snapshot', + title: 'Update Snapshot', + description: `FlowFuse platform automation tool: + Updates a snapshot's name and/or description. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. + This is a partial update: only the fields you pass are changed, omitted fields keep their stored value. + name, when passed, must be a non-empty string - the route rejects an empty name. Pass an empty string as description to clear it.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: { + snapshotId, + name: z.string().min(1).optional().describe('New name for the snapshot. Must be non-empty when provided; omit to keep the current name'), + description: z.string().optional().describe('New description for the snapshot. Pass an empty string to clear it; omit to keep the current description') + }, + handler: async (args, { inject }) => { + const payload = {} + if (args.name !== undefined) { + payload.name = args.name + } + if (args.description !== undefined) { + payload.description = args.description + } + const response = await inject({ method: 'PUT', url: `/api/v1/snapshots/${args.snapshotId}`, payload }) + return response + } + }, + { + name: 'platform_export_snapshot', + title: 'Export Snapshot', + description: `FlowFuse platform automation tool: + Exports the full content of a snapshot (flows, credentials, settings, and environment variables) so it can be imported elsewhere with platform_import_snapshot. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. + credentialSecret is always required, even when credentials are excluded via components - the route rejects the request with a 400 without it. The exported credentials are re-encrypted with this secret, and the SAME secret must be supplied when importing the result, so remember it. + The export contains sensitive data: by default it includes the encrypted flow credentials and the values of ALL environment variables, including hidden (secret) ones. Use components to narrow what is included, for example envVars: "keys" to strip env values. + Unlike platform_get_snapshot_full, this is treated as a write operation because it extracts credentials and secret values.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: { + snapshotId, + credentialSecret: z.string().describe('Secret used to re-encrypt the exported credentials. Required on every export; the same secret is needed to import the result'), + components: z.object({ + flows: z.boolean().optional().describe('Include flows in the export (default true). Excluding flows also excludes credentials'), + credentials: z.boolean().optional().describe('Include the encrypted flow credentials (default true)'), + envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables to include: "all" keeps keys and values (default, exposes hidden values), "keys" keeps only the names, false removes them entirely') + }).optional().describe('Optional selection of which snapshot components to include in the export') + }, + handler: async (args, { inject }) => { + const payload = { credentialSecret: args.credentialSecret } + if (args.components !== undefined) { + payload.components = args.components + } + const response = await inject({ method: 'POST', url: `/api/v1/snapshots/${args.snapshotId}/export`, payload }) + return response + } + }, + { + name: 'platform_import_snapshot', + title: 'Import Snapshot', + description: `FlowFuse platform automation tool: + Imports a previously exported snapshot into a hosted instance or a remote instance (device), creating a new snapshot owned by that target. The response is the new snapshot's metadata; importing does not deploy it. + ownerId must match ownerType: pass the hosted instance UUID with ownerType "instance", or the device hashid with ownerType "device". + credentialSecret is required when the snapshot contains encrypted credentials (a flows.credentials object with a "$" property) and credentials are not excluded via components. It must be the same secret used at export; a wrong secret fails with a 400 "Invalid credential secret". Credentials can only be imported in encrypted form. + Use components to import selectively, for example envVars: "keys" to import env var names without their values.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + inputSchema: { + ownerId: z.string().describe('Target owner id: hosted instance (project) UUID when ownerType is "instance", or device hashid when ownerType is "device"'), + ownerType: z.enum(['instance', 'device']).describe('Type of resource that will own the imported snapshot'), + snapshot: z.object({ + name: z.string().describe('Name for the imported snapshot'), + description: z.string().optional().describe('Description for the imported snapshot (defaults to empty)'), + flows: z.object({ + flows: z.array(z.any()).describe('Node-RED flows array'), + credentials: z.record(z.string(), z.any()).optional().describe('Encrypted credentials object, as produced by platform_export_snapshot') + }).describe('Flows payload'), + settings: z.object({ + settings: z.record(z.string(), z.any()).optional().describe('Runtime settings'), + env: z.record(z.string(), z.any()).optional().describe('Environment variables (defaults to none)'), + modules: z.record(z.string(), z.any()).optional().describe('Installed module versions') + }).describe('Settings payload') + }).describe('The snapshot content to import, typically the result of platform_export_snapshot'), + credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot credentials: the secret used when the snapshot was exported. Required when the snapshot contains credentials'), + components: z.object({ + flows: z.boolean().optional().describe('Import flows (default true). Excluding flows also excludes credentials'), + credentials: z.boolean().optional().describe('Import the flow credentials (default true)'), + envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables to import: "all" keeps keys and values (default), "keys" keeps only the names, false skips them') + }).optional().describe('Optional selection of which snapshot components to import') + }, + handler: async (args, { inject }) => { + // The import route errors when settings.env is missing entirely, so + // normalise an omitted env to the empty set the route expects. + const snapshot = { ...args.snapshot, settings: { ...args.snapshot?.settings } } + if (!snapshot.settings.env) { + snapshot.settings.env = {} + } + const payload = { ownerId: args.ownerId, ownerType: args.ownerType, snapshot } + if (args.credentialSecret !== undefined) { + payload.credentialSecret = args.credentialSecret + } + if (args.components !== undefined) { + payload.components = args.components + } + const response = await inject({ method: 'POST', url: '/api/v1/snapshots/import', payload }) + return response + } + }, + { + name: 'platform_set_instance_device_target', + title: 'Set Hosted Instance Device Target Snapshot', + description: `FlowFuse platform automation tool: + Sets the target snapshot for the remote instances (devices) assigned to a hosted instance. + CAUTION: this takes effect immediately - every device assigned to the instance is told to deploy the target snapshot as soon as it is set. Confirm with the user before calling this. + The snapshot must belong to the given hosted instance, otherwise the route rejects it with "invalid_snapshot". This tool can only set a target, not clear one. + Use platform_get_hosted_instance_device_target_snapshot to see the current target, and platform_list_instance_snapshots to find a snapshot id.`, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + inputSchema: { + hostedInstanceId, + snapshotId: snapshotId.describe('The hashid of the snapshot to set as the device target. Must be a snapshot of this hosted instance') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: `/api/v1/projects/${args.hostedInstanceId}/devices/settings`, payload: { targetSnapshot: args.snapshotId } }) + return response + } + }, { name: 'platform_get_hosted_instance_device_target_snapshot', title: 'Get Hosted Instance Device Target Snapshot', diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index 0149a33ebc..349cf99068 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -181,4 +181,142 @@ describe('MCP Snapshots Tools', function () { response.should.equal(errorResponse) }) }) + + describe('platform_update_snapshot', function () { + const tool = getTool('platform_update_snapshot') + + it('puts name and description onto the snapshot route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot1' }) } + inject.withArgs({ method: 'PUT', url: '/api/v1/snapshots/snapshot1', payload: { name: 'v2', description: 'second cut' } }).resolves(routeResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', name: 'v2', description: 'second cut' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('only sends the fields that were provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', name: 'v2' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ name: 'v2' }) + }) + + it('keeps an empty-string description so it can clear the stored value', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', description: '' }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ description: '' }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', name: 'v2' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_export_snapshot', function () { + const tool = getTool('platform_export_snapshot') + + it('posts the credential secret to the export route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot1', flows: {} }) } + inject.withArgs({ method: 'POST', url: '/api/v1/snapshots/snapshot1/export', payload: { credentialSecret: 's3cret' } }).resolves(routeResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('forwards the components selection when provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) + + await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret', components: { flows: true, credentials: false, envVars: 'keys' } }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ credentialSecret: 's3cret', components: { flows: true, credentials: false, envVars: 'keys' } }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: 's3cret' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_import_snapshot', function () { + const tool = getTool('platform_import_snapshot') + + const snapshot = { + name: 'imported', + flows: { flows: [] }, + settings: { env: { FOO: 'bar' } } + } + + it('posts the snapshot payload to the import route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ id: 'snapshot2' }) } + inject.withArgs({ + method: 'POST', + url: '/api/v1/snapshots/import', + payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: false } } + }).resolves(routeResponse) + + const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: false } }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('omits credentialSecret and components when not provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot }, { inject }) + + inject.firstCall.args[0].payload.should.eql({ ownerId: 'device1', ownerType: 'device', snapshot }) + }) + + it('fills in settings.env when the snapshot omits it, since the route errors without it', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: { name: 'imported', flows: { flows: [] }, settings: {} } }, { inject }) + + inject.firstCall.args[0].payload.snapshot.settings.should.eql({ env: {} }) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_set_instance_device_target', function () { + const tool = getTool('platform_set_instance_device_target') + + it('posts the target snapshot to the instance device settings route', async function () { + const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) } + inject.withArgs({ method: 'POST', url: `/api/v1/projects/${hostedInstanceId}/devices/settings`, payload: { targetSnapshot: 'snapshot1' } }).resolves(routeResponse) + + const response = await tool.handler({ hostedInstanceId, snapshotId: 'snapshot1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_snapshot' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ hostedInstanceId, snapshotId: 'other' }, { inject }) + response.should.equal(errorResponse) + }) + }) }) From 86752075403e646f6eb5b75633a2fa145c148073 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 15 Sep 2026 17:43:40 +0300 Subject: [PATCH 2/7] Improve snapshot import logic: clarify `credentialSecret` requirements, enforce upfront validation for encrypted data, and handle excluded components properly. Add tests for various encrypted scenarios. --- forge/ee/lib/mcp/tools/snapshots.js | 18 +++++-- .../forge/ee/lib/mcp/tools/snapshots_spec.js | 52 ++++++++++++++++++- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index da7b29c72b..8b5d6f742c 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -157,7 +157,7 @@ module.exports = [ description: `FlowFuse platform automation tool: Imports a previously exported snapshot into a hosted instance or a remote instance (device), creating a new snapshot owned by that target. The response is the new snapshot's metadata; importing does not deploy it. ownerId must match ownerType: pass the hosted instance UUID with ownerType "instance", or the device hashid with ownerType "device". - credentialSecret is required when the snapshot contains encrypted credentials (a flows.credentials object with a "$" property) and credentials are not excluded via components. It must be the same secret used at export; a wrong secret fails with a 400 "Invalid credential secret". Credentials can only be imported in encrypted form. + credentialSecret is required when the snapshot contains encrypted material: flow credentials (a flows.credentials object with a "$" property, unless excluded via components) or hidden environment variable values (env entries carrying a "$" property, unless env vars are excluded entirely with envVars: false). It must be the same secret used at export. A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. Credentials can only be imported in encrypted form. Use components to import selectively, for example envVars: "keys" to import env var names without their values.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { @@ -176,7 +176,7 @@ module.exports = [ modules: z.record(z.string(), z.any()).optional().describe('Installed module versions') }).describe('Settings payload') }).describe('The snapshot content to import, typically the result of platform_export_snapshot'), - credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot credentials: the secret used when the snapshot was exported. Required when the snapshot contains credentials'), + credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot: the secret used when the snapshot was exported. Required when the snapshot contains encrypted flow credentials or hidden env values'), components: z.object({ flows: z.boolean().optional().describe('Import flows (default true). Excluding flows also excludes credentials'), credentials: z.boolean().optional().describe('Import the flow credentials (default true)'), @@ -185,11 +185,21 @@ module.exports = [ }, handler: async (args, { inject }) => { // The import route errors when settings.env is missing entirely, so - // normalise an omitted env to the empty set the route expects. + // normalise an omitted env to the empty set the route expects. Excluded + // env vars are stripped up front too: the route empties them anyway, but + // only after decrypting hidden values, which fails without the secret. const snapshot = { ...args.snapshot, settings: { ...args.snapshot?.settings } } - if (!snapshot.settings.env) { + if (!snapshot.settings.env || args.components?.envVars === false) { snapshot.settings.env = {} } + // The route only guards flow credentials before decrypting; a missing + // secret with encrypted hidden env values surfaces as a 500, so reject + // both encrypted cases here with a clear error instead. + const hasEncryptedEnv = Object.values(snapshot.settings.env).some(env => env && typeof env === 'object' && env.hidden && env.$) + const hasEncryptedCredentials = args.components?.credentials !== false && !!snapshot.flows?.credentials?.$ + if ((hasEncryptedEnv || hasEncryptedCredentials) && !args.credentialSecret) { + return { statusCode: 400, json: () => ({ code: 'invalid_request', error: 'credentialSecret is required: the snapshot contains encrypted flow credentials or hidden environment variable values' }) } + } const payload = { ownerId: args.ownerId, ownerType: args.ownerType, snapshot } if (args.credentialSecret !== undefined) { payload.credentialSecret = args.credentialSecret diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index 349cf99068..ffa5aa5aaf 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -264,10 +264,10 @@ describe('MCP Snapshots Tools', function () { inject.withArgs({ method: 'POST', url: '/api/v1/snapshots/import', - payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: false } } + payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'keys' } } }).resolves(routeResponse) - const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: false } }, { inject }) + const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'keys' } }, { inject }) inject.calledOnce.should.be.true() response.should.equal(routeResponse) @@ -289,6 +289,54 @@ describe('MCP Snapshots Tools', function () { inject.firstCall.args[0].payload.snapshot.settings.should.eql({ env: {} }) }) + it('strips env vars up front when components excludes them, so no secret is needed', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv, components: { envVars: false } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.settings.env.should.eql({}) + }) + + it('rejects a missing credentialSecret when the snapshot has encrypted hidden env values, since the route 500s', async function () { + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects a missing credentialSecret when the snapshot has encrypted flow credentials that are not excluded', async function () { + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('lets an encrypted snapshot through when the credentialSecret is provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encrypted = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encrypted, credentialSecret: 's3cret' }, { inject }) + + inject.calledOnce.should.be.true() + }) + + it('lets encrypted credentials through without a secret when credentials are excluded', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds, components: { credentials: false } }, { inject }) + + inject.calledOnce.should.be.true() + }) + it('passes through an error response', async function () { const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } inject.resolves(errorResponse) From 593093a862d09fed2d58293528c294d5bf6b0026 Mon Sep 17 00:00:00 2001 From: cstns Date: Fri, 18 Sep 2026 15:33:36 +0300 Subject: [PATCH 3/7] Align snapshot tools with the routes they wrap Mark platform_set_instance_device_target destructive: it overwrites the target on every device assigned to the instance, so it belongs behind destructive tool access rather than plain write. Guard platform_update_snapshot: tool input is not validated platform-side, so a blank name reached the controller and surfaced as a 500, and an update with no fields got a 200 with the snapshot unchanged. Reduce env vars to their names up front on a keys-only import. The route discards the values anyway, but decrypts the hidden ones first, which forced a credentialSecret the caller does not need. Share one components schema between export and import, use the toolError helper for the tool's own 400, and keep arg-level mechanics in the arg descriptions rather than repeating them in the tool description. --- forge/ee/lib/mcp/schemas.js | 11 +++++ forge/ee/lib/mcp/tools/snapshots.js | 48 ++++++++++++------- .../forge/ee/lib/mcp/tools/snapshots_spec.js | 30 +++++++++++- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/forge/ee/lib/mcp/schemas.js b/forge/ee/lib/mcp/schemas.js index f6fcb12903..099cf47da7 100644 --- a/forge/ee/lib/mcp/schemas.js +++ b/forge/ee/lib/mcp/schemas.js @@ -7,6 +7,16 @@ const hostedInstanceId = z.string().uuid().describe('The id (UUID) of the hosted const remoteInstanceId = z.string().describe('The hashid of the remote instance') const snapshotId = z.string().describe('The hashid of the snapshot') +// Shared by the snapshot export and import tools: both routes take the same +// component selection, and the controller applies the same defaults to each. +// Direction-specific cautions (an export exposing hidden values, for instance) +// belong in the owning tool's description, not here. +const snapshotComponents = z.object({ + flows: z.boolean().optional().describe('Include the flows (default true). Excluding flows also excludes credentials'), + credentials: z.boolean().optional().describe('Include the encrypted flow credentials (default true)'), + envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them entirely') +}).optional().describe('Optional selection of which snapshot components to include') + // Query fragments composed per tool by spreading only the ones the backing // route's finder actually honors. Not the same as the route's declared query // schema: most list routes reuse a generic PaginationParams that advertises @@ -85,6 +95,7 @@ module.exports = { hostedInstanceId, remoteInstanceId, snapshotId, + snapshotComponents, cursorParam, limitParam, basePagination, diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 8b5d6f742c..eba3f7618f 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -1,6 +1,6 @@ const { z } = require('zod') -const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId } = require('../schemas') +const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId, snapshotComponents, toolError } = require('../schemas') const { blankHiddenEnvValues } = require('../utils') module.exports = [ @@ -104,8 +104,7 @@ module.exports = [ title: 'Update Snapshot', description: `FlowFuse platform automation tool: Updates a snapshot's name and/or description. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. - This is a partial update: only the fields you pass are changed, omitted fields keep their stored value. - name, when passed, must be a non-empty string - the route rejects an empty name. Pass an empty string as description to clear it.`, + This is a partial update: only the fields you pass are changed, omitted fields keep their stored value. Pass at least one of name or description.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { snapshotId, @@ -113,6 +112,13 @@ module.exports = [ description: z.string().optional().describe('New description for the snapshot. Pass an empty string to clear it; omit to keep the current description') }, handler: async (args, { inject }) => { + // Tool input is not validated platform-side, so the schema's own + // constraints are advisory. The controller throws a sequelize + // ValidationError for a blank name and nothing maps that to a status, + // so an empty name would surface as a 500 rather than a usable error. + if (args.name !== undefined && args.name.trim() === '') { + return toolError(400, 'invalid_request', 'name must be a non-empty string; omit it to keep the current name') + } const payload = {} if (args.name !== undefined) { payload.name = args.name @@ -120,6 +126,11 @@ module.exports = [ if (args.description !== undefined) { payload.description = args.description } + // The route replies 200 with the unchanged snapshot when there is + // nothing to update, which reads as a successful edit that never happened. + if (Object.keys(payload).length === 0) { + return toolError(400, 'invalid_request', 'Pass at least one of name or description to update') + } const response = await inject({ method: 'PUT', url: `/api/v1/snapshots/${args.snapshotId}`, payload }) return response } @@ -136,11 +147,7 @@ module.exports = [ inputSchema: { snapshotId, credentialSecret: z.string().describe('Secret used to re-encrypt the exported credentials. Required on every export; the same secret is needed to import the result'), - components: z.object({ - flows: z.boolean().optional().describe('Include flows in the export (default true). Excluding flows also excludes credentials'), - credentials: z.boolean().optional().describe('Include the encrypted flow credentials (default true)'), - envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables to include: "all" keeps keys and values (default, exposes hidden values), "keys" keeps only the names, false removes them entirely') - }).optional().describe('Optional selection of which snapshot components to include in the export') + components: snapshotComponents }, handler: async (args, { inject }) => { const payload = { credentialSecret: args.credentialSecret } @@ -157,8 +164,9 @@ module.exports = [ description: `FlowFuse platform automation tool: Imports a previously exported snapshot into a hosted instance or a remote instance (device), creating a new snapshot owned by that target. The response is the new snapshot's metadata; importing does not deploy it. ownerId must match ownerType: pass the hosted instance UUID with ownerType "instance", or the device hashid with ownerType "device". - credentialSecret is required when the snapshot contains encrypted material: flow credentials (a flows.credentials object with a "$" property, unless excluded via components) or hidden environment variable values (env entries carrying a "$" property, unless env vars are excluded entirely with envVars: false). It must be the same secret used at export. A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. Credentials can only be imported in encrypted form. - Use components to import selectively, for example envVars: "keys" to import env var names without their values.`, + credentialSecret is required when the snapshot contains encrypted material: flow credentials (a flows.credentials object with a "$" property, unless excluded via components) or hidden environment variable values (env entries carrying a "$" property, unless env vars are dropped with envVars: false or reduced to their names with envVars: "keys"). It must be the same secret used at export. A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. Credentials can only be imported in encrypted form. + Use components to import selectively, for example envVars: "keys" to import env var names without their values. + Copy only name, description, flows and settings out of an export; the other fields it carries (id, timestamps, ownerType, user, exportedBy) are not part of this tool's snapshot argument.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { ownerId: z.string().describe('Target owner id: hosted instance (project) UUID when ownerType is "instance", or device hashid when ownerType is "device"'), @@ -177,11 +185,7 @@ module.exports = [ }).describe('Settings payload') }).describe('The snapshot content to import, typically the result of platform_export_snapshot'), credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot: the secret used when the snapshot was exported. Required when the snapshot contains encrypted flow credentials or hidden env values'), - components: z.object({ - flows: z.boolean().optional().describe('Import flows (default true). Excluding flows also excludes credentials'), - credentials: z.boolean().optional().describe('Import the flow credentials (default true)'), - envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables to import: "all" keeps keys and values (default), "keys" keeps only the names, false skips them') - }).optional().describe('Optional selection of which snapshot components to import') + components: snapshotComponents }, handler: async (args, { inject }) => { // The import route errors when settings.env is missing entirely, so @@ -191,6 +195,14 @@ module.exports = [ const snapshot = { ...args.snapshot, settings: { ...args.snapshot?.settings } } if (!snapshot.settings.env || args.components?.envVars === false) { snapshot.settings.env = {} + } else if (args.components?.envVars === 'keys') { + // Same story for a keys-only import: the route keeps the names and + // discards every value, but decrypts the hidden ones first. Reducing + // to the names here produces the identical result without a secret. + snapshot.settings.env = Object.keys(snapshot.settings.env).reduce((acc, key) => { + acc[key] = '' + return acc + }, {}) } // The route only guards flow credentials before decrypting; a missing // secret with encrypted hidden env values surfaces as a 500, so reject @@ -198,7 +210,7 @@ module.exports = [ const hasEncryptedEnv = Object.values(snapshot.settings.env).some(env => env && typeof env === 'object' && env.hidden && env.$) const hasEncryptedCredentials = args.components?.credentials !== false && !!snapshot.flows?.credentials?.$ if ((hasEncryptedEnv || hasEncryptedCredentials) && !args.credentialSecret) { - return { statusCode: 400, json: () => ({ code: 'invalid_request', error: 'credentialSecret is required: the snapshot contains encrypted flow credentials or hidden environment variable values' }) } + return toolError(400, 'invalid_request', 'credentialSecret is required: the snapshot contains encrypted flow credentials or hidden environment variable values') } const payload = { ownerId: args.ownerId, ownerType: args.ownerType, snapshot } if (args.credentialSecret !== undefined) { @@ -219,7 +231,9 @@ module.exports = [ CAUTION: this takes effect immediately - every device assigned to the instance is told to deploy the target snapshot as soon as it is set. Confirm with the user before calling this. The snapshot must belong to the given hosted instance, otherwise the route rejects it with "invalid_snapshot". This tool can only set a target, not clear one. Use platform_get_hosted_instance_device_target_snapshot to see the current target, and platform_list_instance_snapshots to find a snapshot id.`, - annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + // destructiveHint: this overwrites what every assigned device is running, + // rather than adding to it, so it belongs behind destructive tool access. + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, inputSchema: { hostedInstanceId, snapshotId: snapshotId.describe('The hashid of the snapshot to set as the device target. Must be a snapshot of this hosted instance') diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index ffa5aa5aaf..75ad43d6fc 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -211,6 +211,22 @@ describe('MCP Snapshots Tools', function () { inject.firstCall.args[0].payload.should.eql({ description: '' }) }) + it('rejects a blank name without calling the route, which would 500 on it', async function () { + const response = await tool.handler({ snapshotId: 'snapshot1', name: ' ' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects an update with nothing to change, which the route answers 200 to', async function () { + const response = await tool.handler({ snapshotId: 'snapshot1' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + it('passes through an error response', async function () { const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } inject.resolves(errorResponse) @@ -264,10 +280,10 @@ describe('MCP Snapshots Tools', function () { inject.withArgs({ method: 'POST', url: '/api/v1/snapshots/import', - payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'keys' } } + payload: { ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'all' } } }).resolves(routeResponse) - const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'keys' } }, { inject }) + const response = await tool.handler({ ownerId: hostedInstanceId, ownerType: 'instance', snapshot, credentialSecret: 's3cret', components: { envVars: 'all' } }, { inject }) inject.calledOnce.should.be.true() response.should.equal(routeResponse) @@ -299,6 +315,16 @@ describe('MCP Snapshots Tools', function () { inject.firstCall.args[0].payload.snapshot.settings.env.should.eql({}) }) + it('reduces env vars to their names when only keys are wanted, so no secret is needed', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' }, PLAIN: { value: 'keep' } } } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedEnv, components: { envVars: 'keys' } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.settings.env.should.eql({ SECRET: '', PLAIN: '' }) + }) + it('rejects a missing credentialSecret when the snapshot has encrypted hidden env values, since the route 500s', async function () { const encryptedEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { SECRET: { hidden: true, $: 'abc123' } } } } From 588488cfda85c2d35aab979c89ad6f92546f9bd7 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 21 Sep 2026 13:26:09 +0300 Subject: [PATCH 4/7] Harden the snapshot import tool against the route's rough edges Accept an export verbatim: the snapshot argument is loose now, so the six fields an export carries on top of name/description/flows/settings no longer fail validation. Only the four the route reads are forwarded. Reject unencrypted flow credentials, which the route stores in the clear (#8569), and stop asking for a credential secret when components.flows is false, where the secret is never used (#8570). Give settings.env a declared shape so a null value cannot reach the route, which reads every value's properties unguarded (#8568). Document the FF_ env var stripping, and the credentials block every export carries whatever the snapshot holds (#8571), on both tool descriptions. --- forge/ee/lib/mcp/tools/snapshots.js | 70 +++++++++++++++++-- .../forge/ee/lib/mcp/tools/snapshots_spec.js | 59 ++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index eba3f7618f..2f968738c0 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -3,6 +3,19 @@ const { z } = require('zod') const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId, snapshotComponents, toolError } = require('../schemas') const { blankHiddenEnvValues } = require('../utils') +// An env var in a snapshot is either a plain value or an object carrying the +// hidden flag, with "$" holding the encrypted value once it has been exported. +// Spelling the shape out keeps null and other scalars from reaching the import +// route, which reads every value's properties unguarded. +const envVarValue = z.union([ + z.string(), + z.object({ + value: z.string().optional().describe('The value, for an env var that is not hidden or has been decrypted'), + hidden: z.boolean().optional().describe('Whether this is a secret env var'), + $: z.string().optional().describe('The encrypted value, as produced by platform_export_snapshot') + }).loose() +]) + module.exports = [ { name: 'platform_list_instance_snapshots', @@ -142,6 +155,8 @@ module.exports = [ Exports the full content of a snapshot (flows, credentials, settings, and environment variables) so it can be imported elsewhere with platform_import_snapshot. Works for snapshots owned by a hosted instance or a remote instance (device); the owner is resolved automatically from the snapshot. credentialSecret is always required, even when credentials are excluded via components - the route rejects the request with a 400 without it. The exported credentials are re-encrypted with this secret, and the SAME secret must be supplied when importing the result, so remember it. The export contains sensitive data: by default it includes the encrypted flow credentials and the values of ALL environment variables, including hidden (secret) ones. Use components to narrow what is included, for example envVars: "keys" to strip env values. + Environment variables whose names start with FF_ are reserved by the platform and are never included in an export. + The export always carries a credentials block, even for a snapshot with no flows and no credentials, so platform_import_snapshot will ask for this secret again whatever the snapshot holds. Unlike platform_get_snapshot_full, this is treated as a write operation because it extracts credentials and secret values.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { @@ -163,10 +178,13 @@ module.exports = [ title: 'Import Snapshot', description: `FlowFuse platform automation tool: Imports a previously exported snapshot into a hosted instance or a remote instance (device), creating a new snapshot owned by that target. The response is the new snapshot's metadata; importing does not deploy it. + Pass the result of platform_export_snapshot straight through as snapshot; the extra fields an export carries (id, createdAt, updatedAt, ownerType, user, exportedBy) are accepted and ignored. ownerId must match ownerType: pass the hosted instance UUID with ownerType "instance", or the device hashid with ownerType "device". - credentialSecret is required when the snapshot contains encrypted material: flow credentials (a flows.credentials object with a "$" property, unless excluded via components) or hidden environment variable values (env entries carrying a "$" property, unless env vars are dropped with envVars: false or reduced to their names with envVars: "keys"). It must be the same secret used at export. A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. Credentials can only be imported in encrypted form. - Use components to import selectively, for example envVars: "keys" to import env var names without their values. - Copy only name, description, flows and settings out of an export; the other fields it carries (id, timestamps, ownerType, user, exportedBy) are not part of this tool's snapshot argument.`, + credentialSecret must be the same secret used at export. Every snapshot produced by platform_export_snapshot carries an encrypted credentials block, even one with no flows and no credentials, so a secret is in practice always required for an export. It can only be left out for a hand-built snapshot with no encrypted material, or when credentials are excluded with components flows: false or credentials: false. Hidden environment variable values (env entries carrying a "$" property) need it too, unless env vars are dropped with envVars: false or reduced to their names with envVars: "keys". + A wrong secret is only detected through flow credentials (400 "Invalid credential secret"); when no flow credentials are imported, a wrong secret cannot be detected and hidden env values import as corrupted data, so double-check the secret first. + Flow credentials must be encrypted: pass the credentials object from an export, carrying a single "$" property. Unencrypted credentials are rejected, because the platform stores them exactly as given and they would sit in the clear. + Environment variables whose names start with FF_ are reserved by the platform and are dropped on import, so they will not appear in the created snapshot. + Use components to import selectively, for example envVars: "keys" to import env var names without their values.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { ownerId: z.string().describe('Target owner id: hosted instance (project) UUID when ownerType is "instance", or device hashid when ownerType is "device"'), @@ -180,19 +198,37 @@ module.exports = [ }).describe('Flows payload'), settings: z.object({ settings: z.record(z.string(), z.any()).optional().describe('Runtime settings'), - env: z.record(z.string(), z.any()).optional().describe('Environment variables (defaults to none)'), + env: z.record(z.string(), envVarValue).optional().describe('Environment variables, either "NAME": "value" or "NAME": { "value": "...", "hidden": true } for secrets (defaults to none)'), modules: z.record(z.string(), z.any()).optional().describe('Installed module versions') }).describe('Settings payload') - }).describe('The snapshot content to import, typically the result of platform_export_snapshot'), + // Loose so an export can be handed over untouched: it carries id, + // createdAt, updatedAt, ownerType, user and exportedBy on top of the + // four fields the route reads. The handler forwards only those four. + }).loose().describe('The snapshot content to import, typically the result of platform_export_snapshot'), credentialSecret: z.string().optional().describe('Secret to decrypt the snapshot: the secret used when the snapshot was exported. Required when the snapshot contains encrypted flow credentials or hidden env values'), components: snapshotComponents }, handler: async (args, { inject }) => { + // Forward only the four fields the route reads, so the export metadata + // the schema now tolerates is not echoed back into the request body. + const { name, description, flows, settings } = args.snapshot || {} + const snapshot = { name, flows: { ...flows }, settings: { ...settings } } + if (description !== undefined) { + snapshot.description = description + } + + // Excluding the flows drops the credentials along with them, but the + // route guards on the payload as given, so it asks for a secret it will + // never use. Strip them here the way excluded env vars are stripped below. + const credentialsExcluded = args.components?.flows === false || args.components?.credentials === false + if (credentialsExcluded) { + snapshot.flows.credentials = {} + } + // The import route errors when settings.env is missing entirely, so // normalise an omitted env to the empty set the route expects. Excluded // env vars are stripped up front too: the route empties them anyway, but // only after decrypting hidden values, which fails without the secret. - const snapshot = { ...args.snapshot, settings: { ...args.snapshot?.settings } } if (!snapshot.settings.env || args.components?.envVars === false) { snapshot.settings.env = {} } else if (args.components?.envVars === 'keys') { @@ -204,11 +240,31 @@ module.exports = [ return acc }, {}) } + + // The route reads every env value's properties without checking it is + // an object, so a null slips through to a 500. The schema rules those + // out at the gateway; this covers callers the schema does not reach. + const invalidEnv = Object.keys(snapshot.settings.env).find(key => { + const value = snapshot.settings.env[key] + return value === null || (typeof value !== 'string' && typeof value !== 'object') + }) + if (invalidEnv) { + return toolError(400, 'invalid_request', `settings.env.${invalidEnv} must be a string or an object; the platform errors on any other value`) + } + + // Credentials are only re-encrypted for the target when they arrive + // encrypted; anything else is written to the snapshot verbatim and ends + // up stored in the clear, so reject it rather than leak it into the database. + const credentials = snapshot.flows.credentials + if (credentials && !credentials.$ && Object.keys(credentials).length > 0) { + return toolError(400, 'invalid_request', 'flows.credentials must be the encrypted object produced by platform_export_snapshot, carrying a single "$" property; unencrypted credentials would be stored in the clear') + } + // The route only guards flow credentials before decrypting; a missing // secret with encrypted hidden env values surfaces as a 500, so reject // both encrypted cases here with a clear error instead. const hasEncryptedEnv = Object.values(snapshot.settings.env).some(env => env && typeof env === 'object' && env.hidden && env.$) - const hasEncryptedCredentials = args.components?.credentials !== false && !!snapshot.flows?.credentials?.$ + const hasEncryptedCredentials = !credentialsExcluded && !!credentials?.$ if ((hasEncryptedEnv || hasEncryptedCredentials) && !args.credentialSecret) { return toolError(400, 'invalid_request', 'credentialSecret is required: the snapshot contains encrypted flow credentials or hidden environment variable values') } diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index 75ad43d6fc..a391e5e205 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -363,6 +363,65 @@ describe('MCP Snapshots Tools', function () { inject.calledOnce.should.be.true() }) + it('accepts an export verbatim and forwards only the fields the route reads', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const exported = { + id: 'snapshot1', + name: 'imported', + description: 'from an export', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ownerType: 'instance', + user: { id: 'user1' }, + exportedBy: { id: 'user1' }, + flows: { flows: [], credentials: { $: 'abc123' } }, + settings: { env: {} } + } + + tool.inputSchema.snapshot.safeParse(exported).success.should.be.true() + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: exported, credentialSecret: 's3cret' }, { inject }) + + inject.firstCall.args[0].payload.snapshot.should.eql({ + name: 'imported', + description: 'from an export', + flows: { flows: [], credentials: { $: 'abc123' } }, + settings: { env: {} } + }) + }) + + it('lets encrypted credentials through without a secret when the flows are excluded', async function () { + inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot2' }) }) + const encryptedCreds = { name: 'imported', flows: { flows: [], credentials: { $: 'abc123' } }, settings: { env: {} } } + + await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: encryptedCreds, components: { flows: false } }, { inject }) + + inject.calledOnce.should.be.true() + inject.firstCall.args[0].payload.snapshot.flows.credentials.should.eql({}) + }) + + it('rejects unencrypted flow credentials, which the route would store in the clear', async function () { + const plainCreds = { name: 'imported', flows: { flows: [], credentials: { node1: { password: 'hunter2' } } }, settings: { env: {} } } + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: plainCreds }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + + it('rejects an env value that is neither a string nor an object, since the route 500s on it', async function () { + const badEnv = { name: 'imported', flows: { flows: [] }, settings: { env: { BROKEN: null } } } + + tool.inputSchema.snapshot.safeParse(badEnv).success.should.be.false() + + const response = await tool.handler({ ownerId: 'device1', ownerType: 'device', snapshot: badEnv }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + it('passes through an error response', async function () { const errorResponse = { statusCode: 400, json: () => ({ code: 'bad_request' }) } inject.resolves(errorResponse) From 4a587ea599f1450687ffe3063e92460eb43af6c4 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 21 Sep 2026 13:53:10 +0300 Subject: [PATCH 5/7] Correct the snapshot export tool's description and guard a blank secret The line claiming an export always carries a credentials block was only half right: with components credentials:false or flows:false the export carries an empty object and the import needs no secret at all. Say which case is which. A blank credentialSecret now fails in the tool rather than costing a round trip to the route, which reads it as absent and answers 400. Matches what the update tool already does for a blank name. Note that envVars:"keys" drops the hidden flag, so a secret variable comes back as an ordinary empty one. That lives on the shared components schema, so it covers the import tool too. Also carry over the payload-size caution from platform_get_snapshot_full, since an export is always a superset of it. --- forge/ee/lib/mcp/schemas.js | 2 +- forge/ee/lib/mcp/tools/snapshots.js | 10 ++++++++-- test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js | 10 ++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/forge/ee/lib/mcp/schemas.js b/forge/ee/lib/mcp/schemas.js index 099cf47da7..1930f2e005 100644 --- a/forge/ee/lib/mcp/schemas.js +++ b/forge/ee/lib/mcp/schemas.js @@ -14,7 +14,7 @@ const snapshotId = z.string().describe('The hashid of the snapshot') const snapshotComponents = z.object({ flows: z.boolean().optional().describe('Include the flows (default true). Excluding flows also excludes credentials'), credentials: z.boolean().optional().describe('Include the encrypted flow credentials (default true)'), - envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them entirely') + envVars: z.union([z.enum(['all', 'keys']), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them entirely. Note "keys" also drops the hidden flag, so a secret variable comes back as an ordinary empty one') }).optional().describe('Optional selection of which snapshot components to include') // Query fragments composed per tool by spreading only the ones the backing diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 2f968738c0..1c9538d109 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -156,15 +156,21 @@ module.exports = [ credentialSecret is always required, even when credentials are excluded via components - the route rejects the request with a 400 without it. The exported credentials are re-encrypted with this secret, and the SAME secret must be supplied when importing the result, so remember it. The export contains sensitive data: by default it includes the encrypted flow credentials and the values of ALL environment variables, including hidden (secret) ones. Use components to narrow what is included, for example envVars: "keys" to strip env values. Environment variables whose names start with FF_ are reserved by the platform and are never included in an export. - The export always carries a credentials block, even for a snapshot with no flows and no credentials, so platform_import_snapshot will ask for this secret again whatever the snapshot holds. + Unless credentials are excluded (components credentials: false or flows: false), the export carries an encrypted credentials block even when the snapshot has no flows and no credentials at all, so platform_import_snapshot will need this same secret to take the result back in. + This payload can be large, and is always a superset of platform_get_snapshot_full, so only call it when the exported content is actually needed. Unlike platform_get_snapshot_full, this is treated as a write operation because it extracts credentials and secret values.`, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { snapshotId, - credentialSecret: z.string().describe('Secret used to re-encrypt the exported credentials. Required on every export; the same secret is needed to import the result'), + credentialSecret: z.string().min(1).describe('Secret used to re-encrypt the exported credentials. Required on every export; the same secret is needed to import the result'), components: snapshotComponents }, handler: async (args, { inject }) => { + // The route treats a blank secret as an absent one and answers 400. + // Schema constraints are advisory platform-side, so check it here too. + if (!args.credentialSecret) { + return toolError(400, 'invalid_request', 'credentialSecret is required and must not be empty') + } const payload = { credentialSecret: args.credentialSecret } if (args.components !== undefined) { payload.components = args.components diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index a391e5e205..3438b64031 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -249,6 +249,16 @@ describe('MCP Snapshots Tools', function () { response.should.equal(routeResponse) }) + it('rejects a blank credentialSecret without calling the route, which treats it as absent', async function () { + tool.inputSchema.credentialSecret.safeParse('').success.should.be.false() + + const response = await tool.handler({ snapshotId: 'snapshot1', credentialSecret: '' }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + it('forwards the components selection when provided', async function () { inject.resolves({ statusCode: 200, json: () => ({ id: 'snapshot1' }) }) From 2666b0abc63a5b277526d79d692c2fc1975e4f8e Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 21 Sep 2026 14:06:05 +0300 Subject: [PATCH 6/7] Bound the snapshot name in the update tool The name column is 255 wide, so a longer value comes back as a 500 carrying the raw database error (#8576). The tool already shields a blank name for the same reason, this is the other end of the same check. Only bites on postgres: sqlite ignores the declared width, so the guard is in the handler as well as the schema rather than relying on a test that would pass on the default dev database either way. --- forge/ee/lib/mcp/tools/snapshots.js | 11 ++++++++++- test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 1c9538d109..96c1321a5b 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -3,6 +3,9 @@ const { z } = require('zod') const { basePaginationKeys, limitParam, appendQuery, hostedInstanceId, snapshotId, snapshotComponents, toolError } = require('../schemas') const { blankHiddenEnvValues } = require('../utils') +// Width of ProjectSnapshot.name, a DataTypes.STRING column. +const SNAPSHOT_NAME_MAX_LENGTH = 255 + // An env var in a snapshot is either a plain value or an object carrying the // hidden flag, with "$" holding the encrypted value once it has been exported. // Spelling the shape out keeps null and other scalars from reaching the import @@ -121,7 +124,7 @@ module.exports = [ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { snapshotId, - name: z.string().min(1).optional().describe('New name for the snapshot. Must be non-empty when provided; omit to keep the current name'), + name: z.string().min(1).max(SNAPSHOT_NAME_MAX_LENGTH).optional().describe(`New name for the snapshot. Must be non-empty and at most ${SNAPSHOT_NAME_MAX_LENGTH} characters when provided; omit to keep the current name`), description: z.string().optional().describe('New description for the snapshot. Pass an empty string to clear it; omit to keep the current description') }, handler: async (args, { inject }) => { @@ -132,6 +135,12 @@ module.exports = [ if (args.name !== undefined && args.name.trim() === '') { return toolError(400, 'invalid_request', 'name must be a non-empty string; omit it to keep the current name') } + // The name column is 255 wide, and an over-long value surfaces as a 500 + // carrying the raw database error. Note this only bites on postgres: + // sqlite ignores the declared width, so it passes there. + if (args.name !== undefined && args.name.length > SNAPSHOT_NAME_MAX_LENGTH) { + return toolError(400, 'invalid_request', `name must be ${SNAPSHOT_NAME_MAX_LENGTH} characters or fewer`) + } const payload = {} if (args.name !== undefined) { payload.name = args.name diff --git a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js index 3438b64031..2a4cc901a6 100644 --- a/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/snapshots_spec.js @@ -219,6 +219,18 @@ describe('MCP Snapshots Tools', function () { response.json().code.should.equal('invalid_request') }) + it('rejects a name longer than the column, which the route 500s on', async function () { + const tooLong = 'x'.repeat(256) + tool.inputSchema.name.safeParse(tooLong).success.should.be.false() + tool.inputSchema.name.safeParse('x'.repeat(255)).success.should.be.true() + + const response = await tool.handler({ snapshotId: 'snapshot1', name: tooLong }, { inject }) + + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().code.should.equal('invalid_request') + }) + it('rejects an update with nothing to change, which the route answers 200 to', async function () { const response = await tool.handler({ snapshotId: 'snapshot1' }, { inject }) From 9bbd6e0bb407ab12655b9df2f0ce18b93d611755 Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 21 Sep 2026 14:15:56 +0300 Subject: [PATCH 7/7] Say what the device target tool cannot undo, and how to size the blast radius "This tool can only set a target, not clear one" read as a limitation of the tool, so an agent would go looking for another way. There isn't one: the route ignores a null target and answers 200 without changing anything. The only way to remove a target is to delete the snapshot it points at, which clears it from the instance and from every assigned device. The description also tells the caller to confirm before deploying to every assigned device, without giving them anything to confirm against. Point at platform_list_remote_instances scoped by hostedInstanceId, which answers exactly which devices a call will hit. --- forge/ee/lib/mcp/tools/snapshots.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/mcp/tools/snapshots.js b/forge/ee/lib/mcp/tools/snapshots.js index 96c1321a5b..9e64dca940 100644 --- a/forge/ee/lib/mcp/tools/snapshots.js +++ b/forge/ee/lib/mcp/tools/snapshots.js @@ -299,8 +299,9 @@ module.exports = [ title: 'Set Hosted Instance Device Target Snapshot', description: `FlowFuse platform automation tool: Sets the target snapshot for the remote instances (devices) assigned to a hosted instance. - CAUTION: this takes effect immediately - every device assigned to the instance is told to deploy the target snapshot as soon as it is set. Confirm with the user before calling this. - The snapshot must belong to the given hosted instance, otherwise the route rejects it with "invalid_snapshot". This tool can only set a target, not clear one. + CAUTION: this takes effect immediately - every device assigned to the instance is told to deploy the target snapshot as soon as it is set. Confirm with the user before calling this, and call platform_list_remote_instances with this hostedInstanceId first so you can tell them which devices it will hit. + The snapshot must belong to the given hosted instance, otherwise the route rejects it with "invalid_snapshot". + There is no way to clear a target snapshot through the API, not with this tool and not on the underlying route, which ignores a null target and answers 200 without changing anything. Once set, a target can only be replaced by another one, or removed by deleting the snapshot it points at, which clears it from the instance and from every assigned device. Use platform_get_hosted_instance_device_target_snapshot to see the current target, and platform_list_instance_snapshots to find a snapshot id.`, // destructiveHint: this overwrites what every assigned device is running, // rather than adding to it, so it belongs behind destructive tool access.