@francescomalatesta/laravel-forge-mcp 0.11.0 → 0.13.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 +34 -0
- package/dist/forge/client.js +6 -2
- package/dist/tools/backups/create-backup-configuration.js +4 -3
- package/dist/tools/backups/create-backup.js +4 -3
- package/dist/tools/backups/shared.js +0 -5
- package/dist/tools/firewall/create-firewall-rule.js +58 -0
- package/dist/tools/firewall/delete-firewall-rule.js +41 -0
- package/dist/tools/firewall/list-firewall-rules.js +59 -0
- package/dist/tools/firewall/shared.js +22 -0
- package/dist/tools/heartbeats/create-heartbeat.js +39 -0
- package/dist/tools/heartbeats/delete-heartbeat.js +29 -0
- package/dist/tools/heartbeats/list-heartbeats.js +44 -0
- package/dist/tools/heartbeats/shared.js +36 -0
- package/dist/tools/heartbeats/update-heartbeat.js +48 -0
- package/dist/tools/load-balancing/get-load-balancer.js +29 -0
- package/dist/tools/load-balancing/shared.js +27 -0
- package/dist/tools/load-balancing/update-load-balancer.js +61 -0
- package/dist/tools/monitors/create-monitor.js +62 -0
- package/dist/tools/monitors/delete-monitor.js +41 -0
- package/dist/tools/monitors/list-monitors.js +57 -0
- package/dist/tools/monitors/shared.js +28 -0
- package/dist/tools/nginx-templates/create-nginx-template.js +35 -0
- package/dist/tools/nginx-templates/delete-nginx-template.js +28 -0
- package/dist/tools/nginx-templates/list-nginx-templates.js +50 -0
- package/dist/tools/nginx-templates/shared.js +25 -0
- package/dist/tools/nginx-templates/update-nginx-template.js +42 -0
- package/dist/tools/package-credentials/delete-package-credentials.js +41 -0
- package/dist/tools/package-credentials/list-package-credentials.js +47 -0
- package/dist/tools/package-credentials/set-package-credentials.js +73 -0
- package/dist/tools/package-credentials/shared.js +36 -0
- package/dist/tools/redirects/create-redirect-rule.js +55 -0
- package/dist/tools/redirects/delete-redirect-rule.js +40 -0
- package/dist/tools/redirects/export-redirect-rules.js +30 -0
- package/dist/tools/redirects/import-redirect-rules.js +56 -0
- package/dist/tools/redirects/list-redirect-rules.js +53 -0
- package/dist/tools/redirects/reorder-redirect-rules.js +34 -0
- package/dist/tools/redirects/shared.js +24 -0
- package/dist/tools/registry.js +71 -0
- package/dist/tools/security-rules/create-security-rule.js +58 -0
- package/dist/tools/security-rules/delete-security-rule.js +40 -0
- package/dist/tools/security-rules/list-security-rules.js +49 -0
- package/dist/tools/security-rules/shared.js +27 -0
- package/dist/tools/security-rules/update-security-rule.js +58 -0
- package/dist/tools/shared/async.js +4 -0
- package/dist/tools/shared/read.js +13 -1
- package/dist/tools/ssh-keys/add-ssh-key.js +61 -0
- package/dist/tools/ssh-keys/delete-ssh-key.js +41 -0
- package/dist/tools/ssh-keys/get-server-public-key.js +27 -0
- package/dist/tools/ssh-keys/list-ssh-keys.js +48 -0
- package/dist/tools/ssh-keys/regenerate-server-key.js +30 -0
- package/dist/tools/ssh-keys/shared.js +24 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,11 @@ npm run build
|
|
|
106
106
|
| `forge_create_certificate` | sites | Let's Encrypt, existing, CSR or cloned certificate, waiting until installed |
|
|
107
107
|
| `forge_run_certificate_action` | sites | Activate or deactivate a certificate |
|
|
108
108
|
| `forge_delete_certificate` | sites | Delete a certificate |
|
|
109
|
+
| `forge_list_package_credentials` | sites | Credentials for private Composer repositories and npm registries (secrets hidden) |
|
|
110
|
+
| `forge_set_package_credentials` | sites | Add or replace Composer or npm credentials |
|
|
111
|
+
| `forge_delete_package_credentials` | sites | Remove Composer or npm credentials |
|
|
112
|
+
| `forge_get_load_balancer` | sites | Servers a load-balanced site sends traffic to |
|
|
113
|
+
| `forge_update_load_balancer` | sites | Replace the nodes and balancing method, take nodes out of rotation |
|
|
109
114
|
| `forge_run_service_action` | servers | Restart or stop Nginx, MySQL, Postgres, Redis, Supervisor; restart or reload PHP-FPM |
|
|
110
115
|
| `forge_run_server_action` | servers | Reboot or power-cycle a server |
|
|
111
116
|
| `forge_get_server_log` | servers | End of a server log (Nginx, PHP-FPM, MySQL, cron, daemons) |
|
|
@@ -113,6 +118,10 @@ npm run build
|
|
|
113
118
|
| `forge_update_server` | servers | Rename, change IP addresses, timezone or tags |
|
|
114
119
|
| `forge_get_server_network` | servers | Servers that can reach each other on the private network |
|
|
115
120
|
| `forge_update_server_network` | servers | Set the server network |
|
|
121
|
+
| `forge_list_nginx_templates` | servers | Custom Nginx templates for new sites |
|
|
122
|
+
| `forge_create_nginx_template` | servers | Save an Nginx template |
|
|
123
|
+
| `forge_update_nginx_template` | servers | Rename or replace an Nginx template |
|
|
124
|
+
| `forge_delete_nginx_template` | servers | Delete an Nginx template |
|
|
116
125
|
| `forge_list_archived_servers` | servers | Archived servers |
|
|
117
126
|
| `forge_archive_server` | servers | Archive a server (Forge stops managing it) |
|
|
118
127
|
| `forge_unarchive_server` | servers | Restore an archived server |
|
|
@@ -174,6 +183,31 @@ npm run build
|
|
|
174
183
|
| `forge_update_background_process` | jobs | Rename or replace the Supervisor configuration |
|
|
175
184
|
| `forge_run_background_process_action` | jobs | Start, stop or restart a background process, or empty its log |
|
|
176
185
|
| `forge_delete_background_process` | jobs | Stop and remove a background process |
|
|
186
|
+
| `forge_list_firewall_rules` | security | Open and blocked ports of a server |
|
|
187
|
+
| `forge_create_firewall_rule` | security | Allow or deny a port (range) for everyone or an IP/subnet |
|
|
188
|
+
| `forge_delete_firewall_rule` | security | Remove a firewall rule |
|
|
189
|
+
| `forge_list_security_rules` | security | Password-protected (basic auth) paths of a site |
|
|
190
|
+
| `forge_create_security_rule` | security | Protect a site or a path with a password |
|
|
191
|
+
| `forge_update_security_rule` | security | Change name, path or users of a security rule |
|
|
192
|
+
| `forge_delete_security_rule` | security | Remove a password protection |
|
|
193
|
+
| `forge_list_redirect_rules` | security | Redirect rules of a site |
|
|
194
|
+
| `forge_create_redirect_rule` | security | Add a 301 or 302 redirect |
|
|
195
|
+
| `forge_delete_redirect_rule` | security | Remove a redirect |
|
|
196
|
+
| `forge_reorder_redirect_rules` | security | Set the evaluation order of the redirects |
|
|
197
|
+
| `forge_export_redirect_rules` | security | Redirects as CSV, in evaluation order |
|
|
198
|
+
| `forge_import_redirect_rules` | security | Import redirects from CSV (append or replace) |
|
|
199
|
+
| `forge_list_ssh_keys` | security | SSH keys authorized on a server |
|
|
200
|
+
| `forge_add_ssh_key` | security | Authorize a public key |
|
|
201
|
+
| `forge_delete_ssh_key` | security | Revoke an SSH key |
|
|
202
|
+
| `forge_get_server_public_key` | security | The server's own public key (for Git providers, other servers) |
|
|
203
|
+
| `forge_regenerate_server_key` | security | Replace the server's own key pair |
|
|
204
|
+
| `forge_list_heartbeats` | monitoring | Heartbeats of a site and whether they are missing |
|
|
205
|
+
| `forge_create_heartbeat` | monitoring | Alert when a scheduled task stops pinging |
|
|
206
|
+
| `forge_update_heartbeat` | monitoring | Change name, schedule or grace period |
|
|
207
|
+
| `forge_delete_heartbeat` | monitoring | Delete a heartbeat |
|
|
208
|
+
| `forge_list_monitors` | monitoring | CPU, disk and memory monitors and their alert state |
|
|
209
|
+
| `forge_create_monitor` | monitoring | Alert by email when a metric crosses a threshold |
|
|
210
|
+
| `forge_delete_monitor` | monitoring | Remove a monitor |
|
|
177
211
|
| `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
|
|
178
212
|
| `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
|
|
179
213
|
| `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
|
package/dist/forge/client.js
CHANGED
|
@@ -78,11 +78,15 @@ export class ForgeClient {
|
|
|
78
78
|
send(method, url, options) {
|
|
79
79
|
const headers = {
|
|
80
80
|
Authorization: `Bearer ${this.apiToken}`,
|
|
81
|
-
Accept: 'application/json',
|
|
81
|
+
Accept: options.accept ?? 'application/json',
|
|
82
82
|
'User-Agent': this.userAgent,
|
|
83
83
|
};
|
|
84
84
|
let body;
|
|
85
|
-
if (options.body
|
|
85
|
+
if (options.body instanceof FormData) {
|
|
86
|
+
// fetch sets the multipart Content-Type with its boundary.
|
|
87
|
+
body = options.body;
|
|
88
|
+
}
|
|
89
|
+
else if (options.body !== undefined) {
|
|
86
90
|
headers['Content-Type'] = 'application/json';
|
|
87
91
|
body = JSON.stringify(options.body);
|
|
88
92
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { defineTool } from '../define-tool.js';
|
|
3
3
|
import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
4
|
+
import { findNew, listNewestFirst } from '../shared/read.js';
|
|
4
5
|
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
6
|
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
6
7
|
import { databaseRefsInput, resolveDatabaseIds } from '../databases/shared.js';
|
|
7
|
-
import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration,
|
|
8
|
+
import { backupConfigurationOutput, backupConfigurationPhase, backupConfigurationsPath, formatBackupConfiguration, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
|
|
8
9
|
const CHECK_WITH = 'forge_list_backup_configurations';
|
|
9
10
|
export const createBackupConfiguration = defineTool({
|
|
10
11
|
name: 'forge_create_backup_configuration',
|
|
@@ -48,7 +49,7 @@ export const createBackupConfiguration = defineTool({
|
|
|
48
49
|
const databaseIds = await resolveDatabaseIds(client, org, args.server, args.databases, signal);
|
|
49
50
|
const base = backupConfigurationsPath(org, args.server);
|
|
50
51
|
// Forge returns no body: remember the existing configurations to spot the new one.
|
|
51
|
-
const existing = args.wait ? new Set((await
|
|
52
|
+
const existing = args.wait ? new Set((await listNewestFirst(client, base, signal)).map((item) => item.id)) : undefined;
|
|
52
53
|
await client.post(base, {
|
|
53
54
|
body: {
|
|
54
55
|
storage_provider_id: storageProviderId,
|
|
@@ -71,7 +72,7 @@ export const createBackupConfiguration = defineTool({
|
|
|
71
72
|
};
|
|
72
73
|
}
|
|
73
74
|
const result = await waitFor({
|
|
74
|
-
poll: async () => (await
|
|
75
|
+
poll: async () => findNew(await listNewestFirst(client, base, signal), existing),
|
|
75
76
|
phase: (configuration) => (configuration ? backupConfigurationPhase(configuration.status) : 'pending'),
|
|
76
77
|
describe: (configuration) => (configuration ? `Backup configuration is ${configuration.status}` : 'Waiting for the configuration to appear'),
|
|
77
78
|
timeoutSeconds: args.timeout_seconds,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { defineTool } from '../define-tool.js';
|
|
2
2
|
import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { findNew, listNewestFirst } from '../shared/read.js';
|
|
3
4
|
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
4
|
-
import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup,
|
|
5
|
+
import { BACKUP_CONFIGURATION_NOT_FOUND_HINT, backupConfigurationInput, backupOutput, backupPhase, backupsPath, formatBackup, } from './shared.js';
|
|
5
6
|
const CHECK_WITH = 'forge_list_backups';
|
|
6
7
|
export const createBackup = defineTool({
|
|
7
8
|
name: 'forge_create_backup',
|
|
@@ -28,7 +29,7 @@ export const createBackup = defineTool({
|
|
|
28
29
|
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
29
30
|
const base = backupsPath(organization(args.organization), args.server, args.backup_configuration);
|
|
30
31
|
// Forge returns no body: remember the existing backups to spot the new one.
|
|
31
|
-
const existing = args.wait ? new Set((await
|
|
32
|
+
const existing = args.wait ? new Set((await listNewestFirst(client, base, signal)).map((item) => item.id)) : undefined;
|
|
32
33
|
await client.post(base, { signal });
|
|
33
34
|
const action = `back up configuration ${args.backup_configuration}`;
|
|
34
35
|
if (!existing) {
|
|
@@ -38,7 +39,7 @@ export const createBackup = defineTool({
|
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
const result = await waitFor({
|
|
41
|
-
poll: async () => (await
|
|
42
|
+
poll: async () => findNew(await listNewestFirst(client, base, signal), existing),
|
|
42
43
|
phase: (backup) => (backup ? backupPhase(backup.status) : 'pending'),
|
|
43
44
|
describe: (backup) => (backup ? `Backup is ${backup.status}` : 'Waiting for the backup to start'),
|
|
44
45
|
timeoutSeconds: args.timeout_seconds,
|
|
@@ -100,11 +100,6 @@ export function formatBackup(flat) {
|
|
|
100
100
|
export function backupPhase(status) {
|
|
101
101
|
return phaseOf(status, { completed: ['finished'], failed: ['failed'] });
|
|
102
102
|
}
|
|
103
|
-
/** First page of a collection, newest first: used to spot items created by a write that returns no body. */
|
|
104
|
-
export async function newestFirst(client, path, signal) {
|
|
105
|
-
const response = await client.get(path, { query: { sort: ['-created_at'], page: { size: 100 } }, signal });
|
|
106
|
-
return flattenCollection(response.data).items;
|
|
107
|
-
}
|
|
108
103
|
/** Resolves a storage provider ID or name to its ID. */
|
|
109
104
|
export async function resolveStorageProviderId(client, org, ref, signal) {
|
|
110
105
|
if (typeof ref === 'number' || /^\d+$/.test(ref))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { installationPhase, operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
|
|
4
|
+
import { findNew, listNewestFirst } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
7
|
+
import { firewallRuleOutput, firewallRulesPath, formatFirewallRule } from './shared.js';
|
|
8
|
+
const CHECK_WITH = 'forge_list_firewall_rules';
|
|
9
|
+
export const createFirewallRule = defineTool({
|
|
10
|
+
name: 'forge_create_firewall_rule',
|
|
11
|
+
title: 'Create firewall rule',
|
|
12
|
+
description: 'Open (allow) or block (deny) a port or port range on a server, for everyone or for an IP address or subnet. Waits until the rule is installed unless `wait` is false.',
|
|
13
|
+
toolset: 'security',
|
|
14
|
+
operations: ['organizations.servers.firewall-rules.store', 'organizations.servers.firewall-rules.index'],
|
|
15
|
+
permissions: ['server:manage-network'],
|
|
16
|
+
readOnly: false,
|
|
17
|
+
destructive: false,
|
|
18
|
+
idempotent: false,
|
|
19
|
+
async: true,
|
|
20
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
organization: organizationInput,
|
|
23
|
+
server: serverInput,
|
|
24
|
+
name: z.string().min(1).max(50).describe('Name of the rule, e.g. "MySQL from office".'),
|
|
25
|
+
port: z.string().min(1).optional().describe('Port or range, e.g. "3306" or "8000:8010". Omit for every port.'),
|
|
26
|
+
ip_address: z.string().min(1).optional().describe('IP address or subnet allowed/blocked, e.g. "203.0.113.4" or "10.0.0.0/16". Omit for any address.'),
|
|
27
|
+
type: z.enum(['allow', 'deny']).default('allow'),
|
|
28
|
+
...waitInput(60),
|
|
29
|
+
},
|
|
30
|
+
outputSchema: {
|
|
31
|
+
...operationOutput,
|
|
32
|
+
rule: firewallRuleOutput.nullable().describe('The new rule once it shows up.'),
|
|
33
|
+
},
|
|
34
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
35
|
+
const base = firewallRulesPath(organization(args.organization), args.server);
|
|
36
|
+
// Forge returns no body: remember the existing rules with this name to spot the new one.
|
|
37
|
+
const lookup = () => listNewestFirst(client, base, signal, { name: args.name });
|
|
38
|
+
const existing = args.wait ? new Set((await lookup()).map((rule) => rule.id)) : undefined;
|
|
39
|
+
await client.post(base, { body: { name: args.name, port: args.port, ip_address: args.ip_address, type: args.type }, signal });
|
|
40
|
+
const action = `${args.type} ${args.port ? `port ${args.port}` : 'every port'}${args.ip_address ? ` from ${args.ip_address}` : ''}`;
|
|
41
|
+
if (!existing) {
|
|
42
|
+
return {
|
|
43
|
+
structured: { status: 'queued', check_with: CHECK_WITH, rule: null },
|
|
44
|
+
summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const result = await waitFor({
|
|
48
|
+
poll: async () => findNew(await lookup(), existing),
|
|
49
|
+
phase: (rule) => (rule ? installationPhase(rule.status) : 'pending'),
|
|
50
|
+
describe: (rule) => (rule ? `Firewall rule is ${rule.status}` : 'Waiting for the rule to appear'),
|
|
51
|
+
timeoutSeconds: args.timeout_seconds,
|
|
52
|
+
context: { sleep, progress },
|
|
53
|
+
});
|
|
54
|
+
const rule = result.value ? formatFirewallRule(result.value) : null;
|
|
55
|
+
const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: rule ? `Rule ID: ${rule.id}.` : undefined });
|
|
56
|
+
return { structured: { ...done.structured, rule }, summary: done.summary };
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineTool } from '../define-tool.js';
|
|
2
|
+
import { operationOutput, orGone, outcome, queued, waitFor, waitInput } from '../shared/async.js';
|
|
3
|
+
import { readResource } from '../shared/read.js';
|
|
4
|
+
import { organizationInput, serverInput } from '../shared/schemas.js';
|
|
5
|
+
import { FIREWALL_RULE_NOT_FOUND_HINT, firewallRuleInput, firewallRulesPath } from './shared.js';
|
|
6
|
+
const CHECK_WITH = 'forge_list_firewall_rules';
|
|
7
|
+
export const deleteFirewallRule = defineTool({
|
|
8
|
+
name: 'forge_delete_firewall_rule',
|
|
9
|
+
title: 'Delete firewall rule',
|
|
10
|
+
description: 'Remove a firewall rule from a server. Removing an allow rule closes the port: check that nothing (SSH, HTTP) depends on it.',
|
|
11
|
+
toolset: 'security',
|
|
12
|
+
operations: ['organizations.servers.firewall-rules.destroy', 'organizations.servers.firewall-rules.show'],
|
|
13
|
+
permissions: ['server:manage-network'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: true,
|
|
16
|
+
idempotent: true,
|
|
17
|
+
async: true,
|
|
18
|
+
notFoundHint: FIREWALL_RULE_NOT_FOUND_HINT,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
organization: organizationInput,
|
|
21
|
+
server: serverInput,
|
|
22
|
+
rule: firewallRuleInput,
|
|
23
|
+
...waitInput(60),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: operationOutput,
|
|
26
|
+
async handler(args, { client, organization, signal, sleep, progress }) {
|
|
27
|
+
const path = `${firewallRulesPath(organization(args.organization), args.server)}/${encodeURIComponent(String(args.rule))}`;
|
|
28
|
+
await client.delete(path, { signal });
|
|
29
|
+
const action = `delete firewall rule ${args.rule}`;
|
|
30
|
+
if (!args.wait)
|
|
31
|
+
return queued(action, CHECK_WITH);
|
|
32
|
+
const result = await waitFor({
|
|
33
|
+
poll: () => orGone(() => readResource(client, path, signal)),
|
|
34
|
+
phase: (rule) => (rule === null ? 'completed' : 'pending'),
|
|
35
|
+
describe: (rule) => `Firewall rule is ${rule?.status ?? 'removing'}`,
|
|
36
|
+
timeoutSeconds: args.timeout_seconds,
|
|
37
|
+
context: { sleep, progress },
|
|
38
|
+
});
|
|
39
|
+
return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { organizationInput, paginationInput, paginationOutput, paginationSummary, serverInput } from '../shared/schemas.js';
|
|
6
|
+
import { SERVER_NOT_FOUND_HINT } from '../servers/shared.js';
|
|
7
|
+
import { firewallRuleInput, firewallRuleOutput, firewallRulesPath, formatFirewallRule } from './shared.js';
|
|
8
|
+
const SORT = ['created_at', '-created_at', 'updated_at', '-updated_at'];
|
|
9
|
+
export const listFirewallRules = defineTool({
|
|
10
|
+
name: 'forge_list_firewall_rules',
|
|
11
|
+
title: 'List firewall rules',
|
|
12
|
+
description: 'List the firewall rules of a server (which ports are open to which addresses), or get one with `rule`.',
|
|
13
|
+
toolset: 'security',
|
|
14
|
+
operations: ['organizations.servers.firewall-rules.index', 'organizations.servers.firewall-rules.show'],
|
|
15
|
+
permissions: ['server:manage-network'],
|
|
16
|
+
readOnly: true,
|
|
17
|
+
notFoundHint: SERVER_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
organization: organizationInput,
|
|
20
|
+
server: serverInput,
|
|
21
|
+
rule: firewallRuleInput.optional().describe('Return only this rule.'),
|
|
22
|
+
name: z.string().min(1).optional().describe('Filter by name.'),
|
|
23
|
+
port: z.string().min(1).optional().describe('Filter by port, e.g. "3306".'),
|
|
24
|
+
ip_address: z.string().min(1).optional().describe('Filter by IP address or subnet.'),
|
|
25
|
+
type: z.enum(['allow', 'deny']).optional().describe('Filter by type.'),
|
|
26
|
+
status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
|
|
27
|
+
sort: z.array(z.enum(SORT)).min(1).optional(),
|
|
28
|
+
...paginationInput,
|
|
29
|
+
},
|
|
30
|
+
outputSchema: {
|
|
31
|
+
rules: z.array(firewallRuleOutput),
|
|
32
|
+
...paginationOutput,
|
|
33
|
+
},
|
|
34
|
+
async handler(args, { client, organization, signal }) {
|
|
35
|
+
const base = firewallRulesPath(organization(args.organization), args.server);
|
|
36
|
+
if (args.rule !== undefined) {
|
|
37
|
+
const rule = formatFirewallRule(await readResource(client, `${base}/${encodeURIComponent(String(args.rule))}`, signal));
|
|
38
|
+
return { structured: { rules: [rule], next_cursor: null, has_more: false }, summary: `Firewall rule ${rule.name} is ${rule.status}.` };
|
|
39
|
+
}
|
|
40
|
+
const response = await client.get(base, {
|
|
41
|
+
query: {
|
|
42
|
+
filter: { name: args.name, port: args.port, ip_address: args.ip_address, type: args.type, status: args.status },
|
|
43
|
+
sort: args.sort,
|
|
44
|
+
page: { size: args.page_size, cursor: args.cursor },
|
|
45
|
+
},
|
|
46
|
+
signal,
|
|
47
|
+
});
|
|
48
|
+
const page = flattenCollection(response.data);
|
|
49
|
+
const rules = page.items.map(formatFirewallRule);
|
|
50
|
+
return {
|
|
51
|
+
structured: { rules, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
52
|
+
summary: rules.length === 0
|
|
53
|
+
? 'No firewall rules found.'
|
|
54
|
+
: `Found ${rules.length} firewall rule(s): ${rules
|
|
55
|
+
.map((r) => `${r.type} ${r.port ?? 'all ports'} from ${r.ip_address ?? 'anywhere'} (${r.id})`)
|
|
56
|
+
.join(', ')}.${paginationSummary(page.nextCursor)}`,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
3
|
+
import { serverPath } from '../servers/shared.js';
|
|
4
|
+
export function firewallRulesPath(org, server) {
|
|
5
|
+
return `${serverPath(org, server)}/firewall-rules`;
|
|
6
|
+
}
|
|
7
|
+
export const firewallRuleInput = idInput('Firewall rule ID. Use forge_list_firewall_rules to find it.');
|
|
8
|
+
export const FIREWALL_RULE_NOT_FOUND_HINT = 'Check the firewall rule ID with forge_list_firewall_rules.';
|
|
9
|
+
const FIELDS = ['id', 'name', 'type', 'port', 'ip_address', 'status', 'created_at', 'updated_at'];
|
|
10
|
+
export const firewallRuleOutput = z.looseObject({
|
|
11
|
+
id: z.string(),
|
|
12
|
+
name: z.string().nullable(),
|
|
13
|
+
type: z.string().nullable().describe('allow or deny.'),
|
|
14
|
+
port: z.string().nullable().describe('Port or range, e.g. "80" or "8000:8010"; null for every port.'),
|
|
15
|
+
ip_address: z.string().nullable().describe('IP address or subnet; null for any address.'),
|
|
16
|
+
status: z.string().nullable().describe('e.g. installing, installed, removing.'),
|
|
17
|
+
created_at: z.string().nullable(),
|
|
18
|
+
updated_at: z.string().nullable(),
|
|
19
|
+
});
|
|
20
|
+
export function formatFirewallRule(flat) {
|
|
21
|
+
return pick(flat, FIELDS);
|
|
22
|
+
}
|
|
@@ -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 { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
5
|
+
import { REDACTED } from '../shared/secrets.js';
|
|
6
|
+
import { cronInput, formatHeartbeat, frequencyInput, gracePeriodInput, heartbeatOutput, heartbeatsPath, scheduleBody } from './shared.js';
|
|
7
|
+
export const createHeartbeat = defineTool({
|
|
8
|
+
name: 'forge_create_heartbeat',
|
|
9
|
+
title: 'Create heartbeat',
|
|
10
|
+
description: 'Create a heartbeat: Forge alerts when the job does not request its ping URL on schedule (e.g. `->thenPing($url)` on a Laravel scheduled task). Pass `frequency` or `cron`.',
|
|
11
|
+
toolset: 'monitoring',
|
|
12
|
+
operations: ['organizations.servers.sites.heartbeats.store'],
|
|
13
|
+
permissions: ['site:manage-heartbeats'],
|
|
14
|
+
readOnly: false,
|
|
15
|
+
destructive: false,
|
|
16
|
+
idempotent: false,
|
|
17
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
18
|
+
inputSchema: {
|
|
19
|
+
...siteScopeInput,
|
|
20
|
+
name: z.string().min(1).max(255).describe('Name, e.g. "Nightly reports".'),
|
|
21
|
+
frequency: frequencyInput.optional(),
|
|
22
|
+
cron: cronInput.optional(),
|
|
23
|
+
grace_period: gracePeriodInput.default(5),
|
|
24
|
+
},
|
|
25
|
+
outputSchema: {
|
|
26
|
+
heartbeat: heartbeatOutput,
|
|
27
|
+
},
|
|
28
|
+
async handler(args, { client, config, organization, signal }) {
|
|
29
|
+
const response = await client.post(heartbeatsPath(organization(args.organization), args.server, args.site), {
|
|
30
|
+
body: { name: args.name, grace_period: args.grace_period, ...scheduleBody(args.frequency, args.cron) },
|
|
31
|
+
signal,
|
|
32
|
+
});
|
|
33
|
+
const heartbeat = formatHeartbeat(flattenSingle(response.data), config.allowSecrets);
|
|
34
|
+
return {
|
|
35
|
+
structured: { heartbeat },
|
|
36
|
+
summary: `Created heartbeat ${heartbeat.name} (ID ${heartbeat.id}).${heartbeat.ping_url === REDACTED ? ' Its ping URL is hidden: enable FORGE_ALLOW_SECRETS or copy it from the Forge dashboard.' : ` Ping URL: ${heartbeat.ping_url}`}`,
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
4
|
+
import { HEARTBEAT_NOT_FOUND_HINT, heartbeatInput, heartbeatsPath } from './shared.js';
|
|
5
|
+
export const deleteHeartbeat = defineTool({
|
|
6
|
+
name: 'forge_delete_heartbeat',
|
|
7
|
+
title: 'Delete heartbeat',
|
|
8
|
+
description: 'Delete a heartbeat: Forge stops alerting when the job does not ping.',
|
|
9
|
+
toolset: 'monitoring',
|
|
10
|
+
operations: ['organizations.servers.sites.heartbeats.destroy'],
|
|
11
|
+
permissions: ['site:manage-heartbeats'],
|
|
12
|
+
readOnly: false,
|
|
13
|
+
destructive: true,
|
|
14
|
+
idempotent: true,
|
|
15
|
+
notFoundHint: HEARTBEAT_NOT_FOUND_HINT,
|
|
16
|
+
inputSchema: {
|
|
17
|
+
...siteScopeInput,
|
|
18
|
+
heartbeat: heartbeatInput,
|
|
19
|
+
},
|
|
20
|
+
outputSchema: {
|
|
21
|
+
deleted: z.boolean(),
|
|
22
|
+
},
|
|
23
|
+
async handler(args, { client, organization, signal }) {
|
|
24
|
+
await client.delete(`${heartbeatsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.heartbeat))}`, {
|
|
25
|
+
signal,
|
|
26
|
+
});
|
|
27
|
+
return { structured: { deleted: true }, summary: `Deleted heartbeat ${args.heartbeat}.` };
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { readResource } from '../shared/read.js';
|
|
5
|
+
import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
|
|
6
|
+
import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { formatHeartbeat, heartbeatInput, heartbeatOutput, heartbeatsPath } from './shared.js';
|
|
8
|
+
export const listHeartbeats = defineTool({
|
|
9
|
+
name: 'forge_list_heartbeats',
|
|
10
|
+
title: 'List heartbeats',
|
|
11
|
+
description: 'List the heartbeats of a site (scheduled tasks that must ping Forge regularly) with their status: missing means the job stopped running. Get one with `heartbeat`.',
|
|
12
|
+
toolset: 'monitoring',
|
|
13
|
+
operations: ['organizations.servers.sites.heartbeats.index', 'organizations.servers.sites.heartbeats.show'],
|
|
14
|
+
permissions: ['server:view'],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
notFoundHint: SITE_NOT_FOUND_HINT,
|
|
17
|
+
inputSchema: {
|
|
18
|
+
...siteScopeInput,
|
|
19
|
+
heartbeat: heartbeatInput.optional().describe('Return only this heartbeat.'),
|
|
20
|
+
...paginationInput,
|
|
21
|
+
},
|
|
22
|
+
outputSchema: {
|
|
23
|
+
heartbeats: z.array(heartbeatOutput),
|
|
24
|
+
...paginationOutput,
|
|
25
|
+
},
|
|
26
|
+
async handler(args, { client, config, organization, signal }) {
|
|
27
|
+
const base = heartbeatsPath(organization(args.organization), args.server, args.site);
|
|
28
|
+
const format = (flat) => formatHeartbeat(flat, config.allowSecrets);
|
|
29
|
+
if (args.heartbeat !== undefined) {
|
|
30
|
+
const heartbeat = format(await readResource(client, `${base}/${encodeURIComponent(String(args.heartbeat))}`, signal));
|
|
31
|
+
return { structured: { heartbeats: [heartbeat], next_cursor: null, has_more: false }, summary: `Heartbeat ${heartbeat.name} is ${heartbeat.status}.` };
|
|
32
|
+
}
|
|
33
|
+
const response = await client.get(base, { query: { page: { size: args.page_size, cursor: args.cursor } }, signal });
|
|
34
|
+
const page = flattenCollection(response.data);
|
|
35
|
+
const heartbeats = page.items.map(format);
|
|
36
|
+
const missing = heartbeats.filter((heartbeat) => heartbeat.status === 'missing');
|
|
37
|
+
return {
|
|
38
|
+
structured: { heartbeats, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
|
|
39
|
+
summary: heartbeats.length === 0
|
|
40
|
+
? 'No heartbeats found.'
|
|
41
|
+
: `Found ${heartbeats.length} heartbeat(s)${missing.length > 0 ? `; missing: ${missing.map((h) => h.name).join(', ')}` : ''}.${paginationSummary(page.nextCursor)}`,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ToolInputError } from '../errors.js';
|
|
3
|
+
import { redact } from '../shared/secrets.js';
|
|
4
|
+
import { idInput, pick } from '../shared/schemas.js';
|
|
5
|
+
import { sitePath } from '../shared/site-scope.js';
|
|
6
|
+
export function heartbeatsPath(org, server, site) {
|
|
7
|
+
return `${sitePath(org, server, site)}/heartbeats`;
|
|
8
|
+
}
|
|
9
|
+
export const heartbeatInput = idInput('Heartbeat ID. Use forge_list_heartbeats to find it.');
|
|
10
|
+
export const HEARTBEAT_NOT_FOUND_HINT = 'Check the heartbeat ID with forge_list_heartbeats.';
|
|
11
|
+
/** Minutes between expected pings (-1 = custom cron). */
|
|
12
|
+
const FREQUENCIES = [1, 5, 10, 30, 60, 1440, 10080, 312480];
|
|
13
|
+
const GRACE_PERIODS = [1, 2, 5, 10, 30, 60];
|
|
14
|
+
export const frequencyInput = z.literal(FREQUENCIES).describe('Minutes between expected pings: 1, 5, 10, 30, 60, 1440 (daily), 10080 (weekly) or 312480. Use `cron` instead for other schedules.');
|
|
15
|
+
export const cronInput = z.string().min(1).describe('Cron expression of the expected pings, e.g. "0 3 * * *" (instead of `frequency`).');
|
|
16
|
+
export const gracePeriodInput = z.literal(GRACE_PERIODS).describe('Minutes of delay tolerated before the heartbeat is reported missing: 1, 2, 5, 10, 30 or 60.');
|
|
17
|
+
/** Maps `frequency` / `cron` to the API fields (frequency -1 means custom). */
|
|
18
|
+
export function scheduleBody(frequency, cron) {
|
|
19
|
+
if ((frequency === undefined) === (cron === undefined))
|
|
20
|
+
throw new ToolInputError('Pass either `frequency` or `cron`.');
|
|
21
|
+
return cron === undefined ? { frequency } : { frequency: -1, custom_frequency: cron };
|
|
22
|
+
}
|
|
23
|
+
const FIELDS = ['id', 'name', 'status', 'frequency', 'custom_frequency', 'grace_period', 'ping_url'];
|
|
24
|
+
export const heartbeatOutput = z.looseObject({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string().nullable(),
|
|
27
|
+
status: z.string().nullable().describe('pending (no ping yet), beating or missing.'),
|
|
28
|
+
frequency: z.number().nullable().describe('Minutes between expected pings; -1 = custom cron.'),
|
|
29
|
+
custom_frequency: z.string().nullable(),
|
|
30
|
+
grace_period: z.number().nullable(),
|
|
31
|
+
ping_url: z.string().nullable().describe('URL the job must request after each run (hidden unless secrets are allowed).'),
|
|
32
|
+
});
|
|
33
|
+
/** The ping URL lets anyone report the job as alive: it is a secret. */
|
|
34
|
+
export function formatHeartbeat(flat, allowSecrets) {
|
|
35
|
+
return redact(pick(flat, FIELDS), ['ping_url'], allowSecrets);
|
|
36
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenSingle } from '../../forge/jsonapi.js';
|
|
3
|
+
import { defineTool } from '../define-tool.js';
|
|
4
|
+
import { ToolInputError } from '../errors.js';
|
|
5
|
+
import { readResource } from '../shared/read.js';
|
|
6
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
7
|
+
import { cronInput, formatHeartbeat, frequencyInput, gracePeriodInput, HEARTBEAT_NOT_FOUND_HINT, heartbeatInput, heartbeatOutput, heartbeatsPath, scheduleBody, } from './shared.js';
|
|
8
|
+
export const updateHeartbeat = defineTool({
|
|
9
|
+
name: 'forge_update_heartbeat',
|
|
10
|
+
title: 'Update heartbeat',
|
|
11
|
+
description: 'Change the name, schedule (`frequency` or `cron`) or grace period of a heartbeat. Fields not passed keep their value.',
|
|
12
|
+
toolset: 'monitoring',
|
|
13
|
+
operations: ['organizations.servers.sites.heartbeats.update', 'organizations.servers.sites.heartbeats.show'],
|
|
14
|
+
permissions: ['site:manage-heartbeats', 'server:view'],
|
|
15
|
+
readOnly: false,
|
|
16
|
+
destructive: false,
|
|
17
|
+
idempotent: true,
|
|
18
|
+
notFoundHint: HEARTBEAT_NOT_FOUND_HINT,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
...siteScopeInput,
|
|
21
|
+
heartbeat: heartbeatInput,
|
|
22
|
+
name: z.string().min(1).max(255).optional().describe('New name.'),
|
|
23
|
+
frequency: frequencyInput.optional(),
|
|
24
|
+
cron: cronInput.optional(),
|
|
25
|
+
grace_period: gracePeriodInput.optional(),
|
|
26
|
+
},
|
|
27
|
+
outputSchema: {
|
|
28
|
+
heartbeat: heartbeatOutput,
|
|
29
|
+
},
|
|
30
|
+
async handler(args, { client, config, organization, signal }) {
|
|
31
|
+
if (args.name === undefined && args.frequency === undefined && args.cron === undefined && args.grace_period === undefined) {
|
|
32
|
+
throw new ToolInputError('Pass at least one setting to change.');
|
|
33
|
+
}
|
|
34
|
+
const path = `${heartbeatsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.heartbeat))}`;
|
|
35
|
+
const current = await readResource(client, path, signal);
|
|
36
|
+
const schedule = args.frequency !== undefined || args.cron !== undefined
|
|
37
|
+
? scheduleBody(args.frequency, args.cron)
|
|
38
|
+
: current.frequency === -1
|
|
39
|
+
? { frequency: -1, custom_frequency: current.custom_frequency }
|
|
40
|
+
: { frequency: current.frequency };
|
|
41
|
+
const response = await client.put(path, {
|
|
42
|
+
body: { name: args.name ?? current.name, grace_period: args.grace_period ?? current.grace_period, ...schedule },
|
|
43
|
+
signal,
|
|
44
|
+
});
|
|
45
|
+
const heartbeat = formatHeartbeat(flattenSingle(response.data), config.allowSecrets);
|
|
46
|
+
return { structured: { heartbeat }, summary: `Updated heartbeat ${heartbeat.name}.` };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../define-tool.js';
|
|
3
|
+
import { siteScopeInput } from '../shared/site-scope.js';
|
|
4
|
+
import { nodeOutput, nodesPath, readNodes } from './shared.js';
|
|
5
|
+
export const getLoadBalancer = defineTool({
|
|
6
|
+
name: 'forge_get_load_balancer',
|
|
7
|
+
title: 'Get load balancer nodes',
|
|
8
|
+
description: 'List the servers a load-balanced site sends traffic to, with weight, port and backup/down flags.',
|
|
9
|
+
toolset: 'sites',
|
|
10
|
+
operations: ['organizations.servers.sites.load-balancing-nodes.index'],
|
|
11
|
+
permissions: ['site:manage-project'],
|
|
12
|
+
readOnly: true,
|
|
13
|
+
notFoundHint: 'Check the load balancer server and site IDs with forge_list_sites.',
|
|
14
|
+
inputSchema: {
|
|
15
|
+
...siteScopeInput,
|
|
16
|
+
},
|
|
17
|
+
outputSchema: {
|
|
18
|
+
nodes: z.array(nodeOutput),
|
|
19
|
+
},
|
|
20
|
+
async handler(args, { client, organization, signal }) {
|
|
21
|
+
const nodes = await readNodes(client, nodesPath(organization(args.organization), args.server, args.site), signal);
|
|
22
|
+
return {
|
|
23
|
+
structured: { nodes },
|
|
24
|
+
summary: nodes.length === 0
|
|
25
|
+
? 'This site has no load balancing nodes.'
|
|
26
|
+
: `Traffic goes to server(s) ${nodes.map((n) => `${n.server_id} (weight ${n.weight}${n.down ? ', down' : ''}${n.backup ? ', backup' : ''})`).join(', ')}.`,
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { flattenCollection } from '../../forge/jsonapi.js';
|
|
3
|
+
import { sitePath } from '../shared/site-scope.js';
|
|
4
|
+
export function nodesPath(org, server, site) {
|
|
5
|
+
return `${sitePath(org, server, site)}/load-balancing-nodes`;
|
|
6
|
+
}
|
|
7
|
+
export const nodeOutput = z.looseObject({
|
|
8
|
+
server_id: z.string().nullable().describe('Server receiving traffic.'),
|
|
9
|
+
port: z.number().nullable(),
|
|
10
|
+
weight: z.number().nullable().describe('Relative share of traffic.'),
|
|
11
|
+
backup: z.boolean().nullable().describe('Only used when the other servers are unavailable.'),
|
|
12
|
+
down: z.boolean().nullable().describe('Out of rotation.'),
|
|
13
|
+
});
|
|
14
|
+
export function formatNode(flat) {
|
|
15
|
+
return {
|
|
16
|
+
server_id: flat.server_id === undefined || flat.server_id === null ? null : String(flat.server_id),
|
|
17
|
+
port: flat.port ?? null,
|
|
18
|
+
weight: flat.weight ?? null,
|
|
19
|
+
backup: flat.backup ?? null,
|
|
20
|
+
down: flat.down ?? null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Every node of the load balancer (one page of 100 covers any realistic pool). */
|
|
24
|
+
export async function readNodes(client, path, signal) {
|
|
25
|
+
const response = await client.get(path, { query: { page: { size: 100 } }, signal });
|
|
26
|
+
return flattenCollection(response.data).items.map(formatNode);
|
|
27
|
+
}
|