@francescomalatesta/laravel-forge-mcp 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +28 -0
  2. package/dist/forge/client.js +6 -2
  3. package/dist/tools/background-processes/create-background-process.js +81 -0
  4. package/dist/tools/background-processes/delete-background-process.js +41 -0
  5. package/dist/tools/background-processes/get-background-process-log.js +34 -0
  6. package/dist/tools/background-processes/list-background-processes.js +58 -0
  7. package/dist/tools/background-processes/run-background-process-action.js +55 -0
  8. package/dist/tools/background-processes/shared.js +32 -0
  9. package/dist/tools/background-processes/update-background-process.js +34 -0
  10. package/dist/tools/backups/create-backup-configuration.js +4 -3
  11. package/dist/tools/backups/create-backup.js +4 -3
  12. package/dist/tools/backups/shared.js +0 -5
  13. package/dist/tools/commands/get-site-command.js +2 -1
  14. package/dist/tools/commands/run-site-command.js +2 -1
  15. package/dist/tools/commands/shared.js +1 -22
  16. package/dist/tools/firewall/create-firewall-rule.js +58 -0
  17. package/dist/tools/firewall/delete-firewall-rule.js +41 -0
  18. package/dist/tools/firewall/list-firewall-rules.js +59 -0
  19. package/dist/tools/firewall/shared.js +22 -0
  20. package/dist/tools/redirects/create-redirect-rule.js +55 -0
  21. package/dist/tools/redirects/delete-redirect-rule.js +40 -0
  22. package/dist/tools/redirects/export-redirect-rules.js +30 -0
  23. package/dist/tools/redirects/import-redirect-rules.js +56 -0
  24. package/dist/tools/redirects/list-redirect-rules.js +53 -0
  25. package/dist/tools/redirects/reorder-redirect-rules.js +34 -0
  26. package/dist/tools/redirects/shared.js +24 -0
  27. package/dist/tools/registry.js +58 -0
  28. package/dist/tools/scheduled-jobs/create-scheduled-job.js +83 -0
  29. package/dist/tools/scheduled-jobs/delete-scheduled-job.js +42 -0
  30. package/dist/tools/scheduled-jobs/get-scheduled-job.js +39 -0
  31. package/dist/tools/scheduled-jobs/list-scheduled-jobs.js +44 -0
  32. package/dist/tools/scheduled-jobs/shared.js +41 -0
  33. package/dist/tools/security-rules/create-security-rule.js +58 -0
  34. package/dist/tools/security-rules/delete-security-rule.js +40 -0
  35. package/dist/tools/security-rules/list-security-rules.js +49 -0
  36. package/dist/tools/security-rules/shared.js +27 -0
  37. package/dist/tools/security-rules/update-security-rule.js +58 -0
  38. package/dist/tools/shared/async.js +4 -0
  39. package/dist/tools/shared/output.js +23 -0
  40. package/dist/tools/shared/read.js +13 -1
  41. package/dist/tools/ssh-keys/add-ssh-key.js +61 -0
  42. package/dist/tools/ssh-keys/delete-ssh-key.js +41 -0
  43. package/dist/tools/ssh-keys/get-server-public-key.js +27 -0
  44. package/dist/tools/ssh-keys/list-ssh-keys.js +48 -0
  45. package/dist/tools/ssh-keys/regenerate-server-key.js +30 -0
  46. package/dist/tools/ssh-keys/shared.js +24 -0
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -164,6 +164,34 @@ npm run build
164
164
  | `forge_delete_deploy_key` | deployments | Remove the deploy key |
165
165
  | `forge_get_deploy_hook` 🔑 | deployments | The deployment trigger URL |
166
166
  | `forge_regenerate_deploy_hook` 🔑 | deployments | Generate a new deployment trigger URL |
167
+ | `forge_list_scheduled_jobs` | jobs | Scheduled jobs (cron) of a server or a site |
168
+ | `forge_get_scheduled_job` | jobs | A scheduled job with the output of its last run |
169
+ | `forge_create_scheduled_job` | jobs | Schedule a command, optionally with a heartbeat |
170
+ | `forge_delete_scheduled_job` | jobs | Remove a scheduled job |
171
+ | `forge_list_background_processes` | jobs | Supervisor daemons (queue workers, …) and their status |
172
+ | `forge_get_background_process_log` | jobs | End of a background process log |
173
+ | `forge_create_background_process` | jobs | Run a command permanently under Supervisor and wait until it is running |
174
+ | `forge_update_background_process` | jobs | Rename or replace the Supervisor configuration |
175
+ | `forge_run_background_process_action` | jobs | Start, stop or restart a background process, or empty its log |
176
+ | `forge_delete_background_process` | jobs | Stop and remove a background process |
177
+ | `forge_list_firewall_rules` | security | Open and blocked ports of a server |
178
+ | `forge_create_firewall_rule` | security | Allow or deny a port (range) for everyone or an IP/subnet |
179
+ | `forge_delete_firewall_rule` | security | Remove a firewall rule |
180
+ | `forge_list_security_rules` | security | Password-protected (basic auth) paths of a site |
181
+ | `forge_create_security_rule` | security | Protect a site or a path with a password |
182
+ | `forge_update_security_rule` | security | Change name, path or users of a security rule |
183
+ | `forge_delete_security_rule` | security | Remove a password protection |
184
+ | `forge_list_redirect_rules` | security | Redirect rules of a site |
185
+ | `forge_create_redirect_rule` | security | Add a 301 or 302 redirect |
186
+ | `forge_delete_redirect_rule` | security | Remove a redirect |
187
+ | `forge_reorder_redirect_rules` | security | Set the evaluation order of the redirects |
188
+ | `forge_export_redirect_rules` | security | Redirects as CSV, in evaluation order |
189
+ | `forge_import_redirect_rules` | security | Import redirects from CSV (append or replace) |
190
+ | `forge_list_ssh_keys` | security | SSH keys authorized on a server |
191
+ | `forge_add_ssh_key` | security | Authorize a public key |
192
+ | `forge_delete_ssh_key` | security | Revoke an SSH key |
193
+ | `forge_get_server_public_key` | security | The server's own public key (for Git providers, other servers) |
194
+ | `forge_regenerate_server_key` | security | Replace the server's own key pair |
167
195
  | `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
168
196
  | `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
169
197
  | `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
@@ -78,11 +78,15 @@ export class ForgeClient {
78
78
  send(method, url, options) {
79
79
  const headers = {
80
80
  Authorization: `Bearer ${this.apiToken}`,
81
- Accept: 'application/json',
81
+ Accept: options.accept ?? 'application/json',
82
82
  'User-Agent': this.userAgent,
83
83
  };
84
84
  let body;
85
- if (options.body !== undefined) {
85
+ if (options.body instanceof FormData) {
86
+ // fetch sets the multipart Content-Type with its boundary.
87
+ body = options.body;
88
+ }
89
+ else if (options.body !== undefined) {
86
90
  headers['Content-Type'] = 'application/json';
87
91
  body = JSON.stringify(options.body);
88
92
  }
@@ -0,0 +1,81 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
5
+ import { readResource } from '../shared/read.js';
6
+ import { idInput, organizationInput, serverInput } from '../shared/schemas.js';
7
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
8
+ import { backgroundProcessOutput, backgroundProcessesPath, formatBackgroundProcess, processPhase } from './shared.js';
9
+ const CHECK_WITH = 'forge_list_background_processes';
10
+ export const createBackgroundProcess = defineTool({
11
+ name: 'forge_create_background_process',
12
+ title: 'Create background process',
13
+ description: 'Run a command permanently in the background under Supervisor (e.g. `php artisan queue:work`), restarting it when it exits. For Horizon, Octane, Reverb, Pulse or Inertia SSR prefer forge_enable_site_integration. Waits until it is running unless `wait` is false.',
14
+ toolset: 'jobs',
15
+ operations: ['organizations.servers.background-processes.store', 'organizations.servers.background-processes.show'],
16
+ permissions: ['server:create-daemons', 'server:view'],
17
+ readOnly: false,
18
+ destructive: false,
19
+ idempotent: false,
20
+ async: true,
21
+ notFoundHint: SERVER_NOT_FOUND_HINT,
22
+ inputSchema: {
23
+ organization: organizationInput,
24
+ server: serverInput,
25
+ name: z.string().min(1).describe('Name of the background process, e.g. "Queue worker".'),
26
+ command: z.string().min(1).describe('Command to run, e.g. "php artisan queue:work --tries=3".'),
27
+ user: z.enum(['forge', 'root']).default('forge').describe('User the process runs as.'),
28
+ site: idInput('Site the process belongs to.').optional(),
29
+ directory: z.string().min(1).optional().describe('Working directory, e.g. "/home/forge/example.com/current".'),
30
+ processes: z.number().int().min(1).default(1).describe('Number of processes to keep running.'),
31
+ startsecs: z.number().int().min(0).optional().describe('Seconds the process must stay up to be considered started.'),
32
+ stopwaitsecs: z.number().int().min(0).optional().describe('Seconds to wait for a graceful stop before killing it.'),
33
+ stopsignal: z.string().min(1).optional().describe('Signal sent to stop it, e.g. "SIGTERM".'),
34
+ ...waitInput(120),
35
+ },
36
+ outputSchema: {
37
+ ...operationOutput,
38
+ background_process: backgroundProcessOutput.nullable(),
39
+ },
40
+ async handler(args, { client, organization, signal, sleep, progress }) {
41
+ const base = backgroundProcessesPath(organization(args.organization), args.server);
42
+ const response = await client.post(base, {
43
+ body: {
44
+ name: args.name,
45
+ site_id: args.site === undefined ? undefined : Number(args.site),
46
+ command: args.command,
47
+ user: args.user,
48
+ directory: args.directory,
49
+ processes: args.processes,
50
+ startsecs: args.startsecs,
51
+ stopwaitsecs: args.stopwaitsecs,
52
+ stopsignal: args.stopsignal,
53
+ },
54
+ signal,
55
+ });
56
+ const initial = response.data?.data ? flattenSingle(response.data) : undefined;
57
+ const action = `start \`${args.command}\` in the background`;
58
+ if (!initial || !args.wait) {
59
+ return {
60
+ structured: { status: 'queued', check_with: CHECK_WITH, background_process: initial ? formatBackgroundProcess(initial) : null },
61
+ summary: `Forge accepted the request to ${action}. Check it with ${CHECK_WITH}.`,
62
+ };
63
+ }
64
+ const result = await waitFor({
65
+ initial,
66
+ poll: () => readResource(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
67
+ phase: (process) => processPhase(process.status, 'running'),
68
+ describe: (process) => `Background process is ${process.status}`,
69
+ timeoutSeconds: args.timeout_seconds,
70
+ context: { sleep, progress },
71
+ });
72
+ const process = formatBackgroundProcess(result.value ?? initial);
73
+ const done = outcome(result, {
74
+ action,
75
+ checkWith: CHECK_WITH,
76
+ timeoutSeconds: args.timeout_seconds,
77
+ detail: `Process ID: ${process.id}.${result.status === 'failed' ? ' Read why with forge_get_background_process_log.' : ''}`,
78
+ });
79
+ return { structured: { ...done.structured, background_process: process }, summary: done.summary };
80
+ },
81
+ });
@@ -0,0 +1,41 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { BACKGROUND_PROCESS_NOT_FOUND_HINT, backgroundProcessInput, backgroundProcessPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_background_processes';
7
+ export const deleteBackgroundProcess = defineTool({
8
+ name: 'forge_delete_background_process',
9
+ title: 'Delete background process',
10
+ description: 'Stop a background process and remove it from Supervisor.',
11
+ toolset: 'jobs',
12
+ operations: ['organizations.servers.background-processes.destroy', 'organizations.servers.background-processes.show'],
13
+ permissions: ['server:delete-daemons', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: BACKGROUND_PROCESS_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ background_process: backgroundProcessInput,
23
+ ...waitInput(60),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const path = backgroundProcessPath(organization(args.organization), args.server, args.background_process);
28
+ await client.delete(path, { signal });
29
+ const action = `delete background process ${args.background_process}`;
30
+ if (!args.wait)
31
+ return queued(action, CHECK_WITH);
32
+ const result = await waitFor({
33
+ poll: () => orGone(() => readResource(client, path, signal)),
34
+ phase: (process) => (process === null ? 'completed' : 'pending'),
35
+ describe: (process) => `Background process is ${process?.status ?? 'removing'}`,
36
+ timeoutSeconds: args.timeout_seconds,
37
+ context: { sleep, progress },
38
+ });
39
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
40
+ },
41
+ });
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { organizationInput, serverInput, tail } from '../shared/schemas.js';
5
+ import { BACKGROUND_PROCESS_NOT_FOUND_HINT, backgroundProcessInput, backgroundProcessPath } from './shared.js';
6
+ export const getBackgroundProcessLog = defineTool({
7
+ name: 'forge_get_background_process_log',
8
+ title: 'Get background process log',
9
+ description: 'Read the end of the log of a background process (what the worker printed, crash traces). Empty it with forge_run_background_process_action.',
10
+ toolset: 'jobs',
11
+ operations: ['organizations.servers.background-processes.log.show'],
12
+ permissions: ['server:create-daemons'],
13
+ readOnly: true,
14
+ notFoundHint: BACKGROUND_PROCESS_NOT_FOUND_HINT,
15
+ inputSchema: {
16
+ organization: organizationInput,
17
+ server: serverInput,
18
+ background_process: backgroundProcessInput,
19
+ lines: z.number().int().min(0).max(5000).default(100).describe('Return only the last N lines (0 = whole log).'),
20
+ },
21
+ outputSchema: {
22
+ log: z.string(),
23
+ truncated: z.boolean(),
24
+ total_lines: z.number().int(),
25
+ },
26
+ async handler(args, { client, organization, signal }) {
27
+ const path = `${backgroundProcessPath(organization(args.organization), args.server, args.background_process)}/log`;
28
+ const log = tail((await readResource(client, path, signal)).content, args.lines);
29
+ return {
30
+ structured: { log: log.text, truncated: log.truncated, total_lines: log.total_lines },
31
+ summary: log.total_lines === 0 ? 'The log is empty.' : `Showing ${log.truncated ? `the last ${args.lines} of ` : ''}${log.total_lines} line(s).`,
32
+ };
33
+ },
34
+ });
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { readResource } from '../shared/read.js';
5
+ import { idInput, organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
6
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
7
+ import { backgroundProcessInput, backgroundProcessOutput, backgroundProcessPath, backgroundProcessesPath, formatBackgroundProcess, } from './shared.js';
8
+ export const listBackgroundProcesses = defineTool({
9
+ name: 'forge_list_background_processes',
10
+ title: 'List background processes',
11
+ description: 'List the background processes (Supervisor daemons such as queue workers, Horizon or Reverb) on a server with their status, or get one with `background_process`. Filter by site, user or directory.',
12
+ toolset: 'jobs',
13
+ operations: ['organizations.servers.background-processes.index', 'organizations.servers.background-processes.show'],
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SERVER_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ background_process: backgroundProcessInput.optional().describe('Return only this background process.'),
21
+ site: idInput('Only processes of this site.').optional(),
22
+ user: z.string().min(1).optional().describe('Only processes running as this user.'),
23
+ directory: z.string().min(1).optional().describe('Only processes running in this directory.'),
24
+ sort: z.array(z.enum(['user', '-user'])).min(1).optional(),
25
+ ...paginationInput,
26
+ },
27
+ outputSchema: {
28
+ background_processes: z.array(backgroundProcessOutput),
29
+ ...paginationOutput,
30
+ },
31
+ async handler(args, { client, organization, signal }) {
32
+ const org = organization(args.organization);
33
+ if (args.background_process !== undefined) {
34
+ const process = formatBackgroundProcess(await readResource(client, backgroundProcessPath(org, args.server, args.background_process), signal));
35
+ return {
36
+ structured: { background_processes: [process], next_cursor: null, has_more: false },
37
+ summary: `\`${process.command}\` is ${process.status}.`,
38
+ };
39
+ }
40
+ const response = await client.get(backgroundProcessesPath(org, args.server), {
41
+ query: {
42
+ filter: { site_id: args.site === undefined ? undefined : String(args.site), user: args.user, directory: args.directory },
43
+ sort: args.sort,
44
+ page: { size: args.page_size, cursor: args.cursor },
45
+ },
46
+ signal,
47
+ });
48
+ const page = flattenCollection(response.data);
49
+ const processes = page.items.map(formatBackgroundProcess);
50
+ const notRunning = processes.filter((process) => process.status !== 'running');
51
+ return {
52
+ structured: { background_processes: processes, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
53
+ summary: processes.length === 0
54
+ ? 'No background processes found.'
55
+ : `Found ${processes.length} background process(es)${notRunning.length > 0 ? `; not running: ${notRunning.map((p) => `${p.id} (${p.status})`).join(', ')}` : ', all running'}.${paginationSummary(page.nextCursor)}`,
56
+ };
57
+ },
58
+ });
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { readResource } from '../shared/read.js';
5
+ import { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { BACKGROUND_PROCESS_NOT_FOUND_HINT, backgroundProcessInput, backgroundProcessOutput, backgroundProcessPath, formatBackgroundProcess, processPhase, } from './shared.js';
7
+ const CHECK_WITH = 'forge_list_background_processes';
8
+ const TARGET = { start: 'running', stop: 'stopped' };
9
+ export const runBackgroundProcessAction = defineTool({
10
+ name: 'forge_run_background_process_action',
11
+ title: 'Run background process action',
12
+ description: 'Start, stop or restart a background process (e.g. restart queue workers after changing code outside a deployment), or empty its log. Waits for start and stop; restart and empty-log cannot be observed through the API.',
13
+ toolset: 'jobs',
14
+ operations: ['organizations.servers.background-processes.actions.store', 'organizations.servers.background-processes.show'],
15
+ permissions: ['server:create-daemons', 'server:view'],
16
+ readOnly: false,
17
+ destructive: true,
18
+ idempotent: false,
19
+ async: true,
20
+ notFoundHint: BACKGROUND_PROCESS_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ organization: organizationInput,
23
+ server: serverInput,
24
+ background_process: backgroundProcessInput,
25
+ action: z.enum(['restart', 'start', 'stop', 'empty-log']).describe('Action to run.'),
26
+ ...waitInput(60),
27
+ },
28
+ outputSchema: {
29
+ ...operationOutput,
30
+ background_process: backgroundProcessOutput.nullable(),
31
+ },
32
+ async handler(args, { client, organization, signal, sleep, progress }) {
33
+ const path = backgroundProcessPath(organization(args.organization), args.server, args.background_process);
34
+ await client.post(`${path}/actions`, { body: { action: args.action }, signal });
35
+ const action = `${args.action} background process ${args.background_process}`;
36
+ const target = args.action === 'start' || args.action === 'stop' ? TARGET[args.action] : undefined;
37
+ if (!target || !args.wait) {
38
+ // A restart ends in "running" like before it, and the log content is not compared: nothing to wait for.
39
+ const accepted = queued(action, CHECK_WITH);
40
+ return { ...accepted, structured: { ...accepted.structured, background_process: null } };
41
+ }
42
+ const result = await waitFor({
43
+ poll: () => readResource(client, path, signal),
44
+ phase: (process) => processPhase(process.status, target),
45
+ describe: (process) => `Background process is ${process.status}`,
46
+ timeoutSeconds: args.timeout_seconds,
47
+ context: { sleep, progress },
48
+ });
49
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
50
+ return {
51
+ structured: { ...done.structured, background_process: result.value ? formatBackgroundProcess(result.value) : null },
52
+ summary: done.summary,
53
+ };
54
+ },
55
+ });
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { phaseOf } from '../shared/async.js';
3
+ import { idInput, pick } from '../shared/schemas.js';
4
+ import { serverPath } from '../servers/shared.js';
5
+ export function backgroundProcessesPath(org, server) {
6
+ return `${serverPath(org, server)}/background-processes`;
7
+ }
8
+ export function backgroundProcessPath(org, server, process) {
9
+ return `${backgroundProcessesPath(org, server)}/${encodeURIComponent(String(process))}`;
10
+ }
11
+ export const backgroundProcessInput = idInput('Background process ID. Use forge_list_background_processes to find it.');
12
+ export const BACKGROUND_PROCESS_NOT_FOUND_HINT = 'Check the background process ID with forge_list_background_processes.';
13
+ const FIELDS = ['id', 'command', 'user', 'directory', 'processes', 'status', 'created_at'];
14
+ export const backgroundProcessOutput = z.looseObject({
15
+ id: z.string(),
16
+ command: z.string().nullable(),
17
+ user: z.string().nullable(),
18
+ directory: z.string().nullable(),
19
+ processes: z.number().nullable().describe('Number of processes Supervisor keeps running.'),
20
+ status: z
21
+ .string()
22
+ .nullable()
23
+ .describe('installing, removing, restarting, starting, stopping, running, stopped, fatal, backoff, exited or unknown.'),
24
+ created_at: z.string().nullable(),
25
+ });
26
+ export function formatBackgroundProcess(flat) {
27
+ return pick(flat, FIELDS);
28
+ }
29
+ /** Waits for `target`; fatal and exited mean Supervisor gave up (backoff is still retrying). */
30
+ export function processPhase(status, target) {
31
+ return phaseOf(status, { completed: [target], failed: ['fatal', 'exited'] });
32
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, queued } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { BACKGROUND_PROCESS_NOT_FOUND_HINT, backgroundProcessInput, backgroundProcessPath } from './shared.js';
6
+ export const updateBackgroundProcess = defineTool({
7
+ name: 'forge_update_background_process',
8
+ title: 'Update background process',
9
+ description: 'Rename a background process and/or replace its Supervisor configuration (the [program:…] section: command, numprocs, user, stopwaitsecs, …). Supervisor reloads it.',
10
+ toolset: 'jobs',
11
+ operations: ['organizations.servers.background-processes.update'],
12
+ permissions: ['server:create-daemons'],
13
+ readOnly: false,
14
+ destructive: false,
15
+ idempotent: true,
16
+ async: true,
17
+ notFoundHint: BACKGROUND_PROCESS_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ organization: organizationInput,
20
+ server: serverInput,
21
+ background_process: backgroundProcessInput,
22
+ name: z.string().min(1).describe('Name of the background process (required by Forge: pass the current one to keep it).'),
23
+ config: z.string().min(1).optional().describe('Complete Supervisor configuration replacing the current one.'),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal }) {
27
+ await client.put(backgroundProcessPath(organization(args.organization), args.server, args.background_process), {
28
+ body: { name: args.name, config: args.config },
29
+ signal,
30
+ });
31
+ // Neither the name nor the configuration is exposed by the API: nothing to compare.
32
+ return queued(`update background process ${args.background_process}`, 'forge_list_background_processes', '(its status shows whether it is running again)');
33
+ },
34
+ });
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { defineTool } from '../define-tool.js';
3
3
  import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
4
+ import { findNew, listNewestFirst } from '../shared/read.js';
4
5
  import { organizationInput, serverInput } from '../shared/schemas.js';
5
6
  import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
7
  import { databaseRefsInput, resolveDatabaseIds } from '../databases/shared.js';
7
- import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration, newestFirst, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
8
+ import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
8
9
  const CHECK_WITH = 'forge_list_backup_configurations';
9
10
  export const createBackupConfiguration = defineTool({
10
11
  name: 'forge_create_backup_configuration',
@@ -48,7 +49,7 @@ export const createBackupConfiguration = defineTool({
48
49
  const databaseIds = await resolveDatabaseIds(client, org, args.server, args.databases, signal);
49
50
  const base = backupConfigurationsPath(org, args.server);
50
51
  // Forge returns no body: remember the existing configurations to spot the new one.
51
- const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
52
+ const existing = args.wait ? new Set((await listNewestFirst(client, base, signal)).map((item) => item.id)) : undefined;
52
53
  await client.post(base, {
53
54
  body: {
54
55
  storage_provider_id: storageProviderId,
@@ -71,7 +72,7 @@ export const createBackupConfiguration = defineTool({
71
72
  };
72
73
  }
73
74
  const result = await waitFor({
74
- poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
75
+ poll: async () => findNew(await listNewestFirst(client, base, signal), existing),
75
76
  phase: (configuration) => (configuration ? backupConfigurationPhase(configuration.status) : 'pending'),
76
77
  describe: (configuration) => (configuration ? `Backup configuration is ${configuration.status}` : 'Waiting for the configuration to appear'),
77
78
  timeoutSeconds: args.timeout_seconds,
@@ -1,7 +1,8 @@
1
1
  import { defineTool } from '../define-tool.js';
2
2
  import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
3
+ import { findNew, listNewestFirst } from '../shared/read.js';
3
4
  import { organizationInput, serverInput } from '../shared/schemas.js';
4
- import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup, newestFirst, } from './shared.js';
5
+ import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup, } from './shared.js';
5
6
  const CHECK_WITH = 'forge_list_backups';
6
7
  export const createBackup = defineTool({
7
8
  name: 'forge_create_backup',
@@ -28,7 +29,7 @@ export const createBackup = defineTool({
28
29
  async handler(args, { client, organization, signal, sleep, progress }) {
29
30
  const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
30
31
  // Forge returns no body: remember the existing backups to spot the new one.
31
- const existing = args.wait ? new Set((await newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
32
+ const existing = args.wait ? new Set((await listNewestFirst(client, base, signal)).map((item) => item.id)) : undefined;
32
33
  await client.post(base, { signal });
33
34
  const action = `back up configuration ${args.backup_configuration}`;
34
35
  if (!existing) {
@@ -38,7 +39,7 @@ export const createBackup = defineTool({
38
39
  };
39
40
  }
40
41
  const result = await waitFor({
41
- poll: async () => (await newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
42
+ poll: async () => findNew(await listNewestFirst(client, base, signal), existing),
42
43
  phase: (backup) => (backup ? backupPhase(backup.status) : 'pending'),
43
44
  describe: (backup) => (backup ? `Backup is ${backup.status}` : 'Waiting for the backup to start'),
44
45
  timeoutSeconds: args.timeout_seconds,
@@ -100,11 +100,6 @@ export function formatBackup(flat) {
100
100
  export function backupPhase(status) {
101
101
  return phaseOf(status, { completed: ['finished'], failed: ['failed'] });
102
102
  }
103
- /** First page of a collection, newest first: used to spot items created by a write that returns no body. */
104
- export async function newestFirst(client, path, signal) {
105
- const response = await client.get(path, { query: { sort: ['-created_at'], page: { size: 100 } }, signal });
106
- return flattenCollection(response.data).items;
107
- }
108
103
  /** Resolves a storage provider ID or name to its ID. */
109
104
  export async function resolveStorageProviderId(client, org, ref, signal) {
110
105
  if (typeof ref === 'number' || /^\d+$/.test(ref))
@@ -2,7 +2,8 @@ import { z } from 'zod';
2
2
  import { defineTool } from '../define-tool.js';
3
3
  import { readResource } from '../shared/read.js';
4
4
  import { siteScopeInput } from '../shared/site-scope.js';
5
- import { commandInput, commandOutput, commandsPath, fetchOutput, formatCommand, NO_OUTPUT, outputFields, outputLinesInput } from './shared.js';
5
+ import { fetchOutput, NO_OUTPUT, outputFields, outputLinesInput } from '../shared/output.js';
6
+ import { commandInput, commandOutput, commandsPath, formatCommand } from './shared.js';
6
7
  export const getSiteCommand = defineTool({
7
8
  name: 'forge_get_site_command',
8
9
  title: 'Get site command',
@@ -3,7 +3,8 @@ import { flattenCollection } from '../../forge/jsonapi.js';
3
3
  import { defineTool } from '../define-tool.js';
4
4
  import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
5
5
  import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
- import { commandOutput, commandPhase, commandsPath, fetchOutput, formatCommand, NO_OUTPUT, outputFields, outputLinesInput } from './shared.js';
6
+ import { fetchOutput, NO_OUTPUT, outputFields, outputLinesInput } from '../shared/output.js';
7
+ import { commandOutput, commandPhase, commandsPath, formatCommand } from './shared.js';
7
8
  const CHECK_WITH = 'forge_get_site_command';
8
9
  export const runSiteCommand = defineTool({
9
10
  name: 'forge_run_site_command',
@@ -1,8 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { ForgeApiError } from '../../forge/errors.js';
3
2
  import { phaseOf } from '../shared/async.js';
4
- import { readResource } from '../shared/read.js';
5
- import { idInput, pick, tail } from '../shared/schemas.js';
3
+ import { idInput, pick } from '../shared/schemas.js';
6
4
  import { sitePath } from '../shared/site-scope.js';
7
5
  export const COMMAND_STATUSES = ['waiting', 'running', 'finished', 'timeout', 'failed'];
8
6
  export function commandsPath(org, server, site) {
@@ -31,22 +29,3 @@ export function commandPhase(command) {
31
29
  return 'failed';
32
30
  return phase;
33
31
  }
34
- export const outputLinesInput = (fallback) => z.number().int().min(0).max(5000).default(fallback).describe('Return only the last N lines of the output (0 = full output).');
35
- export const outputFields = {
36
- output: z.string().nullable().describe('Command output (last `output_lines` lines), or null when unavailable.'),
37
- output_truncated: z.boolean(),
38
- output_total_lines: z.number().int().nullable(),
39
- };
40
- export const NO_OUTPUT = { output: null, output_truncated: false, output_total_lines: null };
41
- /** Reads the output of a command run; null output when there is none yet. */
42
- export async function fetchOutput(client, path, lines, signal) {
43
- try {
44
- const output = tail((await readResource(client, `${path}/output`, signal)).output, lines);
45
- return { output: output.text, output_truncated: output.truncated, output_total_lines: output.total_lines };
46
- }
47
- catch (error) {
48
- if (error instanceof ForgeApiError && error.status === 404)
49
- return NO_OUTPUT;
50
- throw error;
51
- }
52
- }
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { installationPhase, operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
4
+ import { findNew, listNewestFirst } from '../shared/read.js';
5
+ import { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
7
+ import { firewallRuleOutput, firewallRulesPath, formatFirewallRule } from './shared.js';
8
+ const CHECK_WITH = 'forge_list_firewall_rules';
9
+ export const createFirewallRule = defineTool({
10
+ name: 'forge_create_firewall_rule',
11
+ title: 'Create firewall rule',
12
+ description: 'Open (allow) or block (deny) a port or port range on a server, for everyone or for an IP address or subnet. Waits until the rule is installed unless `wait` is false.',
13
+ toolset: 'security',
14
+ operations: ['organizations.servers.firewall-rules.store', 'organizations.servers.firewall-rules.index'],
15
+ permissions: ['server:manage-network'],
16
+ readOnly: false,
17
+ destructive: false,
18
+ idempotent: false,
19
+ async: true,
20
+ notFoundHint: SERVER_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ organization: organizationInput,
23
+ server: serverInput,
24
+ name: z.string().min(1).max(50).describe('Name of the rule, e.g. "MySQL from office".'),
25
+ port: z.string().min(1).optional().describe('Port or range, e.g. "3306" or "8000:8010". Omit for every port.'),
26
+ ip_address: z.string().min(1).optional().describe('IP address or subnet allowed/blocked, e.g. "203.0.113.4" or "10.0.0.0/16". Omit for any address.'),
27
+ type: z.enum(['allow', 'deny']).default('allow'),
28
+ ...waitInput(60),
29
+ },
30
+ outputSchema: {
31
+ ...operationOutput,
32
+ rule: firewallRuleOutput.nullable().describe('The new rule once it shows up.'),
33
+ },
34
+ async handler(args, { client, organization, signal, sleep, progress }) {
35
+ const base = firewallRulesPath(organization(args.organization), args.server);
36
+ // Forge returns no body: remember the existing rules with this name to spot the new one.
37
+ const lookup = () => listNewestFirst(client, base, signal, { name: args.name });
38
+ const existing = args.wait ? new Set((await lookup()).map((rule) => rule.id)) : undefined;
39
+ await client.post(base, { body: { name: args.name, port: args.port, ip_address: args.ip_address, type: args.type }, signal });
40
+ const action = `${args.type} ${args.port ? `port ${args.port}` : 'every port'}${args.ip_address ? ` from ${args.ip_address}` : ''}`;
41
+ if (!existing) {
42
+ return {
43
+ structured: { status: 'queued', check_with: CHECK_WITH, rule: null },
44
+ summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
45
+ };
46
+ }
47
+ const result = await waitFor({
48
+ poll: async () => findNew(await lookup(), existing),
49
+ phase: (rule) => (rule ? installationPhase(rule.status) : 'pending'),
50
+ describe: (rule) => (rule ? `Firewall rule is ${rule.status}` : 'Waiting for the rule to appear'),
51
+ timeoutSeconds: args.timeout_seconds,
52
+ context: { sleep, progress },
53
+ });
54
+ const rule = result.value ? formatFirewallRule(result.value) : null;
55
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: rule ? `Rule ID: ${rule.id}.` : undefined });
56
+ return { structured: { ...done.structured, rule }, summary: done.summary };
57
+ },
58
+ });
@@ -0,0 +1,41 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { FIREWALL_RULE_NOT_FOUND_HINT, firewallRuleInput, firewallRulesPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_firewall_rules';
7
+ export const deleteFirewallRule = defineTool({
8
+ name: 'forge_delete_firewall_rule',
9
+ title: 'Delete firewall rule',
10
+ description: 'Remove a firewall rule from a server. Removing an allow rule closes the port: check that nothing (SSH, HTTP) depends on it.',
11
+ toolset: 'security',
12
+ operations: ['organizations.servers.firewall-rules.destroy', 'organizations.servers.firewall-rules.show'],
13
+ permissions: ['server:manage-network'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: FIREWALL_RULE_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ rule: firewallRuleInput,
23
+ ...waitInput(60),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const path = `${firewallRulesPath(organization(args.organization), args.server)}/${encodeURIComponent(String(args.rule))}`;
28
+ await client.delete(path, { signal });
29
+ const action = `delete firewall rule ${args.rule}`;
30
+ if (!args.wait)
31
+ return queued(action, CHECK_WITH);
32
+ const result = await waitFor({
33
+ poll: () => orGone(() => readResource(client, path, signal)),
34
+ phase: (rule) => (rule === null ? 'completed' : 'pending'),
35
+ describe: (rule) => `Firewall rule is ${rule?.status ?? 'removing'}`,
36
+ timeoutSeconds: args.timeout_seconds,
37
+ context: { sleep, progress },
38
+ });
39
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
40
+ },
41
+ });