From e04360f83e9a2937f94d7fc47e1f83c1715f9f4a Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 3 Aug 2026 16:12:33 +0200 Subject: [PATCH 1/2] feat(mcp): shared query schema fragments for platform tools Add forge/ee/lib/mcp/schemas.js, a shared module of composable zod fragments the platform read tools import instead of redefining entity-id and pagination/search/sort/audit-log query fields in each tool file. - entity-id params: teamId, applicationId, hostedInstanceId (UUID), remoteInstanceId, snapshotId - query fragments composed per route by spreading only the params the backing finder honors: cursorParam/limitParam (basePagination), pageParam, searchQuery, sortParams, auditLogFilters - appendQuery serialises a tool's supported params onto the request URL The module lives one level above tools/ so the tool loader does not register it as a tool module. Closes #7669 --- forge/ee/lib/mcp/schemas.js | 92 ++++++++++++++++++++++ test/unit/forge/ee/lib/mcp/schemas_spec.js | 76 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 forge/ee/lib/mcp/schemas.js create mode 100644 test/unit/forge/ee/lib/mcp/schemas_spec.js diff --git a/forge/ee/lib/mcp/schemas.js b/forge/ee/lib/mcp/schemas.js new file mode 100644 index 0000000000..03d628a008 --- /dev/null +++ b/forge/ee/lib/mcp/schemas.js @@ -0,0 +1,92 @@ +const { z } = require('zod') + +// Hosted instances are Projects (UUID primary key); the other entities use hashids. +const teamId = z.string().describe('The ID or hashid of the team') +const applicationId = z.string().describe('The ID or hashid of the application') +const hostedInstanceId = z.string().uuid().describe('The UUID of the hosted instance') +const remoteInstanceId = z.string().describe('The ID or hashid of the remote instance') +const snapshotId = z.string().describe('The hashid of the snapshot') + +// Query fragments composed per tool by spreading only the ones the backing +// route's finder actually honors. Not the same as the route's declared query +// schema: most list routes reuse a generic PaginationParams that advertises +// page/sort/dir/order even when the finder ignores them. Match the finder. + +const cursorParam = { + cursor: z.string().optional().describe('Opaque cursor from a previous page') +} +const limitParam = { + limit: z.number().int().min(1).max(50).default(10).describe('Maximum number of records to return (1-50, default 10)') +} +const basePagination = { ...cursorParam, ...limitParam } + +// Only Device.getAll and Project.byTeam read page and compute an offset. +const pageParam = { + page: z.number().int().min(1).optional().default(1).describe('1-based page number (offset pagination)') +} + +const searchQuery = { + query: z.string().optional().describe('Free-text search filter') +} + +// Only Project.byTeam honors sort; no finder reads the legacy `order` alias. +const sortParams = { + sort: z.string().optional().describe('Field name to sort by'), + dir: z.enum(['asc', 'desc']).optional().describe('Sort direction') +} + +// scope is route-specific (its enum differs per entity), so each tool declares it inline. +const auditLogFilters = { + event: z.union([z.string(), z.array(z.string())]).optional().describe('Filter by audit event name, or an array of event names'), + username: z.string().optional().describe('Filter by the username that triggered the event') +} + +const cursorParamKeys = Object.keys(cursorParam) +const limitParamKeys = Object.keys(limitParam) +const basePaginationKeys = Object.keys(basePagination) +const pageParamKeys = Object.keys(pageParam) +const searchQueryKeys = Object.keys(searchQuery) +const sortParamsKeys = Object.keys(sortParams) +const auditLogFilterKeys = Object.keys(auditLogFilters) + +// Serialise the given query keys from args onto a url: only defined values, +// URL-encoded, an array value appended once per element. +function appendQuery (url, args, keys) { + const params = new URLSearchParams() + for (const key of keys) { + const value = args[key] + if (value === undefined || value === null) { + continue + } + if (Array.isArray(value)) { + value.forEach(v => params.append(key, v)) + } else { + params.append(key, value) + } + } + const queryString = params.toString() + return queryString ? `${url}?${queryString}` : url +} + +module.exports = { + teamId, + applicationId, + hostedInstanceId, + remoteInstanceId, + snapshotId, + cursorParam, + limitParam, + basePagination, + pageParam, + searchQuery, + sortParams, + auditLogFilters, + cursorParamKeys, + limitParamKeys, + basePaginationKeys, + pageParamKeys, + searchQueryKeys, + sortParamsKeys, + auditLogFilterKeys, + appendQuery +} diff --git a/test/unit/forge/ee/lib/mcp/schemas_spec.js b/test/unit/forge/ee/lib/mcp/schemas_spec.js new file mode 100644 index 0000000000..ae9d953474 --- /dev/null +++ b/test/unit/forge/ee/lib/mcp/schemas_spec.js @@ -0,0 +1,76 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const FF_UTIL = require('flowforge-test-utils') + +const { + teamId, + applicationId, + hostedInstanceId, + remoteInstanceId, + snapshotId, + basePagination, + cursorParamKeys, + limitParamKeys, + basePaginationKeys, + pageParamKeys, + searchQueryKeys, + sortParamsKeys, + auditLogFilterKeys, + appendQuery +} = FF_UTIL.require('forge/ee/lib/mcp/schemas') + +describe('MCP shared query schemas', function () { + describe('entity-id params', function () { + it('hashid params accept any non-empty string', function () { + for (const param of [teamId, applicationId, remoteInstanceId, snapshotId]) { + param.parse('aBc123').should.equal('aBc123') + } + }) + it('hostedInstanceId accepts a UUID and rejects a hashid', function () { + hostedInstanceId.parse('4f8c1e2a-1b2c-4d3e-8f90-abcdef123456').should.be.a.String() + hostedInstanceId.safeParse('aBc123').success.should.equal(false) + }) + }) + + describe('fragment composition', function () { + it('basePagination is cursor plus limit only (no page)', function () { + basePaginationKeys.should.eql(['cursor', 'limit']) + }) + it('page, search and sort are separate opt-in fragments', function () { + cursorParamKeys.should.eql(['cursor']) + limitParamKeys.should.eql(['limit']) + pageParamKeys.should.eql(['page']) + searchQueryKeys.should.eql(['query']) + sortParamsKeys.should.eql(['sort', 'dir']) + auditLogFilterKeys.should.eql(['event', 'username']) + }) + it('key list matches the shape object it describes', function () { + basePaginationKeys.should.eql(Object.keys(basePagination)) + }) + }) + + describe('appendQuery', function () { + it('returns the url unchanged when no supported params are set', function () { + appendQuery('/api/v1/x', {}, basePaginationKeys).should.equal('/api/v1/x') + }) + it('returns the url unchanged when only unsupported keys are present', function () { + appendQuery('/api/v1/x', { teamId: 'abc' }, basePaginationKeys).should.equal('/api/v1/x') + }) + it('serialises only the defined, supported params', function () { + appendQuery('/api/v1/x', { limit: 10, page: 2, cursor: undefined }, [...basePaginationKeys, ...pageParamKeys]) + .should.equal('/api/v1/x?limit=10&page=2') + }) + it('url-encodes values', function () { + appendQuery('/api/v1/x', { query: 'a b&c' }, searchQueryKeys) + .should.equal('/api/v1/x?query=a+b%26c') + }) + it('appends an array value once per element', function () { + appendQuery('/api/v1/x', { event: ['flows.created', 'flows.deleted'] }, auditLogFilterKeys) + .should.equal('/api/v1/x?event=flows.created&event=flows.deleted') + }) + it('skips null and undefined but keeps other values', function () { + appendQuery('/api/v1/x', { limit: null, page: 1 }, [...basePaginationKeys, ...pageParamKeys]) + .should.equal('/api/v1/x?page=1') + }) + }) +}) From 516c3cb6d573d97c6bd4e43be758e2a355b913d3 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 3 Aug 2026 17:29:56 +0200 Subject: [PATCH 2/2] feat(mcp): add team read tools Expose read-only team, membership, audit-log, and shared-data tools on the platform automation MCP surface. --- forge/ee/lib/mcp/tools/teams.js | 197 +++++++++++++++++- forge/ee/routes/sharedLibrary/index.js | 6 +- forge/routes/auth/permissions.js | 8 + .../unit/forge/ee/lib/mcp/tools/teams_spec.js | 175 ++++++++++++++-- 4 files changed, 366 insertions(+), 20 deletions(-) diff --git a/forge/ee/lib/mcp/tools/teams.js b/forge/ee/lib/mcp/tools/teams.js index ecc053113b..32c318e4ba 100644 --- a/forge/ee/lib/mcp/tools/teams.js +++ b/forge/ee/lib/mcp/tools/teams.js @@ -1,5 +1,14 @@ const { z } = require('zod') +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] + module.exports = [ { name: 'platform_list_teams', @@ -18,11 +27,197 @@ 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_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_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 + } + }, + { + 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 + } + }, + { + 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, + ...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`, args, [...auditLogKeys, 'scope', 'includeChildren']) + const response = await inject({ method: 'GET', url }) + return response + } + }, + { + name: 'platform_export_team_audit_log', + title: 'Export Team Audit Log', + description: `FlowFuse platform automation tool: + 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_npm_packages', + title: 'List Team NPM Packages', + description: `FlowFuse platform automation tool: + 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: { + teamId + }, + handler: async (args, { inject }) => { + const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/npm/packages` }) + return response + } + }, + { + name: 'platform_list_team_git_tokens', + title: 'List Team Git Tokens', + description: `FlowFuse platform automation tool: + 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: { + teamId + }, + handler: async (args, { inject }) => { + 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 c1740abaa9..c20a3f2d75 100644 --- a/forge/routes/auth/permissions.js +++ b/forge/routes/auth/permissions.js @@ -78,6 +78,14 @@ const IMPLICIT_TOKEN_SCOPES = { 'team:read', // get team details // tables 'team:database:list', // list/get databases, list/get tables, query table data + 'team:user:list', // list team members + 'team:user:invite', // list team invitations + 'team:create', // check team slug availability + 'team:audit-log', // get team audit log + // team data + '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/teams_spec.js b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js index 513a4bf731..89b79bc46a 100644 --- a/test/unit/forge/ee/lib/mcp/tools/teams_spec.js +++ b/test/unit/forge/ee/lib/mcp/tools/teams_spec.js @@ -14,27 +14,170 @@ describe('MCP Teams Tools', function () { inject = sinon.stub() }) - describe('platform_list_teams', function () { - const tool = getTool('platform_list_teams') + // Single-route readers: each injects one method+url and returns the response verbatim. + const passthroughTools = [ + { name: 'platform_list_teams', method: 'GET', url: '/api/v1/user/teams', args: {} }, + { name: 'platform_get_team', method: 'GET', url: '/api/v1/teams/team1', args: { teamId: 'team1' } }, + { name: 'platform_get_team_by_slug', method: 'GET', url: '/api/v1/teams/slug/my-team', args: { teamSlug: 'my-team' } }, + { name: 'platform_get_team_membership', method: 'GET', url: '/api/v1/teams/team1/user', args: { teamId: 'team1' } }, + { name: 'platform_list_team_members', method: 'GET', url: '/api/v1/teams/team1/members', args: { teamId: 'team1' } }, + { name: 'platform_list_team_invitations', method: 'GET', url: '/api/v1/teams/team1/invitations', args: { teamId: 'team1' } }, + { name: 'platform_list_team_npm_packages', method: 'GET', url: '/api/v1/teams/team1/npm/packages', args: { teamId: 'team1' } }, + { name: 'platform_list_team_git_tokens', method: 'GET', url: '/api/v1/teams/team1/git/tokens', args: { teamId: 'team1' } } + ] - it('calls the user teams endpoint and returns the response unmodified', async function () { - const injectResponse = { statusCode: 200, json: () => [{ id: 'team1' }] } - inject.resolves(injectResponse) - const response = await tool.handler({}, { inject }) - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/user/teams' }) - response.should.equal(injectResponse) + passthroughTools.forEach(({ name, method, url, args }) => { + describe(name, function () { + const tool = getTool(name) + + it('injects the right route and returns the response', async function () { + const routeResponse = { statusCode: 200, json: () => ({}) } + inject.withArgs({ method, url }).resolves(routeResponse) + + const response = await tool.handler(args, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler(args, { inject }) + response.should.equal(errorResponse) + }) + }) + }) + + describe('platform_get_team_instance_counts', function () { + const tool = getTool('platform_get_team_instance_counts') + + it('serialises instanceType, a state array and applicationId', async function () { + const routeResponse = { statusCode: 200, json: () => ({ count: 0 }) } + const url = '/api/v1/teams/team1/instance-counts?instanceType=hosted&state=running&state=stopped&applicationId=app1' + inject.withArgs({ method: 'GET', url }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1', instanceType: 'hosted', state: ['running', 'stopped'], applicationId: 'app1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('omits state and applicationId when not provided', async function () { + inject.resolves({ statusCode: 200, json: () => ({ count: 0 }) }) + + await tool.handler({ teamId: 'team1', instanceType: 'remote' }, { inject }) + + inject.firstCall.args[0].url.should.equal('/api/v1/teams/team1/instance-counts?instanceType=remote') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1', instanceType: 'hosted' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + describe('platform_check_team_slug_availability', function () { + const tool = getTool('platform_check_team_slug_availability') + + it('posts the slug payload and returns the response', async function () { + const routeResponse = { statusCode: 200, json: () => ({}) } + inject.withArgs({ method: 'POST', url: '/api/v1/teams/check-slug', payload: { slug: 'my-team' } }).resolves(routeResponse) + + const response = await tool.handler({ slug: 'my-team' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 409, json: () => ({ code: 'invalid_slug' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ slug: 'my-team' }, { inject }) + response.should.equal(errorResponse) + }) + }) + + const auditLogTools = [ + { name: 'platform_get_team_audit_log', base: '/api/v1/teams/team1/audit-log' }, + { name: 'platform_export_team_audit_log', base: '/api/v1/teams/team1/audit-log/export' } + ] + + auditLogTools.forEach(({ name, base }) => { + describe(name, function () { + const tool = getTool(name) + + it('injects the bare route when no filters are set', async function () { + const routeResponse = { statusCode: 200, json: () => ({ log: [] }) } + inject.withArgs({ method: 'GET', url: base }).resolves(routeResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('serialises cursor, limit, query, an event array, username, scope and includeChildren', async function () { + inject.resolves({ statusCode: 200, json: () => ({ log: [] }) }) + + await tool.handler({ + teamId: 'team1', + cursor: 'abc', + limit: 20, + query: 'deploy', + event: ['team.settings.updated', 'user.invited'], + username: 'alice', + scope: 'application', + includeChildren: true + }, { inject }) + + inject.firstCall.args[0].url.should.equal( + `${base}?cursor=abc&limit=20&query=deploy&event=team.settings.updated&event=user.invited&username=alice&scope=application&includeChildren=true` + ) + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) + + const response = await tool.handler({ teamId: 'team1' }, { inject }) + response.should.equal(errorResponse) + }) }) }) - describe('platform_get_team', function () { - const tool = getTool('platform_get_team') + describe('platform_list_library_entries', function () { + const tool = getTool('platform_list_library_entries') + + it('lists the library root for an empty path', async function () { + const routeResponse = { statusCode: 200, json: () => ([]) } + inject.withArgs({ method: 'GET', url: '/storage/library/team1/' }).resolves(routeResponse) + + const response = await tool.handler({ libraryId: 'team1', path: '' }, { inject }) + + inject.calledOnce.should.be.true() + response.should.equal(routeResponse) + }) + + it('appends the folder path and type filter', async function () { + inject.resolves({ statusCode: 200, json: () => ([]) }) + + await tool.handler({ libraryId: 'team1', path: 'folder1', type: 'flows' }, { inject }) + + inject.firstCall.args[0].url.should.equal('/storage/library/team1/folder1?type=flows') + }) + + it('passes through an error response', async function () { + const errorResponse = { statusCode: 404, json: () => ({ code: 'not_found' }) } + inject.resolves(errorResponse) - it('calls the team endpoint and returns the response unmodified', async function () { - const injectResponse = { statusCode: 200, json: () => ({ id: 'team1' }) } - inject.resolves(injectResponse) - const response = await tool.handler({ teamId: 'team1' }, { inject }) - inject.firstCall.args[0].should.eql({ method: 'GET', url: '/api/v1/teams/team1' }) - response.should.equal(injectResponse) + const response = await tool.handler({ libraryId: 'team1', path: '' }, { inject }) + response.should.equal(errorResponse) }) }) })