@francescomalatesta/laravel-forge-mcp 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +18 -0
  2. package/dist/forge/client.js +6 -2
  3. package/dist/tools/backups/create-backup-configuration.js +4 -3
  4. package/dist/tools/backups/create-backup.js +4 -3
  5. package/dist/tools/backups/shared.js +0 -5
  6. package/dist/tools/firewall/create-firewall-rule.js +58 -0
  7. package/dist/tools/firewall/delete-firewall-rule.js +41 -0
  8. package/dist/tools/firewall/list-firewall-rules.js +59 -0
  9. package/dist/tools/firewall/shared.js +22 -0
  10. package/dist/tools/redirects/create-redirect-rule.js +55 -0
  11. package/dist/tools/redirects/delete-redirect-rule.js +40 -0
  12. package/dist/tools/redirects/export-redirect-rules.js +30 -0
  13. package/dist/tools/redirects/import-redirect-rules.js +56 -0
  14. package/dist/tools/redirects/list-redirect-rules.js +53 -0
  15. package/dist/tools/redirects/reorder-redirect-rules.js +34 -0
  16. package/dist/tools/redirects/shared.js +24 -0
  17. package/dist/tools/registry.js +37 -0
  18. package/dist/tools/security-rules/create-security-rule.js +58 -0
  19. package/dist/tools/security-rules/delete-security-rule.js +40 -0
  20. package/dist/tools/security-rules/list-security-rules.js +49 -0
  21. package/dist/tools/security-rules/shared.js +27 -0
  22. package/dist/tools/security-rules/update-security-rule.js +58 -0
  23. package/dist/tools/shared/async.js +4 -0
  24. package/dist/tools/shared/read.js +13 -1
  25. package/dist/tools/ssh-keys/add-ssh-key.js +61 -0
  26. package/dist/tools/ssh-keys/delete-ssh-key.js +41 -0
  27. package/dist/tools/ssh-keys/get-server-public-key.js +27 -0
  28. package/dist/tools/ssh-keys/list-ssh-keys.js +48 -0
  29. package/dist/tools/ssh-keys/regenerate-server-key.js +30 -0
  30. package/dist/tools/ssh-keys/shared.js +24 -0
  31. package/package.json +1 -1
package/README.md CHANGED
@@ -174,6 +174,24 @@ npm run build
174
174
  | `forge_update_background_process` | jobs | Rename or replace the Supervisor configuration |
175
175
  | `forge_run_background_process_action` | jobs | Start, stop or restart a background process, or empty its log |
176
176
  | `forge_delete_background_process` | jobs | Stop and remove a background process |
177
+ | `forge_list_firewall_rules` | security | Open and blocked ports of a server |
178
+ | `forge_create_firewall_rule` | security | Allow or deny a port (range) for everyone or an IP/subnet |
179
+ | `forge_delete_firewall_rule` | security | Remove a firewall rule |
180
+ | `forge_list_security_rules` | security | Password-protected (basic auth) paths of a site |
181
+ | `forge_create_security_rule` | security | Protect a site or a path with a password |
182
+ | `forge_update_security_rule` | security | Change name, path or users of a security rule |
183
+ | `forge_delete_security_rule` | security | Remove a password protection |
184
+ | `forge_list_redirect_rules` | security | Redirect rules of a site |
185
+ | `forge_create_redirect_rule` | security | Add a 301 or 302 redirect |
186
+ | `forge_delete_redirect_rule` | security | Remove a redirect |
187
+ | `forge_reorder_redirect_rules` | security | Set the evaluation order of the redirects |
188
+ | `forge_export_redirect_rules` | security | Redirects as CSV, in evaluation order |
189
+ | `forge_import_redirect_rules` | security | Import redirects from CSV (append or replace) |
190
+ | `forge_list_ssh_keys` | security | SSH keys authorized on a server |
191
+ | `forge_add_ssh_key` | security | Authorize a public key |
192
+ | `forge_delete_ssh_key` | security | Revoke an SSH key |
193
+ | `forge_get_server_public_key` | security | The server's own public key (for Git providers, other servers) |
194
+ | `forge_regenerate_server_key` | security | Replace the server's own key pair |
177
195
  | `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
178
196
  | `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
179
197
  | `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
@@ -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 !== undefined) {
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, newestFirst, resolveStorageProviderId, scheduleBody, scheduleInput, storageProviderRefInput, } from './shared.js';
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 newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
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 newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
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, newestFirst, } from './shared.js';
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 newestFirst(client, base, signal)).map((item) => item.id)) : undefined;
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 newestFirst(client, base, signal)).find((item) => !existing.has(item.id)) ?? null,
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,55 @@
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 { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { formatRedirectRule, redirectRuleOutput, redirectRulesPath, redirectTypeInput } from './shared.js';
7
+ const CHECK_WITH = 'forge_list_redirect_rules';
8
+ export const createRedirectRule = defineTool({
9
+ name: 'forge_create_redirect_rule',
10
+ title: 'Create redirect rule',
11
+ description: 'Redirect a path of the site to another path or URL (Nginx rewrite). Waits until the rule is installed unless `wait` is false. For many rules use forge_import_redirect_rules.',
12
+ toolset: 'security',
13
+ operations: ['organizations.servers.sites.redirect-rules.store', 'organizations.servers.sites.redirect-rules.index'],
14
+ permissions: ['site:manage-redirects'],
15
+ readOnly: false,
16
+ destructive: false,
17
+ idempotent: false,
18
+ async: true,
19
+ notFoundHint: SITE_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ ...siteScopeInput,
22
+ from: z.string().min(1).describe('Source path, e.g. "/old-page".'),
23
+ to: z.string().min(1).describe('Destination path or URL, e.g. "/new-page" or "https://example.com/".'),
24
+ type: redirectTypeInput.default('permanent'),
25
+ ...waitInput(60),
26
+ },
27
+ outputSchema: {
28
+ ...operationOutput,
29
+ rule: redirectRuleOutput.nullable().describe('The new rule once it shows up.'),
30
+ },
31
+ async handler(args, { client, organization, signal, sleep, progress }) {
32
+ const base = redirectRulesPath(organization(args.organization), args.server, args.site);
33
+ // Forge returns no body: remember the existing rules for this path to spot the new one.
34
+ const lookup = () => listNewestFirst(client, base, signal, { from: args.from });
35
+ const existing = args.wait ? new Set((await lookup()).map((rule) => rule.id)) : undefined;
36
+ await client.post(base, { body: { from: args.from, to: args.to, type: args.type }, signal });
37
+ const action = `redirect ${args.from} to ${args.to}`;
38
+ if (!existing) {
39
+ return {
40
+ structured: { status: 'queued', check_with: CHECK_WITH, rule: null },
41
+ summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
42
+ };
43
+ }
44
+ const result = await waitFor({
45
+ poll: async () => findNew(await lookup(), existing),
46
+ phase: (rule) => (rule ? installationPhase(rule.status) : 'pending'),
47
+ describe: (rule) => (rule ? `Redirect rule is ${rule.status}` : 'Waiting for the rule to appear'),
48
+ timeoutSeconds: args.timeout_seconds,
49
+ context: { sleep, progress },
50
+ });
51
+ const rule = result.value ? formatRedirectRule(result.value) : null;
52
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: rule ? `Rule ID: ${rule.id}.` : undefined });
53
+ return { structured: { ...done.structured, rule }, summary: done.summary };
54
+ },
55
+ });
@@ -0,0 +1,40 @@
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 { siteScopeInput } from '../shared/site-scope.js';
5
+ import { redirectRuleInput, redirectRulesPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_redirect_rules';
7
+ export const deleteRedirectRule = defineTool({
8
+ name: 'forge_delete_redirect_rule',
9
+ title: 'Delete redirect rule',
10
+ description: 'Remove a redirect rule from a site.',
11
+ toolset: 'security',
12
+ operations: ['organizations.servers.sites.redirect-rules.destroy', 'organizations.servers.sites.redirect-rules.show'],
13
+ permissions: ['site:manage-redirects'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: 'Check the redirect rule ID with forge_list_redirect_rules.',
19
+ inputSchema: {
20
+ ...siteScopeInput,
21
+ rule: redirectRuleInput,
22
+ ...waitInput(60),
23
+ },
24
+ outputSchema: operationOutput,
25
+ async handler(args, { client, organization, signal, sleep, progress }) {
26
+ const path = `${redirectRulesPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.rule))}`;
27
+ await client.delete(path, { signal });
28
+ const action = `delete redirect rule ${args.rule}`;
29
+ if (!args.wait)
30
+ return queued(action, CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: () => orGone(() => readResource(client, path, signal)),
33
+ phase: (rule) => (rule === null ? 'completed' : 'pending'),
34
+ describe: (rule) => `Redirect rule is ${rule?.status ?? 'removing'}`,
35
+ timeoutSeconds: args.timeout_seconds,
36
+ context: { sleep, progress },
37
+ });
38
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
39
+ },
40
+ });
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
4
+ import { redirectRulesPath } from './shared.js';
5
+ export const exportRedirectRules = defineTool({
6
+ name: 'forge_export_redirect_rules',
7
+ title: 'Export redirect rules',
8
+ description: 'Export every redirect rule of a site as CSV (from,to,type header) in evaluation order: use it to review the order, back them up or copy them to another site with forge_import_redirect_rules.',
9
+ toolset: 'security',
10
+ operations: ['organizations.servers.sites.redirect-rules.export'],
11
+ permissions: ['site:manage-redirects'],
12
+ readOnly: true,
13
+ notFoundHint: SITE_NOT_FOUND_HINT,
14
+ inputSchema: {
15
+ ...siteScopeInput,
16
+ },
17
+ outputSchema: {
18
+ csv: z.string().describe('CSV with a from,to,type header row.'),
19
+ rules: z.number().int().describe('Number of rules (rows after the header).'),
20
+ },
21
+ async handler(args, { client, organization, signal }) {
22
+ const response = await client.get(`${redirectRulesPath(organization(args.organization), args.server, args.site)}/export`, {
23
+ accept: 'text/csv',
24
+ signal,
25
+ });
26
+ const csv = typeof response.data === 'string' ? response.data : '';
27
+ const rules = Math.max(0, csv.split('\n').filter((line) => line.trim() !== '').length - 1);
28
+ return { structured: { csv, rules }, summary: `Exported ${rules} redirect rule(s).` };
29
+ },
30
+ });
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { redirectRulesPath } from './shared.js';
7
+ const skippedRow = z.looseObject({
8
+ line: z.string(),
9
+ reason: z.string().nullable(),
10
+ from: z.string(),
11
+ to: z.string(),
12
+ type: z.string(),
13
+ });
14
+ export const importRedirectRules = defineTool({
15
+ name: 'forge_import_redirect_rules',
16
+ title: 'Import redirect rules',
17
+ description: 'Import redirect rules from CSV text with a from,to,type header (type: redirect or permanent). `append` adds them to the existing rules; `replace` DELETES every existing rule first. Invalid and duplicate rows are skipped and reported.',
18
+ toolset: 'security',
19
+ operations: ['organizations.servers.sites.redirect-rules.import'],
20
+ permissions: ['site:manage-redirects'],
21
+ readOnly: false,
22
+ destructive: true,
23
+ idempotent: false,
24
+ async: true,
25
+ notFoundHint: SITE_NOT_FOUND_HINT,
26
+ inputSchema: {
27
+ ...siteScopeInput,
28
+ csv: z.string().min(1).describe('CSV content, e.g. "from,to,type\\n/old,/new,permanent".'),
29
+ mode: z.enum(['append', 'replace']).describe('append: add to the existing rules; replace: remove every existing rule first.'),
30
+ },
31
+ outputSchema: {
32
+ ...operationOutput,
33
+ imported: z.number().int().describe('Rules imported.'),
34
+ invalid: z.array(skippedRow).describe('Rows skipped because they are invalid.'),
35
+ duplicates: z.array(skippedRow).describe('Rows skipped because they repeat a row or an existing rule.'),
36
+ },
37
+ async handler(args, { client, organization, signal }) {
38
+ const header = args.csv.replace(/^\uFEFF/, '').trimStart().split('\n', 1)[0].trim().toLowerCase().replace(/\s/g, '');
39
+ if (header !== 'from,to,type')
40
+ throw new ToolInputError('The CSV must start with the header row "from,to,type".');
41
+ const form = new FormData();
42
+ form.append('file', new Blob([args.csv], { type: 'text/csv' }), 'redirects.csv');
43
+ form.append('mode', args.mode);
44
+ const response = await client.post(`${redirectRulesPath(organization(args.organization), args.server, args.site)}/import`, {
45
+ body: form,
46
+ signal,
47
+ });
48
+ const report = { imported: response.data?.imported ?? 0, invalid: response.data?.invalid ?? [], duplicates: response.data?.duplicates ?? [] };
49
+ const skipped = report.invalid.length + report.duplicates.length;
50
+ // The import report is final; Nginx picks up the rules in the background.
51
+ return {
52
+ structured: { status: 'queued', check_with: 'forge_list_redirect_rules', ...report },
53
+ summary: `Imported ${report.imported} redirect rule(s)${args.mode === 'replace' ? ', replacing the existing ones' : ''}${skipped > 0 ? `; skipped ${report.invalid.length} invalid and ${report.duplicates.length} duplicate row(s)` : ''}. Forge installs them in the background: check them with forge_list_redirect_rules.`,
54
+ };
55
+ },
56
+ });
@@ -0,0 +1,53 @@
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 { formatRedirectRule, REDIRECT_TYPES, redirectRuleInput, redirectRuleOutput, redirectRulesPath } from './shared.js';
8
+ const SORT = ['created_at', '-created_at', 'updated_at', '-updated_at'];
9
+ export const listRedirectRules = defineTool({
10
+ name: 'forge_list_redirect_rules',
11
+ title: 'List redirect rules',
12
+ description: 'List the redirect rules of a site (from → to, 301 or 302), or get one with `rule`. Use forge_export_redirect_rules to see them in evaluation order.',
13
+ toolset: 'security',
14
+ operations: ['organizations.servers.sites.redirect-rules.index', 'organizations.servers.sites.redirect-rules.show'],
15
+ permissions: ['site:manage-redirects'],
16
+ readOnly: true,
17
+ notFoundHint: SITE_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ ...siteScopeInput,
20
+ rule: redirectRuleInput.optional().describe('Return only this rule.'),
21
+ from: z.string().min(1).optional().describe('Filter by source path.'),
22
+ to: z.string().min(1).optional().describe('Filter by destination.'),
23
+ type: z.enum(REDIRECT_TYPES).optional().describe('Filter by type.'),
24
+ status: z.enum(['installing', 'installed', 'removing']).optional().describe('Filter by status.'),
25
+ sort: z.array(z.enum(SORT)).min(1).optional(),
26
+ ...paginationInput,
27
+ },
28
+ outputSchema: {
29
+ rules: z.array(redirectRuleOutput),
30
+ ...paginationOutput,
31
+ },
32
+ async handler(args, { client, organization, signal }) {
33
+ const base = redirectRulesPath(organization(args.organization), args.server, args.site);
34
+ if (args.rule !== undefined) {
35
+ const rule = formatRedirectRule(await readResource(client, `${base}/${encodeURIComponent(String(args.rule))}`, signal));
36
+ return { structured: { rules: [rule], next_cursor: null, has_more: false }, summary: `${rule.from} → ${rule.to} (${rule.type}) is ${rule.status}.` };
37
+ }
38
+ const response = await client.get(base, {
39
+ query: {
40
+ filter: { from: args.from, to: args.to, type: args.type, status: args.status },
41
+ sort: args.sort,
42
+ page: { size: args.page_size, cursor: args.cursor },
43
+ },
44
+ signal,
45
+ });
46
+ const page = flattenCollection(response.data);
47
+ const rules = page.items.map(formatRedirectRule);
48
+ return {
49
+ structured: { rules, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
50
+ summary: rules.length === 0 ? 'No redirect rules found.' : `Found ${rules.length} redirect rule(s).${paginationSummary(page.nextCursor)}`,
51
+ };
52
+ },
53
+ });
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, queued } from '../shared/async.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
5
+ import { redirectRulesPath } from './shared.js';
6
+ export const reorderRedirectRules = defineTool({
7
+ name: 'forge_reorder_redirect_rules',
8
+ title: 'Reorder redirect rules',
9
+ description: 'Set the order in which Nginx evaluates the redirect rules of a site (the first match wins), by listing their IDs in the desired order. IDs that do not belong to the site are ignored.',
10
+ toolset: 'security',
11
+ operations: ['organizations.servers.sites.redirect-rules.reorder'],
12
+ permissions: ['site:manage-redirects'],
13
+ readOnly: false,
14
+ destructive: false,
15
+ idempotent: true,
16
+ async: true,
17
+ notFoundHint: SITE_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ ...siteScopeInput,
20
+ rules: z
21
+ .array(z.union([z.number().int().nonnegative(), z.string().regex(/^\d+$/)]))
22
+ .min(1)
23
+ .describe('Redirect rule IDs in evaluation order, e.g. [12, 9, 15].'),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal }) {
27
+ await client.put(`${redirectRulesPath(organization(args.organization), args.server, args.site)}/reorder`, {
28
+ body: { redirects: args.rules.map(Number) },
29
+ signal,
30
+ });
31
+ // The rule resources carry no position: the export (in evaluation order) shows the result.
32
+ return queued('reorder the redirect rules', 'forge_export_redirect_rules');
33
+ },
34
+ });
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { idInput, pick } from '../shared/schemas.js';
3
+ import { sitePath } from '../shared/site-scope.js';
4
+ export function redirectRulesPath(org, server, site) {
5
+ return `${sitePath(org, server, site)}/redirect-rules`;
6
+ }
7
+ export const redirectRuleInput = idInput('Redirect rule ID. Use forge_list_redirect_rules to find it.');
8
+ export const REDIRECT_TYPES = ['redirect', 'permanent'];
9
+ export const redirectTypeInput = z
10
+ .enum(REDIRECT_TYPES)
11
+ .describe('"permanent" (301, cached by browsers and search engines) or "redirect" (302, temporary).');
12
+ const FIELDS = ['id', 'from', 'to', 'type', 'status', 'created_at', 'updated_at'];
13
+ export const redirectRuleOutput = z.looseObject({
14
+ id: z.string(),
15
+ from: z.string().nullable(),
16
+ to: z.string().nullable(),
17
+ type: z.string().nullable().describe('redirect (302) or permanent (301).'),
18
+ status: z.string().nullable().describe('installing, installed or removing.'),
19
+ created_at: z.string().nullable(),
20
+ updated_at: z.string().nullable(),
21
+ });
22
+ export function formatRedirectRule(flat) {
23
+ return pick(flat, FIELDS);
24
+ }
@@ -54,6 +54,9 @@ import { runDomainAction } from './domains/run-domain-action.js';
54
54
  import { updateDomain } from './domains/update-domain.js';
55
55
  import { getServerEvent } from './events/get-server-event.js';
56
56
  import { listServerEvents } from './events/list-server-events.js';
57
+ import { createFirewallRule } from './firewall/create-firewall-rule.js';
58
+ import { deleteFirewallRule } from './firewall/delete-firewall-rule.js';
59
+ import { listFirewallRules } from './firewall/list-firewall-rules.js';
57
60
  import { disableSiteIntegration } from './integrations/disable-site-integration.js';
58
61
  import { enableSiteIntegration } from './integrations/enable-site-integration.js';
59
62
  import { getSiteIntegrations } from './integrations/get-site-integrations.js';
@@ -68,10 +71,20 @@ import { uninstallPhpVersion } from './php/uninstall-php-version.js';
68
71
  import { updatePhpConfig } from './php/update-php-config.js';
69
72
  import { updatePhpLimits } from './php/update-php-limits.js';
70
73
  import { upgradePhpVersion } from './php/upgrade-php-version.js';
74
+ import { createRedirectRule } from './redirects/create-redirect-rule.js';
75
+ import { deleteRedirectRule } from './redirects/delete-redirect-rule.js';
76
+ import { exportRedirectRules } from './redirects/export-redirect-rules.js';
77
+ import { importRedirectRules } from './redirects/import-redirect-rules.js';
78
+ import { listRedirectRules } from './redirects/list-redirect-rules.js';
79
+ import { reorderRedirectRules } from './redirects/reorder-redirect-rules.js';
71
80
  import { createScheduledJob } from './scheduled-jobs/create-scheduled-job.js';
72
81
  import { deleteScheduledJob } from './scheduled-jobs/delete-scheduled-job.js';
73
82
  import { getScheduledJob } from './scheduled-jobs/get-scheduled-job.js';
74
83
  import { listScheduledJobs } from './scheduled-jobs/list-scheduled-jobs.js';
84
+ import { createSecurityRule } from './security-rules/create-security-rule.js';
85
+ import { deleteSecurityRule } from './security-rules/delete-security-rule.js';
86
+ import { listSecurityRules } from './security-rules/list-security-rules.js';
87
+ import { updateSecurityRule } from './security-rules/update-security-rule.js';
75
88
  import { archiveServer } from './servers/archive-server.js';
76
89
  import { clearServerLog } from './servers/clear-server-log.js';
77
90
  import { deleteServer } from './servers/delete-server.js';
@@ -101,6 +114,11 @@ import { updateSiteEnvironment } from './sites/update-site-environment.js';
101
114
  import { updateSiteHealthcheck } from './sites/update-site-healthcheck.js';
102
115
  import { updateSiteNginxConfig } from './sites/update-site-nginx-config.js';
103
116
  import { updateSiteRepository } from './sites/update-site-repository.js';
117
+ import { addSshKey } from './ssh-keys/add-ssh-key.js';
118
+ import { deleteSshKey } from './ssh-keys/delete-ssh-key.js';
119
+ import { getServerPublicKey } from './ssh-keys/get-server-public-key.js';
120
+ import { listSshKeys } from './ssh-keys/list-ssh-keys.js';
121
+ import { regenerateServerKey } from './ssh-keys/regenerate-server-key.js';
104
122
  import { createStorageProvider } from './storage/create-storage-provider.js';
105
123
  import { deleteStorageProvider } from './storage/delete-storage-provider.js';
106
124
  import { listStorageProviders } from './storage/list-storage-providers.js';
@@ -217,6 +235,25 @@ export const ALL_TOOLS = [
217
235
  updateBackgroundProcess,
218
236
  runBackgroundProcessAction,
219
237
  deleteBackgroundProcess,
238
+ // security
239
+ listFirewallRules,
240
+ createFirewallRule,
241
+ deleteFirewallRule,
242
+ listSecurityRules,
243
+ createSecurityRule,
244
+ updateSecurityRule,
245
+ deleteSecurityRule,
246
+ listRedirectRules,
247
+ createRedirectRule,
248
+ deleteRedirectRule,
249
+ reorderRedirectRules,
250
+ exportRedirectRules,
251
+ importRedirectRules,
252
+ listSshKeys,
253
+ addSshKey,
254
+ deleteSshKey,
255
+ getServerPublicKey,
256
+ regenerateServerKey,
220
257
  // integrations
221
258
  getSiteIntegrations,
222
259
  enableSiteIntegration,
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { installationPhase, operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
5
+ import { readResource } from '../shared/read.js';
6
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
7
+ import { credentialsInput, formatSecurityRule, securityRuleOutput, securityRulesPath } from './shared.js';
8
+ const CHECK_WITH = 'forge_list_security_rules';
9
+ export const createSecurityRule = defineTool({
10
+ name: 'forge_create_security_rule',
11
+ title: 'Create security rule',
12
+ description: 'Password-protect a site, or one path of it, with HTTP basic auth (e.g. a staging site or /admin). Waits until the rule is installed unless `wait` is false.',
13
+ toolset: 'security',
14
+ operations: ['organizations.servers.sites.security-rules.store', 'organizations.servers.sites.security-rules.show'],
15
+ permissions: ['site:manage-security'],
16
+ readOnly: false,
17
+ destructive: false,
18
+ idempotent: false,
19
+ async: true,
20
+ notFoundHint: SITE_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ name: z.string().min(1).max(255).describe('Name of the rule, e.g. "Staging access".'),
24
+ path: z.string().min(1).max(255).optional().describe('Path to protect, e.g. "/admin". Omit to protect the whole site.'),
25
+ credentials: credentialsInput,
26
+ ...waitInput(60),
27
+ },
28
+ outputSchema: {
29
+ ...operationOutput,
30
+ rule: securityRuleOutput.nullable(),
31
+ },
32
+ async handler(args, { client, organization, signal, sleep, progress }) {
33
+ const base = securityRulesPath(organization(args.organization), args.server, args.site);
34
+ const response = await client.post(base, {
35
+ body: { name: args.name, path: args.path, credentials: args.credentials },
36
+ signal,
37
+ });
38
+ const initial = response.data?.data ? flattenSingle(response.data) : undefined;
39
+ const action = `protect ${args.path ?? 'the site'} with a password`;
40
+ if (!initial || !args.wait) {
41
+ return {
42
+ structured: { status: 'queued', check_with: CHECK_WITH, rule: initial ? formatSecurityRule(initial) : null },
43
+ summary: `Forge accepted the request to ${action}. Check it with ${CHECK_WITH}.`,
44
+ };
45
+ }
46
+ const result = await waitFor({
47
+ initial,
48
+ poll: () => readResource(client, `${base}/${encodeURIComponent(initial.id)}`, signal),
49
+ phase: (rule) => installationPhase(rule.status),
50
+ describe: (rule) => `Security rule is ${rule.status}`,
51
+ timeoutSeconds: args.timeout_seconds,
52
+ context: { sleep, progress },
53
+ });
54
+ const rule = formatSecurityRule(result.value ?? initial);
55
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: `Rule ID: ${rule.id}.` });
56
+ return { structured: { ...done.structured, rule }, summary: done.summary };
57
+ },
58
+ });
@@ -0,0 +1,40 @@
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 { siteScopeInput } from '../shared/site-scope.js';
5
+ import { SECURITY_RULE_NOT_FOUND_HINT, securityRuleInput, securityRulesPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_security_rules';
7
+ export const deleteSecurityRule = defineTool({
8
+ name: 'forge_delete_security_rule',
9
+ title: 'Delete security rule',
10
+ description: 'Remove the password protection of a security rule: the path becomes public.',
11
+ toolset: 'security',
12
+ operations: ['organizations.servers.sites.security-rules.destroy', 'organizations.servers.sites.security-rules.show'],
13
+ permissions: ['site:manage-security'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: SECURITY_RULE_NOT_FOUND_HINT,
19
+ inputSchema: {
20
+ ...siteScopeInput,
21
+ rule: securityRuleInput,
22
+ ...waitInput(60),
23
+ },
24
+ outputSchema: operationOutput,
25
+ async handler(args, { client, organization, signal, sleep, progress }) {
26
+ const path = `${securityRulesPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.rule))}`;
27
+ await client.delete(path, { signal });
28
+ const action = `delete security rule ${args.rule}`;
29
+ if (!args.wait)
30
+ return queued(action, CHECK_WITH);
31
+ const result = await waitFor({
32
+ poll: () => orGone(() => readResource(client, path, signal)),
33
+ phase: (rule) => (rule === null ? 'completed' : 'pending'),
34
+ describe: (rule) => `Security rule is ${rule?.status ?? 'removing'}`,
35
+ timeoutSeconds: args.timeout_seconds,
36
+ context: { sleep, progress },
37
+ });
38
+ return outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
39
+ },
40
+ });
@@ -0,0 +1,49 @@
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 { formatSecurityRule, securityRuleInput, securityRuleOutput, securityRulesPath } from './shared.js';
8
+ const SORT = ['path', '-path', 'status', '-status', 'created_at', '-created_at', 'updated_at', '-updated_at'];
9
+ export const listSecurityRules = defineTool({
10
+ name: 'forge_list_security_rules',
11
+ title: 'List security rules',
12
+ description: 'List the password-protected (HTTP basic auth) paths of a site, or get one with `rule`. Credentials are never returned.',
13
+ toolset: 'security',
14
+ operations: ['organizations.servers.sites.security-rules.index', 'organizations.servers.sites.security-rules.show'],
15
+ permissions: ['site:manage-security'],
16
+ readOnly: true,
17
+ notFoundHint: SITE_NOT_FOUND_HINT,
18
+ inputSchema: {
19
+ ...siteScopeInput,
20
+ rule: securityRuleInput.optional().describe('Return only this rule.'),
21
+ path: z.string().min(1).optional().describe('Filter by path.'),
22
+ status: z.string().min(1).optional().describe('Filter by status, e.g. "installed".'),
23
+ sort: z.array(z.enum(SORT)).min(1).optional(),
24
+ ...paginationInput,
25
+ },
26
+ outputSchema: {
27
+ rules: z.array(securityRuleOutput),
28
+ ...paginationOutput,
29
+ },
30
+ async handler(args, { client, organization, signal }) {
31
+ const base = securityRulesPath(organization(args.organization), args.server, args.site);
32
+ if (args.rule !== undefined) {
33
+ const rule = formatSecurityRule(await readResource(client, `${base}/${encodeURIComponent(String(args.rule))}`, signal));
34
+ return { structured: { rules: [rule], next_cursor: null, has_more: false }, summary: `Security rule ${rule.name} is ${rule.status}.` };
35
+ }
36
+ const response = await client.get(base, {
37
+ query: { filter: { path: args.path, status: args.status }, sort: args.sort, page: { size: args.page_size, cursor: args.cursor } },
38
+ signal,
39
+ });
40
+ const page = flattenCollection(response.data);
41
+ const rules = page.items.map(formatSecurityRule);
42
+ return {
43
+ structured: { rules, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
44
+ summary: rules.length === 0
45
+ ? 'No security rules: no path of this site is password protected.'
46
+ : `Found ${rules.length} security rule(s): ${rules.map((r) => `${r.path ?? 'whole site'} (${r.id})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
47
+ };
48
+ },
49
+ });
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { idInput, pick } from '../shared/schemas.js';
3
+ import { sitePath } from '../shared/site-scope.js';
4
+ export function securityRulesPath(org, server, site) {
5
+ return `${sitePath(org, server, site)}/security-rules`;
6
+ }
7
+ export const securityRuleInput = idInput('Security rule ID. Use forge_list_security_rules to find it.');
8
+ export const SECURITY_RULE_NOT_FOUND_HINT = 'Check the security rule ID with forge_list_security_rules.';
9
+ export const credentialsInput = z
10
+ .array(z.object({
11
+ username: z.string().min(1).max(50),
12
+ password: z.string().min(1).describe('Never returned by any tool.'),
13
+ }))
14
+ .min(1)
15
+ .describe('Users allowed through the password prompt.');
16
+ const FIELDS = ['id', 'name', 'path', 'status', 'created_at', 'updated_at'];
17
+ export const securityRuleOutput = z.looseObject({
18
+ id: z.string(),
19
+ name: z.string().nullable(),
20
+ path: z.string().nullable().describe('Protected path; null protects the whole site.'),
21
+ status: z.string().nullable().describe('e.g. installing, installed, removing.'),
22
+ created_at: z.string().nullable(),
23
+ updated_at: z.string().nullable(),
24
+ });
25
+ export function formatSecurityRule(flat) {
26
+ return pick(flat, FIELDS);
27
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { installationPhase, operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { readResource } from '../shared/read.js';
5
+ import { siteScopeInput } from '../shared/site-scope.js';
6
+ import { credentialsInput, formatSecurityRule, SECURITY_RULE_NOT_FOUND_HINT, securityRuleInput, securityRuleOutput, securityRulesPath } from './shared.js';
7
+ const CHECK_WITH = 'forge_list_security_rules';
8
+ export const updateSecurityRule = defineTool({
9
+ name: 'forge_update_security_rule',
10
+ title: 'Update security rule',
11
+ description: 'Change the name, protected path or users of a security rule. Forge cannot return the current passwords, so `credentials` must list every user that should keep access.',
12
+ toolset: 'security',
13
+ operations: ['organizations.servers.sites.security-rules.update', 'organizations.servers.sites.security-rules.show'],
14
+ permissions: ['site:manage-security'],
15
+ readOnly: false,
16
+ destructive: false,
17
+ idempotent: true,
18
+ async: true,
19
+ notFoundHint: SECURITY_RULE_NOT_FOUND_HINT,
20
+ inputSchema: {
21
+ ...siteScopeInput,
22
+ rule: securityRuleInput,
23
+ credentials: credentialsInput.describe('Every user allowed through the password prompt (replaces the list).'),
24
+ name: z.string().min(1).max(255).optional().describe('New name.'),
25
+ path: z.string().min(1).max(255).nullable().optional().describe('New protected path; null protects the whole site.'),
26
+ ...waitInput(60),
27
+ },
28
+ outputSchema: {
29
+ ...operationOutput,
30
+ rule: securityRuleOutput.nullable(),
31
+ },
32
+ async handler(args, { client, organization, signal, sleep, progress }) {
33
+ const path = `${securityRulesPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.rule))}`;
34
+ const current = await readResource(client, path, signal);
35
+ const name = args.name ?? current.name;
36
+ const protectedPath = args.path === undefined ? current.path : args.path;
37
+ await client.put(path, { body: { name, path: protectedPath, credentials: args.credentials }, signal });
38
+ const action = `update security rule ${args.rule}`;
39
+ const changed = (args.name !== undefined && args.name !== current.name) || (args.path !== undefined && args.path !== current.path);
40
+ if (!args.wait || !changed) {
41
+ // Credentials are never returned: only name and path changes can be verified.
42
+ const accepted = queued(action, CHECK_WITH, changed ? undefined : '(credentials are never returned, so the change cannot be verified)');
43
+ return { ...accepted, structured: { ...accepted.structured, rule: null } };
44
+ }
45
+ const result = await waitFor({
46
+ poll: () => readResource(client, path, signal),
47
+ phase: (rule) => {
48
+ const phase = installationPhase(rule.status);
49
+ return phase === 'completed' && (rule.name !== name || (rule.path ?? null) !== (protectedPath ?? null)) ? 'pending' : phase;
50
+ },
51
+ describe: (rule) => `Security rule is ${rule.status}`,
52
+ timeoutSeconds: args.timeout_seconds,
53
+ context: { sleep, progress },
54
+ });
55
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
56
+ return { structured: { ...done.structured, rule: result.value ? formatSecurityRule(result.value) : null }, summary: done.summary };
57
+ },
58
+ });
@@ -50,6 +50,10 @@ export function phaseOf(status, lists) {
50
50
  return lists.completed.includes(value) ? 'completed' : 'pending';
51
51
  return lists.pending?.includes(value) ? 'pending' : 'completed';
52
52
  }
53
+ /** The common lifecycle of installable resources (rules, keys, jobs…): installing → installed → removing. */
54
+ export function installationPhase(status) {
55
+ return phaseOf(status, { pending: ['installing', 'updating', 'removing'], failed: ['failed'] });
56
+ }
53
57
  /**
54
58
  * Polls every POLL_INTERVAL_MS until the phase is final or the timeout expires,
55
59
  * sending MCP progress notifications. Read errors stop the wait without failing
@@ -1,6 +1,18 @@
1
- import { flattenSingle } from '../../forge/jsonapi.js';
1
+ import { flattenCollection, flattenSingle } from '../../forge/jsonapi.js';
2
2
  /** Reads a single JSON:API resource and flattens it. */
3
3
  export async function readResource(client, path, signal) {
4
4
  const response = await client.get(path, { signal });
5
5
  return flattenSingle(response.data);
6
6
  }
7
+ /**
8
+ * First page of a collection, newest first: used to spot the item created by a
9
+ * write that returns no body (remember the IDs before, find the new one after).
10
+ */
11
+ export async function listNewestFirst(client, path, signal, filter = {}) {
12
+ const response = await client.get(path, { query: { filter, sort: ['-created_at'], page: { size: 100 } }, signal });
13
+ return flattenCollection(response.data).items;
14
+ }
15
+ /** Returns the first item whose ID is not in `known`, or null. */
16
+ export function findNew(items, known) {
17
+ return items.find((item) => !known.has(item.id)) ?? null;
18
+ }
@@ -0,0 +1,61 @@
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 { formatSshKey, sshKeyOutput, sshKeysPath } from './shared.js';
8
+ const CHECK_WITH = 'forge_list_ssh_keys';
9
+ export const addSshKey = defineTool({
10
+ name: 'forge_add_ssh_key',
11
+ title: 'Add SSH key',
12
+ description: "Authorize a public SSH key to log in to a server (added to the user's authorized_keys). Waits until it is installed unless `wait` is false.",
13
+ toolset: 'security',
14
+ operations: ['organizations.servers.ssh-keys.store', 'organizations.servers.ssh-keys.index'],
15
+ permissions: ['server:create-keys', 'server:view'],
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).describe('Name of the key, e.g. "Jane laptop".'),
25
+ key: z
26
+ .string()
27
+ .trim()
28
+ .regex(/^(ssh-|ecdsa-|sk-)/, 'Pass the public key (it starts with ssh-ed25519, ssh-rsa, ecdsa-…), never a private key.')
29
+ .describe('Public key, e.g. "ssh-ed25519 AAAA… jane@laptop".'),
30
+ user: z.string().min(1).optional().describe('Server user the key logs in as (default: forge).'),
31
+ ...waitInput(60),
32
+ },
33
+ outputSchema: {
34
+ ...operationOutput,
35
+ ssh_key: sshKeyOutput.nullable().describe('The new key once it shows up.'),
36
+ },
37
+ async handler(args, { client, organization, signal, sleep, progress }) {
38
+ const base = sshKeysPath(organization(args.organization), args.server);
39
+ // Forge returns no body: remember the existing keys with this name to spot the new one.
40
+ const lookup = () => listNewestFirst(client, base, signal, { name: args.name });
41
+ const existing = args.wait ? new Set((await lookup()).map((key) => key.id)) : undefined;
42
+ await client.post(base, { body: { name: args.name, key: args.key, user: args.user }, signal });
43
+ const action = `add the SSH key ${args.name}`;
44
+ if (!existing) {
45
+ return {
46
+ structured: { status: 'queued', check_with: CHECK_WITH, ssh_key: null },
47
+ summary: `Forge accepted the request to ${action}; it runs in the background. Check it with ${CHECK_WITH}.`,
48
+ };
49
+ }
50
+ const result = await waitFor({
51
+ poll: async () => findNew(await lookup(), existing),
52
+ phase: (key) => (key ? installationPhase(key.status) : 'pending'),
53
+ describe: (key) => (key ? `SSH key is ${key.status}` : 'Waiting for the key to appear'),
54
+ timeoutSeconds: args.timeout_seconds,
55
+ context: { sleep, progress },
56
+ });
57
+ const sshKey = result.value ? formatSshKey(result.value) : null;
58
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds, detail: sshKey ? `Key ID: ${sshKey.id}.` : undefined });
59
+ return { structured: { ...done.structured, ssh_key: sshKey }, summary: done.summary };
60
+ },
61
+ });
@@ -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 { sshKeyInput, sshKeysPath } from './shared.js';
6
+ const CHECK_WITH = 'forge_list_ssh_keys';
7
+ export const deleteSshKey = defineTool({
8
+ name: 'forge_delete_ssh_key',
9
+ title: 'Delete SSH key',
10
+ description: 'Revoke an SSH key: it can no longer log in to the server.',
11
+ toolset: 'security',
12
+ operations: ['organizations.servers.ssh-keys.destroy', 'organizations.servers.ssh-keys.show'],
13
+ permissions: ['server:delete-keys', 'server:view'],
14
+ readOnly: false,
15
+ destructive: true,
16
+ idempotent: true,
17
+ async: true,
18
+ notFoundHint: 'Check the key ID with forge_list_ssh_keys.',
19
+ inputSchema: {
20
+ organization: organizationInput,
21
+ server: serverInput,
22
+ key: sshKeyInput,
23
+ ...waitInput(60),
24
+ },
25
+ outputSchema: operationOutput,
26
+ async handler(args, { client, organization, signal, sleep, progress }) {
27
+ const path = `${sshKeysPath(organization(args.organization), args.server)}/${encodeURIComponent(String(args.key))}`;
28
+ await client.delete(path, { signal });
29
+ const action = `delete SSH key ${args.key}`;
30
+ if (!args.wait)
31
+ return queued(action, CHECK_WITH);
32
+ const result = await waitFor({
33
+ poll: () => orGone(() => readResource(client, path, signal)),
34
+ phase: (key) => (key === null ? 'completed' : 'pending'),
35
+ describe: (key) => `SSH key is ${key?.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,27 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { readResource } from '../shared/read.js';
3
+ import { organizationInput, pick, serverInput } from '../shared/schemas.js';
4
+ import { SERVER_NOT_FOUND_HINT, serverPath } from '../servers/shared.js';
5
+ import { serverKeyOutput } from './shared.js';
6
+ export const getServerPublicKey = defineTool({
7
+ name: 'forge_get_server_public_key',
8
+ title: 'Get server public key',
9
+ description: "The server's own public SSH key: add it to a Git provider or to another server so this server can connect to them.",
10
+ toolset: 'security',
11
+ operations: ['organizations.servers.key.show'],
12
+ permissions: ['server:view'],
13
+ readOnly: true,
14
+ notFoundHint: SERVER_NOT_FOUND_HINT,
15
+ inputSchema: {
16
+ organization: organizationInput,
17
+ server: serverInput,
18
+ },
19
+ outputSchema: serverKeyOutput.shape,
20
+ async handler(args, { client, organization, signal }) {
21
+ const key = pick(await readResource(client, `${serverPath(organization(args.organization), args.server)}/key`, signal), [
22
+ 'public_key',
23
+ 'fingerprint',
24
+ ]);
25
+ return { structured: key, summary: `Server public key fingerprint: ${key.fingerprint}.` };
26
+ },
27
+ });
@@ -0,0 +1,48 @@
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 { formatSshKey, sshKeyInput, sshKeyOutput, sshKeysPath } from './shared.js';
8
+ export const listSshKeys = defineTool({
9
+ name: 'forge_list_ssh_keys',
10
+ title: 'List SSH keys',
11
+ description: 'List the SSH keys authorized to log in to a server (name, user, status), or get one with `key`. Key contents are not returned.',
12
+ toolset: 'security',
13
+ operations: ['organizations.servers.ssh-keys.index', 'organizations.servers.ssh-keys.show'],
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SERVER_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ key: sshKeyInput.optional().describe('Return only this key.'),
21
+ name: z.string().min(1).optional().describe('Filter by name.'),
22
+ user: z.string().min(1).optional().describe('Filter by server user.'),
23
+ ...paginationInput,
24
+ },
25
+ outputSchema: {
26
+ keys: z.array(sshKeyOutput),
27
+ ...paginationOutput,
28
+ },
29
+ async handler(args, { client, organization, signal }) {
30
+ const base = sshKeysPath(organization(args.organization), args.server);
31
+ if (args.key !== undefined) {
32
+ const key = formatSshKey(await readResource(client, `${base}/${encodeURIComponent(String(args.key))}`, signal));
33
+ return { structured: { keys: [key], next_cursor: null, has_more: false }, summary: `SSH key ${key.name} (${key.user}) is ${key.status}.` };
34
+ }
35
+ const response = await client.get(base, {
36
+ query: { filter: { name: args.name, user: args.user }, page: { size: args.page_size, cursor: args.cursor } },
37
+ signal,
38
+ });
39
+ const page = flattenCollection(response.data);
40
+ const keys = page.items.map(formatSshKey);
41
+ return {
42
+ structured: { keys, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
43
+ summary: keys.length === 0
44
+ ? 'No SSH keys found.'
45
+ : `Found ${keys.length} SSH key(s): ${keys.map((k) => `${k.name} → ${k.user} (${k.id})`).join(', ')}.${paginationSummary(page.nextCursor)}`,
46
+ };
47
+ },
48
+ });
@@ -0,0 +1,30 @@
1
+ import { defineTool } from '../define-tool.js';
2
+ import { flattenSingle } from '../../forge/jsonapi.js';
3
+ import { organizationInput, pick, serverInput } from '../shared/schemas.js';
4
+ import { SERVER_NOT_FOUND_HINT, serverPath } from '../servers/shared.js';
5
+ import { serverKeyOutput } from './shared.js';
6
+ export const regenerateServerKey = defineTool({
7
+ name: 'forge_regenerate_server_key',
8
+ title: 'Regenerate server key',
9
+ description: "Replace the server's own SSH key pair. Everything that trusted the old public key (Git providers, other servers) rejects the server until the new public key is added there: confirm with the user first.",
10
+ toolset: 'security',
11
+ operations: ['organizations.servers.key.update'],
12
+ permissions: ['server:delete'],
13
+ readOnly: false,
14
+ destructive: true,
15
+ idempotent: false,
16
+ notFoundHint: SERVER_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ organization: organizationInput,
19
+ server: serverInput,
20
+ },
21
+ outputSchema: serverKeyOutput.shape,
22
+ async handler(args, { client, organization, signal }) {
23
+ const response = await client.put(`${serverPath(organization(args.organization), args.server)}/key`, { signal });
24
+ const key = pick(flattenSingle(response.data), ['public_key', 'fingerprint']);
25
+ return {
26
+ structured: key,
27
+ summary: `Generated a new key pair (fingerprint ${key.fingerprint}). Add the new public key wherever the old one was trusted.`,
28
+ };
29
+ },
30
+ });
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { idInput, pick } from '../shared/schemas.js';
3
+ import { serverPath } from '../servers/shared.js';
4
+ export function sshKeysPath(org, server) {
5
+ return `${serverPath(org, server)}/ssh-keys`;
6
+ }
7
+ export const sshKeyInput = idInput('SSH key ID. Use forge_list_ssh_keys to find it.');
8
+ const FIELDS = ['id', 'name', 'user', 'status', 'created_by', 'created_at', 'updated_at'];
9
+ export const sshKeyOutput = z.looseObject({
10
+ id: z.string(),
11
+ name: z.string().nullable(),
12
+ user: z.string().nullable().describe('Server user the key logs in as.'),
13
+ status: z.string().nullable().describe('e.g. installing, installed, removing.'),
14
+ created_by: z.number().nullable().describe('Forge user who added the key.'),
15
+ created_at: z.string().nullable(),
16
+ updated_at: z.string().nullable(),
17
+ });
18
+ export function formatSshKey(flat) {
19
+ return pick(flat, FIELDS);
20
+ }
21
+ export const serverKeyOutput = z.looseObject({
22
+ public_key: z.string().nullable().describe('Add it where the server must authenticate (Git providers, other servers).'),
23
+ fingerprint: z.string().nullable(),
24
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",