@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.
- package/README.md +19 -1
- package/dist/server.js +3 -1
- package/dist/tools/deployments/create-deploy-key.js +5 -2
- package/dist/tools/deployments/create-deployment-webhook.js +33 -9
- package/dist/tools/deployments/delete-deployment-webhook.js +20 -8
- package/dist/tools/deployments/deploy-site.js +39 -34
- package/dist/tools/deployments/reset-deployment-state.js +31 -8
- package/dist/tools/deployments/set-push-to-deploy.js +26 -6
- package/dist/tools/deployments/shared.js +6 -13
- package/dist/tools/registry.js +29 -0
- package/dist/tools/shared/async.js +99 -7
- package/dist/tools/shared/site-scope.js +12 -0
- package/dist/tools/sites/clear-site-log.js +50 -0
- package/dist/tools/sites/create-balanced-site.js +70 -0
- package/dist/tools/sites/create-site.js +113 -0
- package/dist/tools/sites/delete-site.js +38 -0
- package/dist/tools/sites/env-file.js +52 -0
- package/dist/tools/sites/environment.js +29 -0
- package/dist/tools/sites/get-site-environment.js +27 -0
- package/dist/tools/sites/get-site-healthcheck.js +27 -0
- package/dist/tools/sites/get-site-log.js +42 -0
- package/dist/tools/sites/get-site-nginx-config.js +26 -0
- package/dist/tools/sites/logs.js +6 -0
- package/dist/tools/sites/set-site-env-vars.js +67 -0
- package/dist/tools/sites/shared.js +42 -0
- package/dist/tools/sites/update-site-environment.js +34 -0
- package/dist/tools/sites/update-site-healthcheck.js +31 -0
- package/dist/tools/sites/update-site-nginx-config.js +48 -0
- package/dist/tools/sites/update-site-repository.js +68 -0
- package/dist/tools/sites/update-site.js +74 -0
- 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
|
-
🔑
|
|
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,
|
|
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:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
21
|
-
async handler(args, { client, organization, signal }) {
|
|
22
|
-
const
|
|
23
|
-
await client.delete(
|
|
24
|
-
|
|
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 {
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
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: [
|
|
10
|
-
|
|
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:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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,
|
|
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:
|
|
24
|
-
async handler(args, { client, organization, signal }) {
|
|
25
|
-
const
|
|
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
|
-
|
|
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 {
|
|
4
|
+
import { phaseOf } from '../shared/async.js';
|
|
5
5
|
import { relatedField, relatedId } from '../shared/relationships.js';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.'),
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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`).';
|