5.1-b Write tools, non-destructive (phase 2)
- Dominant language
- JavaScript
- Stars
- 400
- Forks
- 89
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 146
Description
**Parent:** #7670 (5.1 Instance lifecycle and actions)
**Tool file:** `forge/ee/lib/mcp/tools/instances.js` (extend), `forge/ee/lib/mcp/tools/devices.js` (extend)
`readOnlyHint: false`, `destructiveHint: false`. Sibling actions that share one concept are consolidated into a single tool with an `action` enum, so one tool covers several state transitions.
Instances:
| Tool | Endpoint(s) | Scope | Annotation |
|---|---|---|---|
| `platform_instance_action` (`instanceType`: `hosted`\|`remote`; `action`: `start`\|`stop`\|`restart`\|`suspend`\|`restartStack`) | hosted: `POST /projects/:id/actions/{action}`; remote: `POST /devices/:id/actions/restart` | `project:change-status` (hosted), `device:change-status` (remote) | write |
Hosted instances:
| Tool | Endpoint(s) | Scope | Annotation |
|---|---|---|---|
| `platform_update_hosted_instance_env` | `PUT /projects/:id` (body `{settings:{env}}` only) | `project:edit-env` | write |
| `platform_update_hosted_instance_settings` | `PUT /projects/:id` (full settings) | `project:edit` | write |
| `platform_import_hosted_instance_flows` | `POST /projects/:id/import` | `project:edit` | write |
Remote instances (devices):
| Tool | Endpoint(s) | Scope | Annotation |
|---|---|---|---|
| `platform_update_remote_instance_settings` | `PUT /devices/:id/settings` | `device:edit-env` | write |
| `platform_set_remote_instance_mode` | `PUT /devices/:id/mode` | `device:editor` | write |
**Consolidation notes:**
- `platform_instance_action` covers both hosted and remote instances through an `instanceType` discriminator, mirroring the read tools. Hosted instances accept the full action set; remote instances (devices) expose only `restart`, and the handler rejects any other action for `instanceType: 'remote'` with a descriptive error. It routes to `POST /projects/:id/actions/{action}` (`project:change-status`) or `POST /devices/:id/actions/restart` (`device:change-status`) based on `instanceType`.
**Design notes:**
- **Split env vs full settings** for hosted instances: `PUT /projects/:id` requires `project:edit-env` only when the body is exactly `{settings:{env}}`, else `project:edit` (and the handler narrows env-only writes). Two distinct tools keep annotations and scopes unambiguous. The env tool exposes a flat `env` list; its handler wraps it into exactly `{settings:{env}}` so the narrower edit-env path is selected, and the agent never sees the wrapper.
- **State guards:** start/stop/restart return 400 when the instance is suspended. Surface these as descriptive errors.
- `platform_set_remote_instance_mode` is license-gated (returns 400 `not_licensed` without an active license) and only accepts `autonomous`/`developer`.
- `update_remote_instance_settings` is owner-vs-member field-restricted: Members may write only `env`/`autoSnapshot`. Document this.
**Tool definitions (description + zod inputSchema):**
```js
platform_instance_action: {
description: 'Applies a lifecycle action to an instance. instanceType selects the target: hosted instances accept start/stop/restart/suspend/restartStack (start/stop/restart return 400 when the instance is suspended); remote instances (devices) accept only restart. Takes no request body.',
inputSchema: z.object({
instanceId: z.string().describe('Instance id: hosted instance UUID, or remote instance (device) hashid'),
instanceType: z.enum(['hosted', 'remote']).describe('Whether instanceId refers to a hosted instance or a remote instance (device)'),
action: z.enum(['start', 'stop', 'restart', 'suspend', 'restartStack']).describe('Lifecycle action to apply; remote instances accept only restart')
})
}
platform_update_hosted_instance_env: {
description: 'Replaces the full set of environment variables on a hosted instance.',
inputSchema: z.object({
instanceId: z.string().uuid().describe('UUID of the hosted instance whose env is updated'),
env: z.array(z.object({
name: z.string().describe('Environment variable name'),
value: z.string().describe('Environment variable value'),
hidden: z.boolean().optional().describe('Whether the value is masked in the UI')
})).describe('Full replacement list of instance environment variables')
})
}
platform_update_hosted_instance_settings: {
description: 'Updates a hosted instance: display name, hostname, general settings, launcher settings, instance type or stack, or copies configuration and flows from a source instance.',
inputSchema: z.object({
instanceId: z.string().uuid().describe('UUID of the hosted instance to update'),
name: z.string().optional().describe('New display name for the instance'),
hostname: z.string().optional().describe('New hostname for the instance'),
settings: z.record(z.any()).optional().describe('Instance settings object (env, palette, editor, and so on)'),
launcherSettings: z.object({
healthCheckInterval: z.number().optional().describe('Launcher health-check interval in milliseconds'),
disableAutoSafeMode: z.boolean().optional().describe('Whether to disable automatic safe mode after repeated crashes')
}).optional().describe('Node-RED launcher settings'),
projectType: z.string().optional().describe('Opaque hashid of the instance type to switch to'),
stack: z.string().optional().describe('Opaque hashid of the stack to switch to'),
sourceProject: z.object({
id: z.string().uuid().describe('UUID of the source instance to copy from'),
options: z.record(z.any()).optional().describe('Flags selecting which parts to copy (flows, credentials, env, and so on)')
}).optional().describe('Source instance to copy configuration and flows from')
})
}
platform_import_hosted_instance_flows: {
description: 'Imports flows into a hosted instance, optionally with credentials and the secret needed to decrypt them.',
inputSchema: z.object({
instanceId: z.string().uuid().describe('UUID of the hosted instance to import flows into'),
flows: z.string().optional().describe('Flows JSON serialized as a string'),
credentials: z.string().optional().describe('Flow credentials JSON serialized as a string'),
credsSecret: z.string().optional().describe('Secret used to decrypt the supplied credentials')
})
}
platform_update_remote_instance_settings: {
description: 'Updates a remote instance (device) settings. Field-restricted by role: Members may write only env and autoSnapshot, while palette, editor, and security require owner role.',
inputSchema: z.object({
deviceId: z.string().describe('Opaque hashid of the remote instance (device) to update'),
env: z.array(z.record(z.any())).optional().describe('Environment variables as {name, value, hidden?} entries'),
autoSnapshot: z.boolean().optional().describe('Whether to auto-create a snapshot on each deploy'),
palette: z.record(z.any()).optional().describe('Palette settings'),
editor: z.record(z.any()).optional().describe('Editor settings'),
security: z.record(z.any()).optional().describe('Security settings such as httpNodeAuth or localAuth')
})
}
platform_set_remote_instance_mode: {
description: 'Sets the operating mode of a remote instance (device). License-gated: returns 400 not_licensed without an active license. When mode is omitted or null it defaults to autonomous.',
inputSchema: z.object({
deviceId: z.string().describe('Opaque hashid of the remote instance (device)'),
mode: z.enum(['autonomous', 'developer']).optional().describe('Device operating mode')
})
}
```
**Tests:**
- `platform_instance_action` routes each hosted enum value to the correct `POST /projects/:id/actions/{action}` route, and `instanceType: 'remote'` to `POST /devices/:id/actions/restart`.
- A non-restart action with `instanceType: 'remote'` returns the descriptive error.
- Read-only PAT is rejected for every tool here.
- Suspended-instance start/stop returns the descriptive 400.
- `set_remote_instance_mode` without a license returns the descriptive gate error.
---
Contributor guide
Research direction
Start in forge/ee/lib/mcp/tools/instances.js and forge/ee/lib/mcp/tools/devices.js, then trace the existing read tools and endpoint handlers. Run the MCP tool tests covering hosted action routing, remote restart validation, read-only PAT rejection, suspended-instance errors, and license gating. Done means all six tools, scopes, role restrictions, state guards, and documented error paths behave as specified.
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
- 68/100