@francescomalatesta/laravel-forge-mcp 0.9.0 → 0.11.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 (25) hide show
  1. package/README.md +19 -0
  2. package/dist/tools/background-processes/create-background-process.js +81 -0
  3. package/dist/tools/background-processes/delete-background-process.js +41 -0
  4. package/dist/tools/background-processes/get-background-process-log.js +34 -0
  5. package/dist/tools/background-processes/list-background-processes.js +58 -0
  6. package/dist/tools/background-processes/run-background-process-action.js +55 -0
  7. package/dist/tools/background-processes/shared.js +32 -0
  8. package/dist/tools/background-processes/update-background-process.js +34 -0
  9. package/dist/tools/commands/delete-site-command.js +29 -0
  10. package/dist/tools/commands/get-site-command.js +37 -0
  11. package/dist/tools/commands/list-site-commands.js +47 -0
  12. package/dist/tools/commands/run-site-command.js +75 -0
  13. package/dist/tools/commands/shared.js +31 -0
  14. package/dist/tools/integrations/disable-site-integration.js +48 -0
  15. package/dist/tools/integrations/enable-site-integration.js +88 -0
  16. package/dist/tools/integrations/get-site-integrations.js +35 -0
  17. package/dist/tools/integrations/shared.js +69 -0
  18. package/dist/tools/registry.js +37 -0
  19. package/dist/tools/scheduled-jobs/create-scheduled-job.js +83 -0
  20. package/dist/tools/scheduled-jobs/delete-scheduled-job.js +42 -0
  21. package/dist/tools/scheduled-jobs/get-scheduled-job.js +39 -0
  22. package/dist/tools/scheduled-jobs/list-scheduled-jobs.js +44 -0
  23. package/dist/tools/scheduled-jobs/shared.js +41 -0
  24. package/dist/tools/shared/output.js +23 -0
  25. package/package.json +1 -1
package/README.md CHANGED
@@ -164,9 +164,28 @@ 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_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
178
+ | `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
179
+ | `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
180
+ | `forge_run_site_command` | commands | Run a command in the site directory and return its output |
181
+ | `forge_list_site_commands` | commands | Commands run on a site with status and exit code |
182
+ | `forge_get_site_command` | commands | A command run with the end of its output |
183
+ | `forge_delete_site_command` | commands | Remove a command run from the history |
167
184
 
168
185
  🔑 Reads or replaces secrets: registered only when `FORGE_ALLOW_SECRETS=true`. Secret fields returned by other tools (e.g. a site's `deployment_url`) are hidden unless secrets are allowed.
169
186
 
187
+ The `commands` toolset runs arbitrary shell commands with the privileges of the site user: enable it explicitly (e.g. `FORGE_TOOLSETS=default,commands`) only when you need it. Note that `all` includes it.
188
+
170
189
  ### Background operations
171
190
 
172
191
  Forge runs most changes in the background. Tools that start one say so in their description and return a `status`: `completed` or `failed` when they waited for the result, `in_progress` if it was still running at the timeout, `queued` when they returned right away. `check_with` names the tool that shows the current state. By default these tools wait for the outcome (sending progress notifications); pass `wait: false` to return immediately, or tune `timeout_seconds`.
@@ -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
+ });
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { siteScopeInput } from '../shared/site-scope.js';
4
+ import { commandInput, commandsPath } from './shared.js';
5
+ export const deleteSiteCommand = defineTool({
6
+ name: 'forge_delete_site_command',
7
+ title: 'Delete site command',
8
+ description: 'Delete a command run and its output from the site history.',
9
+ toolset: 'commands',
10
+ operations: ['organizations.servers.sites.commands.destroy'],
11
+ permissions: ['site:manage-commands'],
12
+ readOnly: false,
13
+ destructive: true,
14
+ idempotent: true,
15
+ notFoundHint: 'Check the command ID with forge_list_site_commands.',
16
+ inputSchema: {
17
+ ...siteScopeInput,
18
+ command: commandInput,
19
+ },
20
+ outputSchema: {
21
+ deleted: z.boolean(),
22
+ },
23
+ async handler(args, { client, organization, signal }) {
24
+ await client.delete(`${commandsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.command))}`, {
25
+ signal,
26
+ });
27
+ return { structured: { deleted: true }, summary: `Deleted command ${args.command} from the history.` };
28
+ },
29
+ });
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { siteScopeInput } from '../shared/site-scope.js';
5
+ import { fetchOutput, NO_OUTPUT, outputFields, outputLinesInput } from '../shared/output.js';
6
+ import { commandInput, commandOutput, commandsPath, formatCommand } from './shared.js';
7
+ export const getSiteCommand = defineTool({
8
+ name: 'forge_get_site_command',
9
+ title: 'Get site command',
10
+ description: 'Get a command run on a site with its status, exit code and the end of its output.',
11
+ toolset: 'commands',
12
+ operations: ['organizations.servers.sites.commands.show', 'organizations.servers.sites.commands.output.show'],
13
+ permissions: ['server:view'],
14
+ readOnly: true,
15
+ notFoundHint: 'Check the command ID with forge_list_site_commands.',
16
+ inputSchema: {
17
+ ...siteScopeInput,
18
+ command: commandInput,
19
+ include_output: z.boolean().default(true).describe('Also fetch the output.'),
20
+ output_lines: outputLinesInput(100),
21
+ },
22
+ outputSchema: {
23
+ command: commandOutput,
24
+ ...outputFields,
25
+ },
26
+ async handler(args, { client, organization, signal }) {
27
+ const path = `${commandsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.command))}`;
28
+ const [command, output] = await Promise.all([
29
+ readResource(client, path, signal).then(formatCommand),
30
+ args.include_output ? fetchOutput(client, path, args.output_lines, signal) : Promise.resolve(NO_OUTPUT),
31
+ ]);
32
+ return {
33
+ structured: { command, ...output },
34
+ summary: `\`${command.command}\` is ${command.status}${command.exit_code === null ? '' : ` (exit code ${command.exit_code})`}.${command.error_output ? ` Error: ${command.error_output}` : ''}`,
35
+ };
36
+ },
37
+ });
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { COMMAND_STATUSES, commandOutput, commandsPath, formatCommand } from './shared.js';
7
+ const SORT = ['status', '-status', 'created_at', '-created_at', 'updated_at', '-updated_at'];
8
+ export const listSiteCommands = defineTool({
9
+ name: 'forge_list_site_commands',
10
+ title: 'List site commands',
11
+ description: 'List the commands run on a site (newest first by default) with status and exit code. Read the output of one with forge_get_site_command.',
12
+ toolset: 'commands',
13
+ operations: ['organizations.servers.sites.commands.index'],
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SITE_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ ...siteScopeInput,
19
+ status: z.enum(COMMAND_STATUSES).optional().describe('Filter by status.'),
20
+ command: z.string().min(1).optional().describe('Filter by command text.'),
21
+ user_id: z.union([z.number().int(), z.string().min(1)]).optional().describe('Filter by the user who ran it.'),
22
+ sort: z.array(z.enum(SORT)).min(1).optional().describe('Defaults to newest first.'),
23
+ ...paginationInput,
24
+ },
25
+ outputSchema: {
26
+ commands: z.array(commandOutput),
27
+ ...paginationOutput,
28
+ },
29
+ async handler(args, { client, organization, signal }) {
30
+ const response = await client.get(commandsPath(organization(args.organization), args.server, args.site), {
31
+ query: {
32
+ filter: { status: args.status, command: args.command, user_id: args.user_id === undefined ? undefined : String(args.user_id) },
33
+ sort: args.sort ?? ['-created_at'],
34
+ page: { size: args.page_size, cursor: args.cursor },
35
+ },
36
+ signal,
37
+ });
38
+ const page = flattenCollection(response.data);
39
+ const commands = page.items.map(formatCommand);
40
+ return {
41
+ structured: { commands, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
42
+ summary: commands.length === 0
43
+ ? 'No commands found.'
44
+ : `Found ${commands.length} command(s); latest: \`${commands[0].command}\` (${commands[0].status}).${paginationSummary(page.nextCursor)}`,
45
+ };
46
+ },
47
+ });
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { fetchOutput, NO_OUTPUT, outputFields, outputLinesInput } from '../shared/output.js';
7
+ import { commandOutput, commandPhase, commandsPath, formatCommand } from './shared.js';
8
+ const CHECK_WITH = 'forge_get_site_command';
9
+ export const runSiteCommand = defineTool({
10
+ name: 'forge_run_site_command',
11
+ title: 'Run site command',
12
+ description: 'Run a shell command in the site directory as the site user (e.g. `php artisan migrate --force`, `php artisan cache:clear`). It runs with the same privileges as the application: confirm commands that change data with the user. Waits for the result and returns the end of the output unless `wait` is false.',
13
+ toolset: 'commands',
14
+ operations: [
15
+ 'organizations.servers.sites.commands.store',
16
+ 'organizations.servers.sites.commands.index',
17
+ 'organizations.servers.sites.commands.output.show',
18
+ ],
19
+ permissions: ['site:manage-commands', 'server:view'],
20
+ readOnly: false,
21
+ destructive: true,
22
+ idempotent: false,
23
+ async: true,
24
+ notFoundHint: SITE_NOT_FOUND_HINT,
25
+ inputSchema: {
26
+ ...siteScopeInput,
27
+ command: z.string().min(1).describe('Command to run, e.g. "php artisan migrate --force".'),
28
+ output_lines: outputLinesInput(100),
29
+ ...waitInput(120),
30
+ },
31
+ outputSchema: {
32
+ ...operationOutput,
33
+ command: commandOutput.nullable().describe('The command run once it shows up.'),
34
+ ...outputFields,
35
+ },
36
+ async handler(args, { client, organization, signal, sleep, progress }) {
37
+ const base = commandsPath(organization(args.organization), args.server, args.site);
38
+ const runs = async () => {
39
+ const response = await client.get(base, {
40
+ query: { filter: { command: args.command }, sort: ['-created_at'], page: { size: 100 } },
41
+ signal,
42
+ });
43
+ return flattenCollection(response.data).items;
44
+ };
45
+ // Forge returns no body: remember earlier runs of the same command to spot the new one.
46
+ const earlier = args.wait ? new Set((await runs()).map((run) => run.id)) : undefined;
47
+ await client.post(base, { body: { command: args.command }, signal });
48
+ const action = `run \`${args.command}\``;
49
+ if (!earlier) {
50
+ return {
51
+ structured: { status: 'queued', check_with: 'forge_list_site_commands', command: null, ...NO_OUTPUT },
52
+ summary: `Forge queued ${action}. Find it with forge_list_site_commands, then read its output with ${CHECK_WITH}.`,
53
+ };
54
+ }
55
+ const result = await waitFor({
56
+ poll: async () => (await runs()).find((run) => !earlier.has(run.id)) ?? null,
57
+ phase: (run) => (run ? commandPhase(run) : 'pending'),
58
+ describe: (run) => (run ? `Command is ${run.status}` : 'Waiting for the command to start'),
59
+ timeoutSeconds: args.timeout_seconds,
60
+ context: { sleep, progress },
61
+ });
62
+ const command = result.value ? formatCommand(result.value) : null;
63
+ const finished = command && (result.status === 'completed' || result.status === 'failed');
64
+ const output = finished ? await fetchOutput(client, `${base}/${encodeURIComponent(command.id)}`, args.output_lines, signal) : NO_OUTPUT;
65
+ const done = outcome(result, {
66
+ action,
67
+ checkWith: CHECK_WITH,
68
+ timeoutSeconds: args.timeout_seconds,
69
+ detail: command
70
+ ? `Command ID: ${command.id}${command.exit_code === null ? '' : `, exit code ${command.exit_code}`}.${command.error_output ? ` Error: ${command.error_output}` : ''}`
71
+ : undefined,
72
+ });
73
+ return { structured: { ...done.structured, command, ...output }, summary: done.summary };
74
+ },
75
+ });
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { phaseOf } from '../shared/async.js';
3
+ import { idInput, pick } from '../shared/schemas.js';
4
+ import { sitePath } from '../shared/site-scope.js';
5
+ export const COMMAND_STATUSES = ['waiting', 'running', 'finished', 'timeout', 'failed'];
6
+ export function commandsPath(org, server, site) {
7
+ return `${sitePath(org, server, site)}/commands`;
8
+ }
9
+ export const commandInput = idInput('Command run ID. Use forge_list_site_commands to find it.');
10
+ const FIELDS = ['id', 'command', 'status', 'exit_code', 'error_output', 'duration', 'user_id', 'created_at', 'updated_at'];
11
+ export const commandOutput = z.looseObject({
12
+ id: z.string(),
13
+ command: z.string().nullable(),
14
+ status: z.string().nullable().describe('waiting, running, finished, timeout or failed.'),
15
+ exit_code: z.number().nullable().describe('Null while running, or when the command never produced one (SSH failure, timeout).'),
16
+ error_output: z.string().nullable().describe('Failure detail when the command did not succeed.'),
17
+ duration: z.string().nullable(),
18
+ user_id: z.number().nullable().describe('User who ran the command.'),
19
+ created_at: z.string().nullable(),
20
+ updated_at: z.string().nullable(),
21
+ });
22
+ export function formatCommand(flat) {
23
+ return pick(flat, FIELDS);
24
+ }
25
+ /** Finished with a non-zero exit code counts as a failure. */
26
+ export function commandPhase(command) {
27
+ const phase = phaseOf(command.status, { completed: ['finished'], failed: ['failed', 'timeout'] });
28
+ if (phase === 'completed' && typeof command.exit_code === 'number' && command.exit_code !== 0)
29
+ return 'failed';
30
+ return phase;
31
+ }
@@ -0,0 +1,48 @@
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 { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
5
+ import { describeIntegration, integrationOperation, integrationOutput, integrationPath, readIntegration } from './shared.js';
6
+ const CHECK_WITH = 'forge_get_site_integrations';
7
+ /** Inertia SSR has no disable endpoint in the API. */
8
+ const DISABLEABLE = ['horizon', 'octane', 'reverb', 'pulse', 'scheduler', 'maintenance'];
9
+ export const disableSiteIntegration = defineTool({
10
+ name: 'forge_disable_site_integration',
11
+ title: 'Disable site integration',
12
+ description: 'Disable a Laravel integration on a site: horizon, octane, reverb or pulse (removes their daemon: queues or servers stop), scheduler (removes the scheduled job) or maintenance (brings the site back up). Inertia SSR cannot be disabled through the API. Waits until it is disabled unless `wait` is false.',
13
+ toolset: 'integrations',
14
+ operations: [...DISABLEABLE.map((name) => integrationOperation(name, 'destroy')), ...DISABLEABLE.map((name) => integrationOperation(name, 'show'))],
15
+ permissions: ['server:delete-daemons', 'server:delete-schedulers', 'site:manage-commands', 'server:view'],
16
+ readOnly: false,
17
+ destructive: true,
18
+ idempotent: true,
19
+ async: true,
20
+ notFoundHint: SITE_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ integration: z.enum(DISABLEABLE).describe('Integration to disable.'),
24
+ ...waitInput(90),
25
+ },
26
+ outputSchema: {
27
+ ...operationOutput,
28
+ integration: integrationOutput.nullable().describe('State after the wait.'),
29
+ },
30
+ async handler(args, { client, organization, signal, sleep, progress }) {
31
+ const path = integrationPath(organization(args.organization), args.server, args.site, args.integration);
32
+ await client.delete(path, { signal });
33
+ const action = `disable ${args.integration}`;
34
+ if (!args.wait) {
35
+ const accepted = queued(action, CHECK_WITH);
36
+ return { ...accepted, structured: { ...accepted.structured, integration: null } };
37
+ }
38
+ const result = await waitFor({
39
+ poll: () => readIntegration(client, path, args.integration, signal),
40
+ phase: (state) => (state.enabled === false && state.status !== 'disabling' ? 'completed' : 'pending'),
41
+ describe: describeIntegration,
42
+ timeoutSeconds: args.timeout_seconds,
43
+ context: { sleep, progress },
44
+ });
45
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
46
+ return { structured: { ...done.structured, integration: result.value ?? null }, summary: done.summary };
47
+ },
48
+ });
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { describeIntegration, INTEGRATION_NAMES, INTEGRATIONS_DESCRIPTION, integrationOperation, integrationOutput, integrationPath, readIntegration, } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_site_integrations';
8
+ const TRANSITIONS = ['enabling', 'disabling'];
9
+ /** Inputs each integration accepts; any other integration-specific input is rejected. */
10
+ const ALLOWED = {
11
+ horizon: [],
12
+ octane: ['octane_server', 'port'],
13
+ reverb: ['host', 'port', 'connections'],
14
+ pulse: [],
15
+ inertia: [],
16
+ scheduler: [],
17
+ maintenance: ['maintenance_status', 'maintenance_secret', 'maintenance_redirect'],
18
+ };
19
+ const SPECIFIC = ['octane_server', 'port', 'host', 'connections', 'maintenance_status', 'maintenance_secret', 'maintenance_redirect'];
20
+ export const enableSiteIntegration = defineTool({
21
+ name: 'forge_enable_site_integration',
22
+ title: 'Enable site integration',
23
+ description: `Enable a Laravel integration on a site: ${INTEGRATIONS_DESCRIPTION} Forge creates the daemon or scheduled job it needs. Octane needs \`octane_server\` and \`port\`; Reverb needs \`host\`, \`port\` and \`connections\`; maintenance mode accepts a bypass secret and a redirect. Waits until the integration is enabled unless \`wait\` is false.`,
24
+ toolset: 'integrations',
25
+ operations: [...INTEGRATION_NAMES.map((name) => integrationOperation(name, 'store')), ...INTEGRATION_NAMES.map((name) => integrationOperation(name, 'show'))],
26
+ permissions: ['server:create-daemons', 'server:create-schedulers', 'site:manage-commands', 'server:view'],
27
+ readOnly: false,
28
+ destructive: false,
29
+ idempotent: true,
30
+ async: true,
31
+ notFoundHint: SITE_NOT_FOUND_HINT,
32
+ inputSchema: {
33
+ ...siteScopeInput,
34
+ integration: z.enum(INTEGRATION_NAMES).describe('Integration to enable.'),
35
+ octane_server: z.enum(['swoole', 'roadrunner', 'frankenphp']).optional().describe('Octane only: application server.'),
36
+ port: z.number().int().min(1).max(65535).optional().describe('Octane and Reverb: port the server listens on, e.g. 8000 or 8080.'),
37
+ host: z.string().min(1).optional().describe('Reverb only: public host name of the WebSocket server, e.g. "ws.example.com".'),
38
+ connections: z.number().int().min(1).max(50000).optional().describe('Reverb only: maximum concurrent connections.'),
39
+ maintenance_status: z
40
+ .union([z.literal(503), z.literal(410), z.literal(307), z.literal(304)])
41
+ .optional()
42
+ .describe('Maintenance only: HTTP status returned while down (default 503).'),
43
+ maintenance_secret: z.string().min(1).optional().describe('Maintenance only: secret path that bypasses maintenance mode (https://site/{secret}).'),
44
+ maintenance_redirect: z.string().min(1).optional().describe('Maintenance only: path or URL every request is redirected to.'),
45
+ ...waitInput(90),
46
+ },
47
+ outputSchema: {
48
+ ...operationOutput,
49
+ integration: integrationOutput.nullable().describe('State after the wait.'),
50
+ },
51
+ async handler(args, { client, organization, signal, sleep, progress }) {
52
+ const { integration } = args;
53
+ const unexpected = SPECIFIC.filter((field) => args[field] !== undefined && !ALLOWED[integration].includes(field));
54
+ if (unexpected.length > 0)
55
+ throw new ToolInputError(`${unexpected.map((f) => `\`${f}\``).join(', ')} not used by ${integration}.`);
56
+ let body;
57
+ if (integration === 'octane') {
58
+ if (!args.octane_server || args.port === undefined)
59
+ throw new ToolInputError('Octane needs `octane_server` and `port`.');
60
+ body = { server: args.octane_server, port: String(args.port) };
61
+ }
62
+ else if (integration === 'reverb') {
63
+ if (!args.host || args.port === undefined || args.connections === undefined) {
64
+ throw new ToolInputError('Reverb needs `host`, `port` and `connections`.');
65
+ }
66
+ body = { host: args.host, port: String(args.port), connections: args.connections };
67
+ }
68
+ else if (integration === 'maintenance') {
69
+ body = { status: args.maintenance_status ?? 503, secret: args.maintenance_secret, redirect: args.maintenance_redirect };
70
+ }
71
+ const path = integrationPath(organization(args.organization), args.server, args.site, integration);
72
+ await client.post(path, { body, signal });
73
+ const action = `enable ${integration}`;
74
+ if (!args.wait) {
75
+ const accepted = queued(action, CHECK_WITH);
76
+ return { ...accepted, structured: { ...accepted.structured, integration: null } };
77
+ }
78
+ const result = await waitFor({
79
+ poll: () => readIntegration(client, path, integration, signal),
80
+ phase: (state) => (state.enabled === true && !TRANSITIONS.includes(state.status ?? '') ? 'completed' : 'pending'),
81
+ describe: describeIntegration,
82
+ timeoutSeconds: args.timeout_seconds,
83
+ context: { sleep, progress },
84
+ });
85
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
86
+ return { structured: { ...done.structured, integration: result.value ?? null }, summary: done.summary };
87
+ },
88
+ });
@@ -0,0 +1,35 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
4
+ import { describeIntegration, INTEGRATION_NAMES, INTEGRATIONS_DESCRIPTION, integrationOperation, integrationOutput, integrationPath, readIntegration } from './shared.js';
5
+ export const getSiteIntegrations = defineTool({
6
+ name: 'forge_get_site_integrations',
7
+ title: 'Get site integrations',
8
+ description: `Show which Laravel integrations are enabled on a site: ${INTEGRATIONS_DESCRIPTION} Pass \`integration\` to read only one.`,
9
+ toolset: 'integrations',
10
+ operations: INTEGRATION_NAMES.map((name) => integrationOperation(name, 'show')),
11
+ permissions: ['server:view'],
12
+ readOnly: true,
13
+ notFoundHint: SITE_NOT_FOUND_HINT,
14
+ inputSchema: {
15
+ ...siteScopeInput,
16
+ integration: z.enum(INTEGRATION_NAMES).optional().describe('Read only this integration (default: all of them).'),
17
+ },
18
+ outputSchema: {
19
+ integrations: z.array(integrationOutput),
20
+ },
21
+ async handler(args, { client, organization, signal }) {
22
+ const org = organization(args.organization);
23
+ const names = args.integration ? [args.integration] : INTEGRATION_NAMES;
24
+ const integrations = await Promise.all(names.map((name) => readIntegration(client, integrationPath(org, args.server, args.site, name), name, signal)));
25
+ const enabled = integrations.filter((state) => state.enabled).map((state) => state.integration);
26
+ return {
27
+ structured: { integrations },
28
+ summary: args.integration
29
+ ? `${describeIntegration(integrations[0])}.`
30
+ : enabled.length === 0
31
+ ? 'No integrations are enabled on this site.'
32
+ : `Enabled integrations: ${enabled.join(', ')}.`,
33
+ };
34
+ },
35
+ });
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ import { relatedId } from '../shared/relationships.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { sitePath } from '../shared/site-scope.js';
5
+ /** Integration name → API path segment and fields of its resource. */
6
+ export const INTEGRATIONS = {
7
+ horizon: { segment: 'horizon', installed: 'horizon_installed', settings: [] },
8
+ octane: { segment: 'octane', installed: 'octane_installed', settings: ['port'] },
9
+ reverb: { segment: 'reverb', installed: 'reverb_installed', settings: ['host', 'port', 'connections'] },
10
+ pulse: { segment: 'pulse', installed: 'pulse_installed', settings: [] },
11
+ inertia: { segment: 'inertia', installed: 'inertia_installed', settings: [] },
12
+ scheduler: { segment: 'laravel-scheduler', installed: 'laravel_installed', settings: [] },
13
+ maintenance: { segment: 'laravel-maintenance', installed: 'laravel_installed', settings: [] },
14
+ };
15
+ export const INTEGRATION_NAMES = Object.keys(INTEGRATIONS);
16
+ export const INTEGRATIONS_DESCRIPTION = 'horizon (queue dashboard and workers), octane (application server), reverb (WebSocket server), pulse (monitoring), inertia (SSR server), scheduler (runs `schedule:run` every minute), maintenance (Laravel maintenance mode).';
17
+ /** OperationId of an integration endpoint, e.g. "organizations.servers.sites.integrations.horizon.store". */
18
+ export function integrationOperation(integration, verb) {
19
+ return `organizations.servers.sites.integrations.${INTEGRATIONS[integration].segment}.${verb}`;
20
+ }
21
+ export function integrationPath(org, server, site, integration) {
22
+ return `${sitePath(org, server, site)}/integrations/${INTEGRATIONS[integration].segment}`;
23
+ }
24
+ export const integrationOutput = z.looseObject({
25
+ integration: z.enum(INTEGRATION_NAMES),
26
+ enabled: z.boolean().nullable().describe('Whether the integration is enabled on the site.'),
27
+ status: z.string().nullable().describe('Transition in progress (maintenance: enabling or disabling), or the raw state when Forge reports one.'),
28
+ package_installed: z.boolean().nullable().describe('Whether the Laravel package (or Laravel itself) is installed in the site.'),
29
+ settings: z.record(z.string(), z.unknown()).describe('Port, host, connections (Octane and Reverb).'),
30
+ background_process_id: z.string().nullable().describe('Daemon running the integration (Horizon, Octane, Reverb, Pulse, Inertia).'),
31
+ scheduled_job_id: z.string().nullable().describe('Scheduled job running the scheduler.'),
32
+ });
33
+ const TRUE = new Set(['true', '1', 'yes', 'enabled', 'on', 'active', 'installed']);
34
+ const FALSE = new Set(['false', '0', 'no', 'disabled', 'off', 'inactive', '']);
35
+ /** Some integrations report `enabled` as a string: normalize it, keeping unknown values as the status. */
36
+ export function formatIntegration(integration, flat) {
37
+ const raw = flat.enabled;
38
+ let enabled = null;
39
+ let status = typeof flat.status === 'string' ? flat.status : null;
40
+ if (typeof raw === 'boolean')
41
+ enabled = raw;
42
+ else if (typeof raw === 'number')
43
+ enabled = raw !== 0;
44
+ else if (typeof raw === 'string') {
45
+ const value = raw.trim().toLowerCase();
46
+ if (TRUE.has(value))
47
+ enabled = true;
48
+ else if (FALSE.has(value))
49
+ enabled = false;
50
+ else
51
+ status ??= raw;
52
+ }
53
+ const { installed, settings } = INTEGRATIONS[integration];
54
+ return {
55
+ integration,
56
+ enabled,
57
+ status,
58
+ package_installed: typeof flat[installed] === 'boolean' ? flat[installed] : null,
59
+ settings: Object.fromEntries(settings.map((key) => [key, flat[key] ?? null])),
60
+ background_process_id: relatedId(flat, 'backgroundProcess'),
61
+ scheduled_job_id: relatedId(flat, 'job'),
62
+ };
63
+ }
64
+ export async function readIntegration(client, path, integration, signal) {
65
+ return formatIntegration(integration, await readResource(client, path, signal));
66
+ }
67
+ export function describeIntegration(state) {
68
+ return `${state.integration} is ${state.status ?? (state.enabled === null ? 'in an unknown state' : state.enabled ? 'enabled' : 'disabled')}`;
69
+ }
@@ -1,3 +1,9 @@
1
+ import { createBackgroundProcess } from './background-processes/create-background-process.js';
2
+ import { deleteBackgroundProcess } from './background-processes/delete-background-process.js';
3
+ import { getBackgroundProcessLog } from './background-processes/get-background-process-log.js';
4
+ import { listBackgroundProcesses } from './background-processes/list-background-processes.js';
5
+ import { runBackgroundProcessAction } from './background-processes/run-background-process-action.js';
6
+ import { updateBackgroundProcess } from './background-processes/update-background-process.js';
1
7
  import { createBackup } from './backups/create-backup.js';
2
8
  import { createBackupConfiguration } from './backups/create-backup-configuration.js';
3
9
  import { deleteBackup } from './backups/delete-backup.js';
@@ -11,6 +17,10 @@ import { deleteCertificate } from './certificates/delete-certificate.js';
11
17
  import { getCertificate } from './certificates/get-certificate.js';
12
18
  import { listCertificates } from './certificates/list-certificates.js';
13
19
  import { runCertificateAction } from './certificates/run-certificate-action.js';
20
+ import { deleteSiteCommand } from './commands/delete-site-command.js';
21
+ import { getSiteCommand } from './commands/get-site-command.js';
22
+ import { listSiteCommands } from './commands/list-site-commands.js';
23
+ import { runSiteCommand } from './commands/run-site-command.js';
14
24
  import { createDatabase } from './databases/create-database.js';
15
25
  import { createDatabaseUser } from './databases/create-database-user.js';
16
26
  import { deleteDatabase } from './databases/delete-database.js';
@@ -44,6 +54,9 @@ import { runDomainAction } from './domains/run-domain-action.js';
44
54
  import { updateDomain } from './domains/update-domain.js';
45
55
  import { getServerEvent } from './events/get-server-event.js';
46
56
  import { listServerEvents } from './events/list-server-events.js';
57
+ import { disableSiteIntegration } from './integrations/disable-site-integration.js';
58
+ import { enableSiteIntegration } from './integrations/enable-site-integration.js';
59
+ import { getSiteIntegrations } from './integrations/get-site-integrations.js';
47
60
  import { listOrganizations } from './organizations/list-organizations.js';
48
61
  import { getPhpConfig } from './php/get-php-config.js';
49
62
  import { getPhpSettings } from './php/get-php-settings.js';
@@ -55,6 +68,10 @@ import { uninstallPhpVersion } from './php/uninstall-php-version.js';
55
68
  import { updatePhpConfig } from './php/update-php-config.js';
56
69
  import { updatePhpLimits } from './php/update-php-limits.js';
57
70
  import { upgradePhpVersion } from './php/upgrade-php-version.js';
71
+ import { createScheduledJob } from './scheduled-jobs/create-scheduled-job.js';
72
+ import { deleteScheduledJob } from './scheduled-jobs/delete-scheduled-job.js';
73
+ import { getScheduledJob } from './scheduled-jobs/get-scheduled-job.js';
74
+ import { listScheduledJobs } from './scheduled-jobs/list-scheduled-jobs.js';
58
75
  import { archiveServer } from './servers/archive-server.js';
59
76
  import { clearServerLog } from './servers/clear-server-log.js';
60
77
  import { deleteServer } from './servers/delete-server.js';
@@ -189,6 +206,26 @@ export const ALL_TOOLS = [
189
206
  getDeployKey,
190
207
  createDeployKey,
191
208
  deleteDeployKey,
209
+ // jobs
210
+ listScheduledJobs,
211
+ getScheduledJob,
212
+ createScheduledJob,
213
+ deleteScheduledJob,
214
+ listBackgroundProcesses,
215
+ getBackgroundProcessLog,
216
+ createBackgroundProcess,
217
+ updateBackgroundProcess,
218
+ runBackgroundProcessAction,
219
+ deleteBackgroundProcess,
220
+ // integrations
221
+ getSiteIntegrations,
222
+ enableSiteIntegration,
223
+ disableSiteIntegration,
224
+ // commands
225
+ runSiteCommand,
226
+ listSiteCommands,
227
+ getSiteCommand,
228
+ deleteSiteCommand,
192
229
  ];
193
230
  /** Tools enabled by the configuration (toolsets, read-only mode, secrets opt-in). */
194
231
  export function selectTools(config, tools = ALL_TOOLS) {
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { ToolInputError } from '../errors.js';
5
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
6
+ import { readResource } from '../shared/read.js';
7
+ import { organizationInput, serverInput } from '../shared/schemas.js';
8
+ import { SITE_NOT_FOUND_HINT } from '../shared/site-scope.js';
9
+ import { CRON_FREQUENCIES, formatScheduledJob, jobSiteInput, scheduledJobOperations, scheduledJobOutput, scheduledJobPhase, scheduledJobsPath, } from './shared.js';
10
+ const CHECK_WITH = 'forge_list_scheduled_jobs';
11
+ export const createScheduledJob = defineTool({
12
+ name: 'forge_create_scheduled_job',
13
+ title: 'Create scheduled job',
14
+ description: 'Schedule a command (cron) on a server, or for a site with `site`. For the Laravel scheduler prefer forge_enable_site_integration with integration "scheduler". Optionally creates a heartbeat that alerts when the job stops running. Waits until it is installed unless `wait` is false.',
15
+ toolset: 'jobs',
16
+ operations: [...scheduledJobOperations('store'), ...scheduledJobOperations('show')],
17
+ permissions: ['server:view'],
18
+ readOnly: false,
19
+ destructive: false,
20
+ idempotent: false,
21
+ async: true,
22
+ notFoundHint: SITE_NOT_FOUND_HINT,
23
+ inputSchema: {
24
+ organization: organizationInput,
25
+ server: serverInput,
26
+ site: jobSiteInput,
27
+ command: z.string().min(1).describe('Command to run, e.g. "php /home/forge/example.com/current/artisan reports:send".'),
28
+ frequency: z.enum(CRON_FREQUENCIES).describe('How often it runs; "custom" needs `cron`, "reboot" runs at boot.'),
29
+ cron: z.string().min(1).optional().describe('Custom frequency only: cron expression, e.g. "*/15 * * * *".'),
30
+ user: z.string().min(1).default('forge').describe('User the job runs as.'),
31
+ name: z.string().min(1).optional().describe('Name of the job.'),
32
+ heartbeat: z.boolean().optional().describe('Create a heartbeat that alerts when the job does not run.'),
33
+ grace_period: z
34
+ .union([z.literal(1), z.literal(2), z.literal(5), z.literal(10), z.literal(30), z.literal(60)])
35
+ .optional()
36
+ .describe('Heartbeat only: minutes of delay tolerated before alerting.'),
37
+ ...waitInput(60),
38
+ },
39
+ outputSchema: {
40
+ ...operationOutput,
41
+ job: scheduledJobOutput.nullable(),
42
+ },
43
+ async handler(args, { client, organization, signal, sleep, progress }) {
44
+ if (args.frequency === 'custom' && !args.cron)
45
+ throw new ToolInputError('A custom frequency needs a `cron` expression.');
46
+ if (args.frequency !== 'custom' && args.cron)
47
+ throw new ToolInputError('`cron` is only used with frequency "custom".');
48
+ if (args.grace_period !== undefined && !args.heartbeat)
49
+ throw new ToolInputError('`grace_period` is only used with `heartbeat: true`.');
50
+ const base = scheduledJobsPath(organization(args.organization), args.server, args.site);
51
+ const response = await client.post(base, {
52
+ body: {
53
+ name: args.name,
54
+ command: args.command,
55
+ user: args.user,
56
+ frequency: args.frequency,
57
+ cron: args.cron,
58
+ heartbeat: args.heartbeat,
59
+ grace_period: args.grace_period,
60
+ },
61
+ signal,
62
+ });
63
+ const initial = response.data?.data ? flattenSingle(response.data) : undefined;
64
+ const action = `schedule \`${args.command}\``;
65
+ if (!initial || !args.wait) {
66
+ return {
67
+ structured: { status: 'queued', check_with: CHECK_WITH, job: initial ? formatScheduledJob(initial) : null },
68
+ summary: `Forge accepted the request to ${action}. Check it with ${CHECK_WITH}.`,
69
+ };
70
+ }
71
+ const result = await waitFor({
72
+ initial,
73
+ poll: () => readResource(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
74
+ phase: (job) => scheduledJobPhase(job.status),
75
+ describe: (job) => `Scheduled job is ${job.status}`,
76
+ timeoutSeconds: args.timeout_seconds,
77
+ context: { sleep, progress },
78
+ });
79
+ const job = formatScheduledJob(result.value ?? initial);
80
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: `Job ID: ${job.id}.` });
81
+ return { structured: { ...done.structured, job }, summary: done.summary };
82
+ },
83
+ });
@@ -0,0 +1,42 @@
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 { JOB_NOT_FOUND_HINT, jobInput, jobSiteInput, scheduledJobOperations, scheduledJobPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_scheduled_jobs';
7
+ export const deleteScheduledJob = defineTool({
8
+ name: 'forge_delete_scheduled_job',
9
+ title: 'Delete scheduled job',
10
+ description: 'Remove a scheduled job from a server (or site, with `site`): the command stops running.',
11
+ toolset: 'jobs',
12
+ operations: [...scheduledJobOperations('destroy'), ...scheduledJobOperations('show')],
13
+ permissions: ['server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: JOB_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ site: jobSiteInput,
23
+ job: jobInput,
24
+ ...waitInput(60),
25
+ },
26
+ outputSchema: operationOutput,
27
+ async handler(args, { client, organization, signal, sleep, progress }) {
28
+ const path = scheduledJobPath(organization(args.organization), args.server, args.site, args.job);
29
+ await client.delete(path, { signal });
30
+ const action = `delete scheduled job ${args.job}`;
31
+ if (!args.wait)
32
+ return queued(action, CHECK_WITH);
33
+ const result = await waitFor({
34
+ poll: () => orGone(() => readResource(client, path, signal)),
35
+ phase: (job) => (job === null ? 'completed' : 'pending'),
36
+ describe: (job) => `Scheduled job is ${job?.status ?? 'removing'}`,
37
+ timeoutSeconds: args.timeout_seconds,
38
+ context: { sleep, progress },
39
+ });
40
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
41
+ },
42
+ });
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { fetchOutput, NO_OUTPUT, outputFields, outputLinesInput } from '../shared/output.js';
4
+ import { readResource } from '../shared/read.js';
5
+ import { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { formatScheduledJob, JOB_NOT_FOUND_HINT, jobInput, jobSiteInput, scheduledJobOperations, scheduledJobOutput, scheduledJobPath, } from './shared.js';
7
+ export const getScheduledJob = defineTool({
8
+ name: 'forge_get_scheduled_job',
9
+ title: 'Get scheduled job',
10
+ description: 'Get a scheduled job with the end of the output of its last run. Pass `site` for jobs that belong to a site.',
11
+ toolset: 'jobs',
12
+ operations: [...scheduledJobOperations('show'), ...scheduledJobOperations('outputs.show')],
13
+ permissions: ['server:view'],
14
+ readOnly: true,
15
+ notFoundHint: JOB_NOT_FOUND_HINT,
16
+ inputSchema: {
17
+ organization: organizationInput,
18
+ server: serverInput,
19
+ site: jobSiteInput,
20
+ job: jobInput,
21
+ include_output: z.boolean().default(true).describe('Also fetch the output of the last run.'),
22
+ output_lines: outputLinesInput(100),
23
+ },
24
+ outputSchema: {
25
+ job: scheduledJobOutput,
26
+ ...outputFields,
27
+ },
28
+ async handler(args, { client, organization, signal }) {
29
+ const path = scheduledJobPath(organization(args.organization), args.server, args.site, args.job);
30
+ const [job, output] = await Promise.all([
31
+ readResource(client, path, signal).then(formatScheduledJob),
32
+ args.include_output ? fetchOutput(client, path, args.output_lines, signal) : Promise.resolve(NO_OUTPUT),
33
+ ]);
34
+ return {
35
+ structured: { job, ...output },
36
+ summary: `\`${job.command}\` runs ${job.frequency} (${job.cron}) as ${job.user}; next run: ${job.next_run_time}.`,
37
+ };
38
+ },
39
+ });
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
5
+ import { SITE_NOT_FOUND_HINT } from '../shared/site-scope.js';
6
+ import { formatScheduledJob, jobSiteInput, scheduledJobOperations, scheduledJobOutput, scheduledJobsPath } from './shared.js';
7
+ const SORT = ['created_at', '-created_at', 'updated_at', '-updated_at', 'status', '-status'];
8
+ export const listScheduledJobs = defineTool({
9
+ name: 'forge_list_scheduled_jobs',
10
+ title: 'List scheduled jobs',
11
+ description: 'List the scheduled jobs (cron) of a server, or of one site with `site`: command, user, frequency, next run. Read the output of the last run with forge_get_scheduled_job.',
12
+ toolset: 'jobs',
13
+ operations: scheduledJobOperations('index'),
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SITE_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ site: jobSiteInput,
21
+ status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
22
+ user: z.string().min(1).optional().describe('Filter by the user the job runs as.'),
23
+ sort: z.array(z.enum(SORT)).min(1).optional(),
24
+ ...paginationInput,
25
+ },
26
+ outputSchema: {
27
+ jobs: z.array(scheduledJobOutput),
28
+ ...paginationOutput,
29
+ },
30
+ async handler(args, { client, organization, signal }) {
31
+ const response = await client.get(scheduledJobsPath(organization(args.organization), args.server, args.site), {
32
+ query: { filter: { status: args.status, user: args.user }, sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
33
+ signal,
34
+ });
35
+ const page = flattenCollection(response.data);
36
+ const jobs = page.items.map(formatScheduledJob);
37
+ return {
38
+ structured: { jobs, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
39
+ summary: jobs.length === 0
40
+ ? 'No scheduled jobs found.'
41
+ : `Found ${jobs.length} scheduled job(s): ${jobs.map((job) => `\`${job.command}\` (${job.id}, ${job.frequency})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
42
+ };
43
+ },
44
+ });
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { phaseOf } from '../shared/async.js';
3
+ import { idInput, pick, siteInput } from '../shared/schemas.js';
4
+ import { sitePath } from '../shared/site-scope.js';
5
+ import { serverPath } from '../servers/shared.js';
6
+ export const CRON_FREQUENCIES = ['minutely', 'hourly', 'nightly', 'weekly', 'monthly', 'reboot', 'custom'];
7
+ /** Scheduled jobs exist on servers and on sites: the same endpoints with or without the site segment. */
8
+ export function scheduledJobsPath(org, server, site) {
9
+ return `${site === undefined ? serverPath(org, server) : sitePath(org, server, site)}/scheduled-jobs`;
10
+ }
11
+ export function scheduledJobPath(org, server, site, job) {
12
+ return `${scheduledJobsPath(org, server, site)}/${encodeURIComponent(String(job))}`;
13
+ }
14
+ /** OperationIds of an endpoint at both levels, e.g. "organizations.servers.scheduled-jobs.show" and the site one. */
15
+ export function scheduledJobOperations(suffix) {
16
+ return [`organizations.servers.scheduled-jobs.${suffix}`, `organizations.servers.sites.scheduled-jobs.${suffix}`];
17
+ }
18
+ export const jobSiteInput = siteInput
19
+ .optional()
20
+ .describe('Site ID for jobs that belong to a site; omit for server-level jobs. Use forge_list_sites to find it.');
21
+ export const jobInput = idInput('Scheduled job ID. Use forge_list_scheduled_jobs to find it.');
22
+ export const JOB_NOT_FOUND_HINT = 'Check the job ID with forge_list_scheduled_jobs, using the same server and (for site jobs) site.';
23
+ const FIELDS = ['id', 'name', 'command', 'user', 'frequency', 'cron', 'status', 'next_run_time', 'created_at', 'updated_at'];
24
+ export const scheduledJobOutput = z.looseObject({
25
+ id: z.string(),
26
+ name: z.string().nullable(),
27
+ command: z.string().nullable(),
28
+ user: z.string().nullable(),
29
+ frequency: z.string().nullable(),
30
+ cron: z.string().nullable().describe('Cron expression the job runs on.'),
31
+ status: z.string().nullable().describe('e.g. installing, installed, removing.'),
32
+ next_run_time: z.string().nullable(),
33
+ created_at: z.string().nullable(),
34
+ updated_at: z.string().nullable(),
35
+ });
36
+ export function formatScheduledJob(flat) {
37
+ return pick(flat, FIELDS);
38
+ }
39
+ export function scheduledJobPhase(status) {
40
+ return phaseOf(status, { pending: ['installing', 'removing', 'updating'], failed: ['failed'] });
41
+ }
@@ -0,0 +1,23 @@
1
+ import { z } from 'zod';
2
+ import { ForgeApiError } from '../../forge/errors.js';
3
+ import { readResource } from './read.js';
4
+ import { tail } from './schemas.js';
5
+ /** Output of a command or scheduled job run, read from `{path}/output`. */
6
+ 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).');
7
+ export const outputFields = {
8
+ output: z.string().nullable().describe('Output (last `output_lines` lines), or null when there is none yet.'),
9
+ output_truncated: z.boolean(),
10
+ output_total_lines: z.number().int().nullable(),
11
+ };
12
+ export const NO_OUTPUT = { output: null, output_truncated: false, output_total_lines: null };
13
+ export async function fetchOutput(client, path, lines, signal) {
14
+ try {
15
+ const output = tail((await readResource(client, `${path}/output`, signal)).output, lines);
16
+ return { output: output.text, output_truncated: output.truncated, output_total_lines: output.total_lines };
17
+ }
18
+ catch (error) {
19
+ if (error instanceof ForgeApiError && error.status === 404)
20
+ return NO_OUTPUT;
21
+ throw error;
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",