@francescomalatesta/laravel-forge-mcp 0.1.0 → 0.2.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 +25 -21
- package/dist/server.js +30 -4
- package/dist/tools/deployments/create-deploy-key.js +28 -0
- package/dist/tools/deployments/create-deployment-webhook.js +28 -0
- package/dist/tools/deployments/delete-deploy-key.js +23 -0
- package/dist/tools/deployments/delete-deployment-webhook.js +26 -0
- package/dist/tools/deployments/deploy-site.js +79 -0
- package/dist/tools/deployments/get-deploy-hook.js +27 -0
- package/dist/tools/deployments/get-deploy-key.js +27 -0
- package/dist/tools/deployments/get-deployment-script.js +29 -0
- package/dist/tools/deployments/get-deployment-status.js +31 -0
- package/dist/tools/deployments/get-deployment.js +39 -0
- package/dist/tools/deployments/list-deployment-webhooks.js +51 -0
- package/dist/tools/deployments/list-deployments.js +55 -0
- package/dist/tools/deployments/regenerate-deploy-hook.js +26 -0
- package/dist/tools/deployments/reset-deployment-state.js +21 -0
- package/dist/tools/deployments/set-push-to-deploy.js +34 -0
- package/dist/tools/deployments/shared.js +85 -0
- package/dist/tools/deployments/update-deployment-script.js +37 -0
- package/dist/tools/events/format.js +24 -0
- package/dist/tools/events/get-server-event.js +55 -0
- package/dist/tools/events/list-server-events.js +56 -0
- package/dist/tools/registry.js +48 -1
- package/dist/tools/servers/format.js +32 -0
- package/dist/tools/servers/get-server.js +33 -0
- package/dist/tools/servers/list-servers.js +3 -29
- package/dist/tools/shared/async.js +16 -0
- package/dist/tools/shared/relationships.js +8 -0
- package/dist/tools/shared/schemas.js +14 -0
- package/dist/tools/shared/secrets.js +13 -0
- package/dist/tools/sites/format.js +66 -0
- package/dist/tools/sites/get-site.js +32 -0
- package/dist/tools/sites/list-sites.js +85 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,31 +70,35 @@ npm run build
|
|
|
70
70
|
| `FORGE_TIMEOUT_MS` | `30000` | Per-request timeout. |
|
|
71
71
|
| `FORGE_MAX_RETRIES` | `2` | Retries for rate limits (429) and transient errors. |
|
|
72
72
|
|
|
73
|
-
###
|
|
74
|
-
|
|
75
|
-
| Toolset | Contents |
|
|
76
|
-
|---|---|
|
|
77
|
-
| `core` | Organizations, servers and other entry points |
|
|
78
|
-
| `sites` | Sites, domains, certificates, Nginx, site configuration |
|
|
79
|
-
| `deployments` | Deployments, scripts, logs, push-to-deploy |
|
|
80
|
-
| `servers` | PHP, services, network, events, server logs |
|
|
81
|
-
| `databases` | Database schemas, users, backups |
|
|
82
|
-
| `jobs` | Scheduled jobs, background processes |
|
|
83
|
-
| `security` | Firewall, security and redirect rules, SSH keys |
|
|
84
|
-
| `integrations` | Horizon, Octane, Reverb, Pulse, Inertia, scheduler, maintenance |
|
|
85
|
-
| `monitoring` | Monitors, heartbeats, health checks |
|
|
86
|
-
| `recipes` | Recipes and runs |
|
|
87
|
-
| `teams` | Teams, members, invitations, roles |
|
|
88
|
-
| `providers` | Providers, regions, sizes, credentials, VPCs |
|
|
89
|
-
| `storage` | Storage providers |
|
|
90
|
-
| `commands` | Arbitrary commands on sites (disabled by default) |
|
|
91
|
-
|
|
92
|
-
## Tools
|
|
73
|
+
### Tools
|
|
93
74
|
|
|
94
75
|
| Tool | Toolset | Description |
|
|
95
76
|
|---|---|---|
|
|
96
77
|
| `forge_list_organizations` | core | List accessible organizations and their slugs |
|
|
97
78
|
| `forge_list_servers` | core | List servers with filters, sorting and pagination |
|
|
79
|
+
| `forge_get_server` | core | Every detail of a server |
|
|
80
|
+
| `forge_list_sites` | core | Sites of an organization, a server or all organizations, with latest deployment |
|
|
81
|
+
| `forge_get_site` | core | Every detail of a site, including its server |
|
|
82
|
+
| `forge_list_server_events` | core | Operations Forge ran on a server or across the organization |
|
|
83
|
+
| `forge_get_server_event` | core | An event with the output of its script |
|
|
84
|
+
| `forge_list_deployments` | deployments | Deployments of a site or of every site on a server |
|
|
85
|
+
| `forge_get_deployment` | deployments | A deployment with the end of its log |
|
|
86
|
+
| `forge_get_deployment_status` | deployments | Whether a deployment is running |
|
|
87
|
+
| `forge_deploy_site` | deployments | Deploy a site and (optionally) wait for the result, with progress notifications |
|
|
88
|
+
| `forge_reset_deployment_state` | deployments | Unblock a stuck deployment |
|
|
89
|
+
| `forge_get_deployment_script` | deployments | Read the deployment script |
|
|
90
|
+
| `forge_update_deployment_script` | deployments | Replace the deployment script |
|
|
91
|
+
| `forge_set_push_to_deploy` | deployments | Enable or disable push to deploy |
|
|
92
|
+
| `forge_list_deployment_webhooks` | deployments | Webhooks notified after deployments |
|
|
93
|
+
| `forge_create_deployment_webhook` | deployments | Add a deployment webhook |
|
|
94
|
+
| `forge_delete_deployment_webhook` | deployments | Remove a deployment webhook |
|
|
95
|
+
| `forge_get_deploy_key` | deployments | The site's SSH deploy key |
|
|
96
|
+
| `forge_create_deploy_key` | deployments | Create a deploy key |
|
|
97
|
+
| `forge_delete_deploy_key` | deployments | Remove the deploy key |
|
|
98
|
+
| `forge_get_deploy_hook` 🔑 | deployments | The deployment trigger URL |
|
|
99
|
+
| `forge_regenerate_deploy_hook` 🔑 | deployments | Generate a new deployment trigger URL |
|
|
100
|
+
|
|
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.
|
|
98
102
|
|
|
99
103
|
## Development
|
|
100
104
|
|
|
@@ -108,7 +112,7 @@ npm run spec:update # download the latest spec and regenerate types
|
|
|
108
112
|
npm run check # typecheck + tests + coverage
|
|
109
113
|
```
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
Every push to `main` with `feat:` or `fix:` commits is released to npm automatically, based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/): see [RELEASING.md](RELEASING.md).
|
|
112
116
|
|
|
113
117
|
### Project structure
|
|
114
118
|
|
package/dist/server.js
CHANGED
|
@@ -7,15 +7,16 @@ const INSTRUCTIONS = `Tools for managing Laravel Forge (servers, sites, deployme
|
|
|
7
7
|
- Every resource belongs to an organization. If an "organization" argument is required and unknown, call forge_list_organizations first.
|
|
8
8
|
- Resources are addressed by ID. Use the list tools (e.g. forge_list_servers) to find IDs instead of guessing.
|
|
9
9
|
- List tools are paginated: when "has_more" is true, pass "next_cursor" as "cursor" to get the next page.
|
|
10
|
-
- Many write operations are asynchronous in Forge: an accepted request is not yet completed
|
|
11
|
-
|
|
10
|
+
- Many write operations are asynchronous in Forge: an accepted request is not yet completed. Follow the outcome with the tool named in the result, or with forge_list_server_events and forge_get_server_event.
|
|
11
|
+
- Site and deployment tools need both the server ID and the site ID: forge_list_sites returns both.`;
|
|
12
|
+
export function createServer({ config, client, tools, sleep = abortableSleep }) {
|
|
12
13
|
const server = new McpServer({ name: SERVER_NAME, version: VERSION }, { instructions: INSTRUCTIONS });
|
|
13
14
|
for (const tool of tools ?? selectTools(config)) {
|
|
14
|
-
registerTool(server, tool, config, client);
|
|
15
|
+
registerTool(server, tool, config, client, sleep);
|
|
15
16
|
}
|
|
16
17
|
return server;
|
|
17
18
|
}
|
|
18
|
-
function registerTool(server, tool, config, client) {
|
|
19
|
+
function registerTool(server, tool, config, client, sleep) {
|
|
19
20
|
server.registerTool(tool.name, {
|
|
20
21
|
title: tool.title,
|
|
21
22
|
description: describe(tool),
|
|
@@ -33,6 +34,16 @@ function registerTool(server, tool, config, client) {
|
|
|
33
34
|
client,
|
|
34
35
|
config,
|
|
35
36
|
signal: extra.signal,
|
|
37
|
+
sleep: (ms) => sleep(ms, extra.signal),
|
|
38
|
+
progress: async (progress, total, message) => {
|
|
39
|
+
const progressToken = extra._meta?.progressToken;
|
|
40
|
+
if (progressToken === undefined)
|
|
41
|
+
return;
|
|
42
|
+
await extra.sendNotification({
|
|
43
|
+
method: 'notifications/progress',
|
|
44
|
+
params: { progressToken, progress, ...(total !== undefined ? { total } : {}), message },
|
|
45
|
+
});
|
|
46
|
+
},
|
|
36
47
|
organization: (explicit) => {
|
|
37
48
|
const slug = explicit ?? config.organization;
|
|
38
49
|
if (!slug)
|
|
@@ -59,3 +70,18 @@ function describe(tool) {
|
|
|
59
70
|
const permissions = tool.permissions.length > 0 ? `\n\nRequired Forge permission: ${tool.permissions.join(', ')}.` : '';
|
|
60
71
|
return `${tool.description}${permissions}`;
|
|
61
72
|
}
|
|
73
|
+
function abortableSleep(ms, signal) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (signal.aborted)
|
|
76
|
+
return reject(signal.reason);
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
signal.removeEventListener('abort', onAbort);
|
|
79
|
+
resolve();
|
|
80
|
+
}, ms);
|
|
81
|
+
const onAbort = () => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(signal.reason);
|
|
84
|
+
};
|
|
85
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { deployKeyOutput } from './get-deploy-key.js';
|
|
4
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
5
|
+
export const createDeployKey = defineTool({
|
|
6
|
+
name: 'forge_create_deploy_key',
|
|
7
|
+
title: 'Create deploy key',
|
|
8
|
+
description: 'Create an SSH deploy key for the site (returns the existing key if there is one). Add the returned public key as a deploy key in the Git provider so the server can pull that repository.',
|
|
9
|
+
toolset: 'deployments',
|
|
10
|
+
operations: ['organizations.servers.sites.deploy-key.store'],
|
|
11
|
+
permissions: ['site:manage-deploys'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: false,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: siteScopeInput,
|
|
17
|
+
outputSchema: deployKeyOutput,
|
|
18
|
+
async handler(args, { client, organization, signal }) {
|
|
19
|
+
const response = await client.post(`${sitePath(organization(args.organization), args.server, args.site)}/deploy-key`, { signal });
|
|
20
|
+
const key = response.data ? (flattenSingle(response.data).key ?? null) : null;
|
|
21
|
+
return {
|
|
22
|
+
structured: { key },
|
|
23
|
+
summary: key
|
|
24
|
+
? 'Deploy key ready: add this public key as a deploy key in the repository settings of the Git provider.'
|
|
25
|
+
: 'Forge accepted the request; read the key with forge_get_deploy_key.',
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { queued, queuedOutput } from '../shared/async.js';
|
|
4
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
5
|
+
export const createDeploymentWebhook = defineTool({
|
|
6
|
+
name: 'forge_create_deployment_webhook',
|
|
7
|
+
title: 'Create deployment webhook',
|
|
8
|
+
description: 'Add a URL that Forge notifies (HTTP POST) after every deployment of the site.',
|
|
9
|
+
toolset: 'deployments',
|
|
10
|
+
operations: ['organizations.servers.sites.webhooks.store'],
|
|
11
|
+
permissions: ['site:manage-notifications'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: false,
|
|
14
|
+
idempotent: false,
|
|
15
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
...siteScopeInput,
|
|
18
|
+
url: z.string().url().describe('URL to notify after each deployment.'),
|
|
19
|
+
},
|
|
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,
|
|
25
|
+
});
|
|
26
|
+
return queued('add the deployment webhook', 'forge_list_deployment_webhooks');
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
4
|
+
export const deleteDeployKey = defineTool({
|
|
5
|
+
name: 'forge_delete_deploy_key',
|
|
6
|
+
title: 'Delete deploy key',
|
|
7
|
+
description: "Remove the site's SSH deploy key. Deployments will fail if the repository is only reachable through this key.",
|
|
8
|
+
toolset: 'deployments',
|
|
9
|
+
operations: ['organizations.servers.sites.deploy-key.destroy'],
|
|
10
|
+
permissions: ['site:manage-deploys'],
|
|
11
|
+
readOnly: false,
|
|
12
|
+
destructive: true,
|
|
13
|
+
idempotent: true,
|
|
14
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
15
|
+
inputSchema: siteScopeInput,
|
|
16
|
+
outputSchema: {
|
|
17
|
+
deleted: z.boolean(),
|
|
18
|
+
},
|
|
19
|
+
async handler(args, { client, organization, signal }) {
|
|
20
|
+
await client.delete(`${sitePath(organization(args.organization), args.server, args.site)}/deploy-key`, { signal });
|
|
21
|
+
return { structured: { deleted: true }, summary: 'Deploy key removed from the site.' };
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { queued, queuedOutput } from '../shared/async.js';
|
|
3
|
+
import { idInput } from '../shared/schemas.js';
|
|
4
|
+
import { siteScopeInput, sitePath } from './shared.js';
|
|
5
|
+
export const deleteDeploymentWebhook = defineTool({
|
|
6
|
+
name: 'forge_delete_deployment_webhook',
|
|
7
|
+
title: 'Delete deployment webhook',
|
|
8
|
+
description: 'Remove a deployment webhook from a site: its URL will no longer be notified after deployments.',
|
|
9
|
+
toolset: 'deployments',
|
|
10
|
+
operations: ['organizations.servers.sites.webhooks.destroy'],
|
|
11
|
+
permissions: ['site:manage-notifications'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
notFoundHint: 'Check the webhook ID with forge_list_deployment_webhooks.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
...siteScopeInput,
|
|
18
|
+
webhook: idInput('Webhook ID. Use forge_list_deployment_webhooks to find it.'),
|
|
19
|
+
},
|
|
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');
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
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;
|
|
6
|
+
export const deploySite = defineTool({
|
|
7
|
+
name: 'forge_deploy_site',
|
|
8
|
+
title: 'Deploy site',
|
|
9
|
+
description: "Deploy a site now by running its deployment script. By default waits for the deployment to finish (up to `timeout_seconds`) and returns its final status with the end of the log; with `wait: false` returns as soon as Forge queues it. Check the deployment script first with forge_get_deployment_script if unsure what it runs.",
|
|
10
|
+
toolset: 'deployments',
|
|
11
|
+
operations: [
|
|
12
|
+
'organizations.servers.sites.deployments.store',
|
|
13
|
+
'organizations.servers.sites.deployments.show',
|
|
14
|
+
'organizations.servers.sites.deployments.log.show',
|
|
15
|
+
],
|
|
16
|
+
permissions: ['site:manage-deploys', 'server:view'],
|
|
17
|
+
readOnly: false,
|
|
18
|
+
destructive: false,
|
|
19
|
+
idempotent: false,
|
|
20
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
...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.'),
|
|
31
|
+
log_lines: logLinesInput(50),
|
|
32
|
+
},
|
|
33
|
+
outputSchema: {
|
|
34
|
+
deployment: deploymentOutput.nullable(),
|
|
35
|
+
finished: z.boolean().describe('Whether the deployment reached a final status (finished, failed, cancelled).'),
|
|
36
|
+
succeeded: z.boolean(),
|
|
37
|
+
...logOutput,
|
|
38
|
+
},
|
|
39
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
40
|
+
const base = sitePath(organization(args.organization), args.server, args.site);
|
|
41
|
+
const created = await client.post(`${base}/deployments`, { signal });
|
|
42
|
+
const noLog = { log: null, log_truncated: false, log_total_lines: null, log_unavailable_reason: null };
|
|
43
|
+
if (!created.data?.data) {
|
|
44
|
+
// Accepted without a deployment in the body: nothing to follow by ID.
|
|
45
|
+
return {
|
|
46
|
+
structured: { deployment: null, finished: false, succeeded: false, ...noLog },
|
|
47
|
+
summary: 'Forge accepted the deployment request. Follow it with forge_list_deployments.',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
let deployment = formatDeployment(flattenSingle(created.data));
|
|
51
|
+
if (!args.wait) {
|
|
52
|
+
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}).`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
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';
|
|
66
|
+
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 };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -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.js';
|
|
5
|
+
export const deployHookOutput = {
|
|
6
|
+
url: z.string().nullable().describe('Calling this URL (GET or POST) triggers a deployment. Treat it as a secret.'),
|
|
7
|
+
};
|
|
8
|
+
export const getDeployHook = defineTool({
|
|
9
|
+
name: 'forge_get_deploy_hook',
|
|
10
|
+
title: 'Get deploy hook URL',
|
|
11
|
+
description: 'Get the deployment trigger URL of a site, used by CI services to deploy it. Anyone with this URL can deploy the site.',
|
|
12
|
+
toolset: 'deployments',
|
|
13
|
+
operations: ['organizations.servers.sites.deployments.deploy-hook.show'],
|
|
14
|
+
permissions: ['site:manage-deploys'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
exposesSecrets: true,
|
|
17
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: siteScopeInput,
|
|
19
|
+
outputSchema: deployHookOutput,
|
|
20
|
+
async handler(args, { client, organization, signal }) {
|
|
21
|
+
const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/deployments/deploy-hook`, { signal });
|
|
22
|
+
return {
|
|
23
|
+
structured: { url: flattenSingle(response.data).url ?? null },
|
|
24
|
+
summary: 'Deploy hook URL retrieved. Keep it secret: calling it deploys the site.',
|
|
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.js';
|
|
5
|
+
export const deployKeyOutput = {
|
|
6
|
+
key: z.string().nullable().describe('Public SSH key to add as a read-only deploy key in the Git provider, or null if none.'),
|
|
7
|
+
};
|
|
8
|
+
export const getDeployKey = defineTool({
|
|
9
|
+
name: 'forge_get_deploy_key',
|
|
10
|
+
title: 'Get deploy key',
|
|
11
|
+
description: "Get the site's public SSH deploy key, which grants the server access to a single repository. Create one with forge_create_deploy_key.",
|
|
12
|
+
toolset: 'deployments',
|
|
13
|
+
operations: ['organizations.servers.sites.deploy-key.show'],
|
|
14
|
+
permissions: ['site:manage-deploys'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: siteScopeInput,
|
|
18
|
+
outputSchema: deployKeyOutput,
|
|
19
|
+
async handler(args, { client, organization, signal }) {
|
|
20
|
+
const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/deploy-key`, { signal });
|
|
21
|
+
const key = flattenSingle(response.data).key ?? null;
|
|
22
|
+
return {
|
|
23
|
+
structured: { key },
|
|
24
|
+
summary: key ? 'The site has a deploy key.' : 'The site has no deploy key; create one with forge_create_deploy_key.',
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
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.js';
|
|
5
|
+
export const deploymentScriptOutput = {
|
|
6
|
+
content: z.string().nullable().describe('The deployment script (bash).'),
|
|
7
|
+
auto_source: z.boolean().nullable().describe('Whether environment variables are sourced automatically before the script runs.'),
|
|
8
|
+
};
|
|
9
|
+
export const getDeploymentScript = defineTool({
|
|
10
|
+
name: 'forge_get_deployment_script',
|
|
11
|
+
title: 'Get deployment script',
|
|
12
|
+
description: 'Get the bash script Forge runs when the site is deployed.',
|
|
13
|
+
toolset: 'deployments',
|
|
14
|
+
operations: ['organizations.servers.sites.deployments.script.show'],
|
|
15
|
+
permissions: ['server:view'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: siteScopeInput,
|
|
19
|
+
outputSchema: deploymentScriptOutput,
|
|
20
|
+
async handler(args, { client, organization, signal }) {
|
|
21
|
+
const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/deployments/script`, { signal });
|
|
22
|
+
const flat = flattenSingle(response.data);
|
|
23
|
+
const content = flat.content ?? null;
|
|
24
|
+
return {
|
|
25
|
+
structured: { content, auto_source: flat.auto_source ?? null },
|
|
26
|
+
summary: content ? `The deployment script has ${content.split('\n').length} line(s).` : 'The site has no deployment script.',
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
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.js';
|
|
5
|
+
export const getDeploymentStatus = defineTool({
|
|
6
|
+
name: 'forge_get_deployment_status',
|
|
7
|
+
title: 'Get deployment status',
|
|
8
|
+
description: 'Check whether a deployment is currently running on a site. A null status means no deployment is in progress. If a deployment looks stuck, forge_reset_deployment_state clears it.',
|
|
9
|
+
toolset: 'deployments',
|
|
10
|
+
operations: ['organizations.servers.sites.deployments.status.show'],
|
|
11
|
+
permissions: ['server:view'],
|
|
12
|
+
readOnly: true,
|
|
13
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
14
|
+
inputSchema: siteScopeInput,
|
|
15
|
+
outputSchema: {
|
|
16
|
+
status: z.string().nullable().describe('Current deployment status, or null when nothing is running.'),
|
|
17
|
+
started_at: z.string().nullable(),
|
|
18
|
+
},
|
|
19
|
+
async handler(args, { client, organization, signal }) {
|
|
20
|
+
const response = await client.get(`${sitePath(organization(args.organization), args.server, args.site)}/deployments/status`, { signal });
|
|
21
|
+
const flat = flattenSingle(response.data);
|
|
22
|
+
const status = flat.status ?? null;
|
|
23
|
+
const startedAt = flat.started_at ?? null;
|
|
24
|
+
return {
|
|
25
|
+
structured: { status, started_at: startedAt },
|
|
26
|
+
summary: status
|
|
27
|
+
? `A deployment is ${status}${startedAt ? ` since ${startedAt}` : ''}.`
|
|
28
|
+
: 'No deployment is currently running on this site.',
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { idInput } from '../shared/schemas.js';
|
|
5
|
+
import { deploymentOutput, fetchLog, formatDeployment, logLinesInput, logOutput, siteScopeInput, sitePath } from './shared.js';
|
|
6
|
+
export const getDeployment = defineTool({
|
|
7
|
+
name: 'forge_get_deployment',
|
|
8
|
+
title: 'Get deployment',
|
|
9
|
+
description: 'Get a deployment with its status, commit, timing and the end of its log. Use it to understand why a deployment failed.',
|
|
10
|
+
toolset: 'deployments',
|
|
11
|
+
operations: ['organizations.servers.sites.deployments.show', 'organizations.servers.sites.deployments.log.show'],
|
|
12
|
+
permissions: ['server:view', 'site:manage-deploys'],
|
|
13
|
+
readOnly: true,
|
|
14
|
+
notFoundHint: 'Check the deployment ID with forge_list_deployments.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
...siteScopeInput,
|
|
17
|
+
deployment: idInput('Deployment ID. Use forge_list_deployments to find it.'),
|
|
18
|
+
include_log: z.boolean().default(true).describe('Also fetch the deployment log.'),
|
|
19
|
+
log_lines: logLinesInput(100),
|
|
20
|
+
},
|
|
21
|
+
outputSchema: {
|
|
22
|
+
deployment: deploymentOutput,
|
|
23
|
+
...logOutput,
|
|
24
|
+
},
|
|
25
|
+
async handler(args, { client, organization, signal }) {
|
|
26
|
+
const base = sitePath(organization(args.organization), args.server, args.site);
|
|
27
|
+
const [response, log] = await Promise.all([
|
|
28
|
+
client.get(`${base}/deployments/${encodeURIComponent(String(args.deployment))}`, { signal }),
|
|
29
|
+
args.include_log
|
|
30
|
+
? fetchLog(client, base, args.deployment, args.log_lines, signal)
|
|
31
|
+
: Promise.resolve({ log: null, log_truncated: false, log_total_lines: null, log_unavailable_reason: null }),
|
|
32
|
+
]);
|
|
33
|
+
const deployment = formatDeployment(flattenSingle(response.data));
|
|
34
|
+
return {
|
|
35
|
+
structured: { deployment, ...log },
|
|
36
|
+
summary: `Deployment ${deployment.id} is ${deployment.status}${deployment.commit_message ? ` (commit: "${deployment.commit_message}")` : ''}.${log.log_unavailable_reason ? ` ${log.log_unavailable_reason}` : ''}`,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection, flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { idInput, paginationInput, paginationOutput, paginationSummary, pick } from '../shared/schemas.js';
|
|
5
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
6
|
+
const WEBHOOK_FIELDS = ['id', 'url', 'created_at', 'updated_at'];
|
|
7
|
+
const webhookOutput = z.looseObject({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
url: z.string().nullable().describe('URL notified after every deployment.'),
|
|
10
|
+
created_at: z.string().nullable(),
|
|
11
|
+
updated_at: z.string().nullable(),
|
|
12
|
+
});
|
|
13
|
+
export const listDeploymentWebhooks = defineTool({
|
|
14
|
+
name: 'forge_list_deployment_webhooks',
|
|
15
|
+
title: 'List deployment webhooks',
|
|
16
|
+
description: 'List the URLs Forge notifies after each deployment of a site, or get one webhook by ID with `webhook`.',
|
|
17
|
+
toolset: 'deployments',
|
|
18
|
+
operations: ['organizations.servers.sites.webhooks.index', 'organizations.servers.sites.webhooks.show'],
|
|
19
|
+
permissions: ['server:view'],
|
|
20
|
+
readOnly: true,
|
|
21
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
22
|
+
inputSchema: {
|
|
23
|
+
...siteScopeInput,
|
|
24
|
+
webhook: idInput('Return only this webhook.').optional(),
|
|
25
|
+
...paginationInput,
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
webhooks: z.array(webhookOutput),
|
|
29
|
+
...paginationOutput,
|
|
30
|
+
},
|
|
31
|
+
async handler(args, { client, organization, signal }) {
|
|
32
|
+
const base = `${sitePath(organization(args.organization), args.server, args.site)}/webhooks`;
|
|
33
|
+
if (args.webhook !== undefined) {
|
|
34
|
+
const response = await client.get(`${base}/${encodeURIComponent(String(args.webhook))}`, { signal });
|
|
35
|
+
const webhook = pick(flattenSingle(response.data), WEBHOOK_FIELDS);
|
|
36
|
+
return { structured: { webhooks: [webhook], next_cursor: null, has_more: false }, summary: `Webhook ${webhook.id}: ${webhook.url}.` };
|
|
37
|
+
}
|
|
38
|
+
const response = await client.get(base, {
|
|
39
|
+
query: { page: { size: args.page_size, cursor: args.cursor } },
|
|
40
|
+
signal,
|
|
41
|
+
});
|
|
42
|
+
const page = flattenCollection(response.data);
|
|
43
|
+
const webhooks = page.items.map((item) => pick(item, WEBHOOK_FIELDS));
|
|
44
|
+
return {
|
|
45
|
+
structured: { webhooks, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
46
|
+
summary: webhooks.length === 0
|
|
47
|
+
? 'The site has no deployment webhooks.'
|
|
48
|
+
: `Found ${webhooks.length} deployment webhook(s).${paginationSummary(page.nextCursor)}`,
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { apiPath } from '../../forge/path.js';
|
|
4
|
+
import { defineTool } from '../define-tool.js';
|
|
5
|
+
import { idInput, organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { deploymentOutput, formatDeployment, sitePath } from './shared.js';
|
|
7
|
+
export const listDeployments = defineTool({
|
|
8
|
+
name: 'forge_list_deployments',
|
|
9
|
+
title: 'List deployments',
|
|
10
|
+
description: 'List recent deployments of a site, or of every site on a server when `site` is omitted, newest first by default. Shows status, commit and timing. Use forge_get_deployment for the log of one deployment.',
|
|
11
|
+
toolset: 'deployments',
|
|
12
|
+
operations: ['organizations.servers.sites.deployments.index', 'organizations.servers.deployments.index'],
|
|
13
|
+
permissions: ['server:view'],
|
|
14
|
+
readOnly: true,
|
|
15
|
+
notFoundHint: 'Check the server and site IDs with forge_list_sites.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
organization: organizationInput,
|
|
18
|
+
server: serverInput,
|
|
19
|
+
site: idInput('Only list deployments of this site. Omit to list deployments of every site on the server.').optional(),
|
|
20
|
+
commit_hash: z.string().min(1).optional().describe('Filter by commit hash.'),
|
|
21
|
+
commit_message: z.string().min(1).optional().describe('Filter by commit message.'),
|
|
22
|
+
commit_author: z.string().min(1).optional().describe('Filter by commit author.'),
|
|
23
|
+
sort: z.enum(['-created_at', 'created_at']).default('-created_at').describe('Sort by creation date.'),
|
|
24
|
+
...paginationInput,
|
|
25
|
+
},
|
|
26
|
+
outputSchema: {
|
|
27
|
+
deployments: z.array(deploymentOutput),
|
|
28
|
+
...paginationOutput,
|
|
29
|
+
},
|
|
30
|
+
async handler(args, { client, organization, signal }) {
|
|
31
|
+
const org = organization(args.organization);
|
|
32
|
+
const onSite = args.site !== undefined;
|
|
33
|
+
const path = onSite
|
|
34
|
+
? `${sitePath(org, args.server, args.site)}/deployments`
|
|
35
|
+
: apiPath `/orgs/${org}/servers/${args.server}/deployments`;
|
|
36
|
+
const response = await client.get(path, {
|
|
37
|
+
query: {
|
|
38
|
+
filter: { commit_hash: args.commit_hash, commit_message: args.commit_message, commit_author: args.commit_author },
|
|
39
|
+
sort: [args.sort],
|
|
40
|
+
include: onSite ? undefined : ['site', 'initiator'],
|
|
41
|
+
page: { size: args.page_size, cursor: args.cursor },
|
|
42
|
+
},
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
const page = flattenCollection(response.data);
|
|
46
|
+
const deployments = page.items.map(formatDeployment);
|
|
47
|
+
const scope = onSite ? `site ${args.site}` : `server ${args.server}`;
|
|
48
|
+
return {
|
|
49
|
+
structured: { deployments, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
50
|
+
summary: deployments.length === 0
|
|
51
|
+
? `No deployments found for ${scope}.`
|
|
52
|
+
: `Found ${deployments.length} deployment(s) for ${scope}; the first is ${deployments[0].status}.${paginationSummary(page.nextCursor)}`,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { deployHookOutput } from './get-deploy-hook.js';
|
|
4
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
5
|
+
export const regenerateDeployHook = defineTool({
|
|
6
|
+
name: 'forge_regenerate_deploy_hook',
|
|
7
|
+
title: 'Regenerate deploy hook URL',
|
|
8
|
+
description: 'Generate a new deployment trigger URL for a site, e.g. after it leaked. The previous URL stops working immediately: CI pipelines using it must be updated.',
|
|
9
|
+
toolset: 'deployments',
|
|
10
|
+
operations: ['organizations.servers.sites.deployments.deploy-hook.update'],
|
|
11
|
+
permissions: ['site:manage-deploys'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: false,
|
|
15
|
+
exposesSecrets: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: siteScopeInput,
|
|
18
|
+
outputSchema: deployHookOutput,
|
|
19
|
+
async handler(args, { client, organization, signal }) {
|
|
20
|
+
const response = await client.put(`${sitePath(organization(args.organization), args.server, args.site)}/deployments/deploy-hook`, { signal });
|
|
21
|
+
return {
|
|
22
|
+
structured: { url: flattenSingle(response.data).url ?? null },
|
|
23
|
+
summary: 'A new deploy hook URL was generated; the previous one no longer works.',
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { queued, queuedOutput } from '../shared/async.js';
|
|
3
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput, sitePath } from './shared.js';
|
|
4
|
+
export const resetDeploymentState = defineTool({
|
|
5
|
+
name: 'forge_reset_deployment_state',
|
|
6
|
+
title: 'Reset deployment state',
|
|
7
|
+
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
|
+
toolset: 'deployments',
|
|
9
|
+
operations: ['organizations.servers.sites.deployments.status.destroy'],
|
|
10
|
+
permissions: ['site:manage-deploys'],
|
|
11
|
+
readOnly: false,
|
|
12
|
+
destructive: false,
|
|
13
|
+
idempotent: true,
|
|
14
|
+
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');
|
|
20
|
+
},
|
|
21
|
+
});
|