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