@francescomalatesta/laravel-forge-mcp 0.5.0 → 0.6.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,17 @@ 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) |
109
120
  | `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
110
121
  | `forge_get_deployment` | deployments | A deployment with the end of its log |
111
122
  | `forge_get_deployment_status` | deployments | Whether a deployment is running |
@@ -28,8 +28,19 @@ 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 { archiveServer } from './servers/archive-server.js';
32
+ import { clearServerLog } from './servers/clear-server-log.js';
33
+ import { deleteServer } from './servers/delete-server.js';
31
34
  import { getServer } from './servers/get-server.js';
35
+ import { getServerLog } from './servers/get-server-log.js';
36
+ import { getServerNetwork } from './servers/get-server-network.js';
37
+ import { listArchivedServers } from './servers/list-archived-servers.js';
32
38
  import { listServers } from './servers/list-servers.js';
39
+ import { runServerAction } from './servers/run-server-action.js';
40
+ import { runServiceAction } from './servers/run-service-action.js';
41
+ import { unarchiveServer } from './servers/unarchive-server.js';
42
+ import { updateServer } from './servers/update-server.js';
43
+ import { updateServerNetwork } from './servers/update-server-network.js';
33
44
  import { clearSiteLog } from './sites/clear-site-log.js';
34
45
  import { createBalancedSite } from './sites/create-balanced-site.js';
35
46
  import { createSite } from './sites/create-site.js';
@@ -83,6 +94,18 @@ export const ALL_TOOLS = [
83
94
  createCertificate,
84
95
  runCertificateAction,
85
96
  deleteCertificate,
97
+ // servers
98
+ runServiceAction,
99
+ runServerAction,
100
+ getServerLog,
101
+ clearServerLog,
102
+ updateServer,
103
+ getServerNetwork,
104
+ updateServerNetwork,
105
+ listArchivedServers,
106
+ archiveServer,
107
+ unarchiveServer,
108
+ deleteServer,
86
109
  // deployments
87
110
  listDeployments,
88
111
  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.6.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",