Skip to content
49 changes: 49 additions & 0 deletions forge/ee/lib/mcp/tools/library.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const { z } = require('zod')

const { teamId, toolError } = require('../schemas')

module.exports = [
{
name: 'platform_create_library_entry',
title: 'Create Team Library Entry',
description: `FlowFuse platform automation tool:
Saves an entry in a team's shared library, the library the team's Node-RED instances and devices share in the editor's import and export dialogs.
An entry is identified by its path and type. If an entry with the same path and type already exists, its body is replaced, and its meta too when meta is given, and the call still succeeds, so repeating a call is safe but silently overwrites. The same path can hold separate entries of different types.
Folders are implicit: they exist while an entry sits below them, so saving "utils/helpers/retry" creates the folders "utils" and "utils/helpers". A path cannot be both an entry and a folder; saving one that clashes with the other fails with 400.
The shared library is a plan-gated feature. A 404 means it is not enabled for the team's plan, the team does not exist, or the caller is not a member of it.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
path: z.string().min(1).max(1024).describe('Entry path using "/" to separate folders, for example "utils/retry". No leading or trailing slash, and no empty, "." or ".." segments'),
type: z.string().min(1).describe('Entry type. "flows" holds a flow and "functions" holds the code of a function node; nodes can register other types'),
body: z.union([z.string(), z.array(z.any()), z.record(z.string(), z.any())]).describe('Entry contents. For "flows", the flow as an array of nodes or a JSON string of one. For "functions", the code as a string. Other values are stored as JSON text'),
meta: z.record(z.string(), z.any()).optional().describe('Metadata shown when the entry is listed, for example { "description": "..." }')
},
outputSchema: {
entry: z.object({
path: z.string(),
type: z.string()
})
},
handler: async (args, { inject }) => {
const segments = args.path.split('/')
// The URL resolves "." and ".." segments, even encoded ones, before the route sees the name
if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
return toolError(400, 'invalid_request', 'path must not start or end with "/" or contain empty, "." or ".." segments')
}
const payload = { type: args.type, body: args.body }
if (args.meta !== undefined) {
payload.meta = args.meta
}
const url = `/storage/library/${args.teamId}/${segments.map(encodeURIComponent).join('/')}`
const response = await inject({ method: 'POST', url, payload })
if (response.statusCode >= 400) {
return response
}
return {
statusCode: response.statusCode,
json: () => ({ entry: { path: args.path, type: args.type } })
}
}
}
]
23 changes: 23 additions & 0 deletions forge/ee/lib/mcp/tools/teams.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,29 @@ module.exports = [
return response
}
},
{
name: 'platform_update_git_token',
title: 'Update Team Git Token',
description: `FlowFuse platform automation tool:
Renames a git token configured for a team. The name is the only editable field: this never changes or returns the stored credential, and the reply holds only the token's id, name and type.
Renaming a token to the name it already has succeeds without changing anything.
Only team owners can update git tokens. Use platform_list_team_git_tokens to find the token id.
Git integration is a plan-gated feature. A 404 means the token does not exist in this team, or git integration is not enabled for the team's plan.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
tokenId: pathId.describe('The hashid of the git token, as returned by platform_list_team_git_tokens'),
name: z.string().trim().min(1).max(255).describe('New name for the git token')
},
outputSchema: {
id: z.string(),
name: z.string(),
type: z.string()
},
handler: async (args, { inject }) => {
return inject({ method: 'PUT', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}`, payload: { name: args.name } })
}
},
{
name: 'platform_create_team',
title: 'Create Team',
Expand Down
87 changes: 87 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/library_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
const should = require('should') // eslint-disable-line no-unused-vars
const sinon = require('sinon')
const { z } = require('zod')

const tools = require('../../../../../../../forge/ee/lib/mcp/tools/library')

function getTool (name) {
return tools.find(tool => tool.name === name)
}

describe('MCP Library Tools', function () {
let inject

beforeEach(function () {
inject = sinon.stub()
})

describe('platform_create_library_entry', function () {
const tool = getTool('platform_create_library_entry')
const created = () => ({ statusCode: 201, json: () => JSON.parse('') })

it('is a non-destructive, idempotent write', function () {
tool.annotations.should.containEql({ readOnlyHint: false, destructiveHint: false, idempotentHint: true })
})

it('posts type, body and meta to the library path and returns the entry', async function () {
inject.resolves(created())
const meta = { description: 'retry helper' }
const response = await tool.handler({ teamId: 'team1', path: 'utils/retry', type: 'functions', body: 'return msg', meta }, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({
method: 'POST',
url: '/storage/library/team1/utils/retry',
payload: { type: 'functions', body: 'return msg', meta }
})
response.statusCode.should.equal(201)
response.json().should.eql({ entry: { path: 'utils/retry', type: 'functions' } })
})

it('omits meta when none is given', async function () {
inject.resolves(created())
await tool.handler({ teamId: 'team1', path: 'a', type: 'functions', body: 'x' }, { inject })
inject.firstCall.args[0].payload.should.eql({ type: 'functions', body: 'x' })
})

it('passes an object body through for the route to store as JSON text', async function () {
inject.resolves(created())
const body = [{ id: 'n1', type: 'tab' }]
await tool.handler({ teamId: 'team1', path: 'flow', type: 'flows', body }, { inject })
inject.firstCall.args[0].payload.body.should.eql(body)
})

it('encodes each path segment but keeps the folder separators', async function () {
inject.resolves(created())
await tool.handler({ teamId: 'team1', path: 'my dir/a#b?c/p%q', type: 'functions', body: 'x' }, { inject })
inject.firstCall.args[0].url.should.equal('/storage/library/team1/my%20dir/a%23b%3Fc/p%25q')
})

const invalidPaths = ['/a', 'a/', 'a//b', 'a/./b', 'a/../b', '..', '.']
invalidPaths.forEach(path => {
it(`rejects the path '${path}' without calling the route`, async function () {
const response = await tool.handler({ teamId: 'team1', path, type: 'functions', body: 'x' }, { inject })
inject.called.should.be.false()
response.statusCode.should.equal(400)
response.json().code.should.equal('invalid_request')
})
})

it('passes error responses through unchanged', async function () {
const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_name', error: 'Invalid entry name' }) }
inject.resolves(errorResponse)
const response = await tool.handler({ teamId: 'team1', path: 'a/b', type: 'functions', body: 'x' }, { inject })
response.should.equal(errorResponse)
})

it('requires a non-empty path and type, and accepts string, array and object bodies', function () {
const schema = z.object(tool.inputSchema)
const valid = { teamId: 'team1', path: 'a', type: 'functions', body: 'x' }
schema.safeParse(valid).success.should.be.true()
schema.safeParse({ ...valid, body: [] }).success.should.be.true()
schema.safeParse({ ...valid, body: { a: 1 } }).success.should.be.true()
schema.safeParse({ ...valid, body: 5 }).success.should.be.false()
schema.safeParse({ ...valid, path: '' }).success.should.be.false()
schema.safeParse({ ...valid, type: '' }).success.should.be.false()
})
})
})
26 changes: 26 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/teams_spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const should = require('should') // eslint-disable-line no-unused-vars
const sinon = require('sinon')
const { z } = require('zod')

const tools = require('../../../../../../../forge/ee/lib/mcp/tools/teams')

Expand Down Expand Up @@ -389,4 +390,29 @@ describe('MCP Teams Tools', function () {
response.should.equal(errorResponse)
})
})

describe('platform_update_git_token', function () {
const tool = getTool('platform_update_git_token')
const args = { teamId: 'team1', tokenId: 'tok1', name: 'renamed' }

it('is a non-destructive, idempotent write', function () {
tool.annotations.should.containEql({ readOnlyHint: false, destructiveHint: false, idempotentHint: true })
})

it('puts only the name to the token route and returns the response', async function () {
const routeResponse = { statusCode: 200, json: () => ({ id: 'tok1', name: 'renamed', type: 'github' }) }
inject.resolves(routeResponse)
const response = await tool.handler(args, { inject })
inject.calledOnce.should.be.true()
inject.firstCall.args[0].should.eql({ method: 'PUT', url: '/api/v1/teams/team1/git/tokens/tok1', payload: { name: 'renamed' } })
response.should.equal(routeResponse)
})

it('requires a non-empty name', function () {
const schema = z.object(tool.inputSchema)
schema.safeParse(args).success.should.be.true()
schema.safeParse({ ...args, name: '' }).success.should.be.false()
schema.safeParse({ ...args, name: ' ' }).success.should.be.false()
})
})
})
Loading