@francescomalatesta/laravel-forge-mcp 0.5.0 → 0.7.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.
package/README.md CHANGED
@@ -106,6 +106,27 @@ npm run build
106
106
  | `forge_create_certificate` | sites | Let's Encrypt, existing, CSR or cloned certificate, waiting until installed |
107
107
  | `forge_run_certificate_action` | sites | Activate or deactivate a certificate |
108
108
  | `forge_delete_certificate` | sites | Delete a certificate |
109
+ | `forge_run_service_action` | servers | Restart or stop Nginx, MySQL, Postgres, Redis, Supervisor; restart or reload PHP-FPM |
110
+ | `forge_run_server_action` | servers | Reboot or power-cycle a server |
111
+ | `forge_get_server_log` | servers | End of a server log (Nginx, PHP-FPM, MySQL, cron, daemons) |
112
+ | `forge_clear_server_log` | servers | Empty a server log |
113
+ | `forge_update_server` | servers | Rename, change IP addresses, timezone or tags |
114
+ | `forge_get_server_network` | servers | Servers that can reach each other on the private network |
115
+ | `forge_update_server_network` | servers | Set the server network |
116
+ | `forge_list_archived_servers` | servers | Archived servers |
117
+ | `forge_archive_server` | servers | Archive a server (Forge stops managing it) |
118
+ | `forge_unarchive_server` | servers | Restore an archived server |
119
+ | `forge_delete_server` | servers | Delete a server (optionally keeping it at the provider) |
120
+ | `forge_list_php_versions` | servers | Installed PHP versions |
121
+ | `forge_get_php_settings` | servers | Default CLI and site versions, upload and execution limits, OPcache |
122
+ | `forge_install_php_version` | servers | Install a PHP version (optionally as default) and wait until installed |
123
+ | `forge_upgrade_php_version` | servers | Update a PHP version to its latest patch release |
124
+ | `forge_uninstall_php_version` | servers | Uninstall a PHP version |
125
+ | `forge_set_default_php_version` | servers | Default PHP version for the CLI or for new sites |
126
+ | `forge_update_php_limits` | servers | Max upload size and max execution time |
127
+ | `forge_set_php_opcache` | servers | Enable or disable OPcache |
128
+ | `forge_get_php_config` | servers | FPM or CLI php.ini, or the FPM pool configuration |
129
+ | `forge_update_php_config` | servers | Replace a PHP configuration file |
109
130
  | `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
110
131
  | `forge_get_deployment` | deployments | A deployment with the end of its log |
111
132
  | `forge_get_deployment_status` | deployments | Whether a deployment is running |
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ export const phpConfigInput = z
4
+ .enum(['fpm', 'cli', 'pool'])
5
+ .describe('"fpm": php.ini used by PHP-FPM (web requests); "cli": php.ini of the `php` command; "pool": PHP-FPM pool configuration (workers, user).');
6
+ export const poolUserInput = z
7
+ .string()
8
+ .min(1)
9
+ .optional()
10
+ .describe('Pool only: the Linux user of an isolated site\'s pool (default: the "forge" pool).');
11
+ export async function readPhpConfig(client, path, user, signal) {
12
+ const response = await client.get(path, { query: { user }, signal });
13
+ return flattenSingle(response.data).configuration ?? '';
14
+ }
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { organizationInput, serverInput } from '../shared/schemas.js';
4
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
5
+ import { phpConfigInput, poolUserInput, readPhpConfig } from './config-files.js';
6
+ import { phpVersionRefInput, phpVersionsPath, resolvePhpVersionId } from './shared.js';
7
+ export const getPhpConfig = defineTool({
8
+ name: 'forge_get_php_config',
9
+ title: 'Get PHP configuration file',
10
+ description: 'Read a configuration file of an installed PHP version: the FPM or CLI php.ini, or the PHP-FPM pool configuration.',
11
+ toolset: 'servers',
12
+ operations: [
13
+ 'organizations.servers.php.versions.configs.fpm.show',
14
+ 'organizations.servers.php.versions.configs.cli.show',
15
+ 'organizations.servers.php.versions.configs.pool.show',
16
+ 'organizations.servers.php.versions.index',
17
+ ],
18
+ permissions: ['server:view'],
19
+ readOnly: true,
20
+ notFoundHint: SERVER_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ organization: organizationInput,
23
+ server: serverInput,
24
+ php_version: phpVersionRefInput,
25
+ config: phpConfigInput,
26
+ user: poolUserInput,
27
+ },
28
+ outputSchema: {
29
+ content: z.string().describe('Content of the configuration file.'),
30
+ },
31
+ async handler(args, { client, organization, signal }) {
32
+ const org = organization(args.organization);
33
+ const id = await resolvePhpVersionId(client, org, args.server, args.php_version, signal);
34
+ const path = `${phpVersionsPath(org, args.server)}/${encodeURIComponent(id)}/configs/${args.config}`;
35
+ const content = await readPhpConfig(client, path, args.config === 'pool' ? args.user : undefined, signal);
36
+ return { structured: { content }, summary: `The ${args.config} configuration has ${content.trimEnd().split('\n').length} line(s).` };
37
+ },
38
+ });
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { organizationInput, serverInput } from '../shared/schemas.js';
4
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
5
+ import { readPhpSetting } from './settings.js';
6
+ export const getPhpSettings = defineTool({
7
+ name: 'forge_get_php_settings',
8
+ title: 'Get PHP settings',
9
+ description: "Get a server's PHP settings in one call: default CLI version, default version for new sites, max upload size, max execution time and whether OPcache is enabled.",
10
+ toolset: 'servers',
11
+ operations: [
12
+ 'organizations.servers.php.cli-version.show',
13
+ 'organizations.servers.php.site-version.show',
14
+ 'organizations.servers.php.max-upload-size.show',
15
+ 'organizations.servers.php.max-execution-time.show',
16
+ 'organizations.servers.php.opcache.show',
17
+ ],
18
+ permissions: ['server:view'],
19
+ readOnly: true,
20
+ notFoundHint: SERVER_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ organization: organizationInput,
23
+ server: serverInput,
24
+ },
25
+ outputSchema: {
26
+ cli_version: z.string().nullable().describe('Version used by the `php` command, e.g. "8.3".'),
27
+ site_version: z.string().nullable().describe('Default version for new sites.'),
28
+ max_upload_size: z.number().nullable().describe('Max upload size in MB.'),
29
+ max_execution_time: z.number().nullable().describe('Max execution time in seconds.'),
30
+ opcache_enabled: z.boolean().nullable(),
31
+ },
32
+ async handler(args, { client, organization, signal }) {
33
+ const org = organization(args.organization);
34
+ const read = (name) => readPhpSetting(client, org, args.server, name, signal);
35
+ const [cli, site, upload, execution, opcache] = await Promise.all([
36
+ read('cli-version'),
37
+ read('site-version'),
38
+ read('max-upload-size'),
39
+ read('max-execution-time'),
40
+ read('opcache'),
41
+ ]);
42
+ const settings = {
43
+ cli_version: cli.version ?? null,
44
+ site_version: site.version ?? null,
45
+ max_upload_size: upload.max_upload_size ?? null,
46
+ max_execution_time: execution.max_execution_time ?? null,
47
+ opcache_enabled: opcache.opcache_enabled ?? null,
48
+ };
49
+ return {
50
+ structured: settings,
51
+ summary: `PHP CLI ${settings.cli_version}, sites ${settings.site_version}, upload ${settings.max_upload_size} MB, execution ${settings.max_execution_time}s, OPcache ${settings.opcache_enabled ? 'on' : 'off'}.`,
52
+ };
53
+ },
54
+ });
@@ -0,0 +1,56 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
+ import { PHP_VERSIONS } from '../sites/shared.js';
7
+ import { findPhpVersion, phpVersionOutput, phpVersionPhase, phpVersionsPath, versionNumber } from './shared.js';
8
+ const CHECK_WITH = 'forge_list_php_versions';
9
+ export const installPhpVersion = defineTool({
10
+ name: 'forge_install_php_version',
11
+ title: 'Install PHP version',
12
+ description: 'Install another PHP version on a server, optionally making it the default for the CLI and/or new sites. Waits until it is installed unless `wait` is false.',
13
+ toolset: 'servers',
14
+ operations: ['organizations.servers.php.versions.store', 'organizations.servers.php.versions.index'],
15
+ permissions: ['server:manage-php', 'server:view'],
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
+ version: z.enum(PHP_VERSIONS).describe('Version to install, e.g. "php84".'),
25
+ cli_default: z.boolean().optional().describe('Make it the version used by the `php` command.'),
26
+ site_default: z.boolean().optional().describe('Make it the default for new sites.'),
27
+ ...waitInput(300),
28
+ },
29
+ outputSchema: {
30
+ ...operationOutput,
31
+ php_version: phpVersionOutput.nullable(),
32
+ },
33
+ async handler(args, { client, organization, signal, sleep, progress }) {
34
+ const org = organization(args.organization);
35
+ await client.post(phpVersionsPath(org, args.server), {
36
+ body: { version: args.version, cli_default: args.cli_default, site_default: args.site_default },
37
+ signal,
38
+ });
39
+ const action = `install ${args.version}`;
40
+ const number = versionNumber(args.version);
41
+ if (!args.wait || !number) {
42
+ const accepted = queued(action, CHECK_WITH);
43
+ return { ...accepted, structured: { ...accepted.structured, php_version: null } };
44
+ }
45
+ // Forge returns no body: follow the version once it appears in the list.
46
+ const result = await waitFor({
47
+ poll: () => findPhpVersion(client, org, args.server, number, signal).then((found) => found ?? null),
48
+ phase: (found) => (found ? phpVersionPhase(found.status) : 'pending'),
49
+ describe: (found) => `PHP ${number} is ${found?.status ?? 'queued'}`,
50
+ timeoutSeconds: args.timeout_seconds,
51
+ context: { sleep, progress },
52
+ });
53
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
54
+ return { structured: { ...done.structured, php_version: result.value ?? null }, summary: done.summary };
55
+ },
56
+ });
@@ -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 { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
+ import { formatPhpVersion, phpVersionOutput, phpVersionsPath } from './shared.js';
7
+ const SORT = ['version', '-version', 'status', '-status', 'created_at', '-created_at', 'updated_at', '-updated_at'];
8
+ export const listPhpVersions = defineTool({
9
+ name: 'forge_list_php_versions',
10
+ title: 'List PHP versions',
11
+ description: 'List the PHP versions installed on a server with their IDs and status. Default CLI and site versions are in forge_get_php_settings.',
12
+ toolset: 'servers',
13
+ operations: ['organizations.servers.php.versions.index'],
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SERVER_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ version: z.string().min(1).optional().describe('Filter by version, e.g. "8.3".'),
21
+ status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
22
+ sort: z.array(z.enum(SORT)).min(1).optional(),
23
+ ...paginationInput,
24
+ },
25
+ outputSchema: {
26
+ php_versions: z.array(phpVersionOutput),
27
+ ...paginationOutput,
28
+ },
29
+ async handler(args, { client, organization, signal }) {
30
+ const response = await client.get(phpVersionsPath(organization(args.organization), args.server), {
31
+ query: {
32
+ filter: { version: args.version, status: args.status },
33
+ sort: args.sort,
34
+ page: { size: args.page_size, cursor: args.cursor },
35
+ },
36
+ signal,
37
+ });
38
+ const page = flattenCollection(response.data);
39
+ const versions = page.items.map(formatPhpVersion);
40
+ return {
41
+ structured: { php_versions: versions, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
42
+ summary: versions.length === 0
43
+ ? 'No PHP versions found.'
44
+ : `Installed: ${versions.map((v) => `${v.version} (${v.status}, ID ${v.id})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
45
+ };
46
+ },
47
+ });
@@ -0,0 +1,54 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { SERVER_NOT_FOUND_HINT, serverPath } from '../servers/shared.js';
7
+ import { readPhpSetting } from './settings.js';
8
+ import { versionNumber } from './shared.js';
9
+ const CHECK_WITH = 'forge_get_php_settings';
10
+ export const setDefaultPhpVersion = defineTool({
11
+ name: 'forge_set_default_php_version',
12
+ title: 'Set default PHP version',
13
+ description: 'Set the PHP version used by the `php` command on the server ("cli") or the default for new sites ("site"). The version must be installed (forge_list_php_versions). Existing sites keep their version: change it with forge_update_site.',
14
+ toolset: 'servers',
15
+ operations: [
16
+ 'organizations.servers.php.cli-version.update',
17
+ 'organizations.servers.php.site-version.update',
18
+ 'organizations.servers.php.cli-version.show',
19
+ 'organizations.servers.php.site-version.show',
20
+ ],
21
+ permissions: ['server:manage-php', 'server:view'],
22
+ readOnly: false,
23
+ destructive: false,
24
+ idempotent: true,
25
+ async: true,
26
+ notFoundHint: SERVER_NOT_FOUND_HINT,
27
+ inputSchema: {
28
+ organization: organizationInput,
29
+ server: serverInput,
30
+ target: z.enum(['cli', 'site']).describe('"cli" for the `php` command, "site" for new sites.'),
31
+ php_version: z.string().min(1).describe('Version, e.g. "8.3" or "php83".'),
32
+ ...waitInput(120),
33
+ },
34
+ outputSchema: operationOutput,
35
+ async handler(args, { client, organization, signal, sleep, progress }) {
36
+ const number = versionNumber(args.php_version);
37
+ if (!number)
38
+ throw new ToolInputError(`Unknown PHP version "${args.php_version}": use a version like "8.3".`);
39
+ const org = organization(args.organization);
40
+ const setting = args.target === 'cli' ? 'cli-version' : 'site-version';
41
+ await client.put(`${serverPath(org, args.server)}/php/${setting}`, { body: { php_version: number }, signal });
42
+ const action = `set the default ${args.target} PHP version to ${number}`;
43
+ if (!args.wait)
44
+ return queued(action, CHECK_WITH);
45
+ const result = await waitFor({
46
+ poll: async () => (await readPhpSetting(client, org, args.server, setting, signal)).version,
47
+ phase: (version) => (version === number ? 'completed' : 'pending'),
48
+ describe: (version) => `Default ${args.target} PHP version is ${version}`,
49
+ timeoutSeconds: args.timeout_seconds,
50
+ context: { sleep, progress },
51
+ });
52
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
53
+ },
54
+ });
@@ -0,0 +1,52 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT, serverPath } from '../servers/shared.js';
6
+ import { readPhpSetting } from './settings.js';
7
+ const CHECK_WITH = 'forge_get_php_settings';
8
+ export const setPhpOpcache = defineTool({
9
+ name: 'forge_set_php_opcache',
10
+ title: 'Enable or disable OPcache',
11
+ description: 'Enable or disable PHP OPcache on the server. Enabled OPcache speeds up PHP; with it, code changes are picked up only after PHP-FPM reloads (Forge deployments reload it).',
12
+ toolset: 'servers',
13
+ operations: [
14
+ 'organizations.servers.php.opcache.store',
15
+ 'organizations.servers.php.opcache.destroy',
16
+ 'organizations.servers.php.opcache.show',
17
+ ],
18
+ permissions: ['server:manage-php', 'server:view'],
19
+ readOnly: false,
20
+ destructive: false,
21
+ idempotent: true,
22
+ async: true,
23
+ notFoundHint: SERVER_NOT_FOUND_HINT,
24
+ inputSchema: {
25
+ organization: organizationInput,
26
+ server: serverInput,
27
+ enabled: z.boolean(),
28
+ ...waitInput(120),
29
+ },
30
+ outputSchema: operationOutput,
31
+ async handler(args, { client, organization, signal, sleep, progress }) {
32
+ const org = organization(args.organization);
33
+ const path = `${serverPath(org, args.server)}/php/opcache`;
34
+ if (args.enabled) {
35
+ await client.post(path, { signal });
36
+ }
37
+ else {
38
+ await client.delete(path, { signal });
39
+ }
40
+ const action = `${args.enabled ? 'enable' : 'disable'} OPcache`;
41
+ if (!args.wait)
42
+ return queued(action, CHECK_WITH);
43
+ const result = await waitFor({
44
+ poll: async () => (await readPhpSetting(client, org, args.server, 'opcache', signal)).opcache_enabled,
45
+ phase: (enabled) => (enabled === args.enabled ? 'completed' : 'pending'),
46
+ describe: () => `Waiting for OPcache to be ${args.enabled ? 'enabled' : 'disabled'}`,
47
+ timeoutSeconds: args.timeout_seconds,
48
+ context: { sleep, progress },
49
+ });
50
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
51
+ },
52
+ });
@@ -0,0 +1,7 @@
1
+ import { flattenSingle } from '../../forge/jsonapi.js';
2
+ import { serverPath } from '../servers/shared.js';
3
+ /** Reads one PHP setting endpoint (`/php/<name>`) and returns its attributes. */
4
+ export async function readPhpSetting(client, org, server, name, signal) {
5
+ const response = await client.get(`${serverPath(org, server)}/php/${name}`, { signal });
6
+ return flattenSingle(response.data);
7
+ }
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { phaseOf } from '../shared/async.js';
4
+ import { pick } from '../shared/schemas.js';
5
+ import { ToolInputError } from '../errors.js';
6
+ import { serverPath } from '../servers/shared.js';
7
+ /** Version numbers used by the default-version endpoints, matching the install identifiers. */
8
+ const VERSION_NUMBERS = {
9
+ php56: '5.6',
10
+ php70: '7.0',
11
+ php71: '7.1',
12
+ php72: '7.2',
13
+ php73: '7.3',
14
+ php74: '7.4',
15
+ php80: '8.0',
16
+ php81: '8.1',
17
+ php82: '8.2',
18
+ php83: '8.3',
19
+ php84: '8.4',
20
+ php85: '8.5',
21
+ };
22
+ export const PHP_VERSION_NUMBERS = ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'];
23
+ /** "php83", "8.3", "php8.3" or "PHP 8.3" → "8.3"; undefined when not a known version. */
24
+ export function versionNumber(value) {
25
+ const compact = value.trim().toLowerCase().replace(/\s+/g, '');
26
+ const dotted = compact.replace(/^php/, '');
27
+ if (PHP_VERSION_NUMBERS.includes(dotted))
28
+ return dotted;
29
+ return VERSION_NUMBERS[compact];
30
+ }
31
+ export const phpVersionRefInput = z
32
+ .union([z.number().int().nonnegative(), z.string().min(1)])
33
+ .describe('Installed PHP version: its ID (from forge_list_php_versions) or the version, e.g. "8.3" or "php83".');
34
+ export function phpVersionsPath(org, server) {
35
+ return `${serverPath(org, server)}/php/versions`;
36
+ }
37
+ export const phpVersionOutput = z.looseObject({
38
+ id: z.string().describe('PHP version ID.'),
39
+ version: z.string().nullable().describe('e.g. "8.3".'),
40
+ binary_name: z.string().nullable().describe('e.g. "php8.3".'),
41
+ status: z.string().nullable(),
42
+ created_at: z.string().nullable(),
43
+ });
44
+ export function formatPhpVersion(flat) {
45
+ return pick(flat, ['id', 'version', 'binary_name', 'status', 'created_at']);
46
+ }
47
+ /** Installed version with this number, or undefined. */
48
+ export async function findPhpVersion(client, org, server, number, signal) {
49
+ const response = await client.get(phpVersionsPath(org, server), {
50
+ query: { filter: { version: number }, page: { size: 100 } },
51
+ signal,
52
+ });
53
+ const match = flattenCollection(response.data).items.find((item) => item.version === number);
54
+ return match ? formatPhpVersion(match) : undefined;
55
+ }
56
+ /** Resolves an ID or a version ("8.3", "php83") to the ID of the installed version. */
57
+ export async function resolvePhpVersionId(client, org, server, ref, signal) {
58
+ // Digits only is an ID; versions are written with a dot ("8.3") or a "php" prefix.
59
+ if (typeof ref === 'number' || /^\d+$/.test(ref))
60
+ return String(ref);
61
+ const number = versionNumber(ref);
62
+ if (!number)
63
+ throw new ToolInputError(`Unknown PHP version "${ref}": use an ID from forge_list_php_versions or a version like "8.3".`);
64
+ const installed = await findPhpVersion(client, org, server, number, signal);
65
+ if (!installed) {
66
+ throw new ToolInputError(`PHP ${number} is not installed on server ${server}. Install it with forge_install_php_version.`);
67
+ }
68
+ return installed.id;
69
+ }
70
+ export function phpVersionPhase(status) {
71
+ return phaseOf(status, { pending: ['installing', 'updating', 'removing', 'uninstalling'], failed: ['failed'] });
72
+ }
@@ -0,0 +1,48 @@
1
+ import { flattenSingle } from '../../forge/jsonapi.js';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
+ import { phpVersionRefInput, phpVersionsPath, resolvePhpVersionId } from './shared.js';
7
+ const CHECK_WITH = 'forge_list_php_versions';
8
+ export const uninstallPhpVersion = defineTool({
9
+ name: 'forge_uninstall_php_version',
10
+ title: 'Uninstall PHP version',
11
+ description: 'Uninstall a PHP version from a server. Sites still using it stop working: move them first with forge_update_site.',
12
+ toolset: 'servers',
13
+ operations: [
14
+ 'organizations.servers.php.versions.destroy',
15
+ 'organizations.servers.php.versions.show',
16
+ 'organizations.servers.php.versions.index',
17
+ ],
18
+ permissions: ['server:manage-php', 'server:view'],
19
+ readOnly: false,
20
+ destructive: true,
21
+ idempotent: true,
22
+ async: true,
23
+ notFoundHint: SERVER_NOT_FOUND_HINT,
24
+ inputSchema: {
25
+ organization: organizationInput,
26
+ server: serverInput,
27
+ php_version: phpVersionRefInput,
28
+ ...waitInput(180),
29
+ },
30
+ outputSchema: operationOutput,
31
+ async handler(args, { client, organization, signal, sleep, progress }) {
32
+ const org = organization(args.organization);
33
+ const id = await resolvePhpVersionId(client, org, args.server, args.php_version, signal);
34
+ const path = `${phpVersionsPath(org, args.server)}/${encodeURIComponent(id)}`;
35
+ await client.delete(path, { signal });
36
+ const action = `uninstall PHP ${args.php_version}`;
37
+ if (!args.wait)
38
+ return queued(action, CHECK_WITH);
39
+ const result = await waitFor({
40
+ poll: () => orGone(async () => flattenSingle((await client.get(path, { signal })).data)),
41
+ phase: (version) => (version === null ? 'completed' : 'pending'),
42
+ describe: (version) => `PHP ${args.php_version} is ${version?.status ?? 'removing'}`,
43
+ timeoutSeconds: args.timeout_seconds,
44
+ context: { sleep, progress },
45
+ });
46
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
47
+ },
48
+ });
@@ -0,0 +1,62 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
6
+ import { phpConfigInput, poolUserInput, readPhpConfig } from './config-files.js';
7
+ import { phpVersionRefInput, phpVersionsPath, resolvePhpVersionId } from './shared.js';
8
+ const CHECK_WITH = 'forge_get_php_config';
9
+ export const updatePhpConfig = defineTool({
10
+ name: 'forge_update_php_config',
11
+ title: 'Replace PHP configuration file',
12
+ description: 'Replace a whole configuration file of an installed PHP version (FPM or CLI php.ini, or the FPM pool) and reload PHP. Read it first with forge_get_php_config and send the complete new file: an invalid file can stop PHP-FPM and every site using it.',
13
+ toolset: 'servers',
14
+ operations: [
15
+ 'organizations.servers.php.versions.configs.fpm.update',
16
+ 'organizations.servers.php.versions.configs.cli.update',
17
+ 'organizations.servers.php.versions.configs.pool.update',
18
+ 'organizations.servers.php.versions.configs.fpm.show',
19
+ 'organizations.servers.php.versions.configs.cli.show',
20
+ 'organizations.servers.php.versions.configs.pool.show',
21
+ 'organizations.servers.php.versions.index',
22
+ ],
23
+ permissions: ['server:manage-php', 'server:view'],
24
+ readOnly: false,
25
+ destructive: true,
26
+ idempotent: true,
27
+ async: true,
28
+ notFoundHint: SERVER_NOT_FOUND_HINT,
29
+ inputSchema: {
30
+ organization: organizationInput,
31
+ server: serverInput,
32
+ php_version: phpVersionRefInput,
33
+ config: phpConfigInput,
34
+ content: z.string().min(1).describe('The complete new configuration file.'),
35
+ user: poolUserInput,
36
+ ...waitInput(120),
37
+ },
38
+ outputSchema: operationOutput,
39
+ async handler(args, { client, organization, signal, sleep, progress }) {
40
+ const org = organization(args.organization);
41
+ const id = await resolvePhpVersionId(client, org, args.server, args.php_version, signal);
42
+ const path = `${phpVersionsPath(org, args.server)}/${encodeURIComponent(id)}/configs/${args.config}`;
43
+ const user = args.config === 'pool' ? args.user : undefined;
44
+ await client.put(path, { body: { config: args.content, ...(user ? { user } : {}) }, signal });
45
+ const action = `update the ${args.config} configuration`;
46
+ if (!args.wait)
47
+ return queued(action, CHECK_WITH);
48
+ const result = await waitFor({
49
+ poll: () => readPhpConfig(client, path, user, signal),
50
+ phase: (content) => (content.trimEnd() === args.content.trimEnd() ? 'completed' : 'pending'),
51
+ describe: () => `Waiting for the ${args.config} configuration to be applied`,
52
+ timeoutSeconds: args.timeout_seconds,
53
+ context: { sleep, progress },
54
+ });
55
+ return outcome(result, {
56
+ action,
57
+ checkWith: CHECK_WITH,
58
+ timeoutSeconds: args.timeout_seconds,
59
+ detail: result.status === 'in_progress' ? 'If PHP rejected the file, check forge_list_server_events.' : undefined,
60
+ });
61
+ },
62
+ });
@@ -0,0 +1,64 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { SERVER_NOT_FOUND_HINT, serverPath } from '../servers/shared.js';
7
+ import { readPhpSetting } from './settings.js';
8
+ const CHECK_WITH = 'forge_get_php_settings';
9
+ export const updatePhpLimits = defineTool({
10
+ name: 'forge_update_php_limits',
11
+ title: 'Update PHP limits',
12
+ description: 'Change the max upload size and/or max execution time of every PHP version on the server. PHP-FPM is reloaded.',
13
+ toolset: 'servers',
14
+ operations: [
15
+ 'organizations.servers.php.max-upload-size.update',
16
+ 'organizations.servers.php.max-execution-time.update',
17
+ 'organizations.servers.php.max-upload-size.show',
18
+ 'organizations.servers.php.max-execution-time.show',
19
+ ],
20
+ permissions: ['server:manage-php', 'server:view'],
21
+ readOnly: false,
22
+ destructive: false,
23
+ idempotent: true,
24
+ async: true,
25
+ notFoundHint: SERVER_NOT_FOUND_HINT,
26
+ inputSchema: {
27
+ organization: organizationInput,
28
+ server: serverInput,
29
+ max_upload_size: z.number().int().min(1).optional().describe('Max upload size in MB.'),
30
+ max_execution_time: z.number().int().min(1).optional().describe('Max execution time in seconds.'),
31
+ ...waitInput(120),
32
+ },
33
+ outputSchema: operationOutput,
34
+ async handler(args, { client, organization, signal, sleep, progress }) {
35
+ if (args.max_upload_size === undefined && args.max_execution_time === undefined) {
36
+ throw new ToolInputError('Pass `max_upload_size` and/or `max_execution_time`.');
37
+ }
38
+ const org = organization(args.organization);
39
+ const base = `${serverPath(org, args.server)}/php`;
40
+ if (args.max_upload_size !== undefined) {
41
+ await client.put(`${base}/max-upload-size`, { body: { max_upload_size: args.max_upload_size }, signal });
42
+ }
43
+ if (args.max_execution_time !== undefined) {
44
+ await client.put(`${base}/max-execution-time`, { body: { max_execution_time: args.max_execution_time }, signal });
45
+ }
46
+ const action = 'update the PHP limits';
47
+ if (!args.wait)
48
+ return queued(action, CHECK_WITH);
49
+ const result = await waitFor({
50
+ poll: async () => ({
51
+ upload: args.max_upload_size === undefined ? undefined : (await readPhpSetting(client, org, args.server, 'max-upload-size', signal)).max_upload_size,
52
+ execution: args.max_execution_time === undefined ? undefined : (await readPhpSetting(client, org, args.server, 'max-execution-time', signal)).max_execution_time,
53
+ }),
54
+ phase: (current) => (args.max_upload_size === undefined || current.upload === args.max_upload_size) &&
55
+ (args.max_execution_time === undefined || current.execution === args.max_execution_time)
56
+ ? 'completed'
57
+ : 'pending',
58
+ describe: () => 'Waiting for the PHP limits to be applied',
59
+ timeoutSeconds: args.timeout_seconds,
60
+ context: { sleep, progress },
61
+ });
62
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
63
+ },
64
+ });
@@ -0,0 +1,31 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { operationOutput, queued } from '../shared/async.js';
3
+ import { organizationInput, serverInput } from '../shared/schemas.js';
4
+ import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
5
+ import { phpVersionRefInput, phpVersionsPath, resolvePhpVersionId } from './shared.js';
6
+ export const upgradePhpVersion = defineTool({
7
+ name: 'forge_upgrade_php_version',
8
+ title: 'Upgrade PHP to the latest patch release',
9
+ description: 'Update an installed PHP version to its latest patch release (e.g. 8.3.x). PHP-FPM restarts during the update.',
10
+ toolset: 'servers',
11
+ operations: ['organizations.servers.php.versions.update', 'organizations.servers.php.versions.index'],
12
+ permissions: ['server:manage-php', 'server:view'],
13
+ readOnly: false,
14
+ destructive: false,
15
+ idempotent: true,
16
+ async: true,
17
+ notFoundHint: SERVER_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ organization: organizationInput,
20
+ server: serverInput,
21
+ php_version: phpVersionRefInput,
22
+ },
23
+ outputSchema: operationOutput,
24
+ async handler(args, { client, organization, signal }) {
25
+ const org = organization(args.organization);
26
+ const id = await resolvePhpVersionId(client, org, args.server, args.php_version, signal);
27
+ await client.put(`${phpVersionsPath(org, args.server)}/${encodeURIComponent(id)}`, { signal });
28
+ // The patch level is not exposed by the API: the server events record the outcome.
29
+ return queued(`upgrade PHP ${args.php_version}`, 'forge_list_server_events', `(server ${args.server})`);
30
+ },
31
+ });
@@ -28,8 +28,29 @@ import { updateDomain } from './domains/update-domain.js';
28
28
  import { getServerEvent } from './events/get-server-event.js';
29
29
  import { listServerEvents } from './events/list-server-events.js';
30
30
  import { listOrganizations } from './organizations/list-organizations.js';
31
+ import { getPhpConfig } from './php/get-php-config.js';
32
+ import { getPhpSettings } from './php/get-php-settings.js';
33
+ import { installPhpVersion } from './php/install-php-version.js';
34
+ import { listPhpVersions } from './php/list-php-versions.js';
35
+ import { setDefaultPhpVersion } from './php/set-default-php-version.js';
36
+ import { setPhpOpcache } from './php/set-php-opcache.js';
37
+ import { uninstallPhpVersion } from './php/uninstall-php-version.js';
38
+ import { updatePhpConfig } from './php/update-php-config.js';
39
+ import { updatePhpLimits } from './php/update-php-limits.js';
40
+ import { upgradePhpVersion } from './php/upgrade-php-version.js';
41
+ import { archiveServer } from './servers/archive-server.js';
42
+ import { clearServerLog } from './servers/clear-server-log.js';
43
+ import { deleteServer } from './servers/delete-server.js';
31
44
  import { getServer } from './servers/get-server.js';
45
+ import { getServerLog } from './servers/get-server-log.js';
46
+ import { getServerNetwork } from './servers/get-server-network.js';
47
+ import { listArchivedServers } from './servers/list-archived-servers.js';
32
48
  import { listServers } from './servers/list-servers.js';
49
+ import { runServerAction } from './servers/run-server-action.js';
50
+ import { runServiceAction } from './servers/run-service-action.js';
51
+ import { unarchiveServer } from './servers/unarchive-server.js';
52
+ import { updateServer } from './servers/update-server.js';
53
+ import { updateServerNetwork } from './servers/update-server-network.js';
33
54
  import { clearSiteLog } from './sites/clear-site-log.js';
34
55
  import { createBalancedSite } from './sites/create-balanced-site.js';
35
56
  import { createSite } from './sites/create-site.js';
@@ -83,6 +104,29 @@ export const ALL_TOOLS = [
83
104
  createCertificate,
84
105
  runCertificateAction,
85
106
  deleteCertificate,
107
+ // servers
108
+ runServiceAction,
109
+ runServerAction,
110
+ getServerLog,
111
+ clearServerLog,
112
+ updateServer,
113
+ getServerNetwork,
114
+ updateServerNetwork,
115
+ listArchivedServers,
116
+ archiveServer,
117
+ unarchiveServer,
118
+ deleteServer,
119
+ // servers: PHP
120
+ listPhpVersions,
121
+ getPhpSettings,
122
+ installPhpVersion,
123
+ upgradePhpVersion,
124
+ uninstallPhpVersion,
125
+ setDefaultPhpVersion,
126
+ updatePhpLimits,
127
+ setPhpOpcache,
128
+ getPhpConfig,
129
+ updatePhpConfig,
86
130
  // deployments
87
131
  listDeployments,
88
132
  getDeployment,
@@ -0,0 +1,40 @@
1
+ import { apiPath } from '../../forge/path.js';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT, archivedServerIds } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_archived_servers';
7
+ export const archiveServer = defineTool({
8
+ name: 'forge_archive_server',
9
+ title: 'Archive server',
10
+ description: 'Archive a server: Forge stops managing it (no deployments, no monitoring) but the machine keeps running at the provider and can be restored with forge_unarchive_server.',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.archives.store', 'organizations.servers.archives.index'],
13
+ permissions: ['server:archive', 'server:view'],
14
+ readOnly: false,
15
+ destructive: false,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: SERVER_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ ...waitInput(120),
23
+ },
24
+ outputSchema: operationOutput,
25
+ async handler(args, { client, organization, signal, sleep, progress }) {
26
+ const org = organization(args.organization);
27
+ await client.post(apiPath `/orgs/${org}/servers/archives`, { body: { server_id: Number(args.server) }, signal });
28
+ const action = `archive server ${args.server}`;
29
+ if (!args.wait)
30
+ return queued(action, CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: () => archivedServerIds(client, org, signal),
33
+ phase: (ids) => (ids.has(String(args.server)) ? 'completed' : 'pending'),
34
+ describe: () => `Waiting for server ${args.server} to be archived`,
35
+ timeoutSeconds: args.timeout_seconds,
36
+ context: { sleep, progress },
37
+ });
38
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
39
+ },
40
+ });
@@ -0,0 +1,45 @@
1
+ import { flattenSingle } from '../../forge/jsonapi.js';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { serverLogKeyInput } from './server-logs.js';
6
+ import { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_server_log';
8
+ export const clearServerLog = defineTool({
9
+ name: 'forge_clear_server_log',
10
+ title: 'Clear server log',
11
+ description: 'Empty a server-level log. The previous content is lost.',
12
+ toolset: 'servers',
13
+ operations: ['organizations.servers.logs.destroy', 'organizations.servers.logs.show'],
14
+ permissions: ['server:manage-logs'],
15
+ readOnly: false,
16
+ destructive: true,
17
+ idempotent: true,
18
+ async: true,
19
+ notFoundHint: SERVER_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ organization: organizationInput,
22
+ server: serverInput,
23
+ key: serverLogKeyInput,
24
+ ...waitInput(60),
25
+ },
26
+ outputSchema: operationOutput,
27
+ async handler(args, { client, organization, signal, sleep, progress }) {
28
+ const path = `${serverPath(organization(args.organization), args.server)}/logs/${encodeURIComponent(args.key)}`;
29
+ await client.delete(path, { signal });
30
+ const action = `clear the ${args.key} log`;
31
+ if (!args.wait)
32
+ return queued(action, CHECK_WITH);
33
+ const result = await waitFor({
34
+ poll: async () => {
35
+ const response = await client.get(path, { signal });
36
+ return (flattenSingle(response.data).content ?? '').trim();
37
+ },
38
+ phase: (content) => (content === '' ? 'completed' : 'pending'),
39
+ describe: () => `Waiting for the ${args.key} log to be cleared`,
40
+ timeoutSeconds: args.timeout_seconds,
41
+ context: { sleep, progress },
42
+ });
43
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
44
+ },
45
+ });
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { SERVER_NOT_FOUND_HINT, readServer, serverPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_servers';
7
+ export const deleteServer = defineTool({
8
+ name: 'forge_delete_server',
9
+ title: 'Delete server',
10
+ description: 'Delete a server from Forge and destroy it at the provider, with every site and database on it. With `preserve_at_provider: true` it is only removed from Forge and keeps running at the provider. This cannot be undone: consider forge_archive_server instead.',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.destroy', 'organizations.servers.show'],
13
+ permissions: ['server:delete', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: SERVER_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ preserve_at_provider: z.boolean().default(false).describe('Only remove the server from Forge, keeping the machine at the provider.'),
23
+ ...waitInput(300),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const org = organization(args.organization);
28
+ await client.delete(serverPath(org, args.server), {
29
+ query: { preserve_at_provider: args.preserve_at_provider ? 'true' : undefined },
30
+ signal,
31
+ });
32
+ const action = `delete server ${args.server}${args.preserve_at_provider ? ' from Forge (keeping it at the provider)' : ''}`;
33
+ if (!args.wait)
34
+ return queued(action, CHECK_WITH);
35
+ const result = await waitFor({
36
+ poll: () => orGone(() => readServer(client, org, args.server, signal)),
37
+ phase: (server) => (server === null ? 'completed' : 'pending'),
38
+ describe: () => `Waiting for server ${args.server} to be deleted`,
39
+ timeoutSeconds: args.timeout_seconds,
40
+ context: { sleep, progress },
41
+ });
42
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
43
+ },
44
+ });
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { organizationInput, serverInput, tail } from '../shared/schemas.js';
5
+ import { serverLogKeyInput } from './server-logs.js';
6
+ import { serverPath } from './shared.js';
7
+ export const getServerLog = defineTool({
8
+ name: 'forge_get_server_log',
9
+ title: 'Get server log',
10
+ description: 'Read the end of a server-level log (Nginx, PHP-FPM, MySQL, cron, daemons). For site logs use forge_get_site_log.',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.logs.show'],
13
+ permissions: ['server:manage-logs'],
14
+ readOnly: true,
15
+ notFoundHint: 'Check the server ID with forge_list_servers; the log key may not exist on this server (the service may not be installed).',
16
+ inputSchema: {
17
+ organization: organizationInput,
18
+ server: serverInput,
19
+ key: serverLogKeyInput,
20
+ lines: z.number().int().min(0).max(5000).default(100).describe('Return only the last N lines (0 = everything Forge returns).'),
21
+ },
22
+ outputSchema: {
23
+ key: z.string(),
24
+ content: z.string(),
25
+ truncated: z.boolean(),
26
+ total_lines: z.number().int(),
27
+ },
28
+ async handler(args, { client, organization, signal }) {
29
+ const path = `${serverPath(organization(args.organization), args.server)}/logs/${encodeURIComponent(args.key)}`;
30
+ const response = await client.get(path, { signal });
31
+ const output = tail(flattenSingle(response.data).content, args.lines);
32
+ return {
33
+ structured: { key: args.key, content: output.text, truncated: output.truncated, total_lines: output.total_lines },
34
+ summary: output.total_lines === 0
35
+ ? `The ${args.key} log is empty.`
36
+ : `${args.key} log: ${output.truncated ? `last ${args.lines} of ${output.total_lines}` : output.total_lines} line(s).`,
37
+ };
38
+ },
39
+ });
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { formatServer, serverOutput } from './format.js';
6
+ import { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
7
+ export const getServerNetwork = defineTool({
8
+ name: 'forge_get_server_network',
9
+ title: 'Get server network',
10
+ description: 'List the servers in this server\'s network: Forge opens the firewall between them so they can reach each other (e.g. an app server and its database or cache server).',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.network.show'],
13
+ permissions: ['server:view'],
14
+ readOnly: true,
15
+ notFoundHint: SERVER_NOT_FOUND_HINT,
16
+ inputSchema: {
17
+ organization: organizationInput,
18
+ server: serverInput,
19
+ },
20
+ outputSchema: {
21
+ servers: z.array(serverOutput),
22
+ },
23
+ async handler(args, { client, organization, signal }) {
24
+ const response = await client.get(`${serverPath(organization(args.organization), args.server)}/network`, { signal });
25
+ const servers = flattenCollection(response.data).items.map((item) => formatServer(item, false));
26
+ return {
27
+ structured: { servers },
28
+ summary: servers.length === 0
29
+ ? 'No other servers are in this server\'s network.'
30
+ : `${servers.length} server(s) in the network: ${servers.map((s) => `${s.name} (${s.id})`).join(', ')}.`,
31
+ };
32
+ },
33
+ });
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { apiPath } from '../../forge/path.js';
4
+ import { defineTool } from '../define-tool.js';
5
+ import { organizationInput, paginationInput, paginationOutput, paginationSummary, responseFormatInput } from '../shared/schemas.js';
6
+ import { formatServer, serverOutput } from './format.js';
7
+ export const listArchivedServers = defineTool({
8
+ name: 'forge_list_archived_servers',
9
+ title: 'List archived servers',
10
+ description: 'List the archived servers of the organization: servers Forge no longer manages but kept so they can be restored.',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.archives.index'],
13
+ permissions: ['server:view'],
14
+ readOnly: true,
15
+ notFoundHint: 'Check the organization slug with forge_list_organizations.',
16
+ inputSchema: {
17
+ organization: organizationInput,
18
+ sort: z.array(z.enum(['created_at', '-created_at', 'updated_at', '-updated_at'])).min(1).optional(),
19
+ ...paginationInput,
20
+ response_format: responseFormatInput,
21
+ },
22
+ outputSchema: {
23
+ servers: z.array(serverOutput),
24
+ ...paginationOutput,
25
+ },
26
+ async handler(args, { client, organization, signal }) {
27
+ const response = await client.get(apiPath `/orgs/${organization(args.organization)}/servers/archives`, {
28
+ query: { sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
29
+ signal,
30
+ });
31
+ const page = flattenCollection(response.data);
32
+ const servers = page.items.map((item) => formatServer(item, args.response_format === 'detailed'));
33
+ return {
34
+ structured: { servers, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
35
+ summary: servers.length === 0 ? 'No archived servers.' : `Found ${servers.length} archived server(s).${paginationSummary(page.nextCursor)}`,
36
+ };
37
+ },
38
+ });
@@ -0,0 +1,29 @@
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 { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
6
+ export const runServerAction = defineTool({
7
+ name: 'forge_run_server_action',
8
+ title: 'Reboot or power-cycle a server',
9
+ description: 'Reboot the server ("reboot", a clean OS restart) or power-cycle it ("power-cycle", a hard restart through the provider, for unresponsive servers). Every site on the server is unavailable meanwhile.',
10
+ toolset: 'servers',
11
+ operations: ['organizations.servers.actions.store'],
12
+ permissions: ['server:manage-services'],
13
+ readOnly: false,
14
+ destructive: true,
15
+ idempotent: false,
16
+ async: true,
17
+ notFoundHint: SERVER_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ organization: organizationInput,
20
+ server: serverInput,
21
+ action: z.enum(['reboot', 'power-cycle']),
22
+ },
23
+ outputSchema: operationOutput,
24
+ async handler(args, { client, organization, signal }) {
25
+ await client.post(`${serverPath(organization(args.organization), args.server)}/actions`, { body: { action: args.action }, signal });
26
+ // The server resource exposes no reliable reboot state to wait for.
27
+ return queued(`${args.action} server ${args.server}`, 'forge_list_server_events', `(server ${args.server})`);
28
+ },
29
+ });
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput, queued } from '../shared/async.js';
5
+ import { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { PHP_VERSIONS } from '../sites/shared.js';
7
+ import { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
8
+ /** Actions each service endpoint accepts, from the spec. */
9
+ const SERVICE_ACTIONS = {
10
+ nginx: ['reboot', 'stop'],
11
+ mysql: ['reboot', 'stop'],
12
+ postgres: ['reboot', 'stop'],
13
+ redis: ['reboot'],
14
+ supervisor: ['reboot'],
15
+ php: ['reboot', 'reload'],
16
+ };
17
+ export const runServiceAction = defineTool({
18
+ name: 'forge_run_service_action',
19
+ title: 'Restart, reload or stop a service',
20
+ description: [
21
+ 'Run an action on a service of the server.',
22
+ '"reboot" restarts it (nginx, mysql, postgres, redis, supervisor, php);',
23
+ '"stop" stops it (nginx, mysql, postgres);',
24
+ '"reload" gracefully reloads PHP-FPM (php).',
25
+ 'For php, pass `php_version`. Restarting a service briefly interrupts the sites that use it.',
26
+ ].join(' '),
27
+ toolset: 'servers',
28
+ operations: [
29
+ 'organizations.servers.services.nginx.actions.store',
30
+ 'organizations.servers.services.mysql.actions.store',
31
+ 'organizations.servers.services.postgres.actions.store',
32
+ 'organizations.servers.services.redis.actions.store',
33
+ 'organizations.servers.services.supervisor.actions.store',
34
+ 'organizations.servers.services.php.actions.store',
35
+ ],
36
+ permissions: ['server:manage-services'],
37
+ readOnly: false,
38
+ destructive: true,
39
+ idempotent: false,
40
+ async: true,
41
+ notFoundHint: SERVER_NOT_FOUND_HINT,
42
+ inputSchema: {
43
+ organization: organizationInput,
44
+ server: serverInput,
45
+ service: z.enum(Object.keys(SERVICE_ACTIONS)),
46
+ action: z.enum(['reboot', 'stop', 'reload']).default('reboot'),
47
+ php_version: z.enum(PHP_VERSIONS).optional().describe('PHP version whose FPM to act on (required for php), e.g. "php83".'),
48
+ },
49
+ outputSchema: operationOutput,
50
+ async handler(args, { client, organization, signal }) {
51
+ const allowed = SERVICE_ACTIONS[args.service];
52
+ if (!allowed.includes(args.action)) {
53
+ throw new ToolInputError(`${args.service} supports: ${allowed.join(', ')}.`);
54
+ }
55
+ if (args.service === 'php' && !args.php_version) {
56
+ throw new ToolInputError('Pass `php_version` (e.g. "php83") to act on PHP-FPM. The server PHP versions are in forge_get_server.');
57
+ }
58
+ await client.post(`${serverPath(organization(args.organization), args.server)}/services/${args.service}/actions`, {
59
+ body: { action: args.action, ...(args.service === 'php' ? { version: args.php_version } : {}) },
60
+ signal,
61
+ });
62
+ const target = args.service === 'php' ? `${args.php_version} FPM` : args.service;
63
+ // Service state is not exposed by the API: the server events record the outcome.
64
+ return queued(`${args.action} ${target}`, 'forge_list_server_events', `(server ${args.server})`);
65
+ },
66
+ });
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod';
2
+ export const serverLogKeyInput = z
3
+ .string()
4
+ .regex(/^[a-z0-9][a-z0-9._-]*$/)
5
+ .describe('Log to read. Common keys: "nginx-access", "nginx-error", "php" (PHP-FPM of the default version), "mysql", "cron", "daemon"; availability depends on the installed services.');
@@ -0,0 +1,18 @@
1
+ import { flattenCollection, flattenSingle } from '../../forge/jsonapi.js';
2
+ import { apiPath } from '../../forge/path.js';
3
+ export function serverPath(org, server) {
4
+ return apiPath `/orgs/${org}/servers/${server}`;
5
+ }
6
+ export const SERVER_NOT_FOUND_HINT = 'Check the server ID with forge_list_servers.';
7
+ export async function readServer(client, org, server, signal) {
8
+ const response = await client.get(serverPath(org, server), { signal });
9
+ return flattenSingle(response.data);
10
+ }
11
+ /** IDs of the archived servers on the first page (most recently updated first). */
12
+ export async function archivedServerIds(client, org, signal) {
13
+ const response = await client.get(apiPath `/orgs/${org}/servers/archives`, {
14
+ query: { sort: ['-updated_at'], page: { size: 100 } },
15
+ signal,
16
+ });
17
+ return new Set(flattenCollection(response.data).items.map((server) => server.id));
18
+ }
@@ -0,0 +1,40 @@
1
+ import { apiPath } from '../../forge/path.js';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { organizationInput, serverInput } from '../shared/schemas.js';
5
+ import { archivedServerIds } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_servers';
7
+ export const unarchiveServer = defineTool({
8
+ name: 'forge_unarchive_server',
9
+ title: 'Unarchive server',
10
+ description: 'Restore an archived server so Forge manages it again.',
11
+ toolset: 'servers',
12
+ operations: ['organizations.servers.archives.destroy', 'organizations.servers.archives.index'],
13
+ permissions: ['server:archive', 'server:view'],
14
+ readOnly: false,
15
+ destructive: false,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: 'Check the server ID with forge_list_archived_servers.',
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput.describe('ID of the archived server. Use forge_list_archived_servers to find it.'),
22
+ ...waitInput(120),
23
+ },
24
+ outputSchema: operationOutput,
25
+ async handler(args, { client, organization, signal, sleep, progress }) {
26
+ const org = organization(args.organization);
27
+ await client.delete(apiPath `/orgs/${org}/servers/archives/${args.server}`, { signal });
28
+ const action = `unarchive server ${args.server}`;
29
+ if (!args.wait)
30
+ return queued(action, CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: () => archivedServerIds(client, org, signal),
33
+ phase: (ids) => (ids.has(String(args.server)) ? 'pending' : 'completed'),
34
+ describe: () => `Waiting for server ${args.server} to be restored`,
35
+ timeoutSeconds: args.timeout_seconds,
36
+ context: { sleep, progress },
37
+ });
38
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
39
+ },
40
+ });
@@ -0,0 +1,46 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
5
+ import { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_server_network';
8
+ export const updateServerNetwork = defineTool({
9
+ name: 'forge_update_server_network',
10
+ title: 'Update server network',
11
+ description: "Set which servers are in this server's network (the full list: servers not listed are removed). Read the current list first with forge_get_server_network.",
12
+ toolset: 'servers',
13
+ operations: ['organizations.servers.network.update', 'organizations.servers.network.show'],
14
+ permissions: ['server:manage-network', 'server:view'],
15
+ readOnly: false,
16
+ destructive: true,
17
+ idempotent: true,
18
+ async: true,
19
+ notFoundHint: SERVER_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ organization: organizationInput,
22
+ server: serverInput,
23
+ servers: z.array(z.number().int()).describe('IDs of every server that should be in the network (can be empty).'),
24
+ ...waitInput(120),
25
+ },
26
+ outputSchema: operationOutput,
27
+ async handler(args, { client, organization, signal, sleep, progress }) {
28
+ const path = `${serverPath(organization(args.organization), args.server)}/network`;
29
+ await client.put(path, { body: { servers: args.servers }, signal });
30
+ const action = 'update the server network';
31
+ if (!args.wait)
32
+ return queued(action, CHECK_WITH);
33
+ const wanted = [...new Set(args.servers.map(String))].sort().join(',');
34
+ const result = await waitFor({
35
+ poll: async () => {
36
+ const response = await client.get(path, { signal });
37
+ return flattenCollection(response.data).items.map((item) => item.id).sort().join(',');
38
+ },
39
+ phase: (current) => (current === wanted ? 'completed' : 'pending'),
40
+ describe: () => 'Waiting for the network to be updated',
41
+ timeoutSeconds: args.timeout_seconds,
42
+ context: { sleep, progress },
43
+ });
44
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
45
+ },
46
+ });
@@ -0,0 +1,41 @@
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 { organizationInput, serverInput } from '../shared/schemas.js';
6
+ import { formatServer, serverOutput } from './format.js';
7
+ import { SERVER_NOT_FOUND_HINT, serverPath } from './shared.js';
8
+ export const updateServer = defineTool({
9
+ name: 'forge_update_server',
10
+ title: 'Update server',
11
+ description: "Change a server's name, public or private IP address (when the provider changed it), timezone or tags in Forge. Only the given fields change.",
12
+ toolset: 'servers',
13
+ operations: ['organizations.servers.update'],
14
+ permissions: ['server:manage-meta'],
15
+ readOnly: false,
16
+ destructive: false,
17
+ idempotent: true,
18
+ notFoundHint: SERVER_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ name: z.string().min(1).optional(),
23
+ ip_address: z.string().min(1).optional().describe('Public IP address Forge uses to connect.'),
24
+ private_ip_address: z.string().min(1).optional(),
25
+ timezone: z.string().min(1).optional().describe('IANA timezone, e.g. "Europe/Rome".'),
26
+ tags: z.array(z.string().min(1)).optional().describe('Replaces the server tags.'),
27
+ },
28
+ outputSchema: {
29
+ server: serverOutput,
30
+ },
31
+ async handler(args, { client, organization, signal }) {
32
+ const { organization: _org, server: id, ...body } = args;
33
+ if (Object.values(body).every((value) => value === undefined)) {
34
+ throw new ToolInputError('Nothing to update: pass at least one field to change.');
35
+ }
36
+ const response = await client.put(serverPath(organization(args.organization), id), { body, signal });
37
+ const server = formatServer(flattenSingle(response.data), true);
38
+ const changed = Object.entries(body).filter(([, value]) => value !== undefined).map(([field]) => field);
39
+ return { structured: { server }, summary: `Server ${server.id} updated: ${changed.join(', ')}.` };
40
+ },
41
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",