5.4-b Write tools, non-destructive (phase 2)
- Dominant language
- JavaScript
- Stars
- 400
- Forks
- 89
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 149
Description
**Parent:** #7673 (5.4 Teams and membership)
**Tool file:** `forge/ee/lib/mcp/tools/teams.js` (extend), new `forge/ee/lib/mcp/tools/members.js`
`readOnlyHint: false`, `destructiveHint: false`.
| Tool | Endpoint | Scope | Annotation |
|---|---|---|---|
| `platform_create_team` | `POST /teams` | `team:create` | write |
| `platform_update_team` | `PUT /teams/:teamId` (name/slug only) | `team:edit` | write |
| `platform_change_member_role` | `PUT /teams/:teamId/members/:userId` (role only) | `team:user:change-role` | write |
| `platform_invite_team_member` | `POST /teams/:teamId/invitations` | `team:user:invite` | write |
| `platform_resend_team_invitation` | `POST /teams/:teamId/invitations/:invitationId` | `team:user:invite` | write |
**Design notes:**
- **`platform_update_team` must expose only `name`/`slug`.** `PUT /teams/:teamId` has mutually-exclusive branches: `type` and `suspended` are lifecycle concerns, `features` is owner-restricted to `['ai']`, and **`properties` is admin-only** (tracked in 5.4-d). Do not expose those.
- **`platform_change_member_role` takes only `role`.** The route accepts `role` XOR `permissions`; the `permissions` (granular RBAC) path needs the `rbacApplication` feature. Leave granular permissions out of v1. Handle the last-owner-demotion 403 and the SSO-managed-user block as descriptive errors.
- `invite` and `resend` are kept as separate tools: invite creates a new invitation (`POST /invitations`), resend acts on an existing one (`POST /invitations/:invitationId`). They are not consolidated.
**Tool definitions (description + zod inputSchema; roles are numeric: 5=Dashboard, 10=Viewer, 30=Member, 50=Owner):**
```js
platform_create_team: {
description: 'Create a new team. A slug is generated from the name when omitted.',
inputSchema: z.object({
name: z.string().describe('Display name for the new team'),
type: z.string().describe('Team type hashid selecting the tier/plan for the team'),
slug: z.string().regex(/^[a-z0-9-_]+$/i).optional().describe('Optional team slug (URL identifier; lowercase letters, digits, hyphen and underscore)'),
trial: z.boolean().optional().describe('Request trial-mode setup (only honoured when billing is active and eligibility rules pass)'),
billingInterval: z.enum(['month', 'year']).optional().describe('Billing cycle for the subscription checkout session')
})
}
platform_update_team: {
description: 'Update a team. Only name and slug are editable through this tool; type, suspended, features and properties are not exposed (properties is admin-only).',
inputSchema: z.object({
teamId: z.string().describe('Team hashid'),
name: z.string().optional().describe('New display name for the team'),
slug: z.string().regex(/^[a-z0-9-_]+$/i).optional().describe('New team slug (URL identifier; lowercase letters, digits, hyphen and underscore)')
})
}
platform_change_member_role: {
description: 'Change a team member role. Role only; the granular-permissions path is left out of v1. Demoting the last owner returns a descriptive 403, and editing an SSO-managed member returns a descriptive block.',
inputSchema: z.object({
teamId: z.string().describe('Team hashid'),
userId: z.string().describe('User hashid of the 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')
})
}
platform_invite_team_member: {
description: 'Invite one or more people to a team. The invited role is granted when the invitation is accepted, defaulting to 30=Member when omitted.',
inputSchema: z.object({
teamId: z.string().describe('Team hashid'),
user: z.string().describe('Comma-separated list of usernames and/or email addresses to invite (maximum 5 entries per call after de-duplication)'),
role: z.union([z.literal(5), z.literal(10), z.literal(30), z.literal(50)]).optional().describe('Team role: 5=Dashboard, 10=Viewer, 30=Member, 50=Owner')
})
}
platform_resend_team_invitation: {
description: 'Resend an existing team invitation. No request body.',
inputSchema: z.object({
teamId: z.string().describe('Team hashid'),
invitationId: z.string().describe('Invitation hashid to resend')
})
}
```
**Tests:**
- Write tools rejected for read-only PAT.
- `update_team` payload cannot include `properties`/`type`/`suspended` (schema-restricted).
- `change_member_role` last-owner demotion returns the descriptive 403.
- SSO-managed member edit returns the descriptive block.
---
Contributor guide
Research direction
Start by reading forge/ee/lib/mcp/tools/teams.js and the related tool tests, then add forge/ee/lib/mcp/tools/members.js for the member operations. Run the write-tool tests, including read-only PAT rejection, schema restrictions, last-owner demotion, and SSO-managed member handling. Done means all five tools expose the specified inputs, endpoints, scopes, and annotations without exposing restricted fields or granular permissions.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- api, backend
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 64/100