From c34d835cfe5176cc52cef47cbfc52f73addfd9db Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 7 Oct 2026 19:42:56 +0200 Subject: [PATCH 1/4] Add MCP tools to create a shared library entry and rename a git token --- forge/ee/lib/mcp/tools/library.js | 63 ++++++++++ forge/ee/lib/mcp/tools/teams.js | 36 ++++++ .../forge/ee/lib/mcp/tools/library_spec.js | 119 ++++++++++++++++++ .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 58 +++++++++ 4 files changed, 276 insertions(+) create mode 100644 forge/ee/lib/mcp/tools/library.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/library_spec.js diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js new file mode 100644 index 0000000000..fea0700792 --- /dev/null +++ b/forge/ee/lib/mcp/tools/library.js @@ -0,0 +1,63 @@ +const { z } = require('zod') + +const { teamId, toolError } = require('../schemas') + +const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) + +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 and meta are replaced 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 }) => { + if (!isHashid(args.teamId)) { + return toolError(400, 'invalid_request', 'teamId must be a hashid') + } + const segments = args.path.split('/') + if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) { + return toolError(400, 'invalid_request', 'path must not start or end with "/" or contain empty, "." or ".." segments') + } + if (args.type === 'flows' && typeof args.body === 'string') { + try { + JSON.parse(args.body) + } catch (err) { + return toolError(400, 'invalid_request', 'The body of a "flows" entry must be valid JSON: an array of nodes or a JSON string of one') + } + } + 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 === 404) { + return toolError(404, 'not_found', 'The shared library is not enabled for this team, or the team does not exist, or you are not a member of it') + } + if (response.statusCode >= 400) { + return response + } + return { + statusCode: response.statusCode, + json: () => ({ entry: { path: args.path, type: args.type } }) + } + } + } +] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index ca9ca92f17..f0c7260d19 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -178,6 +178,42 @@ 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: z.string().describe('The hashid of the git token, as returned by platform_list_team_git_tokens'), + name: z.string().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 }) => { + if (!isHashid(args.teamId) || !isHashid(args.tokenId)) { + return toolError(400, 'invalid_request', 'teamId and tokenId must be hashids') + } + const url = `/api/v1/teams/${args.teamId}/git/tokens` + const response = await inject({ method: 'PUT', url: `${url}/${args.tokenId}`, payload: { name: args.name } }) + if (response.statusCode !== 404) { + return response + } + // The route answers 404 for a missing token and for a team without git integration alike + const probe = await inject({ method: 'GET', url }) + if (probe.statusCode === 404) { + return toolError(404, 'not_found', 'Git integration is not enabled for this team, or the team does not exist') + } + return response + } + }, { name: 'platform_create_team', title: 'Create Team', diff --git a/test/unit/forge/ee/lib/mcp/tools/library_spec.js b/test/unit/forge/ee/lib/mcp/tools/library_spec.js new file mode 100644 index 0000000000..73d7fd66b0 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -0,0 +1,119 @@ +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('rejects a teamId that is not a hashid without calling the route', async function () { + const response = await tool.handler({ teamId: '../x', path: 'a', type: 'functions', body: 'x' }, { inject }) + inject.called.should.be.false() + response.statusCode.should.equal(400) + }) + + it('rejects a flows body that is not valid JSON, which would break reading the entry back', async function () { + const response = await tool.handler({ teamId: 'team1', path: 'a', type: 'flows', body: 'not json' }, { inject }) + inject.called.should.be.false() + response.statusCode.should.equal(400) + response.json().error.should.match(/valid JSON/) + }) + + it('accepts a flows body that is a JSON string', async function () { + inject.resolves(created()) + await tool.handler({ teamId: 'team1', path: 'a', type: 'flows', body: '[{"id":"n1"}]' }, { inject }) + inject.calledOnce.should.be.true() + }) + + it('does not require a function body to be JSON', async function () { + inject.resolves(created()) + await tool.handler({ teamId: 'team1', path: 'a', type: 'functions', body: 'not json' }, { inject }) + inject.calledOnce.should.be.true() + }) + + it('explains a 404 as the feature, team or membership being unavailable', async function () { + inject.resolves({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) + const response = await tool.handler({ teamId: 'team1', path: 'a', type: 'functions', body: 'x' }, { inject }) + response.statusCode.should.equal(404) + response.json().error.should.match(/shared library is not enabled/) + }) + + it('passes through other error responses 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() + }) + }) +}) diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index aa95d8c32e..f176606cf4 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -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') @@ -408,4 +409,61 @@ describe('MCP Teams Tools', function () { inject.called.should.be.false() }) }) + + describe('platform_update_git_token', function () { + const tool = getTool('platform_update_git_token') + const args = { teamId: 'team1', tokenId: 'tok1', name: 'renamed' } + const notFound = () => ({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) + + 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('rejects ids that are not hashids without calling the route', async function () { + for (const bad of [{ teamId: '../x' }, { tokenId: 'a/../b' }]) { + const response = await tool.handler({ ...args, ...bad }, { inject }) + response.statusCode.should.equal(400) + } + inject.called.should.be.false() + }) + + it('keeps the route 404 when the team has git integration but the token is missing', async function () { + const missing = notFound() + inject.onFirstCall().resolves(missing) + inject.onSecondCall().resolves({ statusCode: 200, json: () => ({ tokens: [] }) }) + const response = await tool.handler(args, { inject }) + inject.secondCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/git/tokens' }) + response.should.equal(missing) + }) + + it('explains a 404 as git integration being unavailable when the token list is also 404', async function () { + inject.callsFake(async () => notFound()) + const response = await tool.handler(args, { inject }) + response.statusCode.should.equal(404) + response.json().error.should.match(/Git integration is not enabled/) + }) + + it('passes through other errors without probing', async function () { + const forbidden = { statusCode: 403, json: () => ({ code: 'unauthorized' }) } + inject.resolves(forbidden) + const response = await tool.handler(args, { inject }) + inject.calledOnce.should.be.true() + response.should.equal(forbidden) + }) + + 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() + }) + }) }) From d05c75295755b726944aa3fe22f15ba4a4c2647e Mon Sep 17 00:00:00 2001 From: andypalmi Date: Wed, 7 Oct 2026 19:58:14 +0200 Subject: [PATCH 2/4] Reject a blank git token name and describe how library meta is kept on overwrite --- forge/ee/lib/mcp/tools/library.js | 2 +- forge/ee/lib/mcp/tools/teams.js | 2 +- test/unit/forge/ee/lib/mcp/tools/teams_spec.js | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index fea0700792..8fa3ddcad8 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -10,7 +10,7 @@ module.exports = [ 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 and meta are replaced and the call still succeeds, so repeating a call is safe but silently overwrites. The same path can hold separate entries of different types. + 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 }, diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index f0c7260d19..422952b001 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -190,7 +190,7 @@ module.exports = [ inputSchema: { teamId, tokenId: z.string().describe('The hashid of the git token, as returned by platform_list_team_git_tokens'), - name: z.string().min(1).max(255).describe('New name for the git token') + name: z.string().trim().min(1).max(255).describe('New name for the git token') }, outputSchema: { id: z.string(), diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index f176606cf4..ae0045f79c 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -464,6 +464,7 @@ describe('MCP Teams Tools', 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() }) }) }) From 166822b95267cf46dfe15f54c091b9e9ebaf6003 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Fri, 9 Oct 2026 13:43:17 +0200 Subject: [PATCH 3/4] Rely on the shared id guard in the library and git token update tools --- forge/ee/lib/mcp/tools/library.js | 5 ----- forge/ee/lib/mcp/tools/teams.js | 5 +---- test/unit/forge/ee/lib/mcp/tools/library_spec.js | 6 ------ test/unit/forge/ee/lib/mcp/tools/teams_spec.js | 8 -------- 4 files changed, 1 insertion(+), 23 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index 8fa3ddcad8..6cc10285d4 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -2,8 +2,6 @@ const { z } = require('zod') const { teamId, toolError } = require('../schemas') -const isHashid = (id) => /^[A-Za-z0-9]+$/.test(id) - module.exports = [ { name: 'platform_create_library_entry', @@ -28,9 +26,6 @@ module.exports = [ }) }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId)) { - return toolError(400, 'invalid_request', 'teamId must be a hashid') - } const segments = args.path.split('/') if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) { return toolError(400, 'invalid_request', 'path must not start or end with "/" or contain empty, "." or ".." segments') diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index c09e913da2..97e6b4db71 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -189,7 +189,7 @@ module.exports = [ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { teamId, - tokenId: z.string().describe('The hashid of the git token, as returned by platform_list_team_git_tokens'), + 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: { @@ -198,9 +198,6 @@ module.exports = [ type: z.string() }, handler: async (args, { inject }) => { - if (!isHashid(args.teamId) || !isHashid(args.tokenId)) { - return toolError(400, 'invalid_request', 'teamId and tokenId must be hashids') - } const url = `/api/v1/teams/${args.teamId}/git/tokens` const response = await inject({ method: 'PUT', url: `${url}/${args.tokenId}`, payload: { name: args.name } }) if (response.statusCode !== 404) { diff --git a/test/unit/forge/ee/lib/mcp/tools/library_spec.js b/test/unit/forge/ee/lib/mcp/tools/library_spec.js index 73d7fd66b0..257faf0f51 100644 --- a/test/unit/forge/ee/lib/mcp/tools/library_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -66,12 +66,6 @@ describe('MCP Library Tools', function () { }) }) - it('rejects a teamId that is not a hashid without calling the route', async function () { - const response = await tool.handler({ teamId: '../x', path: 'a', type: 'functions', body: 'x' }, { inject }) - inject.called.should.be.false() - response.statusCode.should.equal(400) - }) - it('rejects a flows body that is not valid JSON, which would break reading the entry back', async function () { const response = await tool.handler({ teamId: 'team1', path: 'a', type: 'flows', body: 'not json' }, { inject }) inject.called.should.be.false() diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index ae0045f79c..f3ec20686f 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -428,14 +428,6 @@ describe('MCP Teams Tools', function () { response.should.equal(routeResponse) }) - it('rejects ids that are not hashids without calling the route', async function () { - for (const bad of [{ teamId: '../x' }, { tokenId: 'a/../b' }]) { - const response = await tool.handler({ ...args, ...bad }, { inject }) - response.statusCode.should.equal(400) - } - inject.called.should.be.false() - }) - it('keeps the route 404 when the team has git integration but the token is missing', async function () { const missing = notFound() inject.onFirstCall().resolves(missing) From 6e7cb6fef919737abdc02747b0bd74fa009e39f3 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 12 Oct 2026 15:42:07 +0200 Subject: [PATCH 4/4] Let the library create and git token rename tools rely on the route validation and 404 messages --- forge/ee/lib/mcp/tools/library.js | 11 +------- forge/ee/lib/mcp/tools/teams.js | 12 +------- .../forge/ee/lib/mcp/tools/library_spec.js | 28 +------------------ .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 25 ----------------- 4 files changed, 3 insertions(+), 73 deletions(-) diff --git a/forge/ee/lib/mcp/tools/library.js b/forge/ee/lib/mcp/tools/library.js index 6cc10285d4..e0303bd292 100644 --- a/forge/ee/lib/mcp/tools/library.js +++ b/forge/ee/lib/mcp/tools/library.js @@ -27,25 +27,16 @@ module.exports = [ }, 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') } - if (args.type === 'flows' && typeof args.body === 'string') { - try { - JSON.parse(args.body) - } catch (err) { - return toolError(400, 'invalid_request', 'The body of a "flows" entry must be valid JSON: an array of nodes or a JSON string of one') - } - } 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 === 404) { - return toolError(404, 'not_found', 'The shared library is not enabled for this team, or the team does not exist, or you are not a member of it') - } if (response.statusCode >= 400) { return response } diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index 341c6610ce..c2c682c431 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -196,17 +196,7 @@ module.exports = [ type: z.string() }, handler: async (args, { inject }) => { - const url = `/api/v1/teams/${args.teamId}/git/tokens` - const response = await inject({ method: 'PUT', url: `${url}/${args.tokenId}`, payload: { name: args.name } }) - if (response.statusCode !== 404) { - return response - } - // The route answers 404 for a missing token and for a team without git integration alike - const probe = await inject({ method: 'GET', url }) - if (probe.statusCode === 404) { - return toolError(404, 'not_found', 'Git integration is not enabled for this team, or the team does not exist') - } - return response + return inject({ method: 'PUT', url: `/api/v1/teams/${args.teamId}/git/tokens/${args.tokenId}`, payload: { name: args.name } }) } }, { diff --git a/test/unit/forge/ee/lib/mcp/tools/library_spec.js b/test/unit/forge/ee/lib/mcp/tools/library_spec.js index 257faf0f51..55284bb434 100644 --- a/test/unit/forge/ee/lib/mcp/tools/library_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/library_spec.js @@ -66,33 +66,7 @@ describe('MCP Library Tools', function () { }) }) - it('rejects a flows body that is not valid JSON, which would break reading the entry back', async function () { - const response = await tool.handler({ teamId: 'team1', path: 'a', type: 'flows', body: 'not json' }, { inject }) - inject.called.should.be.false() - response.statusCode.should.equal(400) - response.json().error.should.match(/valid JSON/) - }) - - it('accepts a flows body that is a JSON string', async function () { - inject.resolves(created()) - await tool.handler({ teamId: 'team1', path: 'a', type: 'flows', body: '[{"id":"n1"}]' }, { inject }) - inject.calledOnce.should.be.true() - }) - - it('does not require a function body to be JSON', async function () { - inject.resolves(created()) - await tool.handler({ teamId: 'team1', path: 'a', type: 'functions', body: 'not json' }, { inject }) - inject.calledOnce.should.be.true() - }) - - it('explains a 404 as the feature, team or membership being unavailable', async function () { - inject.resolves({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) - const response = await tool.handler({ teamId: 'team1', path: 'a', type: 'functions', body: 'x' }, { inject }) - response.statusCode.should.equal(404) - response.json().error.should.match(/shared library is not enabled/) - }) - - it('passes through other error responses unchanged', async function () { + 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 }) diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index e338cc3871..b965957af9 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -394,7 +394,6 @@ describe('MCP Teams Tools', function () { describe('platform_update_git_token', function () { const tool = getTool('platform_update_git_token') const args = { teamId: 'team1', tokenId: 'tok1', name: 'renamed' } - const notFound = () => ({ statusCode: 404, json: () => ({ code: 'not_found', error: 'Not Found' }) }) it('is a non-destructive, idempotent write', function () { tool.annotations.should.containEql({ readOnlyHint: false, destructiveHint: false, idempotentHint: true }) @@ -409,30 +408,6 @@ describe('MCP Teams Tools', function () { response.should.equal(routeResponse) }) - it('keeps the route 404 when the team has git integration but the token is missing', async function () { - const missing = notFound() - inject.onFirstCall().resolves(missing) - inject.onSecondCall().resolves({ statusCode: 200, json: () => ({ tokens: [] }) }) - const response = await tool.handler(args, { inject }) - inject.secondCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1/git/tokens' }) - response.should.equal(missing) - }) - - it('explains a 404 as git integration being unavailable when the token list is also 404', async function () { - inject.callsFake(async () => notFound()) - const response = await tool.handler(args, { inject }) - response.statusCode.should.equal(404) - response.json().error.should.match(/Git integration is not enabled/) - }) - - it('passes through other errors without probing', async function () { - const forbidden = { statusCode: 403, json: () => ({ code: 'unauthorized' }) } - inject.resolves(forbidden) - const response = await tool.handler(args, { inject }) - inject.calledOnce.should.be.true() - response.should.equal(forbidden) - }) - it('requires a non-empty name', function () { const schema = z.object(tool.inputSchema) schema.safeParse(args).success.should.be.true()