@francescomalatesta/laravel-forge-mcp 0.3.0 → 0.4.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
@@ -81,6 +81,20 @@ npm run build
81
81
  | `forge_get_site` | core | Every detail of a site, including its server |
82
82
  | `forge_list_server_events` | core | Operations Forge ran on a server or across the organization |
83
83
  | `forge_get_server_event` | core | An event with the output of its script |
84
+ | `forge_create_site` | sites | Create a site (optionally with repository and database) and wait until it is installed |
85
+ | `forge_create_balanced_site` | sites | Create a site on a load balancer |
86
+ | `forge_update_site` | sites | Change PHP version, type, directories, branch, push to deploy, release retention |
87
+ | `forge_update_site_repository` | sites | Switch source control provider, repository or branch |
88
+ | `forge_delete_site` | sites | Delete a site |
89
+ | `forge_set_site_env_vars` | sites | Set or remove .env variables without exposing the file |
90
+ | `forge_get_site_environment` 🔑 | sites | Read the whole .env file |
91
+ | `forge_update_site_environment` 🔑 | sites | Replace the whole .env file |
92
+ | `forge_get_site_nginx_config` | sites | Read the site's Nginx configuration |
93
+ | `forge_update_site_nginx_config` | sites | Replace the site's Nginx configuration |
94
+ | `forge_get_site_log` | sites | End of the application, Nginx access or Nginx error log |
95
+ | `forge_clear_site_log` | sites | Empty a site log |
96
+ | `forge_get_site_healthcheck` | sites | Healthcheck URL used after zero-downtime deployments |
97
+ | `forge_update_site_healthcheck` | sites | Set or remove the healthcheck URL |
84
98
  | `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
85
99
  | `forge_get_deployment` | deployments | A deployment with the end of its log |
86
100
  | `forge_get_deployment_status` | deployments | Whether a deployment is running |
@@ -98,7 +112,7 @@ npm run build
98
112
  | `forge_get_deploy_hook` 🔑 | deployments | The deployment trigger URL |
99
113
  | `forge_regenerate_deploy_hook` 🔑 | deployments | Generate a new deployment trigger URL |
100
114
 
101
- 🔑 Returns secrets: registered only when `FORGE_ALLOW_SECRETS=true`. Secret fields returned by other tools (e.g. a site's `deployment_url`) are hidden unless secrets are allowed.
115
+ 🔑 Reads or replaces secrets: registered only when `FORGE_ALLOW_SECRETS=true`. Secret fields returned by other tools (e.g. a site's `deployment_url`) are hidden unless secrets are allowed.
102
116
 
103
117
  ### Background operations
104
118
 
@@ -1,20 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import { ForgeApiError } from '../../forge/errors.js';
3
3
  import { flattenSingle } from '../../forge/jsonapi.js';
4
- import { apiPath } from '../../forge/path.js';
5
4
  import { phaseOf } from '../shared/async.js';
6
5
  import { relatedField, relatedId } from '../shared/relationships.js';
7
- import { organizationInput, serverInput, siteInput, tail } from '../shared/schemas.js';
8
- /** Arguments identifying a site: deployment endpoints are nested under the server. */
9
- export const siteScopeInput = {
10
- organization: organizationInput,
11
- server: serverInput,
12
- site: siteInput,
13
- };
14
- export function sitePath(org, server, site) {
15
- return apiPath `/orgs/${org}/servers/${server}/sites/${site}`;
16
- }
17
- export const SITE_NOT_FOUND_HINT = 'Check the server and site IDs with forge_list_sites (each site lists its `server_id`).';
6
+ import { tail } from '../shared/schemas.js';
7
+ export { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
18
8
  /** Deployments complete when finished and fail when failed, failed in the build or cancelled. */
19
9
  export function deploymentPhase(status) {
20
10
  return phaseOf(status, { completed: ['finished'], failed: ['failed', 'failed-build', 'cancelled'] });
@@ -19,8 +19,22 @@ import { listServerEvents } from './events/list-server-events.js';
19
19
  import { listOrganizations } from './organizations/list-organizations.js';
20
20
  import { getServer } from './servers/get-server.js';
21
21
  import { listServers } from './servers/list-servers.js';
22
+ import { clearSiteLog } from './sites/clear-site-log.js';
23
+ import { createBalancedSite } from './sites/create-balanced-site.js';
24
+ import { createSite } from './sites/create-site.js';
25
+ import { deleteSite } from './sites/delete-site.js';
26
+ import { getSiteEnvironment } from './sites/get-site-environment.js';
27
+ import { getSiteHealthcheck } from './sites/get-site-healthcheck.js';
28
+ import { getSiteLog } from './sites/get-site-log.js';
29
+ import { getSiteNginxConfig } from './sites/get-site-nginx-config.js';
22
30
  import { getSite } from './sites/get-site.js';
23
31
  import { listSites } from './sites/list-sites.js';
32
+ import { setSiteEnvVars } from './sites/set-site-env-vars.js';
33
+ import { updateSite } from './sites/update-site.js';
34
+ import { updateSiteEnvironment } from './sites/update-site-environment.js';
35
+ import { updateSiteHealthcheck } from './sites/update-site-healthcheck.js';
36
+ import { updateSiteNginxConfig } from './sites/update-site-nginx-config.js';
37
+ import { updateSiteRepository } from './sites/update-site-repository.js';
24
38
  /** Every tool shipped by the server. Add new tools here. */
25
39
  export const ALL_TOOLS = [
26
40
  // core
@@ -31,6 +45,21 @@ export const ALL_TOOLS = [
31
45
  getSite,
32
46
  listServerEvents,
33
47
  getServerEvent,
48
+ // sites
49
+ createSite,
50
+ createBalancedSite,
51
+ updateSite,
52
+ updateSiteRepository,
53
+ deleteSite,
54
+ setSiteEnvVars,
55
+ getSiteEnvironment,
56
+ updateSiteEnvironment,
57
+ getSiteNginxConfig,
58
+ updateSiteNginxConfig,
59
+ getSiteLog,
60
+ clearSiteLog,
61
+ getSiteHealthcheck,
62
+ updateSiteHealthcheck,
34
63
  // deployments
35
64
  listDeployments,
36
65
  getDeployment,
@@ -0,0 +1,12 @@
1
+ import { apiPath } from '../../forge/path.js';
2
+ import { organizationInput, serverInput, siteInput } from './schemas.js';
3
+ /** Arguments identifying a site: site endpoints are nested under the server. */
4
+ export const siteScopeInput = {
5
+ organization: organizationInput,
6
+ server: serverInput,
7
+ site: siteInput,
8
+ };
9
+ export function sitePath(org, server, site) {
10
+ return apiPath `/orgs/${org}/servers/${server}/sites/${site}`;
11
+ }
12
+ export const SITE_NOT_FOUND_HINT = 'Check the server and site IDs with forge_list_sites (each site lists its `server_id`).';
@@ -0,0 +1,50 @@
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 { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
5
+ import { siteLogInput } from './logs.js';
6
+ const CHECK_WITH = 'forge_get_site_log';
7
+ export const clearSiteLog = defineTool({
8
+ name: 'forge_clear_site_log',
9
+ title: 'Clear site log',
10
+ description: 'Empty a site log (application, Nginx access or Nginx error). The previous content is lost.',
11
+ toolset: 'sites',
12
+ operations: [
13
+ 'organizations.servers.sites.logs.application.destroy',
14
+ 'organizations.servers.sites.logs.nginx-access.destroy',
15
+ 'organizations.servers.sites.logs.nginx-error.destroy',
16
+ 'organizations.servers.sites.logs.application.show',
17
+ 'organizations.servers.sites.logs.nginx-access.show',
18
+ 'organizations.servers.sites.logs.nginx-error.show',
19
+ ],
20
+ permissions: ['server:manage-logs'],
21
+ readOnly: false,
22
+ destructive: true,
23
+ idempotent: true,
24
+ async: true,
25
+ notFoundHint: SITE_NOT_FOUND_HINT,
26
+ inputSchema: {
27
+ ...siteScopeInput,
28
+ log: siteLogInput,
29
+ ...waitInput(60),
30
+ },
31
+ outputSchema: operationOutput,
32
+ async handler(args, { client, organization, signal, sleep, progress }) {
33
+ const path = `${sitePath(organization(args.organization), args.server, args.site)}/logs/${args.log}`;
34
+ await client.delete(path, { signal });
35
+ const action = `clear the ${args.log} log`;
36
+ if (!args.wait)
37
+ return queued(action, CHECK_WITH);
38
+ const result = await waitFor({
39
+ poll: async () => {
40
+ const response = await client.get(path, { signal });
41
+ return (flattenSingle(response.data).content ?? '').trim();
42
+ },
43
+ phase: (content) => (content === '' ? 'completed' : 'pending'),
44
+ describe: () => `Waiting for the ${args.log} log to be cleared`,
45
+ timeoutSeconds: args.timeout_seconds,
46
+ context: { sleep, progress },
47
+ });
48
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
49
+ },
50
+ });
@@ -0,0 +1,70 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { apiPath } from '../../forge/path.js';
4
+ import { defineTool } from '../define-tool.js';
5
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
6
+ import { organizationInput, serverInput } from '../shared/schemas.js';
7
+ import { formatSite, siteOutput } from './format.js';
8
+ import { readSite, sitePhase } from './shared.js';
9
+ const CHECK_WITH = 'forge_get_site';
10
+ const node = z.object({
11
+ server_id: z.number().int().describe('Server receiving traffic.'),
12
+ port: z.number().int().min(1).max(65535).optional(),
13
+ weight: z.number().int().min(1).optional().describe('Relative share of traffic.'),
14
+ backup: z.boolean().optional().describe('Only used when the other servers are unavailable.'),
15
+ down: z.boolean().optional().describe('Temporarily take this server out of rotation.'),
16
+ });
17
+ export const createBalancedSite = defineTool({
18
+ name: 'forge_create_balanced_site',
19
+ title: 'Create load-balanced site',
20
+ description: 'Create a site on a load balancer server that distributes traffic to sites on other servers. Waits until Forge finishes installing it unless `wait` is false.',
21
+ toolset: 'sites',
22
+ operations: ['organizations.servers.sites.storeOnBalancer', 'organizations.sites.show'],
23
+ permissions: ['site:create', 'server:view'],
24
+ readOnly: false,
25
+ destructive: false,
26
+ idempotent: false,
27
+ async: true,
28
+ notFoundHint: 'Check the load balancer server ID with forge_list_servers (type "loadbalancer").',
29
+ inputSchema: {
30
+ organization: organizationInput,
31
+ server: serverInput.describe('ID of the load balancer server.'),
32
+ domain: z.string().min(1).describe('Domain of the site, e.g. "example.com".'),
33
+ balancing: z.array(node).min(1).describe('Servers that receive the traffic.'),
34
+ balancer_method: z.enum(['round_robin', 'least_conn', 'ip_hash']).optional(),
35
+ balancer_keepalive_max_connections: z.number().int().optional(),
36
+ allow_wildcard_subdomains: z.boolean().optional(),
37
+ ...waitInput(300),
38
+ },
39
+ outputSchema: {
40
+ ...operationOutput,
41
+ site: siteOutput.nullable(),
42
+ },
43
+ async handler(args, { client, config, organization, signal, sleep, progress }) {
44
+ const org = organization(args.organization);
45
+ const { organization: _org, server, wait, timeout_seconds, ...body } = args;
46
+ const created = await client.post(apiPath `/orgs/${org}/servers/${server}/sites/balancer`, {
47
+ body,
48
+ signal,
49
+ });
50
+ const initial = created.data?.data ? flattenSingle(created.data) : undefined;
51
+ const format = (flat) => formatSite(flat, { detailed: false, allowSecrets: config.allowSecrets, serverId: String(server) });
52
+ const action = `create the load-balanced site "${args.domain}"`;
53
+ if (!initial || !wait) {
54
+ return {
55
+ structured: { status: 'queued', check_with: initial ? CHECK_WITH : 'forge_list_sites', site: initial ? format(initial) : null },
56
+ summary: `Forge accepted the request to ${action}. Follow it with ${initial ? `${CHECK_WITH} (site ${initial.id})` : 'forge_list_sites'}.`,
57
+ };
58
+ }
59
+ const result = await waitFor({
60
+ initial,
61
+ poll: () => readSite(client, org, initial.id, signal),
62
+ phase: (site) => sitePhase(site.status),
63
+ describe: (site) => `Site ${site.id} is ${site.status}`,
64
+ timeoutSeconds: timeout_seconds,
65
+ context: { sleep, progress },
66
+ });
67
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: timeout_seconds });
68
+ return { structured: { ...done.structured, site: format(result.value ?? initial) }, summary: done.summary };
69
+ },
70
+ });
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { apiPath } from '../../forge/path.js';
4
+ import { defineTool } from '../define-tool.js';
5
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
6
+ import { organizationInput, serverInput } from '../shared/schemas.js';
7
+ import { formatSite, siteOutput } from './format.js';
8
+ import { PHP_VERSIONS, SITE_TYPES, SOURCE_CONTROL_PROVIDERS, readSite, sitePhase } from './shared.js';
9
+ const CHECK_WITH = 'forge_get_site';
10
+ const sharedPath = z.object({
11
+ from: z.string().min(1).describe("Path relative to the project's root directory on the server."),
12
+ to: z.string().min(1).describe("Path relative to the release directory to link it to."),
13
+ });
14
+ export const createSite = defineTool({
15
+ name: 'forge_create_site',
16
+ title: 'Create site',
17
+ description: 'Create a site (virtual host) on a server, optionally installing a Git repository and connecting a database. Waits until Forge finishes installing it unless `wait` is false. Find database and source-control IDs with the related list tools; deploy it afterwards with forge_deploy_site.',
18
+ toolset: 'sites',
19
+ operations: ['organizations.servers.sites.store', 'organizations.sites.show'],
20
+ permissions: ['site:create', 'server:view'],
21
+ readOnly: false,
22
+ destructive: false,
23
+ idempotent: false,
24
+ async: true,
25
+ notFoundHint: 'Check the server ID with forge_list_servers.',
26
+ inputSchema: {
27
+ organization: organizationInput,
28
+ server: serverInput,
29
+ name: z.string().min(1).describe('Primary domain of the site, e.g. "example.com".'),
30
+ type: z.enum(SITE_TYPES).default('laravel').describe('Application type.'),
31
+ domain_mode: z
32
+ .enum(['custom', 'on-forge'])
33
+ .optional()
34
+ .describe('"custom" for your own domain, "on-forge" for a Forge-provided domain.'),
35
+ www_redirect_type: z.enum(['from-www', 'to-www', 'none']).optional().describe('www redirection for the domain.'),
36
+ allow_wildcard_subdomains: z.boolean().optional(),
37
+ php_version: z.enum(PHP_VERSIONS).optional().describe('PHP version; the server default when omitted.'),
38
+ web_directory: z.string().min(1).optional().describe('Public directory relative to the root, e.g. "/public".'),
39
+ root_directory: z.string().min(1).optional().describe('Project root directory.'),
40
+ is_isolated: z.boolean().optional().describe('Run the site as its own Linux user (website isolation).'),
41
+ isolated_user: z
42
+ .string()
43
+ .regex(/^[a-z][-a-z0-9_]*$/)
44
+ .max(32)
45
+ .optional()
46
+ .describe('Linux user for an isolated site.'),
47
+ zero_downtime_deployments: z.boolean().optional(),
48
+ shared_paths: z.array(sharedPath).optional().describe('Paths shared between releases with zero-downtime deployments.'),
49
+ nginx_template_id: z.number().int().optional().describe('Nginx template to use.'),
50
+ source_control_provider: z.enum(SOURCE_CONTROL_PROVIDERS).optional(),
51
+ source_control_provider_id: z.number().int().optional().describe('Source control connection to deploy through.'),
52
+ repository: z.string().min(1).optional().describe('Repository to install, e.g. "acme/app".'),
53
+ branch: z.string().min(1).optional().describe('Branch to deploy.'),
54
+ install_composer_dependencies: z.boolean().optional(),
55
+ generate_deploy_key: z.boolean().optional().describe('Generate a deploy key for this repository only.'),
56
+ push_to_deploy: z.boolean().optional().describe('Deploy automatically on every push to the branch.'),
57
+ database_id: z.number().int().optional().describe('Existing database to use with the site.'),
58
+ database_user_id: z.number().int().optional().describe('Existing database user to use with the site.'),
59
+ frontend_package_manager: z.string().min(1).optional().describe('Package manager for frontend apps, e.g. "npm".'),
60
+ frontend_build_command: z.string().min(1).optional().describe('Build command for frontend assets.'),
61
+ nuxt_next_mode: z.string().min(1).optional().describe('Render mode for Next.js / Nuxt.js apps.'),
62
+ nuxt_next_port: z.number().int().optional().describe('Port used by Next.js / Nuxt.js apps.'),
63
+ statamic_setup: z.string().min(1).optional().describe('Statamic setup type.'),
64
+ statamic_starter_kit: z.string().min(1).optional(),
65
+ statamic_super_user_email: z.string().email().optional(),
66
+ statamic_super_user_password: z.string().min(1).optional(),
67
+ tags: z.array(z.string().min(1)).optional(),
68
+ ...waitInput(300),
69
+ },
70
+ outputSchema: {
71
+ ...operationOutput,
72
+ site: siteOutput.nullable(),
73
+ },
74
+ async handler(args, { client, config, organization, signal, sleep, progress }) {
75
+ const org = organization(args.organization);
76
+ const { organization: _org, server, wait, timeout_seconds, ...body } = args;
77
+ const created = await client.post(apiPath `/orgs/${org}/servers/${server}/sites`, {
78
+ body,
79
+ signal,
80
+ });
81
+ const initial = created.data?.data ? flattenSingle(created.data) : undefined;
82
+ const format = (flat) => formatSite(flat, { detailed: false, allowSecrets: config.allowSecrets, serverId: String(server) });
83
+ const action = `create the site "${args.name}"`;
84
+ if (!initial) {
85
+ return {
86
+ structured: { status: 'queued', check_with: 'forge_list_sites', site: null },
87
+ summary: `Forge accepted the request to ${action}. Find it with forge_list_sites.`,
88
+ };
89
+ }
90
+ if (!wait) {
91
+ return {
92
+ structured: { status: 'queued', check_with: CHECK_WITH, site: format(initial) },
93
+ summary: `Forge is creating site ${initial.id}. Follow it with ${CHECK_WITH}.`,
94
+ };
95
+ }
96
+ const result = await waitFor({
97
+ initial,
98
+ poll: () => readSite(client, org, initial.id, signal),
99
+ phase: (site) => sitePhase(site.status),
100
+ describe: (site) => `Site ${site.id} is ${site.status}`,
101
+ timeoutSeconds: timeout_seconds,
102
+ context: { sleep, progress },
103
+ });
104
+ const site = format(result.value ?? initial);
105
+ const done = outcome(result, {
106
+ action,
107
+ checkWith: CHECK_WITH,
108
+ timeoutSeconds: timeout_seconds,
109
+ detail: result.status === 'completed' ? `Site ID ${site.id} on server ${site.server_id}; deploy it with forge_deploy_site.` : undefined,
110
+ });
111
+ return { structured: { ...done.structured, site }, summary: done.summary };
112
+ },
113
+ });
@@ -0,0 +1,38 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
3
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
4
+ import { readSite } from './shared.js';
5
+ const CHECK_WITH = 'forge_list_sites';
6
+ export const deleteSite = defineTool({
7
+ name: 'forge_delete_site',
8
+ title: 'Delete site',
9
+ description: 'Permanently delete a site from its server, including its files, Nginx configuration and certificates. Databases are kept. This cannot be undone.',
10
+ toolset: 'sites',
11
+ operations: ['organizations.servers.sites.destroy', 'organizations.sites.show'],
12
+ permissions: ['site:delete', 'server:view'],
13
+ readOnly: false,
14
+ destructive: true,
15
+ idempotent: true,
16
+ async: true,
17
+ notFoundHint: SITE_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ ...siteScopeInput,
20
+ ...waitInput(120),
21
+ },
22
+ outputSchema: operationOutput,
23
+ async handler(args, { client, organization, signal, sleep, progress }) {
24
+ const org = organization(args.organization);
25
+ await client.delete(sitePath(org, args.server, args.site), { signal });
26
+ const action = `delete site ${args.site}`;
27
+ if (!args.wait)
28
+ return queued(action, CHECK_WITH);
29
+ const result = await waitFor({
30
+ poll: () => orGone(() => readSite(client, org, args.site, signal)),
31
+ phase: (site) => (site === null ? 'completed' : 'pending'),
32
+ describe: (site) => `Site is ${site?.status ?? 'removing'}`,
33
+ timeoutSeconds: args.timeout_seconds,
34
+ context: { sleep, progress },
35
+ });
36
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
37
+ },
38
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Minimal, lossless editing of .env files: only the targeted keys change,
3
+ * every other line (comments, blank lines, ordering) is kept as is.
4
+ */
5
+ export const ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
6
+ /** Formats a value so dotenv parsers read it back unchanged. */
7
+ export function formatEnvValue(value) {
8
+ if (/^[A-Za-z0-9_.:/@,+\-=]*$/.test(value))
9
+ return value;
10
+ // Single quotes are literal (no interpolation, no escapes) when the value allows it.
11
+ if (!value.includes("'") && !value.includes('\n'))
12
+ return `'${value}'`;
13
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`;
14
+ }
15
+ function keyOf(line) {
16
+ return /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=/.exec(line)?.[1];
17
+ }
18
+ export function patchEnv(content, { set = {}, unset = [] }) {
19
+ const lines = content.length > 0 ? content.replace(/\n$/, '').split('\n') : [];
20
+ const updated = [];
21
+ const removed = [];
22
+ const pending = new Map(Object.entries(set));
23
+ const toRemove = new Set(unset);
24
+ const result = [];
25
+ for (const line of lines) {
26
+ const key = keyOf(line);
27
+ if (key !== undefined && toRemove.has(key)) {
28
+ if (!removed.includes(key))
29
+ removed.push(key);
30
+ continue;
31
+ }
32
+ if (key !== undefined && Object.hasOwn(set, key)) {
33
+ // Keep the first occurrence (updated in place) and drop duplicates.
34
+ if (!pending.has(key))
35
+ continue;
36
+ const exportPrefix = /^\s*export\s+/.test(line) ? 'export ' : '';
37
+ result.push(`${exportPrefix}${key}=${formatEnvValue(pending.get(key))}`);
38
+ pending.delete(key);
39
+ updated.push(key);
40
+ continue;
41
+ }
42
+ result.push(line);
43
+ }
44
+ const added = [...pending.keys()];
45
+ for (const [key, value] of pending)
46
+ result.push(`${key}=${formatEnvValue(value)}`);
47
+ return { content: `${result.join('\n')}\n`, updated, added, removed };
48
+ }
49
+ /** Whether two .env contents are the same, ignoring trailing whitespace. */
50
+ export function sameEnv(a, b) {
51
+ return (a ?? '').trimEnd() === b.trimEnd();
52
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { waitFor } from '../shared/async.js';
4
+ import { sameEnv } from './env-file.js';
5
+ /** Options of the environment update endpoint shared by the .env tools. */
6
+ export const environmentUpdateOptions = {
7
+ cache: z.boolean().optional().describe('Cache the configuration after updating (`php artisan config:cache`).'),
8
+ queues: z.boolean().optional().describe('Restart queue workers after updating.'),
9
+ };
10
+ export async function readEnvironment(client, base, signal) {
11
+ const response = await client.get(`${base}/environment`, { signal });
12
+ return flattenSingle(response.data).content ?? '';
13
+ }
14
+ /** Writes the .env file and, when asked, waits until Forge serves the new content. */
15
+ export async function writeEnvironment(client, base, content, options, context) {
16
+ await client.put(`${base}/environment`, {
17
+ body: { environment: content, cache: options.cache, queues: options.queues },
18
+ signal: context.signal,
19
+ });
20
+ if (!options.wait)
21
+ return undefined;
22
+ return waitFor({
23
+ poll: () => readEnvironment(client, base, context.signal),
24
+ phase: (current) => (sameEnv(current, content) ? 'completed' : 'pending'),
25
+ describe: () => 'Waiting for Forge to write the .env file',
26
+ timeoutSeconds: options.timeoutSeconds,
27
+ context,
28
+ });
29
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
5
+ export const getSiteEnvironment = defineTool({
6
+ name: 'forge_get_site_environment',
7
+ title: 'Get site .env',
8
+ description: "Read the site's .env file. It contains secrets (database passwords, API keys): only share what the user needs. To change a few variables without reading the file, use forge_set_site_env_vars.",
9
+ toolset: 'sites',
10
+ operations: ['organizations.servers.sites.environment.show'],
11
+ permissions: ['server:view'],
12
+ readOnly: true,
13
+ exposesSecrets: true,
14
+ notFoundHint: SITE_NOT_FOUND_HINT,
15
+ inputSchema: siteScopeInput,
16
+ outputSchema: {
17
+ content: z.string().nullable().describe('Content of the .env file.'),
18
+ },
19
+ async handler(args, { client, organization, signal }) {
20
+ const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/environment`, { signal });
21
+ const content = flattenSingle(response.data).content ?? null;
22
+ return {
23
+ structured: { content },
24
+ summary: content ? `The .env file has ${content.trimEnd().split('\n').length} line(s).` : 'The site has no .env content.',
25
+ };
26
+ },
27
+ });
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
5
+ export const healthcheckOutput = {
6
+ healthcheck_endpoint: z.string().nullable().describe('URL Forge checks after zero-downtime deployments, or null.'),
7
+ };
8
+ export const getSiteHealthcheck = defineTool({
9
+ name: 'forge_get_site_healthcheck',
10
+ title: 'Get site healthcheck',
11
+ description: 'Get the healthcheck URL Forge calls to verify the site after zero-downtime deployments.',
12
+ toolset: 'sites',
13
+ operations: ['organizations.servers.sites.healthcheck.show'],
14
+ permissions: ['site:manage-project'],
15
+ readOnly: true,
16
+ notFoundHint: SITE_NOT_FOUND_HINT,
17
+ inputSchema: siteScopeInput,
18
+ outputSchema: healthcheckOutput,
19
+ async handler(args, { client, organization, signal }) {
20
+ const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/healthcheck`, { signal });
21
+ const endpoint = flattenSingle(response.data).healthcheck_endpoint ?? null;
22
+ return {
23
+ structured: { healthcheck_endpoint: endpoint },
24
+ summary: endpoint ? `Healthcheck endpoint: ${endpoint}.` : 'No healthcheck endpoint is configured.',
25
+ };
26
+ },
27
+ });
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { tail } from '../shared/schemas.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
6
+ import { siteLogInput } from './logs.js';
7
+ export const getSiteLog = defineTool({
8
+ name: 'forge_get_site_log',
9
+ title: 'Get site log',
10
+ description: 'Read the end of a site log: the application log, the Nginx access log or the Nginx error log. Use it to diagnose errors after a deployment.',
11
+ toolset: 'sites',
12
+ operations: [
13
+ 'organizations.servers.sites.logs.application.show',
14
+ 'organizations.servers.sites.logs.nginx-access.show',
15
+ 'organizations.servers.sites.logs.nginx-error.show',
16
+ ],
17
+ permissions: ['server:manage-logs'],
18
+ readOnly: true,
19
+ notFoundHint: SITE_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ ...siteScopeInput,
22
+ log: siteLogInput,
23
+ lines: z.number().int().min(0).max(5000).default(100).describe('Return only the last N lines (0 = everything Forge returns).'),
24
+ },
25
+ outputSchema: {
26
+ log: z.string(),
27
+ content: z.string().describe('Log content (last `lines` lines).'),
28
+ truncated: z.boolean(),
29
+ total_lines: z.number().int(),
30
+ },
31
+ async handler(args, { client, organization, signal }) {
32
+ const path = `${sitePath(organization(args.organization), args.server, args.site)}/logs/${args.log}`;
33
+ const response = await client.get(path, { signal });
34
+ const output = tail(flattenSingle(response.data).content, args.lines);
35
+ return {
36
+ structured: { log: args.log, content: output.text, truncated: output.truncated, total_lines: output.total_lines },
37
+ summary: output.total_lines === 0
38
+ ? `The ${args.log} log is empty.`
39
+ : `${args.log} log: ${output.truncated ? `last ${args.lines} of ${output.total_lines}` : output.total_lines} line(s).`,
40
+ };
41
+ },
42
+ });
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
5
+ export const getSiteNginxConfig = defineTool({
6
+ name: 'forge_get_site_nginx_config',
7
+ title: 'Get site Nginx configuration',
8
+ description: "Read the site's Nginx configuration file (server blocks, locations, PHP-FPM socket, SSL settings).",
9
+ toolset: 'sites',
10
+ operations: ['organizations.servers.sites.nginx.show'],
11
+ permissions: ['server:view'],
12
+ readOnly: true,
13
+ notFoundHint: SITE_NOT_FOUND_HINT,
14
+ inputSchema: siteScopeInput,
15
+ outputSchema: {
16
+ content: z.string().nullable().describe('Nginx configuration of the site.'),
17
+ },
18
+ async handler(args, { client, organization, signal }) {
19
+ const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/nginx`, { signal });
20
+ const content = flattenSingle(response.data).content ?? null;
21
+ return {
22
+ structured: { content },
23
+ summary: content ? `The Nginx configuration has ${content.trimEnd().split('\n').length} line(s).` : 'No Nginx configuration found.',
24
+ };
25
+ },
26
+ });
@@ -0,0 +1,6 @@
1
+ import { z } from 'zod';
2
+ /** Site logs; each value is also the endpoint path segment (`/logs/<log>`). */
3
+ export const siteLogInput = z
4
+ .enum(['application', 'nginx-access', 'nginx-error'])
5
+ .default('application')
6
+ .describe('"application": the app log (e.g. storage/logs/laravel.log); "nginx-access" / "nginx-error": the site\'s Nginx logs.');
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput, outcome, queued, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
6
+ import { ENV_KEY_PATTERN, patchEnv } from './env-file.js';
7
+ import { environmentUpdateOptions, readEnvironment, writeEnvironment } from './environment.js';
8
+ // Reading the file back needs FORGE_ALLOW_SECRETS; the tool itself never returns values.
9
+ const CHECK_WITH = 'forge_get_site_environment';
10
+ const envKey = z.string().regex(ENV_KEY_PATTERN, 'Invalid variable name');
11
+ export const setSiteEnvVars = defineTool({
12
+ name: 'forge_set_site_env_vars',
13
+ title: 'Set site environment variables',
14
+ description: "Set or remove specific variables in a site's .env file, keeping every other line unchanged. The file content and current values are never returned, so this works without exposing secrets. Values are quoted automatically when needed.",
15
+ toolset: 'sites',
16
+ operations: ['organizations.servers.sites.environment.show', 'organizations.servers.sites.environment.update'],
17
+ permissions: ['server:view', 'site:manage-environment'],
18
+ readOnly: false,
19
+ destructive: false,
20
+ idempotent: true,
21
+ async: true,
22
+ notFoundHint: SITE_NOT_FOUND_HINT,
23
+ inputSchema: {
24
+ ...siteScopeInput,
25
+ set: z.record(envKey, z.string()).optional().describe('Variables to add or update, e.g. {"APP_DEBUG": "false"}.'),
26
+ unset: z.array(envKey).optional().describe('Variables to remove.'),
27
+ ...environmentUpdateOptions,
28
+ ...waitInput(60),
29
+ },
30
+ outputSchema: {
31
+ ...operationOutput,
32
+ updated: z.array(z.string()).describe('Existing variables that were set.'),
33
+ added: z.array(z.string()).describe('Variables appended to the file.'),
34
+ removed: z.array(z.string()).describe('Variables removed from the file.'),
35
+ not_found: z.array(z.string()).describe('Variables to remove that were not in the file.'),
36
+ },
37
+ async handler(args, { client, organization, signal, sleep, progress }) {
38
+ const set = args.set ?? {};
39
+ const unset = args.unset ?? [];
40
+ if (Object.keys(set).length === 0 && unset.length === 0) {
41
+ throw new ToolInputError('Nothing to change: pass `set` and/or `unset`.');
42
+ }
43
+ const conflicting = unset.filter((key) => Object.hasOwn(set, key));
44
+ if (conflicting.length > 0)
45
+ throw new ToolInputError(`Variables both set and unset: ${conflicting.join(', ')}.`);
46
+ const base = sitePath(organization(args.organization), args.server, args.site);
47
+ const current = await readEnvironment(client, base, signal);
48
+ const patch = patchEnv(current, { set, unset });
49
+ const notFound = unset.filter((key) => !patch.removed.includes(key));
50
+ const changes = { updated: patch.updated, added: patch.added, removed: patch.removed, not_found: notFound };
51
+ if (patch.updated.length + patch.added.length + patch.removed.length === 0) {
52
+ return {
53
+ structured: { status: 'completed', check_with: CHECK_WITH, ...changes },
54
+ summary: `Nothing to change: ${notFound.join(', ')} not found in the .env file.`,
55
+ };
56
+ }
57
+ const result = await writeEnvironment(client, base, patch.content, { cache: args.cache, queues: args.queues, wait: args.wait, timeoutSeconds: args.timeout_seconds }, { signal, sleep, progress });
58
+ const list = [...patch.updated, ...patch.added].join(', ');
59
+ const action = [list && `set ${list}`, patch.removed.length > 0 && `remove ${patch.removed.join(', ')}`]
60
+ .filter(Boolean)
61
+ .join(' and ');
62
+ const done = result
63
+ ? outcome(result, { action: `${action} in the .env file`, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds })
64
+ : queued(`${action} in the .env file`, CHECK_WITH);
65
+ return { structured: { ...done.structured, ...changes }, summary: done.summary };
66
+ },
67
+ });
@@ -0,0 +1,42 @@
1
+ import { flattenSingle } from '../../forge/jsonapi.js';
2
+ import { apiPath } from '../../forge/path.js';
3
+ import { phaseOf } from '../shared/async.js';
4
+ export const SITE_TYPES = [
5
+ 'laravel',
6
+ 'symfony',
7
+ 'statamic',
8
+ 'wordpress',
9
+ 'phpmyadmin',
10
+ 'php',
11
+ 'nextjs',
12
+ 'nuxtjs',
13
+ 'static-html',
14
+ 'other',
15
+ 'custom',
16
+ ];
17
+ export const PHP_VERSIONS = [
18
+ 'php5',
19
+ 'php56-old',
20
+ 'php56',
21
+ 'php70',
22
+ 'php71',
23
+ 'php72',
24
+ 'php73',
25
+ 'php74',
26
+ 'php80',
27
+ 'php81',
28
+ 'php82',
29
+ 'php83',
30
+ 'php84',
31
+ 'php85',
32
+ ];
33
+ export const SOURCE_CONTROL_PROVIDERS = ['github', 'gitlab', 'bitbucket', 'gitlab-custom', 'custom'];
34
+ /** Reads a site by ID (the organization-level endpoint needs no server). */
35
+ export async function readSite(client, org, site, signal) {
36
+ const response = await client.get(apiPath `/orgs/${org}/sites/${site}`, { signal });
37
+ return flattenSingle(response.data);
38
+ }
39
+ /** Site provisioning: transitional statuses from the SiteResource enum. */
40
+ export function sitePhase(status) {
41
+ return phaseOf(status, { pending: ['creating', 'installing', 'removing', 'uninstalling'], failed: ['failed'] });
42
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitInput } from '../shared/async.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
5
+ import { environmentUpdateOptions, writeEnvironment } from './environment.js';
6
+ const CHECK_WITH = 'forge_get_site_environment';
7
+ export const updateSiteEnvironment = defineTool({
8
+ name: 'forge_update_site_environment',
9
+ title: 'Replace site .env',
10
+ description: "Replace the whole .env file of a site. Read it first with forge_get_site_environment and send the complete new content: anything omitted is lost. To change only some variables prefer forge_set_site_env_vars.",
11
+ toolset: 'sites',
12
+ operations: ['organizations.servers.sites.environment.update', 'organizations.servers.sites.environment.show'],
13
+ permissions: ['site:manage-environment', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ // Only usable safely after reading the full (secret) file, so it shares its opt-in.
19
+ exposesSecrets: true,
20
+ notFoundHint: SITE_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ content: z.string().describe('The complete new .env content.'),
24
+ ...environmentUpdateOptions,
25
+ ...waitInput(60),
26
+ },
27
+ outputSchema: operationOutput,
28
+ async handler(args, { client, organization, signal, sleep, progress }) {
29
+ const base = sitePath(organization(args.organization), args.server, args.site);
30
+ const result = await writeEnvironment(client, base, args.content, { cache: args.cache, queues: args.queues, wait: args.wait, timeoutSeconds: args.timeout_seconds }, { signal, sleep, progress });
31
+ const action = 'replace the .env file';
32
+ return result ? outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds }) : queued(action, CHECK_WITH);
33
+ },
34
+ });
@@ -0,0 +1,31 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
4
+ import { healthcheckOutput } from './get-site-healthcheck.js';
5
+ export const updateSiteHealthcheck = defineTool({
6
+ name: 'forge_update_site_healthcheck',
7
+ title: 'Update site healthcheck',
8
+ description: 'Set or remove (null) the healthcheck URL Forge calls to verify the site after zero-downtime deployments.',
9
+ toolset: 'sites',
10
+ operations: ['organizations.servers.sites.healthcheck.update'],
11
+ permissions: ['site:manage-project'],
12
+ readOnly: false,
13
+ destructive: false,
14
+ idempotent: true,
15
+ notFoundHint: SITE_NOT_FOUND_HINT,
16
+ inputSchema: {
17
+ ...siteScopeInput,
18
+ healthcheck_endpoint: z.string().url().nullable().describe('Healthcheck URL, or null to remove it.'),
19
+ },
20
+ outputSchema: healthcheckOutput,
21
+ async handler(args, { client, organization, signal }) {
22
+ await client.put(`${sitePath(organization(args.organization), args.server, args.site)}/healthcheck`, {
23
+ body: { healthcheck_endpoint: args.healthcheck_endpoint },
24
+ signal,
25
+ });
26
+ return {
27
+ structured: { healthcheck_endpoint: args.healthcheck_endpoint },
28
+ summary: args.healthcheck_endpoint ? `Healthcheck set to ${args.healthcheck_endpoint}.` : 'Healthcheck removed.',
29
+ };
30
+ },
31
+ });
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
6
+ const CHECK_WITH = 'forge_get_site_nginx_config';
7
+ export const updateSiteNginxConfig = defineTool({
8
+ name: 'forge_update_site_nginx_config',
9
+ title: 'Replace site Nginx configuration',
10
+ description: "Replace the site's whole Nginx configuration and reload Nginx. Read it first with forge_get_site_nginx_config and send the complete new file: an invalid configuration can take the site (or every site on the server) offline.",
11
+ toolset: 'sites',
12
+ operations: ['organizations.servers.sites.nginx.update', 'organizations.servers.sites.nginx.show'],
13
+ permissions: ['site:manage-nginx', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: SITE_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ ...siteScopeInput,
21
+ config: z.string().min(1).describe('The complete new Nginx configuration.'),
22
+ ...waitInput(60),
23
+ },
24
+ outputSchema: operationOutput,
25
+ async handler(args, { client, organization, signal, sleep, progress }) {
26
+ const path = `${sitePath(organization(args.organization), args.server, args.site)}/nginx`;
27
+ await client.put(path, { body: { config: args.config }, signal });
28
+ const action = 'update the Nginx configuration';
29
+ if (!args.wait)
30
+ return queued(action, CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: async () => {
33
+ const response = await client.get(path, { signal });
34
+ return flattenSingle(response.data).content ?? '';
35
+ },
36
+ phase: (content) => (content.trimEnd() === args.config.trimEnd() ? 'completed' : 'pending'),
37
+ describe: () => 'Waiting for Forge to apply the Nginx configuration',
38
+ timeoutSeconds: args.timeout_seconds,
39
+ context: { sleep, progress },
40
+ });
41
+ return outcome(result, {
42
+ action,
43
+ checkWith: CHECK_WITH,
44
+ timeoutSeconds: args.timeout_seconds,
45
+ detail: result.status === 'in_progress' ? 'If Nginx rejected the file, check forge_list_server_events.' : undefined,
46
+ });
47
+ },
48
+ });
@@ -0,0 +1,68 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, phaseOf, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
6
+ import { formatSite, siteOutput } from './format.js';
7
+ import { SOURCE_CONTROL_PROVIDERS, readSite } from './shared.js';
8
+ const CHECK_WITH = 'forge_get_site';
9
+ export const updateSiteRepository = defineTool({
10
+ name: 'forge_update_site_repository',
11
+ title: 'Update site repository',
12
+ description: 'Change the source control provider, repository or branch a site deploys from. Waits until Forge installs the repository unless `wait` is false; deploy afterwards with forge_deploy_site.',
13
+ toolset: 'sites',
14
+ operations: ['organizations.servers.sites.git.update', 'organizations.sites.show'],
15
+ permissions: ['site:manage-project', 'server:view'],
16
+ readOnly: false,
17
+ destructive: false,
18
+ idempotent: true,
19
+ async: true,
20
+ notFoundHint: SITE_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ source_control_provider: z.enum(SOURCE_CONTROL_PROVIDERS),
24
+ source_control_provider_id: z
25
+ .number()
26
+ .int()
27
+ .optional()
28
+ .describe('Source control connection to deploy through, when the organization has several.'),
29
+ repository: z.string().min(1).describe('Repository, e.g. "acme/app" (or an SSH URL for custom Git).'),
30
+ branch: z.string().min(1).describe('Branch to deploy.'),
31
+ per_account_public_key: z.string().min(1).optional().describe('Existing deploy public key to pin to the site.'),
32
+ per_account_private_key: z.string().min(1).optional().describe('Private key matching per_account_public_key.'),
33
+ ...waitInput(120),
34
+ },
35
+ outputSchema: {
36
+ ...operationOutput,
37
+ site: siteOutput.nullable(),
38
+ },
39
+ async handler(args, { client, config, organization, signal, sleep, progress }) {
40
+ const org = organization(args.organization);
41
+ const { organization: _org, server, site, wait, timeout_seconds, ...body } = args;
42
+ const response = await client.put(`${sitePath(org, server, site)}/git`, { body, signal });
43
+ const initial = response.data?.data ? flattenSingle(response.data) : undefined;
44
+ const format = (flat) => formatSite(flat, { detailed: false, allowSecrets: config.allowSecrets, serverId: String(server) });
45
+ const action = `switch the site to ${args.repository}@${args.branch}`;
46
+ if (!wait) {
47
+ return {
48
+ structured: { status: 'queued', check_with: CHECK_WITH, site: initial ? format(initial) : null },
49
+ summary: `Forge accepted the request to ${action}. Follow it with ${CHECK_WITH}.`,
50
+ };
51
+ }
52
+ const result = await waitFor({
53
+ ...(initial ? { initial } : {}),
54
+ poll: () => readSite(client, org, site, signal),
55
+ phase: (current) => {
56
+ const repository = (current.repository ?? {});
57
+ const phase = phaseOf(repository.status, { pending: ['installing', 'removing'] });
58
+ return phase === 'completed' && repository.branch !== args.branch ? 'pending' : phase;
59
+ },
60
+ describe: (current) => `Repository is ${current.repository?.status ?? 'updating'}`,
61
+ timeoutSeconds: timeout_seconds,
62
+ context: { sleep, progress },
63
+ });
64
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: timeout_seconds });
65
+ const latest = result.value ?? initial;
66
+ return { structured: { ...done.structured, site: latest ? format(latest) : null }, summary: done.summary };
67
+ },
68
+ });
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
6
+ import { PHP_VERSIONS, SITE_TYPES, readSite } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_site';
8
+ export const updateSite = defineTool({
9
+ name: 'forge_update_site',
10
+ title: 'Update site',
11
+ description: "Change a site's settings: PHP version, application type, web or root directory, deployed branch, push to deploy and zero-downtime release retention. Only the given fields change. To switch repository or provider use forge_update_site_repository.",
12
+ toolset: 'sites',
13
+ operations: ['organizations.servers.sites.update', 'organizations.sites.show'],
14
+ permissions: ['site:create', 'server:view'],
15
+ readOnly: false,
16
+ destructive: false,
17
+ idempotent: true,
18
+ async: true,
19
+ notFoundHint: SITE_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ ...siteScopeInput,
22
+ php_version: z.enum(PHP_VERSIONS).optional(),
23
+ type: z.enum(SITE_TYPES).optional().describe('Application type.'),
24
+ directory: z.string().min(1).optional().describe('Web (public) directory, e.g. "/public".'),
25
+ root_path: z.string().min(1).optional().describe('Project root directory.'),
26
+ repository_branch: z.string().min(1).optional().describe('Branch to deploy.'),
27
+ push_to_deploy: z.boolean().optional().describe('Deploy automatically on every push to the branch.'),
28
+ deployment_retention: z
29
+ .number()
30
+ .int()
31
+ .min(1)
32
+ .max(100)
33
+ .optional()
34
+ .describe('Zero-downtime releases to keep on the server.'),
35
+ ...waitInput(60),
36
+ },
37
+ outputSchema: operationOutput,
38
+ async handler(args, { client, organization, signal, sleep, progress }) {
39
+ const org = organization(args.organization);
40
+ const { organization: _org, server, site, wait, timeout_seconds, ...body } = args;
41
+ if (Object.values(body).every((value) => value === undefined)) {
42
+ throw new ToolInputError('Nothing to update: pass at least one setting to change.');
43
+ }
44
+ await client.put(sitePath(org, server, site), { body, signal });
45
+ // Settings that the site resource reflects; PHP version and type are applied but not exposed reliably.
46
+ const checks = [];
47
+ if (body.directory !== undefined)
48
+ checks.push({ field: 'web_directory', matches: (s) => s.web_directory === body.directory });
49
+ if (body.root_path !== undefined)
50
+ checks.push({ field: 'root_directory', matches: (s) => s.root_directory === body.root_path });
51
+ if (body.repository_branch !== undefined) {
52
+ checks.push({ field: 'branch', matches: (s) => s.repository?.branch === body.repository_branch });
53
+ }
54
+ if (body.push_to_deploy !== undefined)
55
+ checks.push({ field: 'quick_deploy', matches: (s) => s.quick_deploy === body.push_to_deploy });
56
+ if (body.deployment_retention !== undefined) {
57
+ checks.push({ field: 'deployment_retention', matches: (s) => s.deployment_retention === body.deployment_retention });
58
+ }
59
+ const action = 'update the site settings';
60
+ if (!wait)
61
+ return queued(action, CHECK_WITH);
62
+ if (checks.length === 0) {
63
+ return queued(action, CHECK_WITH, '(the site resource does not report PHP version and type changes)');
64
+ }
65
+ const result = await waitFor({
66
+ poll: () => readSite(client, org, site, signal),
67
+ phase: (current) => (checks.every((check) => check.matches(current)) ? 'completed' : 'pending'),
68
+ describe: (current) => `Waiting for ${checks.filter((check) => !check.matches(current)).map((c) => c.field).join(', ')}`,
69
+ timeoutSeconds: timeout_seconds,
70
+ context: { sleep, progress },
71
+ });
72
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: timeout_seconds });
73
+ },
74
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",