From aff2226caea75f2d7242be5a914a1757d1c7955f Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 6 Jul 2026 17:45:52 +0200 Subject: [PATCH 1/3] feat(mcp): add teams and membership read tools Add read-only MCP tools for team lookup, dashboards, audit log, members and invitations, with unit and equivalence tests. Query params are url-encoded via the shared appendQuery helper. Allow-list their scopes for the expert-mcp platform token. --- forge/ee/lib/mcp/tools/members.js | 49 +++ forge/ee/lib/mcp/tools/teams.js | 160 ++++++- forge/routes/auth/permissions.js | 7 + .../forge/ee/lib/mcp/tools/members_spec.js | 136 ++++++ .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 389 ++++++++++++++++++ 5 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 forge/ee/lib/mcp/tools/members.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/members_spec.js create mode 100644 test/unit/forge/ee/lib/mcp/tools/teams_spec.js diff --git a/forge/ee/lib/mcp/tools/members.js b/forge/ee/lib/mcp/tools/members.js new file mode 100644 index 0000000000..0b483f6545 --- /dev/null +++ b/forge/ee/lib/mcp/tools/members.js @@ -0,0 +1,49 @@ +const { teamId } = require('../schemas') + +module.exports = [ + { + name: 'platform_get_team_membership', + title: 'Get Team Membership', + description: `FlowFuse platform automation tool: + Gets the authenticated user's own membership (role) in a team. + Use this to check what role the current user holds in a team before attempting an action that needs a specific role.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/user` }) + return response + } + }, + { + name: 'platform_list_team_members', + title: 'List Team Members', + description: `FlowFuse platform automation tool: + Lists the members of a team, including their role and, when SSO is enabled, whether their membership is SSO-managed. + Use this to see who belongs to a team before inviting, removing, or changing the role of a member.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/members` }) + return response + } + }, + { + name: 'platform_list_team_invitations', + title: 'List Team Invitations', + description: `FlowFuse platform automation tool: + Lists the pending invitations for a team. + This requires the Owner role, so a non-Owner credential will get an access error even though this tool itself is read-only.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/invitations` }) + return response + } + } +] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index ecc053113b..bc825c13b8 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -1,5 +1,7 @@ const { z } = require('zod') +const { teamId, basePagination, basePaginationKeys, limitParam, limitParamKeys, pageParam, pageParamKeys, searchQuery, searchQueryKeys, appendQuery } = require('../schemas') + module.exports = [ { name: 'platform_list_teams', @@ -18,11 +20,167 @@ module.exports = [ description: 'FlowFuse platform automation tool: Get details of a specific team by its ID, including team type, member count, hosted instance and remote instance counts.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { - teamId: z.string().describe('The ID or hashid of the team') + teamId }, handler: async (args, { inject }) => { const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}` }) return response } + }, + { + name: 'platform_get_team_by_slug', + title: 'Get Team By Slug', + description: `FlowFuse platform automation tool: + Gets details of a specific team using its slug (URL identifier) instead of its hashid. + Use this when you only know the team's slug, for example from a URL, and need the same details as platform_get_team.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamSlug: z.string().regex(/^[a-z0-9-_]+$/i).describe('Team slug (URL identifier; lowercase letters, digits, hyphen and underscore)') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/slug/${args.teamSlug}` }) + return response + } + }, + { + name: 'platform_list_team_projects', + title: 'List Team Projects', + description: `FlowFuse platform automation tool: + Lists the hosted instances (projects) in a team, with optional name filtering, sorting, and pagination. + Use this for a lighter-weight or differently sorted view of a team's hosted instances than looking up each application individually.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + query: z.string().optional().describe('Filter instances by name substring'), + sort: z.enum(['name', 'createdAt', 'updatedAt', 'application.name', 'flowLastUpdatedAt']).optional().describe('Field to sort the instance list by'), + dir: z.enum(['asc', 'desc']).optional().describe('Sort direction for the sort field'), + includeMeta: z.boolean().optional().describe('Include instance settings and metadata in each row (default false)'), + orderByMostRecentFlows: z.boolean().optional().describe('Order results by most recently updated flows (default false)'), + ...limitParam, + ...pageParam + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/projects`, args, [ + 'query', 'sort', 'dir', 'includeMeta', 'orderByMostRecentFlows', ...limitParamKeys, ...pageParamKeys + ]) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_list_team_application_statuses', + title: 'List Team Application Statuses', + description: `FlowFuse platform automation tool: + Lists the applications in a team along with the live status of their associated hosted instances and remote instances. + Use this to get a status overview across an entire team without querying each application individually.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + associationsLimit: z.number().optional().describe('Maximum number of associated instances and devices to include per application') + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/applications/status`, args, ['associationsLimit']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_list_team_dashboard_instances', + title: 'List Team Dashboard Instances', + description: `FlowFuse platform automation tool: + Lists the hosted instances in a team that have the Node-RED dashboard module installed. + Use this to find instances that expose a dashboard rather than checking every instance individually.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/dashboard-instances` }) + return response + } + }, + { + name: 'platform_get_team_instance_counts', + title: 'Get Team Instance Counts', + description: `FlowFuse platform automation tool: + Counts a team's instances of the given type, optionally narrowed by state and application. + instanceType is required: use "hosted" for hosted instances or "remote" for remote instances (devices). + Use this for quick totals instead of listing and counting every instance yourself.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + instanceType: z.enum(['remote', 'hosted']).describe('Instance type to count'), + state: z.array(z.string()).optional().describe('Optional list of instance states to filter the counts by (defaults to empty)'), + applicationId: z.string().optional().describe('Application hashid to scope the counts to a single application') + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/instance-counts`, args, ['instanceType', 'state', 'applicationId']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_list_team_provisioning_tokens', + title: 'List Team Provisioning Tokens', + description: `FlowFuse platform automation tool: + Lists a team's device provisioning tokens. This summary view omits the token secret. + Use this to see what provisioning tokens exist for a team without exposing their secrets.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/devices/provisioning` }) + return response + } + }, + { + name: 'platform_list_team_types', + title: 'List Team Types', + description: `FlowFuse platform automation tool: + Lists the team types (tiers/plans) available on the platform, with name search, active-state filtering and pagination. + Use this to see what team types exist before creating a team or to look up a team's current type.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + ...basePagination, + ...searchQuery, + filter: z.enum(['all', 'active', 'inactive']).optional().describe('Which team types to include by active state (default active only)') + }, + handler: async (args, { inject }) => { + const url = appendQuery('/api/v1/team-types', args, [...basePaginationKeys, ...searchQueryKeys, 'filter']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_get_team_type', + title: 'Get Team Type', + description: `FlowFuse platform automation tool: + Gets the details of a single team type by its hashid. + Use this to inspect the tier/plan a team is on, or to check a team type before assigning it to a new team.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamTypeId: z.string().describe('Team type hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/team-types/${args.teamTypeId}` }) + return response + } + }, + { + name: 'platform_check_team_slug_availability', + title: 'Check Team Slug Availability', + description: `FlowFuse platform automation tool: + Checks whether a team slug is available before creating a team. This does not create or change anything. + The value "create" is reserved and is always rejected. + Use this before calling a team creation tool to make sure the chosen slug is not already taken.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + slug: z.string().regex(/^[a-z0-9-_]+$/i).describe('Team slug to check; lowercase letters, digits, hyphen and underscore') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'POST', url: '/api/v1/teams/check-slug', payload: { slug: args.slug } }) + return response + } } ] diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index 15a713be0c..cc37fd8f11 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -48,6 +48,13 @@ const IMPLICIT_TOKEN_SCOPES = { // teams 'user:team:list', // list teams 'team:read', // get team details + 'team:user:list', // list team members + 'team:user:invite', // list team invitations + 'team:device:provisioning-token:list', // list team device provisioning tokens + // team types + 'team-type:list', // list team types + 'team-type:read', // get team type + 'team:create', // check team slug availability // platform 'stack:list', 'flow-blueprint:list', diff --git a/test/unit/forge/ee/lib/mcp/tools/members_spec.js b/test/unit/forge/ee/lib/mcp/tools/members_spec.js new file mode 100644 index 0000000000..4b5b08b1b7 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/members_spec.js @@ -0,0 +1,136 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence') + +const FF_UTIL = require('flowforge-test-utils') + +const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/members') +const findTool = toolFinder(tools) + +describe('MCP members tools', function () { + it('exposes exactly the expected tool names', function () { + tools.map(t => t.name).should.eql([ + 'platform_get_team_membership', + 'platform_list_team_members', + 'platform_list_team_invitations' + ]) + }) + + it('every tool is annotated as read-only and non-destructive', function () { + tools.forEach(tool => { + tool.annotations.should.have.property('readOnlyHint', true) + tool.annotations.should.have.property('destructiveHint', false) + }) + }) + + describe('platform_get_team_membership', function () { + const tool = findTool('platform_get_team_membership') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId/user', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/user') + }) + }) + + describe('platform_list_team_members', function () { + const tool = findTool('platform_list_team_members') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId/members', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/members') + }) + }) + + describe('platform_list_team_invitations', function () { + const tool = findTool('platform_list_team_invitations') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId/invitations', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/invitations') + }) + }) +}) + +describe('MCP members tools smoke test', function () { + const setup = require('../../../setup') + + let app + let token + + before(async function () { + app = await setup({ ai: { enabled: true }, expert: { enabled: true } }) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + describe('tool responses match their backing routes', function () { + it('platform_get_team_membership matches GET /api/v1/teams/:teamId/user', async function () { + const tool = findTool('platform_get_team_membership') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/user` + }) + }) + + it('platform_list_team_members matches GET /api/v1/teams/:teamId/members', async function () { + const tool = findTool('platform_list_team_members') + const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/members` + }) + routeResponse.statusCode.should.equal(200) + routeResponse.json().members.should.not.be.empty() + }) + + it('platform_list_team_invitations matches GET /api/v1/teams/:teamId/invitations', async function () { + const bob = await app.factory.createUser({ + admin: false, + username: 'bob-invitee', + name: 'Bob Invitee', + email: 'bob-invitee@example.com', + password: 'bbPassword' + }) + await app.factory.createInvitation(app.team, app.user, bob) + + const tool = findTool('platform_list_team_invitations') + const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/invitations` + }) + routeResponse.statusCode.should.equal(200) + routeResponse.json().invitations.should.not.be.empty() + }) + }) +}) diff --git a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js new file mode 100644 index 0000000000..613f0d93ad --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -0,0 +1,389 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence') + +const FF_UTIL = require('flowforge-test-utils') + +const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/teams') +const findTool = toolFinder(tools) + +describe('MCP teams tools', function () { + it('exposes exactly the expected tool names', function () { + tools.map(t => t.name).should.eql([ + 'platform_list_teams', + 'platform_get_team', + 'platform_get_team_by_slug', + 'platform_list_team_projects', + 'platform_list_team_application_statuses', + 'platform_list_team_dashboard_instances', + 'platform_get_team_instance_counts', + 'platform_list_team_provisioning_tokens', + 'platform_list_team_types', + 'platform_get_team_type', + 'platform_check_team_slug_availability' + ]) + }) + + it('every tool is annotated as read-only and non-destructive', function () { + tools.forEach(tool => { + tool.annotations.should.have.property('readOnlyHint', true) + tool.annotations.should.have.property('destructiveHint', false) + }) + }) + + describe('platform_list_teams', function () { + const tool = findTool('platform_list_teams') + + it('has no input params', function () { + Object.keys(tool.inputSchema).should.eql([]) + }) + + it('calls GET /api/v1/user/teams', async function () { + const { calls, inject } = recordingInject() + await tool.handler({}, { inject }) + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/user/teams') + }) + }) + + describe('platform_get_team', function () { + const tool = findTool('platform_get_team') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1') + }) + }) + + describe('platform_get_team_by_slug', function () { + const tool = findTool('platform_get_team_by_slug') + + it('has the teamSlug input', function () { + Object.keys(tool.inputSchema).should.eql(['teamSlug']) + }) + + it('calls GET /api/v1/teams/slug/:teamSlug', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamSlug: 'my-team' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/slug/my-team') + }) + }) + + describe('platform_list_team_projects', function () { + const tool = findTool('platform_list_team_projects') + + it('has the expected input params', function () { + Object.keys(tool.inputSchema).should.eql([ + 'teamId', 'query', 'sort', 'dir', 'includeMeta', 'orderByMostRecentFlows', 'limit', 'page' + ]) + }) + + it('calls GET with only the supported, defined params serialised', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1', sort: 'name', dir: 'desc', limit: 10 }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/projects?sort=name&dir=desc&limit=10') + }) + + it('url-encodes a query value with special characters', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1', query: 'a b&c=d' }, { inject }) + calls[0].url.should.equal('/api/v1/teams/team1/projects?query=a+b%26c%3Dd') + }) + }) + + describe('platform_list_team_application_statuses', function () { + const tool = findTool('platform_list_team_application_statuses') + + it('has the expected input params', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'associationsLimit']) + }) + + it('calls GET /api/v1/teams/:teamId/applications/status with associationsLimit', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1', associationsLimit: 5 }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/applications/status?associationsLimit=5') + }) + + it('omits the query string when associationsLimit is not set', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].url.should.equal('/api/v1/teams/team1/applications/status') + }) + }) + + describe('platform_list_team_dashboard_instances', function () { + const tool = findTool('platform_list_team_dashboard_instances') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId/dashboard-instances', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/dashboard-instances') + }) + }) + + describe('platform_get_team_instance_counts', function () { + const tool = findTool('platform_get_team_instance_counts') + + it('has the expected input params', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'instanceType', 'state', 'applicationId']) + }) + + it('serialises a state array as repeated params', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1', instanceType: 'hosted', state: ['running', 'stopped'] }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=hosted&state=running&state=stopped') + }) + + it('includes applicationId when set and omits state when not provided', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1', instanceType: 'remote', applicationId: 'app1' }, { inject }) + calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=remote&applicationId=app1') + }) + }) + + describe('platform_list_team_provisioning_tokens', function () { + const tool = findTool('platform_list_team_provisioning_tokens') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET /api/v1/teams/:teamId/devices/provisioning', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/devices/provisioning') + }) + }) + + describe('platform_list_team_types', function () { + const tool = findTool('platform_list_team_types') + + it('has pagination plus search and filter params', function () { + Object.keys(tool.inputSchema).should.eql(['cursor', 'limit', 'query', 'filter']) + }) + + it('calls GET /api/v1/team-types', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ cursor: 'abc' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/team-types?cursor=abc') + }) + + it('serialises search and filter params', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ query: 'starter', filter: 'active' }, { inject }) + calls[0].url.should.equal('/api/v1/team-types?query=starter&filter=active') + }) + }) + + describe('platform_get_team_type', function () { + const tool = findTool('platform_get_team_type') + + it('has the teamTypeId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamTypeId']) + }) + + it('calls GET /api/v1/team-types/:teamTypeId', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ teamTypeId: 'tt1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/team-types/tt1') + }) + }) + + describe('platform_check_team_slug_availability', function () { + const tool = findTool('platform_check_team_slug_availability') + + it('has the slug input', function () { + Object.keys(tool.inputSchema).should.eql(['slug']) + }) + + it('calls POST /api/v1/teams/check-slug with the slug payload', async function () { + const { calls, inject } = recordingInject() + await tool.handler({ slug: 'my-team' }, { inject }) + calls[0].method.should.equal('POST') + calls[0].url.should.equal('/api/v1/teams/check-slug') + calls[0].payload.should.eql({ slug: 'my-team' }) + }) + }) +}) + +describe('MCP teams tools smoke test', function () { + const setup = require('../../../setup') + + let app + let token + + before(async function () { + app = await setup({ ai: { enabled: true }, expert: { enabled: true } }) + // check-slug requires 'team:create', gated behind this setting for non-admin callers like the expert-mcp token below + await app.settings.set('team:create', true) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + it('lists the seeded team projects/instances', async function () { + const tool = findTool('platform_list_team_projects') + const response = await tool.handler({ teamId: app.team.hashid }, { inject }) + response.statusCode.should.equal(200) + const body = response.json() + body.should.have.property('count', 1) + body.should.have.property('projects') + body.projects.should.be.an.Array() + body.projects.should.have.length(1) + body.projects[0].should.have.property('name', app.instance.name) + }) + + describe('tool responses match their backing routes', function () { + it('platform_list_teams matches GET /api/v1/user/teams', async function () { + const tool = findTool('platform_list_teams') + await expectToolMatchesRoute(inject, tool, {}, { + method: 'GET', + url: '/api/v1/user/teams' + }) + }) + + it('platform_get_team matches GET /api/v1/teams/:teamId', async function () { + const tool = findTool('platform_get_team') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}` + }) + }) + + it('platform_get_team_by_slug matches GET /api/v1/teams/slug/:teamSlug', async function () { + const tool = findTool('platform_get_team_by_slug') + await expectToolMatchesRoute(inject, tool, { teamSlug: app.team.slug }, { + method: 'GET', + url: `/api/v1/teams/slug/${app.team.slug}` + }) + }) + + it('platform_list_team_projects matches GET /api/v1/teams/:teamId/projects', async function () { + const tool = findTool('platform_list_team_projects') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/projects` + }) + }) + + it('platform_list_team_application_statuses matches GET /api/v1/teams/:teamId/applications/status', async function () { + const tool = findTool('platform_list_team_application_statuses') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/applications/status` + }) + }) + + it('platform_list_team_dashboard_instances matches GET /api/v1/teams/:teamId/dashboard-instances', async function () { + // Seed in the PAT user's own team, since the route's per-application RBAC check needs real membership + const dashboardApplication = await app.factory.createApplication({ name: 'dashboard-application' }, app.team) + await app.factory.createInstance( + { name: 'dashboard-instance' }, + dashboardApplication, + app.stack, + app.template, + app.projectType, + { + start: false, + settings: { + palette: { modules: [{ name: '@flowfuse/node-red-dashboard', version: '~1.15.0', local: true }] } + } + } + ) + + const tool = findTool('platform_list_team_dashboard_instances') + const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/dashboard-instances` + }) + routeResponse.statusCode.should.equal(200) + routeResponse.json().projects.should.have.length(1) + }) + + it('platform_get_team_instance_counts matches GET /api/v1/teams/:teamId/instance-counts', async function () { + const tool = findTool('platform_get_team_instance_counts') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, instanceType: 'hosted' }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/instance-counts?instanceType=hosted` + }) + }) + + it('platform_list_team_provisioning_tokens matches GET /api/v1/teams/:teamId/devices/provisioning', async function () { + await app.db.controllers.AccessToken.createTokenForTeamDeviceProvisioning('equivalence-test-token', app.team) + + const tool = findTool('platform_list_team_provisioning_tokens') + const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/devices/provisioning` + }) + routeResponse.statusCode.should.equal(200) + routeResponse.json().tokens.should.not.be.empty() + }) + + it('platform_list_team_types matches GET /api/v1/team-types', async function () { + const tool = findTool('platform_list_team_types') + await expectToolMatchesRoute(inject, tool, {}, { + method: 'GET', + url: '/api/v1/team-types' + }) + }) + + it('platform_get_team_type matches GET /api/v1/team-types/:teamTypeId', async function () { + const tool = findTool('platform_get_team_type') + await expectToolMatchesRoute(inject, tool, { teamTypeId: app.defaultTeamType.hashid }, { + method: 'GET', + url: `/api/v1/team-types/${app.defaultTeamType.hashid}` + }) + }) + + it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for an available slug', async function () { + const tool = findTool('platform_check_team_slug_availability') + await expectToolMatchesRoute(inject, tool, { slug: 'a-brand-new-team-slug' }, { + method: 'POST', + url: '/api/v1/teams/check-slug', + payload: { slug: 'a-brand-new-team-slug' } + }) + }) + + it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for a taken slug', async function () { + const tool = findTool('platform_check_team_slug_availability') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { slug: app.team.slug }, { + method: 'POST', + url: '/api/v1/teams/check-slug', + payload: { slug: app.team.slug } + }) + viaTool.statusCode.should.equal(409) + }) + }) +}) From 830fbcc38def7a321ae9689086304a919d13fb08 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 7 Jul 2026 14:58:02 +0200 Subject: [PATCH 2/3] refactor(mcp): fold membership and team-scoped data tools into teams --- forge/ee/lib/mcp/tools/members.js | 49 -- forge/ee/lib/mcp/tools/teams.js | 279 +++++-- forge/ee/routes/sharedLibrary/index.js | 6 +- forge/routes/auth/permissions.js | 10 +- .../forge/ee/lib/mcp/tools/members_spec.js | 136 ---- .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 732 ++++++++++++++---- 6 files changed, 804 insertions(+), 408 deletions(-) delete mode 100644 forge/ee/lib/mcp/tools/members.js delete mode 100644 test/unit/forge/ee/lib/mcp/tools/members_spec.js diff --git a/forge/ee/lib/mcp/tools/members.js b/forge/ee/lib/mcp/tools/members.js deleted file mode 100644 index 0b483f6545..0000000000 --- a/forge/ee/lib/mcp/tools/members.js +++ /dev/null @@ -1,49 +0,0 @@ -const { teamId } = require('../schemas') - -module.exports = [ - { - name: 'platform_get_team_membership', - title: 'Get Team Membership', - description: `FlowFuse platform automation tool: - Gets the authenticated user's own membership (role) in a team. - Use this to check what role the current user holds in a team before attempting an action that needs a specific role.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/user` }) - return response - } - }, - { - name: 'platform_list_team_members', - title: 'List Team Members', - description: `FlowFuse platform automation tool: - Lists the members of a team, including their role and, when SSO is enabled, whether their membership is SSO-managed. - Use this to see who belongs to a team before inviting, removing, or changing the role of a member.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/members` }) - return response - } - }, - { - name: 'platform_list_team_invitations', - title: 'List Team Invitations', - description: `FlowFuse platform automation tool: - Lists the pending invitations for a team. - This requires the Owner role, so a non-Owner credential will get an access error even though this tool itself is read-only.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/invitations` }) - return response - } - } -] diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index bc825c13b8..dc252f5683 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -1,6 +1,22 @@ const { z } = require('zod') -const { teamId, basePagination, basePaginationKeys, limitParam, limitParamKeys, pageParam, pageParamKeys, searchQuery, searchQueryKeys, appendQuery } = require('../schemas') +const { teamId, basePagination, basePaginationKeys, searchQuery, searchQueryKeys, auditLogFilters, auditLogFilterKeys, appendQuery } = require('../schemas') + +// Audit-log routes accept cursor+limit pagination, free-text query, event +// (single name or array) and username. scope narrows which entity levels are +// returned; includeChildren pulls in descendant entries within the chosen scope. +const includeChildren = z.boolean().optional().describe('Also include audit entries from child entities within the chosen scope') +const auditLogInput = { ...basePagination, ...searchQuery, ...auditLogFilters } +const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] + +// Strips credentials (including the password) before returning results to the caller. +function redactDatabaseCredentials (database) { + if (!database) { + return database + } + const { credentials, ...rest } = database + return rest +} module.exports = [ { @@ -43,143 +59,276 @@ module.exports = [ } }, { - name: 'platform_list_team_projects', - title: 'List Team Projects', + name: 'platform_get_team_instance_counts', + title: 'Get Team Instance Counts', description: `FlowFuse platform automation tool: - Lists the hosted instances (projects) in a team, with optional name filtering, sorting, and pagination. - Use this for a lighter-weight or differently sorted view of a team's hosted instances than looking up each application individually.`, + Counts a team's instances of the given type, optionally narrowed by state and application. + instanceType is required: use "hosted" for hosted instances or "remote" for remote instances (devices). + Use this for quick totals instead of listing and counting every instance yourself.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId, - query: z.string().optional().describe('Filter instances by name substring'), - sort: z.enum(['name', 'createdAt', 'updatedAt', 'application.name', 'flowLastUpdatedAt']).optional().describe('Field to sort the instance list by'), - dir: z.enum(['asc', 'desc']).optional().describe('Sort direction for the sort field'), - includeMeta: z.boolean().optional().describe('Include instance settings and metadata in each row (default false)'), - orderByMostRecentFlows: z.boolean().optional().describe('Order results by most recently updated flows (default false)'), - ...limitParam, - ...pageParam - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/projects`, args, [ - 'query', 'sort', 'dir', 'includeMeta', 'orderByMostRecentFlows', ...limitParamKeys, ...pageParamKeys - ]) + instanceType: z.enum(['remote', 'hosted']).describe('Instance type to count'), + state: z.array(z.string()).optional().describe('Optional list of instance states to filter the counts by (defaults to empty)'), + applicationId: z.string().optional().describe('Application hashid to scope the counts to a single application') + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/instance-counts`, args, ['instanceType', 'state', 'applicationId']) const response = await inject({ method: 'GET', url }) return response } }, { - name: 'platform_list_team_application_statuses', - title: 'List Team Application Statuses', + name: 'platform_check_team_slug_availability', + title: 'Check Team Slug Availability', description: `FlowFuse platform automation tool: - Lists the applications in a team along with the live status of their associated hosted instances and remote instances. - Use this to get a status overview across an entire team without querying each application individually.`, + Checks whether a team slug is available before creating a team. This does not create or change anything. + The value "create" is reserved and is always rejected. + Use this before calling a team creation tool to make sure the chosen slug is not already taken.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { - teamId, - associationsLimit: z.number().optional().describe('Maximum number of associated instances and devices to include per application') + slug: z.string().regex(/^[a-z0-9-_]+$/i).describe('Team slug to check; lowercase letters, digits, hyphen and underscore') }, handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/applications/status`, args, ['associationsLimit']) - const response = await inject({ method: 'GET', url }) + const response = await inject({ method: 'POST', url: '/api/v1/teams/check-slug', payload: { slug: args.slug } }) return response } }, { - name: 'platform_list_team_dashboard_instances', - title: 'List Team Dashboard Instances', + name: 'platform_get_team_membership', + title: 'Get Team Membership', description: `FlowFuse platform automation tool: - Lists the hosted instances in a team that have the Node-RED dashboard module installed. - Use this to find instances that expose a dashboard rather than checking every instance individually.`, + Gets the authenticated user's own membership (role) in a team. + Use this to check what role the current user holds in a team before attempting an action that needs a specific role.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/dashboard-instances` }) + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/user` }) return response } }, { - name: 'platform_get_team_instance_counts', - title: 'Get Team Instance Counts', + name: 'platform_list_team_members', + title: 'List Team Members', description: `FlowFuse platform automation tool: - Counts a team's instances of the given type, optionally narrowed by state and application. - instanceType is required: use "hosted" for hosted instances or "remote" for remote instances (devices). - Use this for quick totals instead of listing and counting every instance yourself.`, + Lists the members of a team, including their role and, when SSO is enabled, whether their membership is SSO-managed. + Use this to see who belongs to a team before inviting, removing, or changing the role of a member.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/members` }) + return response + } + }, + { + name: 'platform_list_team_invitations', + title: 'List Team Invitations', + description: `FlowFuse platform automation tool: + Lists the pending invitations for a team. + This requires the Owner role, so a non-Owner credential will get an access error even though this tool itself is read-only.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/invitations` }) + return response + } + }, + { + name: 'platform_get_team_audit_log', + title: 'Get Team Audit Log', + description: `FlowFuse platform automation tool: + Reads the audit log for a team, showing events like membership changes, billing changes, + and administrative actions taken across the team's applications, instances, and devices. + A team-scoped PAT only sees audit log entries for teams it is scoped to. + Use this when the user asks what happened on a team, or wants to investigate recent changes.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId, - instanceType: z.enum(['remote', 'hosted']).describe('Instance type to count'), - state: z.array(z.string()).optional().describe('Optional list of instance states to filter the counts by (defaults to empty)'), - applicationId: z.string().optional().describe('Application hashid to scope the counts to a single application') + ...auditLogInput, + scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), + includeChildren }, handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/instance-counts`, args, ['instanceType', 'state', 'applicationId']) + const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log`, args, [...auditLogKeys, 'scope', 'includeChildren']) const response = await inject({ method: 'GET', url }) return response } }, { - name: 'platform_list_team_provisioning_tokens', - title: 'List Team Provisioning Tokens', + name: 'platform_export_team_audit_log', + title: 'Export Team Audit Log', description: `FlowFuse platform automation tool: - Lists a team's device provisioning tokens. This summary view omits the token secret. - Use this to see what provisioning tokens exist for a team without exposing their secrets.`, + Exports the team audit log as a CSV file. + Use this when the user wants a downloadable or shareable copy of the team's audit history, + rather than reading entries directly with platform_get_team_audit_log.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + ...auditLogInput, + scope: z.enum(['team', 'application', 'project', 'device']).optional().describe('Entity level to include (default team)'), + includeChildren + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/audit-log/export`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_list_team_databases', + title: 'List Team Databases', + description: `FlowFuse platform automation tool: + Lists the FlowFuse Tables databases for a team. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning results, so no credentials are ever exposed.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { teamId }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/devices/provisioning` }) + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) + if (response.statusCode >= 400) { + return response + } + const databases = response.json().map(redactDatabaseCredentials) + return { + statusCode: response.statusCode, + json: () => databases + } + } + }, + { + name: 'platform_get_team_database', + title: 'Get Team Database', + description: `FlowFuse platform automation tool: + Gets a single FlowFuse Tables database for a team. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning the result, so no credentials are ever exposed.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + databaseId: z.string().describe('database hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) + if (response.statusCode >= 400) { + return response + } + const database = redactDatabaseCredentials(response.json()) + return { + statusCode: response.statusCode, + json: () => database + } + } + }, + { + name: 'platform_list_database_tables', + title: 'List Database Tables', + description: `FlowFuse platform automation tool: + Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + databaseId: z.string().describe('database hashid') + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) return response } }, { - name: 'platform_list_team_types', - title: 'List Team Types', + name: 'platform_get_database_table', + title: 'Get Database Table', description: `FlowFuse platform automation tool: - Lists the team types (tiers/plans) available on the platform, with name search, active-state filtering and pagination. - Use this to see what team types exist before creating a team or to look up a team's current type.`, + Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). Does not return row data or credentials. + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_query_database_table_data to read row data instead.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { - ...basePagination, - ...searchQuery, - filter: z.enum(['all', 'active', 'inactive']).optional().describe('Which team types to include by active state (default active only)') + teamId, + databaseId: z.string().describe('database hashid'), + tableName: z.string().describe('Name of the database table') }, handler: async (args, { inject }) => { - const url = appendQuery('/api/v1/team-types', args, [...basePaginationKeys, ...searchQueryKeys, 'filter']) + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}` }) + return response + } + }, + { + name: 'platform_query_database_table_data', + title: 'Query Database Table Data', + description: `FlowFuse platform automation tool: + Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. + At most 10 rows are returned per call (the limit is capped at 10 by the platform). + FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. + Use platform_get_database_table first if you need to know the column names and types.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + teamId, + databaseId: z.string().describe('database hashid'), + tableName: z.string().describe('Name of the database table'), + limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data`, args, ['limit']) const response = await inject({ method: 'GET', url }) return response } }, { - name: 'platform_get_team_type', - title: 'Get Team Type', + name: 'platform_list_team_npm_packages', + title: 'List Team NPM Packages', description: `FlowFuse platform automation tool: - Gets the details of a single team type by its hashid. - Use this to inspect the tier/plan a team is on, or to check a team type before assigning it to a new team.`, + Lists the private npm packages owned by a team. + The npm registry is a plan-gated feature; if it is not enabled for the team's plan, or the team does not exist, the underlying API's error response is returned as-is.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { - teamTypeId: z.string().describe('Team type hashid') + teamId }, handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/team-types/${args.teamTypeId}` }) + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/npm/packages` }) return response } }, { - name: 'platform_check_team_slug_availability', - title: 'Check Team Slug Availability', + name: 'platform_list_team_git_tokens', + title: 'List Team Git Tokens', description: `FlowFuse platform automation tool: - Checks whether a team slug is available before creating a team. This does not create or change anything. - The value "create" is reserved and is always rejected. - Use this before calling a team creation tool to make sure the chosen slug is not already taken.`, + Lists the git tokens configured for a team. The response never includes the raw stored personal access token, only its ID, name, and type. + Git integration is a plan-gated feature; if it is not enabled for the team's plan, or the team does not exist, the underlying API's error response is returned as-is.`, annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: { - slug: z.string().regex(/^[a-z0-9-_]+$/i).describe('Team slug to check; lowercase letters, digits, hyphen and underscore') + teamId }, handler: async (args, { inject }) => { - const response = await inject({ method: 'POST', url: '/api/v1/teams/check-slug', payload: { slug: args.slug } }) + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/git/tokens` }) + return response + } + }, + { + name: 'platform_list_library_entries', + title: 'List Library Entries', + description: `FlowFuse platform automation tool: + Lists entries in the team shared library (reusable flows, functions, and other snippets shared across a team's hosted and remote instances). + Pass an empty path to list the library root, or a folder path to list its contents. + The shared library is enabled by default, but a team may still not exist or the caller may not be a member; either case returns the underlying API's error response as-is.`, + annotations: { readOnlyHint: true, destructiveHint: false }, + inputSchema: { + libraryId: z.string().describe('shared-library hashid (the team hashid)'), + path: z.string().default('').describe('Library entry path; empty string lists the library root'), + type: z.string().optional().describe('entry type filter query param (e.g. flows, functions)') + }, + handler: async (args, { inject }) => { + const url = appendQuery(`/storage/library/${args.libraryId}/${args.path || ''}`, args, ['type']) + const response = await inject({ method: 'GET', url }) return response } } diff --git a/forge/ee/routes/sharedLibrary/index.js b/forge/ee/routes/sharedLibrary/index.js index 52017ad92d..ea0f60d2db 100644 --- a/forge/ee/routes/sharedLibrary/index.js +++ b/forge/ee/routes/sharedLibrary/index.js @@ -25,9 +25,9 @@ module.exports = async function (app) { // Device exists and the auth token is for this team return } - } else if (!request.session.ownerType) { - // This is a logged-in user. Get their teamMembership so the needsPermission - // checks in the routes will evaluate properly + } else if (!request.session.ownerType || request.session.ownerType === 'user' || request.session.ownerType === 'user:expert-mcp') { + // Cookie sessions and personal/platform-automation tokens both populate + // request.session.User the same way (see forge/routes/auth/index.js). request.teamMembership = await request.session.User.getTeamMembership(request.team.id) if (request.teamMembership) { return diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index cc37fd8f11..d037462283 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -50,11 +50,13 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:read', // get team details 'team:user:list', // list team members 'team:user:invite', // list team invitations - 'team:device:provisioning-token:list', // list team device provisioning tokens - // team types - 'team-type:list', // list team types - 'team-type:read', // get team type 'team:create', // check team slug availability + 'team:audit-log', // get team audit log + // team data + 'team:database:list', // list team databases and tables + 'team:packages:read', // list team npm packages + 'team:git:tokens:list', // list team git tokens + 'library:entry:list', // list team shared library entries // platform 'stack:list', 'flow-blueprint:list', diff --git a/test/unit/forge/ee/lib/mcp/tools/members_spec.js b/test/unit/forge/ee/lib/mcp/tools/members_spec.js deleted file mode 100644 index 4b5b08b1b7..0000000000 --- a/test/unit/forge/ee/lib/mcp/tools/members_spec.js +++ /dev/null @@ -1,136 +0,0 @@ -const should = require('should') // eslint-disable-line no-unused-vars - -const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence') - -const FF_UTIL = require('flowforge-test-utils') - -const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/members') -const findTool = toolFinder(tools) - -describe('MCP members tools', function () { - it('exposes exactly the expected tool names', function () { - tools.map(t => t.name).should.eql([ - 'platform_get_team_membership', - 'platform_list_team_members', - 'platform_list_team_invitations' - ]) - }) - - it('every tool is annotated as read-only and non-destructive', function () { - tools.forEach(tool => { - tool.annotations.should.have.property('readOnlyHint', true) - tool.annotations.should.have.property('destructiveHint', false) - }) - }) - - describe('platform_get_team_membership', function () { - const tool = findTool('platform_get_team_membership') - - it('has the teamId input', function () { - Object.keys(tool.inputSchema).should.eql(['teamId']) - }) - - it('calls GET /api/v1/teams/:teamId/user', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1' }, { inject }) - calls.should.have.length(1) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/user') - }) - }) - - describe('platform_list_team_members', function () { - const tool = findTool('platform_list_team_members') - - it('has the teamId input', function () { - Object.keys(tool.inputSchema).should.eql(['teamId']) - }) - - it('calls GET /api/v1/teams/:teamId/members', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1' }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/members') - }) - }) - - describe('platform_list_team_invitations', function () { - const tool = findTool('platform_list_team_invitations') - - it('has the teamId input', function () { - Object.keys(tool.inputSchema).should.eql(['teamId']) - }) - - it('calls GET /api/v1/teams/:teamId/invitations', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1' }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/invitations') - }) - }) -}) - -describe('MCP members tools smoke test', function () { - const setup = require('../../../setup') - - let app - let token - - before(async function () { - app = await setup({ ai: { enabled: true }, expert: { enabled: true } }) - token = await createExpertMcpToken(app) - }) - - after(async function () { - await app.close() - }) - - function inject (options) { - return app.inject({ - ...options, - headers: { - ...(options.headers || {}), - authorization: `Bearer ${token}` - } - }) - } - - describe('tool responses match their backing routes', function () { - it('platform_get_team_membership matches GET /api/v1/teams/:teamId/user', async function () { - const tool = findTool('platform_get_team_membership') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/user` - }) - }) - - it('platform_list_team_members matches GET /api/v1/teams/:teamId/members', async function () { - const tool = findTool('platform_list_team_members') - const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/members` - }) - routeResponse.statusCode.should.equal(200) - routeResponse.json().members.should.not.be.empty() - }) - - it('platform_list_team_invitations matches GET /api/v1/teams/:teamId/invitations', async function () { - const bob = await app.factory.createUser({ - admin: false, - username: 'bob-invitee', - name: 'Bob Invitee', - email: 'bob-invitee@example.com', - password: 'bbPassword' - }) - await app.factory.createInvitation(app.team, app.user, bob) - - const tool = findTool('platform_list_team_invitations') - const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/invitations` - }) - routeResponse.statusCode.should.equal(200) - routeResponse.json().invitations.should.not.be.empty() - }) - }) -}) 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 613f0d93ad..e6b99754ca 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -1,26 +1,44 @@ const should = require('should') // eslint-disable-line no-unused-vars +const sinon = require('sinon') const { expectToolMatchesRoute, createExpertMcpToken, toolFinder, recordingInject } = require('../../../../../../lib/mcpToolEquivalence') const FF_UTIL = require('flowforge-test-utils') const tools = FF_UTIL.require('forge/ee/lib/mcp/tools/teams') +const { basePaginationKeys, searchQueryKeys, auditLogFilterKeys } = FF_UTIL.require('forge/ee/lib/mcp/schemas') const findTool = toolFinder(tools) +// audit-log routes honor cursor+limit, free-text query, and event/username. +// The team routes also honor scope + includeChildren. +const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] +const auditLogScopedKeys = [...auditLogKeys, 'scope', 'includeChildren'] + +function jsonResponse (statusCode, body) { + return { statusCode, json: () => body } +} + describe('MCP teams tools', function () { it('exposes exactly the expected tool names', function () { tools.map(t => t.name).should.eql([ 'platform_list_teams', 'platform_get_team', 'platform_get_team_by_slug', - 'platform_list_team_projects', - 'platform_list_team_application_statuses', - 'platform_list_team_dashboard_instances', 'platform_get_team_instance_counts', - 'platform_list_team_provisioning_tokens', - 'platform_list_team_types', - 'platform_get_team_type', - 'platform_check_team_slug_availability' + 'platform_check_team_slug_availability', + 'platform_get_team_membership', + 'platform_list_team_members', + 'platform_list_team_invitations', + 'platform_get_team_audit_log', + 'platform_export_team_audit_log', + 'platform_list_team_databases', + 'platform_get_team_database', + 'platform_list_database_tables', + 'platform_get_database_table', + 'platform_query_database_table_data', + 'platform_list_team_npm_packages', + 'platform_list_team_git_tokens', + 'platform_list_library_entries' ]) }) @@ -77,150 +95,339 @@ describe('MCP teams tools', function () { }) }) - describe('platform_list_team_projects', function () { - const tool = findTool('platform_list_team_projects') + describe('platform_get_team_instance_counts', function () { + const tool = findTool('platform_get_team_instance_counts') it('has the expected input params', function () { - Object.keys(tool.inputSchema).should.eql([ - 'teamId', 'query', 'sort', 'dir', 'includeMeta', 'orderByMostRecentFlows', 'limit', 'page' - ]) + Object.keys(tool.inputSchema).should.eql(['teamId', 'instanceType', 'state', 'applicationId']) }) - it('calls GET with only the supported, defined params serialised', async function () { + it('serialises a state array as repeated params', async function () { const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1', sort: 'name', dir: 'desc', limit: 10 }, { inject }) + await tool.handler({ teamId: 'team1', instanceType: 'hosted', state: ['running', 'stopped'] }, { inject }) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/projects?sort=name&dir=desc&limit=10') + calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=hosted&state=running&state=stopped') }) - it('url-encodes a query value with special characters', async function () { + it('includes applicationId when set and omits state when not provided', async function () { const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1', query: 'a b&c=d' }, { inject }) - calls[0].url.should.equal('/api/v1/teams/team1/projects?query=a+b%26c%3Dd') + await tool.handler({ teamId: 'team1', instanceType: 'remote', applicationId: 'app1' }, { inject }) + calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=remote&applicationId=app1') }) }) - describe('platform_list_team_application_statuses', function () { - const tool = findTool('platform_list_team_application_statuses') + describe('platform_check_team_slug_availability', function () { + const tool = findTool('platform_check_team_slug_availability') - it('has the expected input params', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'associationsLimit']) + it('has the slug input', function () { + Object.keys(tool.inputSchema).should.eql(['slug']) }) - it('calls GET /api/v1/teams/:teamId/applications/status with associationsLimit', async function () { + it('calls POST /api/v1/teams/check-slug with the slug payload', async function () { const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1', associationsLimit: 5 }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/applications/status?associationsLimit=5') + await tool.handler({ slug: 'my-team' }, { inject }) + calls[0].method.should.equal('POST') + calls[0].url.should.equal('/api/v1/teams/check-slug') + calls[0].payload.should.eql({ slug: 'my-team' }) + }) + }) + + describe('platform_get_team_membership', function () { + const tool = findTool('platform_get_team_membership') + + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) }) - it('omits the query string when associationsLimit is not set', async function () { + it('calls GET /api/v1/teams/:teamId/user', async function () { const { calls, inject } = recordingInject() await tool.handler({ teamId: 'team1' }, { inject }) - calls[0].url.should.equal('/api/v1/teams/team1/applications/status') + calls.should.have.length(1) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/user') }) }) - describe('platform_list_team_dashboard_instances', function () { - const tool = findTool('platform_list_team_dashboard_instances') + describe('platform_list_team_members', function () { + const tool = findTool('platform_list_team_members') it('has the teamId input', function () { Object.keys(tool.inputSchema).should.eql(['teamId']) }) - it('calls GET /api/v1/teams/:teamId/dashboard-instances', async function () { + it('calls GET /api/v1/teams/:teamId/members', async function () { const { calls, inject } = recordingInject() await tool.handler({ teamId: 'team1' }, { inject }) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/dashboard-instances') + calls[0].url.should.equal('/api/v1/teams/team1/members') }) }) - describe('platform_get_team_instance_counts', function () { - const tool = findTool('platform_get_team_instance_counts') + describe('platform_list_team_invitations', function () { + const tool = findTool('platform_list_team_invitations') - it('has the expected input params', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'instanceType', 'state', 'applicationId']) + it('has the teamId input', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) }) - it('serialises a state array as repeated params', async function () { + it('calls GET /api/v1/teams/:teamId/invitations', async function () { const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1', instanceType: 'hosted', state: ['running', 'stopped'] }, { inject }) + await tool.handler({ teamId: 'team1' }, { inject }) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=hosted&state=running&state=stopped') + calls[0].url.should.equal('/api/v1/teams/team1/invitations') }) + }) - it('includes applicationId when set and omits state when not provided', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ teamId: 'team1', instanceType: 'remote', applicationId: 'app1' }, { inject }) - calls[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=remote&applicationId=app1') + describe('team audit-log tools', function () { + function stubInject (response = { statusCode: 200, json: () => ({}) }) { + return sinon.stub().resolves(response) + } + + const toolDefinitions = [ + { + name: 'platform_get_team_audit_log', + base: id => `/api/v1/teams/${id}/audit-log` + }, + { + name: 'platform_export_team_audit_log', + base: id => `/api/v1/teams/${id}/audit-log/export` + } + ] + + toolDefinitions.forEach(def => { + describe(def.name, function () { + it('exposes the expected inputSchema keys', function () { + const tool = findTool(def.name) + const expectedKeys = ['teamId', ...auditLogScopedKeys] + Object.keys(tool.inputSchema).sort().should.deepEqual(expectedKeys.sort()) + }) + + it('calls inject with the correct method and bare URL when no query params are given', async function () { + const tool = findTool(def.name) + const inject = stubInject() + await tool.handler({ teamId: 'abc123' }, { inject }) + inject.calledOnce.should.be.true() + const call = inject.firstCall.args[0] + call.method.should.equal('GET') + call.url.should.equal(def.base('abc123')) + }) + + it('serialises pagination params onto the URL', async function () { + const tool = findTool(def.name) + const inject = stubInject() + await tool.handler({ teamId: 'abc123', limit: 10 }, { inject }) + const call = inject.firstCall.args[0] + call.url.should.equal(`${def.base('abc123')}?limit=10`) + }) + }) + }) + + it('serialises an array `event` filter as one repeated query param per element', async function () { + const tool = findTool('platform_get_team_audit_log') + const inject = stubInject() + await tool.handler({ teamId: 'team1', event: ['user.login', 'user.logout'], limit: 5 }, { inject }) + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/teams/team1/audit-log?limit=5&event=user.login&event=user.logout') + }) + + it('serialises the `username` filter alongside other query params', async function () { + const tool = findTool('platform_get_team_audit_log') + const inject = stubInject() + await tool.handler({ teamId: 'team1', username: 'alice', limit: 20 }, { inject }) + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/teams/team1/audit-log?limit=20&username=alice') + }) + + it('serialises the scope and includeChildren filters', async function () { + const tool = findTool('platform_get_team_audit_log') + const inject = stubInject() + await tool.handler({ teamId: 'team1', scope: 'application', includeChildren: true }, { inject }) + const call = inject.firstCall.args[0] + call.url.should.equal('/api/v1/teams/team1/audit-log?scope=application&includeChildren=true') }) }) - describe('platform_list_team_provisioning_tokens', function () { - const tool = findTool('platform_list_team_provisioning_tokens') + describe('platform_list_team_databases', function () { + const tool = findTool('platform_list_team_databases') - it('has the teamId input', function () { + it('declares the expected inputSchema keys', function () { Object.keys(tool.inputSchema).should.eql(['teamId']) }) - it('calls GET /api/v1/teams/:teamId/devices/provisioning', async function () { - const { calls, inject } = recordingInject() + it('calls GET on the team databases endpoint', async function () { + const { calls, inject } = recordingInject(jsonResponse(200, [])) await tool.handler({ teamId: 'team1' }, { inject }) + calls.should.have.length(1) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/devices/provisioning') + calls[0].url.should.equal('/api/v1/teams/team1/databases') + }) + + it('strips credentials from each database in the result', async function () { + const { inject } = recordingInject(jsonResponse(200, [ + { id: 'db1', name: 'db-one', credentials: { user: 'u', password: 'secret' } } + ])) + const result = await tool.handler({ teamId: 'team1' }, { inject }) + result.statusCode.should.equal(200) + result.json().should.eql([{ id: 'db1', name: 'db-one' }]) + }) + + it('passes error responses through unchanged', async function () { + const errorResponse = jsonResponse(404, { code: 'not_found', error: 'Not Found - not available on team' }) + const { inject } = recordingInject(errorResponse) + const result = await tool.handler({ teamId: 'team1' }, { inject }) + result.should.equal(errorResponse) }) }) - describe('platform_list_team_types', function () { - const tool = findTool('platform_list_team_types') + describe('platform_get_team_database', function () { + const tool = findTool('platform_get_team_database') - it('has pagination plus search and filter params', function () { - Object.keys(tool.inputSchema).should.eql(['cursor', 'limit', 'query', 'filter']) + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId']) }) - it('calls GET /api/v1/team-types', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ cursor: 'abc' }, { inject }) + it('calls GET on the single database endpoint', async function () { + const { calls, inject } = recordingInject(jsonResponse(200, { id: 'db1', name: 'db-one', credentials: { password: 'secret' } })) + await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/team-types?cursor=abc') + calls[0].url.should.equal('/api/v1/teams/team1/databases/db1') }) - it('serialises search and filter params', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ query: 'starter', filter: 'active' }, { inject }) - calls[0].url.should.equal('/api/v1/team-types?query=starter&filter=active') + it('strips credentials from the result', async function () { + const { inject } = recordingInject(jsonResponse(200, { id: 'db1', name: 'db-one', credentials: { password: 'secret' } })) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + result.json().should.eql({ id: 'db1', name: 'db-one' }) + }) + + it('passes error responses through unchanged', async function () { + const errorResponse = jsonResponse(404, { code: 'not_found', error: 'Not Found' }) + const { inject } = recordingInject(errorResponse) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + result.should.equal(errorResponse) + }) + + it('returns a falsy body unchanged without attempting to strip credentials', async function () { + const { inject } = recordingInject(jsonResponse(200, null)) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + should.equal(result.json(), null) }) }) - describe('platform_get_team_type', function () { - const tool = findTool('platform_get_team_type') + describe('platform_list_database_tables', function () { + const tool = findTool('platform_list_database_tables') - it('has the teamTypeId input', function () { - Object.keys(tool.inputSchema).should.eql(['teamTypeId']) + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId']) }) - it('calls GET /api/v1/team-types/:teamTypeId', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ teamTypeId: 'tt1' }, { inject }) + it('calls GET on the tables endpoint (this endpoint does not paginate)', async function () { + const { calls, inject } = recordingInject(jsonResponse(200, { count: 0, tables: [] })) + await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/team-types/tt1') + calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables') + }) + + it('returns the response unchanged', async function () { + const response = jsonResponse(200, { count: 0, tables: [] }) + const { inject } = recordingInject(response) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) + result.should.equal(response) }) }) - describe('platform_check_team_slug_availability', function () { - const tool = findTool('platform_check_team_slug_availability') + describe('platform_get_database_table', function () { + const tool = findTool('platform_get_database_table') - it('has the slug input', function () { - Object.keys(tool.inputSchema).should.eql(['slug']) + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId', 'tableName']) }) - it('calls POST /api/v1/teams/check-slug with the slug payload', async function () { - const { calls, inject } = recordingInject() - await tool.handler({ slug: 'my-team' }, { inject }) - calls[0].method.should.equal('POST') - calls[0].url.should.equal('/api/v1/teams/check-slug') - calls[0].payload.should.eql({ slug: 'my-team' }) + it('calls GET on the table schema endpoint', async function () { + const response = jsonResponse(200, { name: 'users', schema: 'CREATE TABLE users (...)' }) + const { calls, inject } = recordingInject(response) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables/users') + result.should.equal(response) + }) + }) + + describe('platform_query_database_table_data', function () { + const tool = findTool('platform_query_database_table_data') + + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId', 'tableName', 'limit']) + }) + + it('serialises the limit param onto the data endpoint URL', async function () { + const { calls, inject } = recordingInject(jsonResponse(200, { count: 0, rows: [] })) + await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users', limit: 5 }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables/users/data?limit=5') + }) + + it('returns the response unchanged', async function () { + const response = jsonResponse(200, { count: 0, rows: [] }) + const { inject } = recordingInject(response) + const result = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users' }, { inject }) + result.should.equal(response) + }) + }) + + describe('platform_list_team_npm_packages', function () { + const tool = findTool('platform_list_team_npm_packages') + + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET on the npm packages endpoint and returns the response unchanged', async function () { + const response = jsonResponse(200, [{ name: '@team/pkg' }]) + const { calls, inject } = recordingInject(response) + const result = await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/npm/packages') + result.should.equal(response) + }) + }) + + describe('platform_list_team_git_tokens', function () { + const tool = findTool('platform_list_team_git_tokens') + + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['teamId']) + }) + + it('calls GET on the git tokens endpoint and returns the response unchanged', async function () { + const response = jsonResponse(200, [{ id: 'tok1', name: 'ci-token', type: 'github' }]) + const { calls, inject } = recordingInject(response) + const result = await tool.handler({ teamId: 'team1' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/api/v1/teams/team1/git/tokens') + result.should.equal(response) + }) + }) + + describe('platform_list_library_entries', function () { + const tool = findTool('platform_list_library_entries') + + it('declares the expected inputSchema keys', function () { + Object.keys(tool.inputSchema).should.eql(['libraryId', 'path', 'type']) + }) + + it('lists the library root when path is empty and no type filter is given', async function () { + const response = jsonResponse(200, []) + const { calls, inject } = recordingInject(response) + const result = await tool.handler({ libraryId: 'team1', path: '' }, { inject }) + calls[0].method.should.equal('GET') + calls[0].url.should.equal('/storage/library/team1/') + result.should.equal(response) + }) + + it('lists a folder path and appends the type filter as a query param', async function () { + const { calls, inject } = recordingInject(jsonResponse(200, [])) + await tool.handler({ libraryId: 'team1', path: 'folder1', type: 'flows' }, { inject }) + calls[0].url.should.equal('/storage/library/team1/folder1?type=flows') }) }) }) @@ -252,18 +459,6 @@ describe('MCP teams tools smoke test', function () { }) } - it('lists the seeded team projects/instances', async function () { - const tool = findTool('platform_list_team_projects') - const response = await tool.handler({ teamId: app.team.hashid }, { inject }) - response.statusCode.should.equal(200) - const body = response.json() - body.should.have.property('count', 1) - body.should.have.property('projects') - body.projects.should.be.an.Array() - body.projects.should.have.length(1) - body.projects[0].should.have.property('name', app.instance.name) - }) - describe('tool responses match their backing routes', function () { it('platform_list_teams matches GET /api/v1/user/teams', async function () { const tool = findTool('platform_list_teams') @@ -289,101 +484,336 @@ describe('MCP teams tools smoke test', function () { }) }) - it('platform_list_team_projects matches GET /api/v1/teams/:teamId/projects', async function () { - const tool = findTool('platform_list_team_projects') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + it('platform_get_team_instance_counts matches GET /api/v1/teams/:teamId/instance-counts', async function () { + const tool = findTool('platform_get_team_instance_counts') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, instanceType: 'hosted' }, { method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/projects` + url: `/api/v1/teams/${app.team.hashid}/instance-counts?instanceType=hosted` }) }) - it('platform_list_team_application_statuses matches GET /api/v1/teams/:teamId/applications/status', async function () { - const tool = findTool('platform_list_team_application_statuses') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/applications/status` + it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for an available slug', async function () { + const tool = findTool('platform_check_team_slug_availability') + await expectToolMatchesRoute(inject, tool, { slug: 'a-brand-new-team-slug' }, { + method: 'POST', + url: '/api/v1/teams/check-slug', + payload: { slug: 'a-brand-new-team-slug' } }) }) - it('platform_list_team_dashboard_instances matches GET /api/v1/teams/:teamId/dashboard-instances', async function () { - // Seed in the PAT user's own team, since the route's per-application RBAC check needs real membership - const dashboardApplication = await app.factory.createApplication({ name: 'dashboard-application' }, app.team) - await app.factory.createInstance( - { name: 'dashboard-instance' }, - dashboardApplication, - app.stack, - app.template, - app.projectType, - { - start: false, - settings: { - palette: { modules: [{ name: '@flowfuse/node-red-dashboard', version: '~1.15.0', local: true }] } - } - } - ) + it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for a taken slug', async function () { + const tool = findTool('platform_check_team_slug_availability') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { slug: app.team.slug }, { + method: 'POST', + url: '/api/v1/teams/check-slug', + payload: { slug: app.team.slug } + }) + viaTool.statusCode.should.equal(409) + }) - const tool = findTool('platform_list_team_dashboard_instances') - const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + it('platform_get_team_membership matches GET /api/v1/teams/:teamId/user', async function () { + const tool = findTool('platform_get_team_membership') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/dashboard-instances` + url: `/api/v1/teams/${app.team.hashid}/user` }) - routeResponse.statusCode.should.equal(200) - routeResponse.json().projects.should.have.length(1) }) - it('platform_get_team_instance_counts matches GET /api/v1/teams/:teamId/instance-counts', async function () { - const tool = findTool('platform_get_team_instance_counts') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, instanceType: 'hosted' }, { + it('platform_list_team_members matches GET /api/v1/teams/:teamId/members', async function () { + const tool = findTool('platform_list_team_members') + const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/instance-counts?instanceType=hosted` + url: `/api/v1/teams/${app.team.hashid}/members` }) + routeResponse.statusCode.should.equal(200) + routeResponse.json().members.should.not.be.empty() }) - it('platform_list_team_provisioning_tokens matches GET /api/v1/teams/:teamId/devices/provisioning', async function () { - await app.db.controllers.AccessToken.createTokenForTeamDeviceProvisioning('equivalence-test-token', app.team) + it('platform_list_team_invitations matches GET /api/v1/teams/:teamId/invitations', async function () { + const bob = await app.factory.createUser({ + admin: false, + username: 'bob-invitee', + name: 'Bob Invitee', + email: 'bob-invitee@example.com', + password: 'bbPassword' + }) + await app.factory.createInvitation(app.team, app.user, bob) - const tool = findTool('platform_list_team_provisioning_tokens') + const tool = findTool('platform_list_team_invitations') const { routeResponse } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/devices/provisioning` + url: `/api/v1/teams/${app.team.hashid}/invitations` }) routeResponse.statusCode.should.equal(200) - routeResponse.json().tokens.should.not.be.empty() + routeResponse.json().invitations.should.not.be.empty() }) + }) +}) - it('platform_list_team_types matches GET /api/v1/team-types', async function () { - const tool = findTool('platform_list_team_types') - await expectToolMatchesRoute(inject, tool, {}, { +describe('MCP teams audit-log tools - integration smoke', function () { + const setup = require('../../../setup') + + let app + let token + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true } + }) + token = await createExpertMcpToken(app) + }) + + after(async function () { + await app.close() + }) + + function inject (options) { + return app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + } + + it('reads the team audit log via the underlying route', async function () { + const tool = findTool('platform_get_team_audit_log') + + const response = await tool.handler({ teamId: app.team.hashid }, { inject }) + + response.statusCode.should.equal(200) + const body = response.json() + body.should.have.property('meta') + body.should.have.property('count') + body.should.have.property('log') + body.log.should.be.an.Array() + }) + + describe('tool-vs-route equivalence', function () { + let team + + before(async function () { + team = app.team + + // Seed one real entry so assertions compare actual data, not an empty-list shape. + await app.db.controllers.AuditLog.teamLog(team.id, app.user.id, 'team.settings.updated', { settings: { name: team.name } }) + }) + + it('platform_get_team_audit_log matches GET /api/v1/teams/:teamId/audit-log, including query params', async function () { + const tool = findTool('platform_get_team_audit_log') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid, event: 'team.settings.updated', limit: 5 }, { method: 'GET', - url: '/api/v1/team-types' + url: `/api/v1/teams/${team.hashid}/audit-log?limit=5&event=team.settings.updated` }) }) - it('platform_get_team_type matches GET /api/v1/team-types/:teamTypeId', async function () { - const tool = findTool('platform_get_team_type') - await expectToolMatchesRoute(inject, tool, { teamTypeId: app.defaultTeamType.hashid }, { + it('platform_export_team_audit_log matches GET /api/v1/teams/:teamId/audit-log/export', async function () { + const tool = findTool('platform_export_team_audit_log') + await expectToolMatchesRoute(inject, tool, { teamId: team.hashid }, { method: 'GET', - url: `/api/v1/team-types/${app.defaultTeamType.hashid}` + url: `/api/v1/teams/${team.hashid}/audit-log/export`, + raw: true }) }) + }) +}) - it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for an available slug', async function () { - const tool = findTool('platform_check_team_slug_availability') - await expectToolMatchesRoute(inject, tool, { slug: 'a-brand-new-team-slug' }, { - method: 'POST', - url: '/api/v1/teams/check-slug', - payload: { slug: 'a-brand-new-team-slug' } +describe('MCP teams data tools - integration smoke', function () { + const setup = require('../../../setup') + + const NPM_REGISTRY_PORT = 9761 + + let app + let token + let database + let npmRegistryServer + + const inject = (options) => app.inject({ + ...options, + headers: { + ...(options.headers || {}), + authorization: `Bearer ${token}` + } + }) + + before(async function () { + app = await setup({ + ai: { enabled: true }, + expert: { enabled: true }, + tables: { + enabled: true, + driver: { type: 'stub' } + }, + npmRegistry: { + enabled: true, + url: `http://localhost:${NPM_REGISTRY_PORT}`, + admin: { username: 'admin', password: 'secret' } + } + }) + + // These features are off by default; enable them so the smoke test hits the real 200 path. + const defaultTeamTypeProperties = app.defaultTeamType.properties + defaultTeamTypeProperties.features = { + ...defaultTeamTypeProperties.features, + tables: true, + npm: true, + gitIntegration: true + } + app.defaultTeamType.properties = defaultTeamTypeProperties + await app.defaultTeamType.save() + + token = await createExpertMcpToken(app) + + // Minimal stand-in npm registry (pattern from catalogue/index_spec.js) so we get a real 200 response. + npmRegistryServer = require('http').createServer((req, res) => { + if (/^\/-\/all/.test(req.url)) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + const body = { _updated: 99999 } + body[`@flowfuse-${app.team.hashid}/one`] = { + name: `@flowfuse-${app.team.hashid}/one`, + 'dist-tags': { latest: '1.0.0' }, + time: { modified: '2025-02-18T10:13:18.950Z' }, + license: 'Apache-2.0', + versions: { '1.0.0': 'latest' } + } + res.end(JSON.stringify(body)) + } else { + res.writeHead(404) + res.end() + } + }) + await new Promise((resolve) => npmRegistryServer.listen(NPM_REGISTRY_PORT, resolve)) + + // Seed at the model layer, not via the create-database route: the stub tables + // driver keys created-database state by team hashid, which leaks across spec files sharing it. + database = await app.db.models.Table.create({ + TeamId: app.team.id, + name: app.team.hashid, + credentials: { + host: 'localhost', + port: 5432, + database: app.team.hashid, + user: 'postgres', + password: 'smoke-test-password', + ssl: false + }, + meta: {} + }) + + await app.db.models.GitToken.create({ + name: 'ci-token', + token: 'ghp_smoketesttoken', + type: 'github', + TeamId: app.team.id + }) + + await app.db.models.StorageSharedLibrary.create({ + name: 'smoke-flow', + type: 'flows', + meta: JSON.stringify({}), + body: JSON.stringify([]), + TeamId: app.team.id + }) + }) + + after(async function () { + await app.close() + await new Promise((resolve) => npmRegistryServer.close(resolve)) + }) + + it('lists team databases via the same endpoint platform_list_team_databases calls', async function () { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${app.team.hashid}/databases` }) + response.statusCode.should.equal(200) + response.json().should.be.an.Array().and.have.length(1) + }) + + it('platform_list_team_databases matches GET /api/v1/teams/:teamId/databases, with credentials stripped from every entry', async function () { + const tool = findTool('platform_list_team_databases') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/databases`, + transform: (r) => ({ + statusCode: r.statusCode, + body: (r.json() || []).map(({ credentials, ...rest }) => rest) }) }) + viaTool.json().should.be.an.Array().and.have.length(1) + viaTool.json()[0].should.not.have.property('credentials') + }) - it('platform_check_team_slug_availability matches POST /api/v1/teams/check-slug for a taken slug', async function () { - const tool = findTool('platform_check_team_slug_availability') - const { viaTool } = await expectToolMatchesRoute(inject, tool, { slug: app.team.slug }, { - method: 'POST', - url: '/api/v1/teams/check-slug', - payload: { slug: app.team.slug } + it('platform_get_team_database matches GET /api/v1/teams/:teamId/databases/:databaseId, with credentials stripped', async function () { + const tool = findTool('platform_get_team_database') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}`, + transform: (r) => ({ + statusCode: r.statusCode, + body: (function () { + const d = r.json() + if (!d) return d + const { credentials, ...rest } = d + return rest + })() }) - viaTool.statusCode.should.equal(409) }) + viaTool.json().should.not.have.property('credentials') + }) + + it('platform_list_database_tables matches GET /api/v1/teams/:teamId/databases/:databaseId/tables', async function () { + const tool = findTool('platform_list_database_tables') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables` + }) + }) + + it('platform_get_database_table matches GET /api/v1/teams/:teamId/databases/:databaseId/tables/:tableName', async function () { + const tool = findTool('platform_get_database_table') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid, tableName: 'table1' }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables/table1` + }) + }) + + it('platform_query_database_table_data matches GET /api/v1/teams/:teamId/databases/:databaseId/tables/:tableName/data', async function () { + const tool = findTool('platform_query_database_table_data') + await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid, tableName: 'table1' }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables/table1/data` + }) + }) + + it('platform_list_team_npm_packages matches GET /api/v1/teams/:teamId/npm/packages', async function () { + const tool = findTool('platform_list_team_npm_packages') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/npm/packages` + }) + viaTool.statusCode.should.equal(200) + viaTool.json().should.have.property(`@flowfuse-${app.team.hashid}/one`) + }) + + it('platform_list_team_git_tokens matches GET /api/v1/teams/:teamId/git/tokens', async function () { + const tool = findTool('platform_list_team_git_tokens') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/git/tokens` + }) + viaTool.statusCode.should.equal(200) + viaTool.json().tokens.should.be.an.Array().and.have.length(1) + viaTool.json().tokens[0].should.have.property('name', 'ci-token') + }) + + it('platform_list_library_entries matches GET /storage/library/:libraryId/', async function () { + const tool = findTool('platform_list_library_entries') + const { viaTool } = await expectToolMatchesRoute(inject, tool, { libraryId: app.team.hashid, path: '' }, { + method: 'GET', + url: `/storage/library/${app.team.hashid}/` + }) + viaTool.statusCode.should.equal(200) + viaTool.json().should.be.an.Array().and.have.length(1) + viaTool.json()[0].should.have.property('fn', 'smoke-flow') }) }) From b675d3f097611e71305bc5509619f5419bed8097 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 28 Jul 2026 12:32:37 +0200 Subject: [PATCH 3/3] refactor(mcp): remove FlowFuse Tables tools from teams.js The 5 FlowFuse Tables tools (platform_list_team_databases, platform_get_team_database, platform_list_database_tables, platform_get_database_table, platform_query_database_table_data) are being tracked and shipped in a separate PR instead. Remove them and the redactDatabaseCredentials helper, their tests, and the team:database:list scope that only backed these tools. --- forge/ee/lib/mcp/tools/teams.js | 112 --------- forge/routes/auth/permissions.js | 1 - .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 216 ------------------ 3 files changed, 329 deletions(-) diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index dc252f5683..32c318e4ba 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -9,15 +9,6 @@ const includeChildren = z.boolean().optional().describe('Also include audit entr const auditLogInput = { ...basePagination, ...searchQuery, ...auditLogFilters } const auditLogKeys = [...basePaginationKeys, ...searchQueryKeys, ...auditLogFilterKeys] -// Strips credentials (including the password) before returning results to the caller. -function redactDatabaseCredentials (database) { - if (!database) { - return database - } - const { credentials, ...rest } = database - return rest -} - module.exports = [ { name: 'platform_list_teams', @@ -180,109 +171,6 @@ module.exports = [ return response } }, - { - name: 'platform_list_team_databases', - title: 'List Team Databases', - description: `FlowFuse platform automation tool: - Lists the FlowFuse Tables databases for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning results, so no credentials are ever exposed.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases` }) - if (response.statusCode >= 400) { - return response - } - const databases = response.json().map(redactDatabaseCredentials) - return { - statusCode: response.statusCode, - json: () => databases - } - } - }, - { - name: 'platform_get_team_database', - title: 'Get Team Database', - description: `FlowFuse platform automation tool: - Gets a single FlowFuse Tables database for a team. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - The underlying API response includes a credentials object with connection details, including a password. This tool strips that object before returning the result, so no credentials are ever exposed.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - databaseId: z.string().describe('database hashid') - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}` }) - if (response.statusCode >= 400) { - return response - } - const database = redactDatabaseCredentials(response.json()) - return { - statusCode: response.statusCode, - json: () => database - } - } - }, - { - name: 'platform_list_database_tables', - title: 'List Database Tables', - description: `FlowFuse platform automation tool: - Lists the tables defined in a FlowFuse Tables database. The full list is returned; this endpoint does not paginate. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_get_database_table to get the full schema of a single table, or platform_query_database_table_data to read row data.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - databaseId: z.string().describe('database hashid') - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables` }) - return response - } - }, - { - name: 'platform_get_database_table', - title: 'Get Database Table', - description: `FlowFuse platform automation tool: - Gets the schema definition of a single table in a FlowFuse Tables database (column names, types, and constraints). Does not return row data or credentials. - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_query_database_table_data to read row data instead.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - databaseId: z.string().describe('database hashid'), - tableName: z.string().describe('Name of the database table') - }, - handler: async (args, { inject }) => { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}` }) - return response - } - }, - { - name: 'platform_query_database_table_data', - title: 'Query Database Table Data', - description: `FlowFuse platform automation tool: - Reads the row data of a table in a FlowFuse Tables database. There are no column-filter parameters; this returns rows as stored. - At most 10 rows are returned per call (the limit is capped at 10 by the platform). - FlowFuse Tables is a plan-gated feature; if it is not enabled for the team's plan, the underlying API's error response is returned as-is. - Use platform_get_database_table first if you need to know the column names and types.`, - annotations: { readOnlyHint: true, destructiveHint: false }, - inputSchema: { - teamId, - databaseId: z.string().describe('database hashid'), - tableName: z.string().describe('Name of the database table'), - limit: z.number().int().min(1).max(10).default(10).describe('Maximum number of rows to return (1-10, default 10)') - }, - handler: async (args, { inject }) => { - const url = appendQuery(`/api/v1/teams/${args.teamId}/databases/${args.databaseId}/tables/${args.tableName}/data`, args, ['limit']) - const response = await inject({ method: 'GET', url }) - return response - } - }, { name: 'platform_list_team_npm_packages', title: 'List Team NPM Packages', diff --git a/forge/routes/auth/permissions.js b/forge/routes/auth/permissions.js index d037462283..8c71747ca3 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -53,7 +53,6 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:create', // check team slug availability 'team:audit-log', // get team audit log // team data - 'team:database:list', // list team databases and tables 'team:packages:read', // list team npm packages 'team:git:tokens:list', // list team git tokens 'library:entry:list', // list team shared library entries 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 e6b99754ca..0bb102f5b4 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -31,11 +31,6 @@ describe('MCP teams tools', function () { 'platform_list_team_invitations', 'platform_get_team_audit_log', 'platform_export_team_audit_log', - 'platform_list_team_databases', - 'platform_get_team_database', - 'platform_list_database_tables', - 'platform_get_database_table', - 'platform_query_database_table_data', 'platform_list_team_npm_packages', 'platform_list_team_git_tokens', 'platform_list_library_entries' @@ -247,133 +242,6 @@ describe('MCP teams tools', function () { }) }) - describe('platform_list_team_databases', function () { - const tool = findTool('platform_list_team_databases') - - it('declares the expected inputSchema keys', function () { - Object.keys(tool.inputSchema).should.eql(['teamId']) - }) - - it('calls GET on the team databases endpoint', async function () { - const { calls, inject } = recordingInject(jsonResponse(200, [])) - await tool.handler({ teamId: 'team1' }, { inject }) - calls.should.have.length(1) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/databases') - }) - - it('strips credentials from each database in the result', async function () { - const { inject } = recordingInject(jsonResponse(200, [ - { id: 'db1', name: 'db-one', credentials: { user: 'u', password: 'secret' } } - ])) - const result = await tool.handler({ teamId: 'team1' }, { inject }) - result.statusCode.should.equal(200) - result.json().should.eql([{ id: 'db1', name: 'db-one' }]) - }) - - it('passes error responses through unchanged', async function () { - const errorResponse = jsonResponse(404, { code: 'not_found', error: 'Not Found - not available on team' }) - const { inject } = recordingInject(errorResponse) - const result = await tool.handler({ teamId: 'team1' }, { inject }) - result.should.equal(errorResponse) - }) - }) - - describe('platform_get_team_database', function () { - const tool = findTool('platform_get_team_database') - - it('declares the expected inputSchema keys', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId']) - }) - - it('calls GET on the single database endpoint', async function () { - const { calls, inject } = recordingInject(jsonResponse(200, { id: 'db1', name: 'db-one', credentials: { password: 'secret' } })) - await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/databases/db1') - }) - - it('strips credentials from the result', async function () { - const { inject } = recordingInject(jsonResponse(200, { id: 'db1', name: 'db-one', credentials: { password: 'secret' } })) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - result.json().should.eql({ id: 'db1', name: 'db-one' }) - }) - - it('passes error responses through unchanged', async function () { - const errorResponse = jsonResponse(404, { code: 'not_found', error: 'Not Found' }) - const { inject } = recordingInject(errorResponse) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - result.should.equal(errorResponse) - }) - - it('returns a falsy body unchanged without attempting to strip credentials', async function () { - const { inject } = recordingInject(jsonResponse(200, null)) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - should.equal(result.json(), null) - }) - }) - - describe('platform_list_database_tables', function () { - const tool = findTool('platform_list_database_tables') - - it('declares the expected inputSchema keys', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId']) - }) - - it('calls GET on the tables endpoint (this endpoint does not paginate)', async function () { - const { calls, inject } = recordingInject(jsonResponse(200, { count: 0, tables: [] })) - await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables') - }) - - it('returns the response unchanged', async function () { - const response = jsonResponse(200, { count: 0, tables: [] }) - const { inject } = recordingInject(response) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1' }, { inject }) - result.should.equal(response) - }) - }) - - describe('platform_get_database_table', function () { - const tool = findTool('platform_get_database_table') - - it('declares the expected inputSchema keys', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId', 'tableName']) - }) - - it('calls GET on the table schema endpoint', async function () { - const response = jsonResponse(200, { name: 'users', schema: 'CREATE TABLE users (...)' }) - const { calls, inject } = recordingInject(response) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users' }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables/users') - result.should.equal(response) - }) - }) - - describe('platform_query_database_table_data', function () { - const tool = findTool('platform_query_database_table_data') - - it('declares the expected inputSchema keys', function () { - Object.keys(tool.inputSchema).should.eql(['teamId', 'databaseId', 'tableName', 'limit']) - }) - - it('serialises the limit param onto the data endpoint URL', async function () { - const { calls, inject } = recordingInject(jsonResponse(200, { count: 0, rows: [] })) - await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users', limit: 5 }, { inject }) - calls[0].method.should.equal('GET') - calls[0].url.should.equal('/api/v1/teams/team1/databases/db1/tables/users/data?limit=5') - }) - - it('returns the response unchanged', async function () { - const response = jsonResponse(200, { count: 0, rows: [] }) - const { inject } = recordingInject(response) - const result = await tool.handler({ teamId: 'team1', databaseId: 'db1', tableName: 'users' }, { inject }) - result.should.equal(response) - }) - }) - describe('platform_list_team_npm_packages', function () { const tool = findTool('platform_list_team_npm_packages') @@ -627,7 +495,6 @@ describe('MCP teams data tools - integration smoke', function () { let app let token - let database let npmRegistryServer const inject = (options) => app.inject({ @@ -642,10 +509,6 @@ describe('MCP teams data tools - integration smoke', function () { app = await setup({ ai: { enabled: true }, expert: { enabled: true }, - tables: { - enabled: true, - driver: { type: 'stub' } - }, npmRegistry: { enabled: true, url: `http://localhost:${NPM_REGISTRY_PORT}`, @@ -657,7 +520,6 @@ describe('MCP teams data tools - integration smoke', function () { const defaultTeamTypeProperties = app.defaultTeamType.properties defaultTeamTypeProperties.features = { ...defaultTeamTypeProperties.features, - tables: true, npm: true, gitIntegration: true } @@ -686,22 +548,6 @@ describe('MCP teams data tools - integration smoke', function () { }) await new Promise((resolve) => npmRegistryServer.listen(NPM_REGISTRY_PORT, resolve)) - // Seed at the model layer, not via the create-database route: the stub tables - // driver keys created-database state by team hashid, which leaks across spec files sharing it. - database = await app.db.models.Table.create({ - TeamId: app.team.id, - name: app.team.hashid, - credentials: { - host: 'localhost', - port: 5432, - database: app.team.hashid, - user: 'postgres', - password: 'smoke-test-password', - ssl: false - }, - meta: {} - }) - await app.db.models.GitToken.create({ name: 'ci-token', token: 'ghp_smoketesttoken', @@ -723,68 +569,6 @@ describe('MCP teams data tools - integration smoke', function () { await new Promise((resolve) => npmRegistryServer.close(resolve)) }) - it('lists team databases via the same endpoint platform_list_team_databases calls', async function () { - const response = await inject({ method: 'GET', url: `/api/v1/teams/${app.team.hashid}/databases` }) - response.statusCode.should.equal(200) - response.json().should.be.an.Array().and.have.length(1) - }) - - it('platform_list_team_databases matches GET /api/v1/teams/:teamId/databases, with credentials stripped from every entry', async function () { - const tool = findTool('platform_list_team_databases') - const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/databases`, - transform: (r) => ({ - statusCode: r.statusCode, - body: (r.json() || []).map(({ credentials, ...rest }) => rest) - }) - }) - viaTool.json().should.be.an.Array().and.have.length(1) - viaTool.json()[0].should.not.have.property('credentials') - }) - - it('platform_get_team_database matches GET /api/v1/teams/:teamId/databases/:databaseId, with credentials stripped', async function () { - const tool = findTool('platform_get_team_database') - const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}`, - transform: (r) => ({ - statusCode: r.statusCode, - body: (function () { - const d = r.json() - if (!d) return d - const { credentials, ...rest } = d - return rest - })() - }) - }) - viaTool.json().should.not.have.property('credentials') - }) - - it('platform_list_database_tables matches GET /api/v1/teams/:teamId/databases/:databaseId/tables', async function () { - const tool = findTool('platform_list_database_tables') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables` - }) - }) - - it('platform_get_database_table matches GET /api/v1/teams/:teamId/databases/:databaseId/tables/:tableName', async function () { - const tool = findTool('platform_get_database_table') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid, tableName: 'table1' }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables/table1` - }) - }) - - it('platform_query_database_table_data matches GET /api/v1/teams/:teamId/databases/:databaseId/tables/:tableName/data', async function () { - const tool = findTool('platform_query_database_table_data') - await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid, databaseId: database.hashid, tableName: 'table1' }, { - method: 'GET', - url: `/api/v1/teams/${app.team.hashid}/databases/${database.hashid}/tables/table1/data` - }) - }) - it('platform_list_team_npm_packages matches GET /api/v1/teams/:teamId/npm/packages', async function () { const tool = findTool('platform_list_team_npm_packages') const { viaTool } = await expectToolMatchesRoute(inject, tool, { teamId: app.team.hashid }, {