@francescomalatesta/laravel-forge-mcp 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,9 +164,18 @@ npm run build
164
164
  | `forge_delete_deploy_key` | deployments | Remove the deploy key |
165
165
  | `forge_get_deploy_hook` 🔑 | deployments | The deployment trigger URL |
166
166
  | `forge_regenerate_deploy_hook` 🔑 | deployments | Generate a new deployment trigger URL |
167
+ | `forge_get_site_integrations` | integrations | Horizon, Octane, Reverb, Pulse, Inertia SSR, scheduler and maintenance mode status |
168
+ | `forge_enable_site_integration` | integrations | Enable an integration (e.g. put the site in maintenance mode) and wait until it is active |
169
+ | `forge_disable_site_integration` | integrations | Disable an integration (e.g. bring the site back up) |
170
+ | `forge_run_site_command` | commands | Run a command in the site directory and return its output |
171
+ | `forge_list_site_commands` | commands | Commands run on a site with status and exit code |
172
+ | `forge_get_site_command` | commands | A command run with the end of its output |
173
+ | `forge_delete_site_command` | commands | Remove a command run from the history |
167
174
 
168
175
  🔑 Reads or replaces secrets: registered only when `FORGE_ALLOW_SECRETS=true`. Secret fields returned by other tools (e.g. a site's `deployment_url`) are hidden unless secrets are allowed.
169
176
 
177
+ The `commands` toolset runs arbitrary shell commands with the privileges of the site user: enable it explicitly (e.g. `FORGE_TOOLSETS=default,commands`) only when you need it. Note that `all` includes it.
178
+
170
179
  ### Background operations
171
180
 
172
181
  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`.
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { siteScopeInput } from '../shared/site-scope.js';
4
+ import { commandInput, commandsPath } from './shared.js';
5
+ export const deleteSiteCommand = defineTool({
6
+ name: 'forge_delete_site_command',
7
+ title: 'Delete site command',
8
+ description: 'Delete a command run and its output from the site history.',
9
+ toolset: 'commands',
10
+ operations: ['organizations.servers.sites.commands.destroy'],
11
+ permissions: ['site:manage-commands'],
12
+ readOnly: false,
13
+ destructive: true,
14
+ idempotent: true,
15
+ notFoundHint: 'Check the command ID with forge_list_site_commands.',
16
+ inputSchema: {
17
+ ...siteScopeInput,
18
+ command: commandInput,
19
+ },
20
+ outputSchema: {
21
+ deleted: z.boolean(),
22
+ },
23
+ async handler(args, { client, organization, signal }) {
24
+ await client.delete(`${commandsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.command))}`, {
25
+ signal,
26
+ });
27
+ return { structured: { deleted: true }, summary: `Deleted command ${args.command} from the history.` };
28
+ },
29
+ });
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { siteScopeInput } from '../shared/site-scope.js';
5
+ import { commandInput, commandOutput, commandsPath, fetchOutput, formatCommand, NO_OUTPUT, outputFields, outputLinesInput } from './shared.js';
6
+ export const getSiteCommand = defineTool({
7
+ name: 'forge_get_site_command',
8
+ title: 'Get site command',
9
+ description: 'Get a command run on a site with its status, exit code and the end of its output.',
10
+ toolset: 'commands',
11
+ operations: ['organizations.servers.sites.commands.show', 'organizations.servers.sites.commands.output.show'],
12
+ permissions: ['server:view'],
13
+ readOnly: true,
14
+ notFoundHint: 'Check the command ID with forge_list_site_commands.',
15
+ inputSchema: {
16
+ ...siteScopeInput,
17
+ command: commandInput,
18
+ include_output: z.boolean().default(true).describe('Also fetch the output.'),
19
+ output_lines: outputLinesInput(100),
20
+ },
21
+ outputSchema: {
22
+ command: commandOutput,
23
+ ...outputFields,
24
+ },
25
+ async handler(args, { client, organization, signal }) {
26
+ const path = `${commandsPath(organization(args.organization), args.server, args.site)}/${encodeURIComponent(String(args.command))}`;
27
+ const [command, output] = await Promise.all([
28
+ readResource(client, path, signal).then(formatCommand),
29
+ args.include_output ? fetchOutput(client, path, args.output_lines, signal) : Promise.resolve(NO_OUTPUT),
30
+ ]);
31
+ return {
32
+ structured: { command, ...output },
33
+ summary: `\`${command.command}\` is ${command.status}${command.exit_code === null ? '' : ` (exit code ${command.exit_code})`}.${command.error_output ? ` Error: ${command.error_output}` : ''}`,
34
+ };
35
+ },
36
+ });
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { paginationInput, paginationOutput, paginationSummary } from '../shared/schemas.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { COMMAND_STATUSES, commandOutput, commandsPath, formatCommand } from './shared.js';
7
+ const SORT = ['status', '-status', 'created_at', '-created_at', 'updated_at', '-updated_at'];
8
+ export const listSiteCommands = defineTool({
9
+ name: 'forge_list_site_commands',
10
+ title: 'List site commands',
11
+ description: 'List the commands run on a site (newest first by default) with status and exit code. Read the output of one with forge_get_site_command.',
12
+ toolset: 'commands',
13
+ operations: ['organizations.servers.sites.commands.index'],
14
+ permissions: ['server:view'],
15
+ readOnly: true,
16
+ notFoundHint: SITE_NOT_FOUND_HINT,
17
+ inputSchema: {
18
+ ...siteScopeInput,
19
+ status: z.enum(COMMAND_STATUSES).optional().describe('Filter by status.'),
20
+ command: z.string().min(1).optional().describe('Filter by command text.'),
21
+ user_id: z.union([z.number().int(), z.string().min(1)]).optional().describe('Filter by the user who ran it.'),
22
+ sort: z.array(z.enum(SORT)).min(1).optional().describe('Defaults to newest first.'),
23
+ ...paginationInput,
24
+ },
25
+ outputSchema: {
26
+ commands: z.array(commandOutput),
27
+ ...paginationOutput,
28
+ },
29
+ async handler(args, { client, organization, signal }) {
30
+ const response = await client.get(commandsPath(organization(args.organization), args.server, args.site), {
31
+ query: {
32
+ filter: { status: args.status, command: args.command, user_id: args.user_id === undefined ? undefined : String(args.user_id) },
33
+ sort: args.sort ?? ['-created_at'],
34
+ page: { size: args.page_size, cursor: args.cursor },
35
+ },
36
+ signal,
37
+ });
38
+ const page = flattenCollection(response.data);
39
+ const commands = page.items.map(formatCommand);
40
+ return {
41
+ structured: { commands, next_cursor: page.nextCursor, has_more: page.nextCursor !== null },
42
+ summary: commands.length === 0
43
+ ? 'No commands found.'
44
+ : `Found ${commands.length} command(s); latest: \`${commands[0].command}\` (${commands[0].status}).${paginationSummary(page.nextCursor)}`,
45
+ };
46
+ },
47
+ });
@@ -0,0 +1,74 @@
1
+ import { z } from 'zod';
2
+ import { flattenCollection } from '../../forge/jsonapi.js';
3
+ import { defineTool } from '../define-tool.js';
4
+ import { operationOutput, outcome, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { commandOutput, commandPhase, commandsPath, fetchOutput, formatCommand, NO_OUTPUT, outputFields, outputLinesInput } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_site_command';
8
+ export const runSiteCommand = defineTool({
9
+ name: 'forge_run_site_command',
10
+ title: 'Run site command',
11
+ description: 'Run a shell command in the site directory as the site user (e.g. `php artisan migrate --force`, `php artisan cache:clear`). It runs with the same privileges as the application: confirm commands that change data with the user. Waits for the result and returns the end of the output unless `wait` is false.',
12
+ toolset: 'commands',
13
+ operations: [
14
+ 'organizations.servers.sites.commands.store',
15
+ 'organizations.servers.sites.commands.index',
16
+ 'organizations.servers.sites.commands.output.show',
17
+ ],
18
+ permissions: ['site:manage-commands', 'server:view'],
19
+ readOnly: false,
20
+ destructive: true,
21
+ idempotent: false,
22
+ async: true,
23
+ notFoundHint: SITE_NOT_FOUND_HINT,
24
+ inputSchema: {
25
+ ...siteScopeInput,
26
+ command: z.string().min(1).describe('Command to run, e.g. "php artisan migrate --force".'),
27
+ output_lines: outputLinesInput(100),
28
+ ...waitInput(120),
29
+ },
30
+ outputSchema: {
31
+ ...operationOutput,
32
+ command: commandOutput.nullable().describe('The command run once it shows up.'),
33
+ ...outputFields,
34
+ },
35
+ async handler(args, { client, organization, signal, sleep, progress }) {
36
+ const base = commandsPath(organization(args.organization), args.server, args.site);
37
+ const runs = async () => {
38
+ const response = await client.get(base, {
39
+ query: { filter: { command: args.command }, sort: ['-created_at'], page: { size: 100 } },
40
+ signal,
41
+ });
42
+ return flattenCollection(response.data).items;
43
+ };
44
+ // Forge returns no body: remember earlier runs of the same command to spot the new one.
45
+ const earlier = args.wait ? new Set((await runs()).map((run) => run.id)) : undefined;
46
+ await client.post(base, { body: { command: args.command }, signal });
47
+ const action = `run \`${args.command}\``;
48
+ if (!earlier) {
49
+ return {
50
+ structured: { status: 'queued', check_with: 'forge_list_site_commands', command: null, ...NO_OUTPUT },
51
+ summary: `Forge queued ${action}. Find it with forge_list_site_commands, then read its output with ${CHECK_WITH}.`,
52
+ };
53
+ }
54
+ const result = await waitFor({
55
+ poll: async () => (await runs()).find((run) => !earlier.has(run.id)) ?? null,
56
+ phase: (run) => (run ? commandPhase(run) : 'pending'),
57
+ describe: (run) => (run ? `Command is ${run.status}` : 'Waiting for the command to start'),
58
+ timeoutSeconds: args.timeout_seconds,
59
+ context: { sleep, progress },
60
+ });
61
+ const command = result.value ? formatCommand(result.value) : null;
62
+ const finished = command && (result.status === 'completed' || result.status === 'failed');
63
+ const output = finished ? await fetchOutput(client, `${base}/${encodeURIComponent(command.id)}`, args.output_lines, signal) : NO_OUTPUT;
64
+ const done = outcome(result, {
65
+ action,
66
+ checkWith: CHECK_WITH,
67
+ timeoutSeconds: args.timeout_seconds,
68
+ detail: command
69
+ ? `Command ID: ${command.id}${command.exit_code === null ? '' : `, exit code ${command.exit_code}`}.${command.error_output ? ` Error: ${command.error_output}` : ''}`
70
+ : undefined,
71
+ });
72
+ return { structured: { ...done.structured, command, ...output }, summary: done.summary };
73
+ },
74
+ });
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import { ForgeApiError } from '../../forge/errors.js';
3
+ import { phaseOf } from '../shared/async.js';
4
+ import { readResource } from '../shared/read.js';
5
+ import { idInput, pick, tail } from '../shared/schemas.js';
6
+ import { sitePath } from '../shared/site-scope.js';
7
+ export const COMMAND_STATUSES = ['waiting', 'running', 'finished', 'timeout', 'failed'];
8
+ export function commandsPath(org, server, site) {
9
+ return `${sitePath(org, server, site)}/commands`;
10
+ }
11
+ export const commandInput = idInput('Command run ID. Use forge_list_site_commands to find it.');
12
+ const FIELDS = ['id', 'command', 'status', 'exit_code', 'error_output', 'duration', 'user_id', 'created_at', 'updated_at'];
13
+ export const commandOutput = z.looseObject({
14
+ id: z.string(),
15
+ command: z.string().nullable(),
16
+ status: z.string().nullable().describe('waiting, running, finished, timeout or failed.'),
17
+ exit_code: z.number().nullable().describe('Null while running, or when the command never produced one (SSH failure, timeout).'),
18
+ error_output: z.string().nullable().describe('Failure detail when the command did not succeed.'),
19
+ duration: z.string().nullable(),
20
+ user_id: z.number().nullable().describe('User who ran the command.'),
21
+ created_at: z.string().nullable(),
22
+ updated_at: z.string().nullable(),
23
+ });
24
+ export function formatCommand(flat) {
25
+ return pick(flat, FIELDS);
26
+ }
27
+ /** Finished with a non-zero exit code counts as a failure. */
28
+ export function commandPhase(command) {
29
+ const phase = phaseOf(command.status, { completed: ['finished'], failed: ['failed', 'timeout'] });
30
+ if (phase === 'completed' && typeof command.exit_code === 'number' && command.exit_code !== 0)
31
+ return 'failed';
32
+ return phase;
33
+ }
34
+ export const outputLinesInput = (fallback) => z.number().int().min(0).max(5000).default(fallback).describe('Return only the last N lines of the output (0 = full output).');
35
+ export const outputFields = {
36
+ output: z.string().nullable().describe('Command output (last `output_lines` lines), or null when unavailable.'),
37
+ output_truncated: z.boolean(),
38
+ output_total_lines: z.number().int().nullable(),
39
+ };
40
+ export const NO_OUTPUT = { output: null, output_truncated: false, output_total_lines: null };
41
+ /** Reads the output of a command run; null output when there is none yet. */
42
+ export async function fetchOutput(client, path, lines, signal) {
43
+ try {
44
+ const output = tail((await readResource(client, `${path}/output`, signal)).output, lines);
45
+ return { output: output.text, output_truncated: output.truncated, output_total_lines: output.total_lines };
46
+ }
47
+ catch (error) {
48
+ if (error instanceof ForgeApiError && error.status === 404)
49
+ return NO_OUTPUT;
50
+ throw error;
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
4
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
5
+ import { describeIntegration, integrationOperation, integrationOutput, integrationPath, readIntegration } from './shared.js';
6
+ const CHECK_WITH = 'forge_get_site_integrations';
7
+ /** Inertia SSR has no disable endpoint in the API. */
8
+ const DISABLEABLE = ['horizon', 'octane', 'reverb', 'pulse', 'scheduler', 'maintenance'];
9
+ export const disableSiteIntegration = defineTool({
10
+ name: 'forge_disable_site_integration',
11
+ title: 'Disable site integration',
12
+ description: 'Disable a Laravel integration on a site: horizon, octane, reverb or pulse (removes their daemon: queues or servers stop), scheduler (removes the scheduled job) or maintenance (brings the site back up). Inertia SSR cannot be disabled through the API. Waits until it is disabled unless `wait` is false.',
13
+ toolset: 'integrations',
14
+ operations: [...DISABLEABLE.map((name) => integrationOperation(name, 'destroy')), ...DISABLEABLE.map((name) => integrationOperation(name, 'show'))],
15
+ permissions: ['server:delete-daemons', 'server:delete-schedulers', 'site:manage-commands', 'server:view'],
16
+ readOnly: false,
17
+ destructive: true,
18
+ idempotent: true,
19
+ async: true,
20
+ notFoundHint: SITE_NOT_FOUND_HINT,
21
+ inputSchema: {
22
+ ...siteScopeInput,
23
+ integration: z.enum(DISABLEABLE).describe('Integration to disable.'),
24
+ ...waitInput(90),
25
+ },
26
+ outputSchema: {
27
+ ...operationOutput,
28
+ integration: integrationOutput.nullable().describe('State after the wait.'),
29
+ },
30
+ async handler(args, { client, organization, signal, sleep, progress }) {
31
+ const path = integrationPath(organization(args.organization), args.server, args.site, args.integration);
32
+ await client.delete(path, { signal });
33
+ const action = `disable ${args.integration}`;
34
+ if (!args.wait) {
35
+ const accepted = queued(action, CHECK_WITH);
36
+ return { ...accepted, structured: { ...accepted.structured, integration: null } };
37
+ }
38
+ const result = await waitFor({
39
+ poll: () => readIntegration(client, path, args.integration, signal),
40
+ phase: (state) => (state.enabled === false && state.status !== 'disabling' ? 'completed' : 'pending'),
41
+ describe: describeIntegration,
42
+ timeoutSeconds: args.timeout_seconds,
43
+ context: { sleep, progress },
44
+ });
45
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
46
+ return { structured: { ...done.structured, integration: result.value ?? null }, summary: done.summary };
47
+ },
48
+ });
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { defineTool } from '../define-tool.js';
3
+ import { ToolInputError } from '../errors.js';
4
+ import { operationOutput, outcome, queued, waitFor, waitInput } from '../shared/async.js';
5
+ import { SITE_NOT_FOUND_HINT, siteScopeInput } from '../shared/site-scope.js';
6
+ import { describeIntegration, INTEGRATION_NAMES, INTEGRATIONS_DESCRIPTION, integrationOperation, integrationOutput, integrationPath, readIntegration, } from './shared.js';
7
+ const CHECK_WITH = 'forge_get_site_integrations';
8
+ const TRANSITIONS = ['enabling', 'disabling'];
9
+ /** Inputs each integration accepts; any other integration-specific input is rejected. */
10
+ const ALLOWED = {
11
+ horizon: [],
12
+ octane: ['octane_server', 'port'],
13
+ reverb: ['host', 'port', 'connections'],
14
+ pulse: [],
15
+ inertia: [],
16
+ scheduler: [],
17
+ maintenance: ['maintenance_status', 'maintenance_secret', 'maintenance_redirect'],
18
+ };
19
+ const SPECIFIC = ['octane_server', 'port', 'host', 'connections', 'maintenance_status', 'maintenance_secret', 'maintenance_redirect'];
20
+ export const enableSiteIntegration = defineTool({
21
+ name: 'forge_enable_site_integration',
22
+ title: 'Enable site integration',
23
+ description: `Enable a Laravel integration on a site: ${INTEGRATIONS_DESCRIPTION} Forge creates the daemon or scheduled job it needs. Octane needs \`octane_server\` and \`port\`; Reverb needs \`host\`, \`port\` and \`connections\`; maintenance mode accepts a bypass secret and a redirect. Waits until the integration is enabled unless \`wait\` is false.`,
24
+ toolset: 'integrations',
25
+ operations: [...INTEGRATION_NAMES.map((name) => integrationOperation(name, 'store')), ...INTEGRATION_NAMES.map((name) => integrationOperation(name, 'show'))],
26
+ permissions: ['server:create-daemons', 'server:create-schedulers', 'site:manage-commands', 'server:view'],
27
+ readOnly: false,
28
+ destructive: false,
29
+ idempotent: true,
30
+ async: true,
31
+ notFoundHint: SITE_NOT_FOUND_HINT,
32
+ inputSchema: {
33
+ ...siteScopeInput,
34
+ integration: z.enum(INTEGRATION_NAMES).describe('Integration to enable.'),
35
+ octane_server: z.enum(['swoole', 'roadrunner', 'frankenphp']).optional().describe('Octane only: application server.'),
36
+ port: z.number().int().min(1).max(65535).optional().describe('Octane and Reverb: port the server listens on, e.g. 8000 or 8080.'),
37
+ host: z.string().min(1).optional().describe('Reverb only: public host name of the WebSocket server, e.g. "ws.example.com".'),
38
+ connections: z.number().int().min(1).max(50000).optional().describe('Reverb only: maximum concurrent connections.'),
39
+ maintenance_status: z
40
+ .union([z.literal(503), z.literal(410), z.literal(307), z.literal(304)])
41
+ .optional()
42
+ .describe('Maintenance only: HTTP status returned while down (default 503).'),
43
+ maintenance_secret: z.string().min(1).optional().describe('Maintenance only: secret path that bypasses maintenance mode (https://site/{secret}).'),
44
+ maintenance_redirect: z.string().min(1).optional().describe('Maintenance only: path or URL every request is redirected to.'),
45
+ ...waitInput(90),
46
+ },
47
+ outputSchema: {
48
+ ...operationOutput,
49
+ integration: integrationOutput.nullable().describe('State after the wait.'),
50
+ },
51
+ async handler(args, { client, organization, signal, sleep, progress }) {
52
+ const { integration } = args;
53
+ const unexpected = SPECIFIC.filter((field) => args[field] !== undefined && !ALLOWED[integration].includes(field));
54
+ if (unexpected.length > 0)
55
+ throw new ToolInputError(`${unexpected.map((f) => `\`${f}\``).join(', ')} not used by ${integration}.`);
56
+ let body;
57
+ if (integration === 'octane') {
58
+ if (!args.octane_server || args.port === undefined)
59
+ throw new ToolInputError('Octane needs `octane_server` and `port`.');
60
+ body = { server: args.octane_server, port: String(args.port) };
61
+ }
62
+ else if (integration === 'reverb') {
63
+ if (!args.host || args.port === undefined || args.connections === undefined) {
64
+ throw new ToolInputError('Reverb needs `host`, `port` and `connections`.');
65
+ }
66
+ body = { host: args.host, port: String(args.port), connections: args.connections };
67
+ }
68
+ else if (integration === 'maintenance') {
69
+ body = { status: args.maintenance_status ?? 503, secret: args.maintenance_secret, redirect: args.maintenance_redirect };
70
+ }
71
+ const path = integrationPath(organization(args.organization), args.server, args.site, integration);
72
+ await client.post(path, { body, signal });
73
+ const action = `enable ${integration}`;
74
+ if (!args.wait) {
75
+ const accepted = queued(action, CHECK_WITH);
76
+ return { ...accepted, structured: { ...accepted.structured, integration: null } };
77
+ }
78
+ const result = await waitFor({
79
+ poll: () => readIntegration(client, path, integration, signal),
80
+ phase: (state) => (state.enabled === true && !TRANSITIONS.includes(state.status ?? '') ? 'completed' : 'pending'),
81
+ describe: describeIntegration,
82
+ timeoutSeconds: args.timeout_seconds,
83
+ context: { sleep, progress },
84
+ });
85
+ const done = outcome(result, { action, checkWith: CHECK_WITH, timeoutSeconds: args.timeout_seconds });
86
+ return { structured: { ...done.structured, integration: result.value ?? null }, summary: done.summary };
87
+ },
88
+ });
@@ -0,0 +1,35 @@
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 { describeIntegration, INTEGRATION_NAMES, INTEGRATIONS_DESCRIPTION, integrationOperation, integrationOutput, integrationPath, readIntegration } from './shared.js';
5
+ export const getSiteIntegrations = defineTool({
6
+ name: 'forge_get_site_integrations',
7
+ title: 'Get site integrations',
8
+ description: `Show which Laravel integrations are enabled on a site: ${INTEGRATIONS_DESCRIPTION} Pass \`integration\` to read only one.`,
9
+ toolset: 'integrations',
10
+ operations: INTEGRATION_NAMES.map((name) => integrationOperation(name, 'show')),
11
+ permissions: ['server:view'],
12
+ readOnly: true,
13
+ notFoundHint: SITE_NOT_FOUND_HINT,
14
+ inputSchema: {
15
+ ...siteScopeInput,
16
+ integration: z.enum(INTEGRATION_NAMES).optional().describe('Read only this integration (default: all of them).'),
17
+ },
18
+ outputSchema: {
19
+ integrations: z.array(integrationOutput),
20
+ },
21
+ async handler(args, { client, organization, signal }) {
22
+ const org = organization(args.organization);
23
+ const names = args.integration ? [args.integration] : INTEGRATION_NAMES;
24
+ const integrations = await Promise.all(names.map((name) => readIntegration(client, integrationPath(org, args.server, args.site, name), name, signal)));
25
+ const enabled = integrations.filter((state) => state.enabled).map((state) => state.integration);
26
+ return {
27
+ structured: { integrations },
28
+ summary: args.integration
29
+ ? `${describeIntegration(integrations[0])}.`
30
+ : enabled.length === 0
31
+ ? 'No integrations are enabled on this site.'
32
+ : `Enabled integrations: ${enabled.join(', ')}.`,
33
+ };
34
+ },
35
+ });
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+ import { relatedId } from '../shared/relationships.js';
3
+ import { readResource } from '../shared/read.js';
4
+ import { sitePath } from '../shared/site-scope.js';
5
+ /** Integration name → API path segment and fields of its resource. */
6
+ export const INTEGRATIONS = {
7
+ horizon: { segment: 'horizon', installed: 'horizon_installed', settings: [] },
8
+ octane: { segment: 'octane', installed: 'octane_installed', settings: ['port'] },
9
+ reverb: { segment: 'reverb', installed: 'reverb_installed', settings: ['host', 'port', 'connections'] },
10
+ pulse: { segment: 'pulse', installed: 'pulse_installed', settings: [] },
11
+ inertia: { segment: 'inertia', installed: 'inertia_installed', settings: [] },
12
+ scheduler: { segment: 'laravel-scheduler', installed: 'laravel_installed', settings: [] },
13
+ maintenance: { segment: 'laravel-maintenance', installed: 'laravel_installed', settings: [] },
14
+ };
15
+ export const INTEGRATION_NAMES = Object.keys(INTEGRATIONS);
16
+ export const INTEGRATIONS_DESCRIPTION = 'horizon (queue dashboard and workers), octane (application server), reverb (WebSocket server), pulse (monitoring), inertia (SSR server), scheduler (runs `schedule:run` every minute), maintenance (Laravel maintenance mode).';
17
+ /** OperationId of an integration endpoint, e.g. "organizations.servers.sites.integrations.horizon.store". */
18
+ export function integrationOperation(integration, verb) {
19
+ return `organizations.servers.sites.integrations.${INTEGRATIONS[integration].segment}.${verb}`;
20
+ }
21
+ export function integrationPath(org, server, site, integration) {
22
+ return `${sitePath(org, server, site)}/integrations/${INTEGRATIONS[integration].segment}`;
23
+ }
24
+ export const integrationOutput = z.looseObject({
25
+ integration: z.enum(INTEGRATION_NAMES),
26
+ enabled: z.boolean().nullable().describe('Whether the integration is enabled on the site.'),
27
+ status: z.string().nullable().describe('Transition in progress (maintenance: enabling or disabling), or the raw state when Forge reports one.'),
28
+ package_installed: z.boolean().nullable().describe('Whether the Laravel package (or Laravel itself) is installed in the site.'),
29
+ settings: z.record(z.string(), z.unknown()).describe('Port, host, connections (Octane and Reverb).'),
30
+ background_process_id: z.string().nullable().describe('Daemon running the integration (Horizon, Octane, Reverb, Pulse, Inertia).'),
31
+ scheduled_job_id: z.string().nullable().describe('Scheduled job running the scheduler.'),
32
+ });
33
+ const TRUE = new Set(['true', '1', 'yes', 'enabled', 'on', 'active', 'installed']);
34
+ const FALSE = new Set(['false', '0', 'no', 'disabled', 'off', 'inactive', '']);
35
+ /** Some integrations report `enabled` as a string: normalize it, keeping unknown values as the status. */
36
+ export function formatIntegration(integration, flat) {
37
+ const raw = flat.enabled;
38
+ let enabled = null;
39
+ let status = typeof flat.status === 'string' ? flat.status : null;
40
+ if (typeof raw === 'boolean')
41
+ enabled = raw;
42
+ else if (typeof raw === 'number')
43
+ enabled = raw !== 0;
44
+ else if (typeof raw === 'string') {
45
+ const value = raw.trim().toLowerCase();
46
+ if (TRUE.has(value))
47
+ enabled = true;
48
+ else if (FALSE.has(value))
49
+ enabled = false;
50
+ else
51
+ status ??= raw;
52
+ }
53
+ const { installed, settings } = INTEGRATIONS[integration];
54
+ return {
55
+ integration,
56
+ enabled,
57
+ status,
58
+ package_installed: typeof flat[installed] === 'boolean' ? flat[installed] : null,
59
+ settings: Object.fromEntries(settings.map((key) => [key, flat[key] ?? null])),
60
+ background_process_id: relatedId(flat, 'backgroundProcess'),
61
+ scheduled_job_id: relatedId(flat, 'job'),
62
+ };
63
+ }
64
+ export async function readIntegration(client, path, integration, signal) {
65
+ return formatIntegration(integration, await readResource(client, path, signal));
66
+ }
67
+ export function describeIntegration(state) {
68
+ return `${state.integration} is ${state.status ?? (state.enabled === null ? 'in an unknown state' : state.enabled ? 'enabled' : 'disabled')}`;
69
+ }
@@ -11,6 +11,10 @@ import { deleteCertificate } from './certificates/delete-certificate.js';
11
11
  import { getCertificate } from './certificates/get-certificate.js';
12
12
  import { listCertificates } from './certificates/list-certificates.js';
13
13
  import { runCertificateAction } from './certificates/run-certificate-action.js';
14
+ import { deleteSiteCommand } from './commands/delete-site-command.js';
15
+ import { getSiteCommand } from './commands/get-site-command.js';
16
+ import { listSiteCommands } from './commands/list-site-commands.js';
17
+ import { runSiteCommand } from './commands/run-site-command.js';
14
18
  import { createDatabase } from './databases/create-database.js';
15
19
  import { createDatabaseUser } from './databases/create-database-user.js';
16
20
  import { deleteDatabase } from './databases/delete-database.js';
@@ -44,6 +48,9 @@ import { runDomainAction } from './domains/run-domain-action.js';
44
48
  import { updateDomain } from './domains/update-domain.js';
45
49
  import { getServerEvent } from './events/get-server-event.js';
46
50
  import { listServerEvents } from './events/list-server-events.js';
51
+ import { disableSiteIntegration } from './integrations/disable-site-integration.js';
52
+ import { enableSiteIntegration } from './integrations/enable-site-integration.js';
53
+ import { getSiteIntegrations } from './integrations/get-site-integrations.js';
47
54
  import { listOrganizations } from './organizations/list-organizations.js';
48
55
  import { getPhpConfig } from './php/get-php-config.js';
49
56
  import { getPhpSettings } from './php/get-php-settings.js';
@@ -189,6 +196,15 @@ export const ALL_TOOLS = [
189
196
  getDeployKey,
190
197
  createDeployKey,
191
198
  deleteDeployKey,
199
+ // integrations
200
+ getSiteIntegrations,
201
+ enableSiteIntegration,
202
+ disableSiteIntegration,
203
+ // commands
204
+ runSiteCommand,
205
+ listSiteCommands,
206
+ getSiteCommand,
207
+ deleteSiteCommand,
192
208
  ];
193
209
  /** Tools enabled by the configuration (toolsets, read-only mode, secrets opt-in). */
194
210
  export function selectTools(config, tools = ALL_TOOLS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@francescomalatesta/laravel-forge-mcp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Unofficial Model Context Protocol (MCP) server for the Laravel Forge API",
5
5
  "keywords": [
6
6
  "mcp",