Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions forge/ee/lib/mcp/tools/teams.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,127 @@ module.exports = [
const response = await inject({ method: 'GET', url: `/api/v1/teams/${args.teamId}/git/tokens` })
return response
}
},
{
name: 'platform_create_team',
title: 'Create Team',
description: `FlowFuse platform automation tool:
Creates a new team. The calling user becomes the team's owner.
type is the hashid of a team type (tier/plan); call platform_list_team_types to find one. An unknown or inactive type is rejected with "invalid_team_type".
slug is optional: when omitted, one is generated from the name. Slugs may only contain letters, digits, hyphen and underscore, must be unique across the platform, and "create" is reserved.
Non-admin users can only create teams when the platform allows self-service team creation; otherwise the call fails as unauthorized.
On platforms with billing, the response may include a billingURL - a checkout link the user must visit to activate the team's subscription. Surface that link to the user. trial requests trial-mode setup and is only honoured for brand-new users (no other teams, account under a week old) on team types with trials enabled; otherwise it is rejected.
When the calling credential is a team-scoped token, the new team falls outside that scope: the call still succeeds, but follow-up calls against the team (reading it, updating it, inviting to it) return an access error and it will not appear in platform_list_teams.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: {
name: z.string().describe('Display name for the new team'),
type: z.string().describe('The hashid of the team type (tier/plan) for the team, as returned by platform_list_team_types'),
slug: z.string().regex(/^[a-z0-9-_]+$/i).optional().describe('URL identifier for the team: letters, digits, hyphen and underscore, unique across the platform ("create" is reserved). Generated from the name when omitted'),
trial: z.boolean().optional().describe('Request trial-mode setup. Only honoured when billing is active, the team type has trials enabled, and the user is brand new; rejected otherwise'),
billingInterval: z.enum(['month', 'year']).optional().describe('Billing cycle for the subscription checkout session, on platforms with billing')
},
handler: async (args, { inject }) => {
const payload = { name: args.name, type: args.type }
for (const key of ['slug', 'trial', 'billingInterval']) {
if (args[key] !== undefined) {
payload[key] = args[key]
}
}
const response = await inject({ method: 'POST', url: '/api/v1/teams', payload })
return response
}
},
{
name: 'platform_update_team',
title: 'Update Team',
description: `FlowFuse platform automation tool:
Renames a team and/or changes its slug. Only the fields you pass are changed; omitted (or empty) fields keep their stored value. Pass at least one of name or slug.
Changing the slug changes the team's URLs. Slugs may only contain letters, digits, hyphen and underscore, must be unique, and "create" is reserved - conflicts are rejected with a 400.
Team type, suspension, feature toggles and properties are deliberately not editable through this tool.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
name: z.string().min(1).optional().describe('New display name for the team'),
slug: z.string().regex(/^[a-z0-9-_]+$/i).optional().describe('New URL identifier for the team: letters, digits, hyphen and underscore, unique across the platform ("create" is reserved). Changing it changes the team URLs')
},
handler: async (args, { inject }) => {
const payload = {}
if (args.name !== undefined) {
payload.name = args.name
}
if (args.slug !== undefined) {
payload.slug = args.slug
}
// The route replies 200 with the unchanged team when there is nothing
// to update, which reads as a successful edit that never happened.
if (Object.keys(payload).length === 0) {
return toolError(400, 'invalid_request', 'Pass at least one of name or slug to update')
}
const response = await inject({ method: 'PUT', url: `/api/v1/teams/${args.teamId}`, payload })
return response
}
},
{
name: 'platform_change_member_role',
title: 'Change Team Member Role',
description: `FlowFuse platform automation tool:
Changes an existing team member's role. Roles are numeric: 5=Dashboard, 10=Viewer, 30=Member, 50=Owner.
CAUTION: demoting a team's only owner is blocked when the new role is Member, but NOT when it is Viewer or Dashboard. Those succeed and leave the team with no owner at all, after which nobody but a platform admin can restore one, delete the team, or invite anyone to it. Check platform_list_team_members for how many owners a team has before demoting one, and confirm with the user first.
Members whose team membership is managed through SSO cannot be changed here; that fails with "Cannot modify team membership for an SSO managed user".
Other disallowed changes come back as a generic 403 "invalid_request" without detail - the usual causes are the user not being a member of the team, or demoting the only owner to Member. Check membership with platform_list_team_members first when unsure.
Setting the role the member already has succeeds as a no-op.`,
// destructiveHint: a single call can permanently orphan a team (see the
// CAUTION above), which is less recoverable than anything else here, so it
// belongs behind destructive tool access rather than plain write.
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
userId: z.string().describe('The hashid of the team member whose role is changing'),
role: z.union([z.literal(5), z.literal(10), z.literal(30), z.literal(50)]).describe('New team role: 5=Dashboard, 10=Viewer, 30=Member, 50=Owner')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'PUT', url: `/api/v1/teams/${args.teamId}/members/${args.userId}`, payload: { role: args.role } })
return response
}
},
{
name: 'platform_invite_team_member',
title: 'Invite Team Member',
description: `FlowFuse platform automation tool:
Invites people to join a team, by username (existing platform users) or email address. The role is granted when the invitation is accepted; it defaults to 30=Member.
Up to 5 people can be invited per call (after de-duplication); more returns a 429 "too_many_invites".
Read both the status and the body. A fully successful call returns { status: "okay" }. Per-person failures (unknown user, already a member, already invited, email restrictions) come back as HTTP 200 with code "invitation_failed" and error as an object mapping each failed entry to its reason - treat those entries as NOT invited. A call rejected outright, for example because the team's user limit is reached, comes back as HTTP 400, also with code "invitation_failed", but error is a plain string and nobody was invited.
The route is also rate limited to 5 calls per 30 seconds, which is a second, different 429: "too_many_invites" means more than 5 invitees in one call and retrying unchanged will never work, while a rate-limit 429 clears on its own after a few seconds.
Email invitations to people without an account depend on the platform allowing external invitations and having email configured.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
inputSchema: {
teamId,
user: z.string().describe('Comma-separated list of usernames and/or email addresses to invite (maximum 5 per call after de-duplication)'),
role: z.union([z.literal(5), z.literal(10), z.literal(30), z.literal(50)]).optional().describe('Team role granted on acceptance: 5=Dashboard, 10=Viewer, 30=Member, 50=Owner. Defaults to 30=Member')
},
handler: async (args, { inject }) => {
const payload = { user: args.user }
if (args.role !== undefined) {
payload.role = args.role
}
const response = await inject({ method: 'POST', url: `/api/v1/teams/${args.teamId}/invitations`, payload })
return response
}
},
{
name: 'platform_resend_team_invitation',
title: 'Resend Team Invitation',
description: `FlowFuse platform automation tool:
Resends a pending team invitation email and extends the invitation's expiry date. Use platform_list_team_invitations to find the invitation id.
Returns 404 when the invitation does not exist or belongs to a different team. Resends are rate-limited on platforms with rate limits enabled.`,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
inputSchema: {
teamId,
invitationId: z.string().describe('The hashid of the invitation to resend, as returned by platform_list_team_invitations')
},
handler: async (args, { inject }) => {
const response = await inject({ method: 'POST', url: `/api/v1/teams/${args.teamId}/invitations/${args.invitationId}` })
return response
}
}
]
159 changes: 159 additions & 0 deletions test/unit/forge/ee/lib/mcp/tools/teams_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,163 @@ describe('MCP Teams Tools', function () {
response.should.equal(errorResponse)
})
})

describe('platform_create_team', function () {
const tool = getTool('platform_create_team')

it('posts the team payload and returns the response', async function () {
const routeResponse = { statusCode: 200, json: () => ({ id: 'team1' }) }
inject.withArgs({ method: 'POST', url: '/api/v1/teams', payload: { name: 'New Team', type: 'type1', slug: 'new-team' } }).resolves(routeResponse)

const response = await tool.handler({ name: 'New Team', type: 'type1', slug: 'new-team' }, { inject })

inject.calledOnce.should.be.true()
response.should.equal(routeResponse)
})

it('only sends the optional fields that were provided', async function () {
inject.resolves({ statusCode: 200, json: () => ({ id: 'team1' }) })

await tool.handler({ name: 'New Team', type: 'type1' }, { inject })

inject.firstCall.args[0].payload.should.eql({ name: 'New Team', type: 'type1' })
})

it('forwards trial and billingInterval when provided', async function () {
inject.resolves({ statusCode: 200, json: () => ({ id: 'team1' }) })

await tool.handler({ name: 'New Team', type: 'type1', trial: true, billingInterval: 'year' }, { inject })

inject.firstCall.args[0].payload.should.eql({ name: 'New Team', type: 'type1', trial: true, billingInterval: 'year' })
})

it('passes through an error response', async function () {
const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_team_type' }) }
inject.resolves(errorResponse)

const response = await tool.handler({ name: 'New Team', type: 'nope' }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_update_team', function () {
const tool = getTool('platform_update_team')

it('puts name and slug onto the team route', async function () {
const routeResponse = { statusCode: 200, json: () => ({ id: 'team1' }) }
inject.withArgs({ method: 'PUT', url: '/api/v1/teams/team1', payload: { name: 'Renamed', slug: 'renamed' } }).resolves(routeResponse)

const response = await tool.handler({ teamId: 'team1', name: 'Renamed', slug: 'renamed' }, { inject })

inject.calledOnce.should.be.true()
response.should.equal(routeResponse)
})

it('only sends the fields that were provided', async function () {
inject.resolves({ statusCode: 200, json: () => ({ id: 'team1' }) })

await tool.handler({ teamId: 'team1', name: 'Renamed' }, { inject })

inject.firstCall.args[0].payload.should.eql({ name: 'Renamed' })
})

it('never exposes type, suspended, features or properties', function () {
Object.keys(tool.inputSchema).should.eql(['teamId', 'name', 'slug'])
})

it('rejects an update with nothing to change, which the route answers 200 to', async function () {
const response = await tool.handler({ teamId: 'team1' }, { inject })

inject.called.should.be.false()
response.statusCode.should.equal(400)
response.json().code.should.equal('invalid_request')
})

it('passes through an error response', async function () {
const errorResponse = { statusCode: 400, json: () => ({ code: 'invalid_slug' }) }
inject.resolves(errorResponse)

const response = await tool.handler({ teamId: 'team1', slug: 'create' }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_change_member_role', function () {
const tool = getTool('platform_change_member_role')

it('puts the role onto the team member route', async function () {
const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) }
inject.withArgs({ method: 'PUT', url: '/api/v1/teams/team1/members/user1', payload: { role: 50 } }).resolves(routeResponse)

const response = await tool.handler({ teamId: 'team1', userId: 'user1', role: 50 }, { inject })

inject.calledOnce.should.be.true()
response.should.equal(routeResponse)
})

it('rejects a role outside the team role set', function () {
tool.inputSchema.role.safeParse(99).success.should.be.false()
tool.inputSchema.role.safeParse(30).success.should.be.true()
})

it('passes through an error response', async function () {
const errorResponse = { statusCode: 403, json: () => ({ code: 'invalid_request' }) }
inject.resolves(errorResponse)

const response = await tool.handler({ teamId: 'team1', userId: 'user1', role: 30 }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_invite_team_member', function () {
const tool = getTool('platform_invite_team_member')

it('posts the invitation payload and returns the response', async function () {
const routeResponse = { statusCode: 200, json: () => ({ status: 'okay' }) }
inject.withArgs({ method: 'POST', url: '/api/v1/teams/team1/invitations', payload: { user: 'alice, bob@example.com', role: 10 } }).resolves(routeResponse)

const response = await tool.handler({ teamId: 'team1', user: 'alice, bob@example.com', role: 10 }, { inject })

inject.calledOnce.should.be.true()
response.should.equal(routeResponse)
})

it('omits role from the payload when not provided', async function () {
inject.resolves({ statusCode: 200, json: () => ({ status: 'okay' }) })

await tool.handler({ teamId: 'team1', user: 'alice' }, { inject })

inject.firstCall.args[0].payload.should.eql({ user: 'alice' })
})

it('passes through an error response', async function () {
const errorResponse = { statusCode: 429, json: () => ({ code: 'too_many_invites' }) }
inject.resolves(errorResponse)

const response = await tool.handler({ teamId: 'team1', user: 'a,b,c,d,e,f' }, { inject })
response.should.equal(errorResponse)
})
})

describe('platform_resend_team_invitation', function () {
const tool = getTool('platform_resend_team_invitation')

it('posts to the invitation route without a payload', async function () {
const routeResponse = { statusCode: 200, json: () => ({ id: 'invite1' }) }
inject.withArgs({ method: 'POST', url: '/api/v1/teams/team1/invitations/invite1' }).resolves(routeResponse)

const response = await tool.handler({ teamId: 'team1', invitationId: 'invite1' }, { 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({ teamId: 'team1', invitationId: 'invite1' }, { inject })
response.should.equal(errorResponse)
})
})
})
Loading