@francescomalatesta/laravel-forge-mcp 0.2.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.
Files changed (31) hide show
  1. package/README.md +19 -1
  2. package/dist/server.js +3 -1
  3. package/dist/tools/deployments/create-deploy-key.js +5 -2
  4. package/dist/tools/deployments/create-deployment-webhook.js +33 -9
  5. package/dist/tools/deployments/delete-deployment-webhook.js +20 -8
  6. package/dist/tools/deployments/deploy-site.js +39 -34
  7. package/dist/tools/deployments/reset-deployment-state.js +31 -8
  8. package/dist/tools/deployments/set-push-to-deploy.js +26 -6
  9. package/dist/tools/deployments/shared.js +6 -13
  10. package/dist/tools/registry.js +29 -0
  11. package/dist/tools/shared/async.js +99 -7
  12. package/dist/tools/shared/site-scope.js +12 -0
  13. package/dist/tools/sites/clear-site-log.js +50 -0
  14. package/dist/tools/sites/create-balanced-site.js +70 -0
  15. package/dist/tools/sites/create-site.js +113 -0
  16. package/dist/tools/sites/delete-site.js +38 -0
  17. package/dist/tools/sites/env-file.js +52 -0
  18. package/dist/tools/sites/environment.js +29 -0
  19. package/dist/tools/sites/get-site-environment.js +27 -0
  20. package/dist/tools/sites/get-site-healthcheck.js +27 -0
  21. package/dist/tools/sites/get-site-log.js +42 -0
  22. package/dist/tools/sites/get-site-nginx-config.js +26 -0
  23. package/dist/tools/sites/logs.js +6 -0
  24. package/dist/tools/sites/set-site-env-vars.js +67 -0
  25. package/dist/tools/sites/shared.js +42 -0
  26. package/dist/tools/sites/update-site-environment.js +34 -0
  27. package/dist/tools/sites/update-site-healthcheck.js +31 -0
  28. package/dist/tools/sites/update-site-nginx-config.js +48 -0
  29. package/dist/tools/sites/update-site-repository.js +68 -0
  30. package/dist/tools/sites/update-site.js +74 -0
  31. package/package.json +1 -1
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,11 @@ 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.
116
+
117
+ ### Background operations
118
+
119
+ Forge runs most changes in the background. Tools that start one say so in their description and return a `status`: `completed` or `failed` when they waited for the result, `in_progress` if it was still running at the timeout, `queued` when they returned right away. `check_with` names the tool that shows the current state. By default these tools wait for the outcome (sending progress notifications); pass `wait: false` to return immediately, or tune `timeout_seconds`.
102
120
 
103
121
  ## Development
104
122
 
package/dist/server.js CHANGED
@@ -66,9 +66,11 @@ function registerTool(server, tool, config, client, sleep) {
66
66
  }
67
67
  });
68
68
  }
69
+ export const ASYNC_NOTE = 'Forge runs this operation in the background: the result `status` says whether it completed, failed, is still in progress or was only queued, and `check_with` names the tool that shows its current state.';
69
70
  function describe(tool) {
71
+ const async = tool.async ? `\n\n${ASYNC_NOTE}` : '';
70
72
  const permissions = tool.permissions.length > 0 ? `\n\nRequired Forge permission: ${tool.permissions.join(', ')}.` : '';
71
- return `${tool.description}${permissions}`;
73
+ return `${tool.description}${async}${permissions}`;
72
74
  }
73
75
  function abortableSleep(ms, signal) {
74
76
  return new Promise((resolve, reject) => {
@@ -1,5 +1,6 @@
1
1
  import { flattenSingle } from '../../forge/jsonapi.js';
2
2
  import { defineTool } from '../define-tool.js';
3
+ import { operationOutput } from '../shared/async.js';
3
4
  import { deployKeyOutput } from './get-deploy-key.js';
4
5
  import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
5
6
  export const createDeployKey = defineTool({
@@ -12,14 +13,16 @@ export const createDeployKey = defineTool({
12
13
  readOnly: false,
13
14
  destructive: false,
14
15
  idempotent: true,
16
+ // Asynchronous in Forge, but the response already carries the key: no wait needed.
17
+ async: true,
15
18
  notFoundHint: SITE_NOT_FOUND_HINT,
16
19
  inputSchema: siteScopeInput,
17
- outputSchema: deployKeyOutput,
20
+ outputSchema: { ...operationOutput, ...deployKeyOutput },
18
21
  async handler(args, { client, organization, signal }) {
19
22
  const response = await client.post(`${sitePath(organization(args.organization), args.server, args.site)}/deploy-key`, { signal });
20
23
  const key = response.data ? (flattenSingle(response.data).key ?? null) : null;
21
24
  return {
22
- structured: { key },
25
+ structured: { status: key ? 'completed' : 'queued', check_with: 'forge_get_deploy_key', key },
23
26
  summary: key
24
27
  ? 'Deploy key ready: add this public key as a deploy key in the repository settings of the Git provider.'
25
28
  : 'Forge accepted the request; read the key with forge_get_deploy_key.',
@@ -1,28 +1,52 @@
1
1
  import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
2
3
  import { defineTool } from '../define-tool.js';
3
- import { queued, queuedOutput } from '../shared/async.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
5
  import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_deployment_webhooks';
5
7
  export const createDeploymentWebhook = defineTool({
6
8
  name: 'forge_create_deployment_webhook',
7
9
  title: 'Create deployment webhook',
8
10
  description: 'Add a URL that Forge notifies (HTTP POST) after every deployment of the site.',
9
11
  toolset: 'deployments',
10
- operations: ['organizations.servers.sites.webhooks.store'],
11
- permissions: ['site:manage-notifications'],
12
+ operations: ['organizations.servers.sites.webhooks.store', 'organizations.servers.sites.webhooks.index'],
13
+ permissions: ['site:manage-notifications', 'server:view'],
12
14
  readOnly: false,
13
15
  destructive: false,
14
16
  idempotent: false,
17
+ async: true,
15
18
  notFoundHint: SITE_NOT_FOUND_HINT,
16
19
  inputSchema: {
17
20
  ...siteScopeInput,
18
21
  url: z.string().url().describe('URL to notify after each deployment.'),
22
+ ...waitInput(60),
19
23
  },
20
- outputSchema: queuedOutput,
21
- async handler(args, { client, organization, signal }) {
22
- await client.post(`${sitePath(organization(args.organization), args.server, args.site)}/webhooks`, {
23
- body: { url: args.url },
24
- signal,
24
+ outputSchema: {
25
+ ...operationOutput,
26
+ webhook_id: z.string().nullable().describe('ID of the new webhook once it exists.'),
27
+ },
28
+ async handler(args, { client, organization, signal, sleep, progress }) {
29
+ const base = `${sitePath(organization(args.organization), args.server, args.site)}/webhooks`;
30
+ await client.post(base, { body: { url: args.url }, signal });
31
+ if (!args.wait) {
32
+ const accepted = queued('add the deployment webhook', CHECK_WITH);
33
+ return { ...accepted, structured: { ...accepted.structured, webhook_id: null } };
34
+ }
35
+ // Forge returns no body: the webhook is ready once it shows up in the list.
36
+ const result = await waitFor({
37
+ poll: async () => {
38
+ const response = await client.get(base, {
39
+ query: { sort: ['-created_at'], page: { size: 100 } },
40
+ signal,
41
+ });
42
+ return flattenCollection(response.data).items.find((item) => item.url === args.url) ?? null;
43
+ },
44
+ phase: (webhook) => (webhook ? 'completed' : 'pending'),
45
+ describe: () => 'Waiting for the webhook to be created',
46
+ timeoutSeconds: args.timeout_seconds,
47
+ context: { sleep, progress },
25
48
  });
26
- return queued('add the deployment webhook', 'forge_list_deployment_webhooks');
49
+ const done = outcome(result, { action: 'add the deployment webhook', checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
50
+ return { ...done, structured: { ...done.structured, webhook_id: result.value ? String(result.value.id) : null } };
27
51
  },
28
52
  });
@@ -1,26 +1,38 @@
1
1
  import { defineTool } from '../define-tool.js';
2
- import { queued, queuedOutput } from '../shared/async.js';
2
+ import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
3
3
  import { idInput } from '../shared/schemas.js';
4
4
  import { siteScopeInput, sitePath } from './shared.js';
5
+ const CHECK_WITH = 'forge_list_deployment_webhooks';
5
6
  export const deleteDeploymentWebhook = defineTool({
6
7
  name: 'forge_delete_deployment_webhook',
7
8
  title: 'Delete deployment webhook',
8
9
  description: 'Remove a deployment webhook from a site: its URL will no longer be notified after deployments.',
9
10
  toolset: 'deployments',
10
- operations: ['organizations.servers.sites.webhooks.destroy'],
11
- permissions: ['site:manage-notifications'],
11
+ operations: ['organizations.servers.sites.webhooks.destroy', 'organizations.servers.sites.webhooks.show'],
12
+ permissions: ['site:manage-notifications', 'server:view'],
12
13
  readOnly: false,
13
14
  destructive: true,
14
15
  idempotent: true,
16
+ async: true,
15
17
  notFoundHint: 'Check the webhook ID with forge_list_deployment_webhooks.',
16
18
  inputSchema: {
17
19
  ...siteScopeInput,
18
20
  webhook: idInput('Webhook ID. Use forge_list_deployment_webhooks to find it.'),
21
+ ...waitInput(60),
19
22
  },
20
- outputSchema: queuedOutput,
21
- async handler(args, { client, organization, signal }) {
22
- const base = sitePath(organization(args.organization), args.server, args.site);
23
- await client.delete(`${base}/webhooks/${encodeURIComponent(String(args.webhook))}`, { signal });
24
- return queued('delete the deployment webhook', 'forge_list_deployment_webhooks');
23
+ outputSchema: operationOutput,
24
+ async handler(args, { client, organization, signal, sleep, progress }) {
25
+ const path = `${sitePath(organization(args.organization), args.server, args.site)}/webhooks/${encodeURIComponent(String(args.webhook))}`;
26
+ await client.delete(path, { signal });
27
+ if (!args.wait)
28
+ return queued('delete the deployment webhook', CHECK_WITH);
29
+ const result = await waitFor({
30
+ poll: () => orGone(() => client.get(path, { signal })),
31
+ phase: (webhook) => (webhook === null ? 'completed' : 'pending'),
32
+ describe: () => 'Waiting for the webhook to be removed',
33
+ timeoutSeconds: args.timeout_seconds,
34
+ context: { sleep, progress },
35
+ });
36
+ return outcome(result, { action: 'delete the deployment webhook', checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
25
37
  },
26
38
  });
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { flattenSingle } from '../../forge/jsonapi.js';
3
3
  import { defineTool } from '../define-tool.js';
4
- import { FINAL_STATUSES, deploymentOutput, fetchLog, formatDeployment, logLinesInput, logOutput, SITE_NOT_FOUND_HINT, siteScopeInput, sitePath, } from './shared.js';
5
- export const POLL_INTERVAL_MS = 5_000;
4
+ import { operationOutput, waitFor, waitInput } from '../shared/async.js';
5
+ import { deploymentOutput, deploymentPhase, fetchLog, formatDeployment, logLinesInput, logOutput, SITE_NOT_FOUND_HINT, siteScopeInput, sitePath, } from './shared.js';
6
6
  export const deploySite = defineTool({
7
7
  name: 'forge_deploy_site',
8
8
  title: 'Deploy site',
@@ -17,20 +17,15 @@ export const deploySite = defineTool({
17
17
  readOnly: false,
18
18
  destructive: false,
19
19
  idempotent: false,
20
+ async: true,
20
21
  notFoundHint: SITE_NOT_FOUND_HINT,
21
22
  inputSchema: {
22
23
  ...siteScopeInput,
23
- wait: z.boolean().default(true).describe('Wait for the deployment to finish before returning.'),
24
- timeout_seconds: z
25
- .number()
26
- .int()
27
- .min(10)
28
- .max(900)
29
- .default(180)
30
- .describe('Maximum time to wait when `wait` is true.'),
24
+ ...waitInput(180),
31
25
  log_lines: logLinesInput(50),
32
26
  },
33
27
  outputSchema: {
28
+ ...operationOutput,
34
29
  deployment: deploymentOutput.nullable(),
35
30
  finished: z.boolean().describe('Whether the deployment reached a final status (finished, failed, cancelled).'),
36
31
  succeeded: z.boolean(),
@@ -43,37 +38,47 @@ export const deploySite = defineTool({
43
38
  if (!created.data?.data) {
44
39
  // Accepted without a deployment in the body: nothing to follow by ID.
45
40
  return {
46
- structured: { deployment: null, finished: false, succeeded: false, ...noLog },
41
+ structured: { status: 'queued', check_with: 'forge_list_deployments', deployment: null, finished: false, succeeded: false, ...noLog },
47
42
  summary: 'Forge accepted the deployment request. Follow it with forge_list_deployments.',
48
43
  };
49
44
  }
50
- let deployment = formatDeployment(flattenSingle(created.data));
45
+ const queuedDeployment = formatDeployment(flattenSingle(created.data));
51
46
  if (!args.wait) {
52
47
  return {
53
- structured: { deployment, finished: false, succeeded: false, ...noLog },
54
- summary: `Deployment ${deployment.id} was queued. Follow it with forge_get_deployment (deployment ${deployment.id}).`,
48
+ structured: { status: 'queued', check_with: 'forge_get_deployment', deployment: queuedDeployment, finished: false, succeeded: false, ...noLog },
49
+ summary: `Deployment ${queuedDeployment.id} was queued. Follow it with forge_get_deployment (deployment ${queuedDeployment.id}).`,
55
50
  };
56
51
  }
57
- const maxPolls = Math.ceil((args.timeout_seconds * 1000) / POLL_INTERVAL_MS);
58
- for (let poll = 1; poll <= maxPolls && !FINAL_STATUSES.has(deployment.status ?? ''); poll++) {
59
- await progress(poll, maxPolls, `Deployment ${deployment.id} is ${deployment.status ?? 'pending'}`);
60
- await sleep(POLL_INTERVAL_MS);
61
- const response = await client.get(`${base}/deployments/${encodeURIComponent(deployment.id)}`, { signal });
62
- deployment = formatDeployment(flattenSingle(response.data));
63
- }
64
- const finished = FINAL_STATUSES.has(deployment.status ?? '');
65
- const succeeded = deployment.status === 'finished';
52
+ const result = await waitFor({
53
+ initial: queuedDeployment,
54
+ poll: async () => {
55
+ const response = await client.get(`${base}/deployments/${encodeURIComponent(queuedDeployment.id)}`, { signal });
56
+ return formatDeployment(flattenSingle(response.data));
57
+ },
58
+ phase: (deployment) => deploymentPhase(deployment.status),
59
+ describe: (deployment) => `Deployment ${deployment.id} is ${deployment.status ?? 'pending'}`,
60
+ timeoutSeconds: args.timeout_seconds,
61
+ context: { sleep, progress },
62
+ });
63
+ const deployment = result.value ?? queuedDeployment;
64
+ const finished = result.status === 'completed' || result.status === 'failed';
66
65
  const log = finished ? await fetchLog(client, base, deployment.id, args.log_lines, signal) : noLog;
67
- let summary;
68
- if (!finished) {
69
- summary = `Deployment ${deployment.id} is still ${deployment.status} after ${args.timeout_seconds}s. Check it again with forge_get_deployment.`;
70
- }
71
- else if (succeeded) {
72
- summary = `Deployment ${deployment.id} finished successfully.`;
73
- }
74
- else {
75
- summary = `Deployment ${deployment.id} ended with status "${deployment.status}". The end of the log is included; see forge_get_deployment for more lines.`;
76
- }
77
- return { structured: { deployment, finished, succeeded, ...log }, summary };
66
+ const summaries = {
67
+ completed: `Deployment ${deployment.id} finished successfully.`,
68
+ failed: `Deployment ${deployment.id} ended with status "${deployment.status}". The end of the log is included; see forge_get_deployment for more lines.`,
69
+ in_progress: `Deployment ${deployment.id} is still ${deployment.status} after ${args.timeout_seconds}s. Check it again with forge_get_deployment.`,
70
+ queued: `Deployment ${deployment.id} was queued, but the tool ${result.unfollowed}. Follow it with forge_get_deployment.`,
71
+ };
72
+ return {
73
+ structured: {
74
+ status: result.status,
75
+ check_with: 'forge_get_deployment',
76
+ deployment,
77
+ finished,
78
+ succeeded: result.status === 'completed',
79
+ ...log,
80
+ },
81
+ summary: summaries[result.status],
82
+ };
78
83
  },
79
84
  });
@@ -1,21 +1,44 @@
1
+ import { flattenSingle } from '../../forge/jsonapi.js';
1
2
  import { defineTool } from '../define-tool.js';
2
- import { queued, queuedOutput } from '../shared/async.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
3
4
  import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
5
+ const CHECK_WITH = 'forge_get_deployment_status';
4
6
  export const resetDeploymentState = defineTool({
5
7
  name: 'forge_reset_deployment_state',
6
8
  title: 'Reset deployment state',
7
9
  description: 'Clear the "deploying" state of a site when a deployment is stuck (e.g. the server was rebooted mid-deploy) so new deployments can run. It does not stop a running process or roll back code.',
8
10
  toolset: 'deployments',
9
- operations: ['organizations.servers.sites.deployments.status.destroy'],
10
- permissions: ['site:manage-deploys'],
11
+ operations: [
12
+ 'organizations.servers.sites.deployments.status.destroy',
13
+ 'organizations.servers.sites.deployments.status.show',
14
+ ],
15
+ permissions: ['site:manage-deploys', 'server:view'],
11
16
  readOnly: false,
12
17
  destructive: false,
13
18
  idempotent: true,
19
+ async: true,
14
20
  notFoundHint: SITE_NOT_FOUND_HINT,
15
- inputSchema: siteScopeInput,
16
- outputSchema: queuedOutput,
17
- async handler(args, { client, organization, signal }) {
18
- await client.delete(`${sitePath(organization(args.organization), args.server, args.site)}/deployments/status`, { signal });
19
- return queued('reset the deployment state', 'forge_get_deployment_status');
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ ...waitInput(60),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const base = `${sitePath(organization(args.organization), args.server, args.site)}/deployments/status`;
28
+ await client.delete(base, { signal });
29
+ if (!args.wait)
30
+ return queued('reset the deployment state', CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: async () => {
33
+ const response = await client.get(base, { signal });
34
+ return flattenSingle(response.data).status ?? null;
35
+ },
36
+ // The reset is done when the site no longer reports a deployment in progress.
37
+ phase: (status) => (status === null ? 'completed' : 'pending'),
38
+ describe: (status) => `Deployment state is still ${status}`,
39
+ timeoutSeconds: args.timeout_seconds,
40
+ context: { sleep, progress },
41
+ });
42
+ return outcome(result, { action: 'reset the deployment state', checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
20
43
  },
21
44
  });
@@ -1,7 +1,10 @@
1
1
  import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { apiPath } from '../../forge/path.js';
2
4
  import { defineTool } from '../define-tool.js';
3
- import { queued, queuedOutput } from '../shared/async.js';
5
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
6
  import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_site';
5
8
  export const setPushToDeploy = defineTool({
6
9
  name: 'forge_set_push_to_deploy',
7
10
  title: 'Enable or disable push to deploy',
@@ -10,25 +13,42 @@ export const setPushToDeploy = defineTool({
10
13
  operations: [
11
14
  'organizations.servers.sites.deployments.push-to-deploy.store',
12
15
  'organizations.servers.sites.deployments.push-to-deploy.destroy',
16
+ 'organizations.sites.show',
13
17
  ],
14
- permissions: ['site:manage-deploys'],
18
+ permissions: ['site:manage-deploys', 'server:view'],
15
19
  readOnly: false,
16
20
  destructive: false,
17
21
  idempotent: true,
22
+ async: true,
18
23
  notFoundHint: SITE_NOT_FOUND_HINT,
19
24
  inputSchema: {
20
25
  ...siteScopeInput,
21
26
  enabled: z.boolean().describe('true to enable push to deploy, false to disable it.'),
27
+ ...waitInput(60),
22
28
  },
23
- outputSchema: queuedOutput,
24
- async handler(args, { client, organization, signal }) {
25
- const path = `${sitePath(organization(args.organization), args.server, args.site)}/deployments/push-to-deploy`;
29
+ outputSchema: operationOutput,
30
+ async handler(args, { client, organization, signal, sleep, progress }) {
31
+ const org = organization(args.organization);
32
+ const path = `${sitePath(org, args.server, args.site)}/deployments/push-to-deploy`;
26
33
  if (args.enabled) {
27
34
  await client.post(path, { signal });
28
35
  }
29
36
  else {
30
37
  await client.delete(path, { signal });
31
38
  }
32
- return queued(`${args.enabled ? 'enable' : 'disable'} push to deploy`, 'forge_get_site', '(field `quick_deploy`)');
39
+ const action = `${args.enabled ? 'enable' : 'disable'} push to deploy`;
40
+ if (!args.wait)
41
+ return queued(action, CHECK_WITH, '(field `quick_deploy`)');
42
+ const result = await waitFor({
43
+ poll: async () => {
44
+ const response = await client.get(apiPath `/orgs/${org}/sites/${args.site}`, { signal });
45
+ return flattenSingle(response.data).quick_deploy ?? null;
46
+ },
47
+ phase: (quickDeploy) => (quickDeploy === args.enabled ? 'completed' : 'pending'),
48
+ describe: () => `Waiting for push to deploy to be ${args.enabled ? 'enabled' : 'disabled'}`,
49
+ timeoutSeconds: args.timeout_seconds,
50
+ context: { sleep, progress },
51
+ });
52
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
33
53
  },
34
54
  });
@@ -1,21 +1,14 @@
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';
4
+ import { phaseOf } from '../shared/async.js';
5
5
  import { relatedField, relatedId } from '../shared/relationships.js';
6
- import { organizationInput, serverInput, siteInput, tail } from '../shared/schemas.js';
7
- /** Arguments identifying a site: deployment endpoints are nested under the server. */
8
- export const siteScopeInput = {
9
- organization: organizationInput,
10
- server: serverInput,
11
- site: siteInput,
12
- };
13
- export function sitePath(org, server, site) {
14
- return apiPath `/orgs/${org}/servers/${server}/sites/${site}`;
6
+ import { tail } from '../shared/schemas.js';
7
+ export { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from '../shared/site-scope.js';
8
+ /** Deployments complete when finished and fail when failed, failed in the build or cancelled. */
9
+ export function deploymentPhase(status) {
10
+ return phaseOf(status, { completed: ['finished'], failed: ['failed', 'failed-build', 'cancelled'] });
15
11
  }
16
- export const SITE_NOT_FOUND_HINT = 'Check the server and site IDs with forge_list_sites (each site lists its `server_id`).';
17
- /** Deployment statuses after which nothing changes anymore. */
18
- export const FINAL_STATUSES = new Set(['finished', 'failed', 'failed-build', 'cancelled']);
19
12
  export const deploymentOutput = z.looseObject({
20
13
  id: z.string().describe('Deployment ID.'),
21
14
  status: z.string().nullable().describe('queued, pending, deploying, finished, 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,
@@ -1,16 +1,108 @@
1
- import { z } from 'zod';
2
1
  /**
3
- * Forge processes many write operations asynchronously (HTTP 202): the request
4
- * is accepted and the work continues in the background. Tools must say so and
5
- * point to the tool that shows the outcome.
2
+ * Asynchronous operations.
3
+ *
4
+ * Forge runs most write operations in the background (`x-processingMode: async`
5
+ * in the spec, HTTP 202): the request is accepted and the work continues on the
6
+ * server. Every tool covering such an operation follows the same contract,
7
+ * documented in CLAUDE.md ("Asynchronous operations"):
8
+ *
9
+ * - it declares `async: true` (checked against the spec by tests);
10
+ * - its output includes `operationOutput`: `status` + `check_with`;
11
+ * - when the outcome can be observed through the API and the response does not
12
+ * already carry it, it accepts `waitInput()` and follows the operation with
13
+ * `waitFor()`, reading the resource until it leaves its transitional state
14
+ * (or disappears, for deletions: `orGone()`).
6
15
  */
7
- export const queuedOutput = {
8
- status: z.literal('queued').describe('Forge accepted the request; the work continues in the background.'),
9
- check_with: z.string().describe('Tool to call to follow the outcome.'),
16
+ import { z } from 'zod';
17
+ import { ForgeApiError, ForgeConnectionError } from '../../forge/errors.js';
18
+ export const POLL_INTERVAL_MS = 5_000;
19
+ export const OPERATION_STATUSES = ['queued', 'in_progress', 'completed', 'failed'];
20
+ /** Output fields shared by every asynchronous tool. */
21
+ export const operationOutput = {
22
+ status: z
23
+ .enum(OPERATION_STATUSES)
24
+ .describe('Outcome of the background operation. queued: accepted but not followed (wait=false or the state could not be read); in_progress: still running when the wait timed out; completed / failed: final result.'),
25
+ check_with: z.string().describe('Tool that shows the current state of the operation.'),
10
26
  };
27
+ /** `wait` / `timeout_seconds` inputs for tools that can follow their operation. */
28
+ export function waitInput(defaultTimeoutSeconds) {
29
+ return {
30
+ wait: z.boolean().default(true).describe('Wait until Forge finishes the operation before returning.'),
31
+ timeout_seconds: z
32
+ .number()
33
+ .int()
34
+ .min(10)
35
+ .max(900)
36
+ .default(defaultTimeoutSeconds)
37
+ .describe('Maximum time to wait when `wait` is true.'),
38
+ };
39
+ }
40
+ /**
41
+ * Classifies a resource status. `failed` values fail. With `completed`, only
42
+ * those values complete and anything else is pending; otherwise `pending`
43
+ * values are pending and anything else (including unknown values) completes.
44
+ */
45
+ export function phaseOf(status, lists) {
46
+ const value = typeof status === 'string' ? status : '';
47
+ if (lists.failed?.includes(value))
48
+ return 'failed';
49
+ if (lists.completed)
50
+ return lists.completed.includes(value) ? 'completed' : 'pending';
51
+ return lists.pending?.includes(value) ? 'pending' : 'completed';
52
+ }
53
+ /**
54
+ * Polls every POLL_INTERVAL_MS until the phase is final or the timeout expires,
55
+ * sending MCP progress notifications. Read errors stop the wait without failing
56
+ * the tool: the write already succeeded, so the result is "queued" with a reason.
57
+ */
58
+ export async function waitFor(options) {
59
+ const { poll, phase, describe, timeoutSeconds, context } = options;
60
+ const maxPolls = Math.ceil((timeoutSeconds * 1000) / POLL_INTERVAL_MS);
61
+ let value = options.initial;
62
+ try {
63
+ if (value === undefined)
64
+ value = await poll();
65
+ for (let attempt = 1; attempt <= maxPolls && phase(value) === 'pending'; attempt++) {
66
+ await context.progress(attempt, maxPolls, describe(value));
67
+ await context.sleep(POLL_INTERVAL_MS);
68
+ value = await poll();
69
+ }
70
+ }
71
+ catch (error) {
72
+ if (error instanceof ForgeApiError || error instanceof ForgeConnectionError) {
73
+ return { status: 'queued', value, unfollowed: `could not follow the operation (${error.message})` };
74
+ }
75
+ throw error;
76
+ }
77
+ const final = phase(value);
78
+ return { status: final === 'pending' ? 'in_progress' : final, value };
79
+ }
80
+ /** Reads a resource, resolving to null once it no longer exists (404): use it to wait for deletions. */
81
+ export async function orGone(read) {
82
+ try {
83
+ return await read();
84
+ }
85
+ catch (error) {
86
+ if (error instanceof ForgeApiError && error.status === 404)
87
+ return null;
88
+ throw error;
89
+ }
90
+ }
91
+ /** Result for an operation that was accepted and not followed. */
11
92
  export function queued(action, checkWith, hint) {
12
93
  return {
13
94
  structured: { status: 'queued', check_with: checkWith },
14
95
  summary: `Forge accepted the request to ${action}; it runs in the background. Check the outcome with ${checkWith}${hint ? ` ${hint}` : ''}.`,
15
96
  };
16
97
  }
98
+ /** Structured status and summary for the result of `waitFor()` (or a skipped wait). */
99
+ export function outcome(result, { action, checkWith, timeoutSeconds, detail }) {
100
+ const extra = detail ? ` ${detail}` : '';
101
+ const summaries = {
102
+ completed: `Forge completed the request to ${action}.${extra}`,
103
+ failed: `Forge could not ${action}.${extra} Check ${checkWith} for details.`,
104
+ in_progress: `Forge is still working on the request to ${action} after ${timeoutSeconds}s. Check the outcome later with ${checkWith}.${extra}`,
105
+ queued: `Forge accepted the request to ${action}; it runs in the background${result.unfollowed ? `, but the tool ${result.unfollowed}` : ''}. Check the outcome with ${checkWith}.${extra}`,
106
+ };
107
+ return { structured: { status: result.status, check_with: checkWith }, summary: summaries[result.status] };
108
+ }
@@ -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`).';