@francescomalatesta/laravel-forge-mcp 0.6.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
@@ -117,6 +117,16 @@ npm run build
117
117
  | `forge_archive_server` | servers | Archive a server (Forge stops managing it) |
118
118
  | `forge_unarchive_server` | servers | Restore an archived server |
119
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 |
120
130
  | `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
121
131
  | `forge_get_deployment` | deployments | A deployment with the end of its log |
122
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,6 +28,16 @@ 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';
31
41
  import { archiveServer } from './servers/archive-server.js';
32
42
  import { clearServerLog } from './servers/clear-server-log.js';
33
43
  import { deleteServer } from './servers/delete-server.js';
@@ -106,6 +116,17 @@ export const ALL_TOOLS = [
106
116
  archiveServer,
107
117
  unarchiveServer,
108
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,
109
130
  // deployments
110
131
  listDeployments,
111
132
  getDeployment,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.6.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",