hostinger-api-mcp 3.28.6 → 3.30.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/CHANGELOG.md +22 -0
- package/README.md +9 -40
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +1 -0
- package/skills/headless/VERSION +2 -2
- package/skills/headless/hostinger-mcp.mjs +88 -0
- package/skills/headless/push-content.mjs +530 -0
- package/skills/headless/steps/04-content-model/SKILL.md +9 -9
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +83 -252
- package/skills/headless/steps/04-content-model/wordpress/by-hand.md +31 -0
- package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +2 -2
- package/skills/headless/steps/04-content-model/wordpress/woocommerce.md +21 -49
- package/skills/headless/steps/05-design/SKILL.md +1 -1
- package/skills/headless/wait.mjs +15 -95
- package/skills/headless/wp-mcp.mjs +125 -0
- package/skills/maintain-wordpress/SKILL.md +1 -1
- package/skills/troubleshoot-website/SKILL.md +1 -1
- package/src/core/runtime.js +96 -0
- package/src/core/runtime.ts +96 -0
- package/src/core/skills.js +55 -27
- package/src/core/skills.ts +55 -27
- package/src/core/tools/all.js +78 -4
- package/src/core/tools/all.ts +78 -4
- package/src/core/tools/hosting.js +78 -4
- package/src/core/tools/hosting.ts +78 -4
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/gpu.js +1 -1
- package/src/servers/gpu.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
- package/types.d.ts +29 -0
package/skills/headless/wait.mjs
CHANGED
|
@@ -13,17 +13,7 @@
|
|
|
13
13
|
// error retrying can't fix (the JSON says which), 2 when it couldn't run (`fallback` names the
|
|
14
14
|
// operation to poll by hand instead).
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import { homedir } from 'node:os';
|
|
18
|
-
|
|
19
|
-
// ── platform-safe binary names (npm/npx are .cmd on Windows), as in connect/bootstrap.mjs ──────────
|
|
20
|
-
const isWin = process.platform === 'win32';
|
|
21
|
-
const bin = (name) => (isWin ? `${name}.cmd` : name);
|
|
22
|
-
const MCP = [bin('npx'), '-y', '--package=@hostinger/mcp@latest', 'hostinger-api-mcp'];
|
|
23
|
-
// Node >= 18.20.2 / 20.12.2 refuses to spawn a .cmd/.bat without a shell
|
|
24
|
-
// (CVE-2024-27980), so npx.cmd needs shell:true on Windows. Our args are all
|
|
25
|
-
// fixed literals, so enabling the shell introduces no injection risk.
|
|
26
|
-
const spawnShell = isWin;
|
|
16
|
+
import { connect } from './hostinger-mcp.mjs';
|
|
27
17
|
|
|
28
18
|
const log = (message) => process.stderr.write(`wait: ${message}\n`);
|
|
29
19
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -53,83 +43,6 @@ function duration(text) {
|
|
|
53
43
|
return Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];
|
|
54
44
|
}
|
|
55
45
|
|
|
56
|
-
// One MCP server for the whole wait, spoken to over newline-delimited JSON-RPC on stdio. Once the
|
|
57
|
-
// server dies every pending and later call rejects, so the wait ends with exit 2 rather than hanging.
|
|
58
|
-
function connect() {
|
|
59
|
-
const child = spawn(MCP[0], MCP.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], shell: spawnShell, cwd: homedir() });
|
|
60
|
-
const pending = new Map();
|
|
61
|
-
let buffer = '';
|
|
62
|
-
let diag = '';
|
|
63
|
-
let dead = null;
|
|
64
|
-
let nextId = 0;
|
|
65
|
-
const die = (reason) => {
|
|
66
|
-
dead ??= new Error(`the Hostinger MCP server stopped (${reason}): ${diag.trim().slice(-500)}`);
|
|
67
|
-
for (const { reject } of pending.values()) reject(dead);
|
|
68
|
-
pending.clear();
|
|
69
|
-
};
|
|
70
|
-
child.on('error', (e) => die(e.message));
|
|
71
|
-
// 'close' comes after stderr is drained, so the reason is in diag. A write to a dying server fails
|
|
72
|
-
// with EPIPE before that; 'close' still follows, so the EPIPE itself is ignored.
|
|
73
|
-
child.on('close', (code) => die(`exit code ${code}`));
|
|
74
|
-
child.stdin.on('error', () => {});
|
|
75
|
-
child.stderr.on('data', (chunk) => (diag = (diag + chunk).slice(-2000)));
|
|
76
|
-
child.stdout.on('data', (chunk) => {
|
|
77
|
-
buffer += chunk;
|
|
78
|
-
let i;
|
|
79
|
-
while ((i = buffer.indexOf('\n')) >= 0) {
|
|
80
|
-
const line = buffer.slice(0, i);
|
|
81
|
-
buffer = buffer.slice(i + 1);
|
|
82
|
-
try {
|
|
83
|
-
const message = JSON.parse(line);
|
|
84
|
-
pending.get(message.id)?.resolve(message);
|
|
85
|
-
pending.delete(message.id);
|
|
86
|
-
} catch {
|
|
87
|
-
/* not a JSON-RPC line */
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
// A call can stall on a live server (a stuck upstream request), so each one has its own deadline; past
|
|
92
|
-
// it, it answers like an error reply. A stalled poll is retried; a stalled start ends the wait.
|
|
93
|
-
const rpc = (method, params) =>
|
|
94
|
-
new Promise((resolve, reject) => {
|
|
95
|
-
if (dead) return reject(dead);
|
|
96
|
-
const id = (nextId += 1);
|
|
97
|
-
const timer = setTimeout(() => {
|
|
98
|
-
pending.delete(id);
|
|
99
|
-
resolve({ error: { message: `no reply to ${method} in 60 s` } });
|
|
100
|
-
}, 60_000);
|
|
101
|
-
const settle = (fn) => (value) => {
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
fn(value);
|
|
104
|
-
};
|
|
105
|
-
pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
|
|
106
|
-
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
107
|
-
});
|
|
108
|
-
const ready = rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'headless-wait', version: '1' } })
|
|
109
|
-
.then((reply) => {
|
|
110
|
-
if (reply.error) throw new Error(`the Hostinger MCP server didn't start: ${reply.error.message}`);
|
|
111
|
-
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
|
|
112
|
-
});
|
|
113
|
-
// The server keeps "HTTP <status>" in its error text. A 409 while the setup runs, a 429 or a 5xx is a
|
|
114
|
-
// poll that didn't answer; any other 4xx (expired sign-in) or an unknown domain won't fix itself by waiting.
|
|
115
|
-
const execute = async (operation, params) => {
|
|
116
|
-
await ready;
|
|
117
|
-
const reply = await rpc('tools/call', { name: 'execute', arguments: { operation, params } });
|
|
118
|
-
const text = reply.result?.content?.[0]?.text ?? reply.error?.message ?? 'unknown error';
|
|
119
|
-
if (reply.error || reply.result?.isError) {
|
|
120
|
-
const status = Number(text.match(/HTTP (\d{3})/)?.[1]);
|
|
121
|
-
const terminal = (status >= 400 && status < 500 && ![408, 409, 425, 429].includes(status)) || /No website found/.test(text);
|
|
122
|
-
return { error: text.slice(0, 300), terminal };
|
|
123
|
-
}
|
|
124
|
-
try {
|
|
125
|
-
return { data: JSON.parse(text) };
|
|
126
|
-
} catch {
|
|
127
|
-
return { error: text.slice(0, 300), terminal: false };
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
return { execute, close: () => child.kill() };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
46
|
async function httpStatus(url) {
|
|
134
47
|
try {
|
|
135
48
|
const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
|
|
@@ -157,6 +70,10 @@ async function poll(label, timeoutMs, check) {
|
|
|
157
70
|
}
|
|
158
71
|
}
|
|
159
72
|
|
|
73
|
+
// A 409 while the setup runs, a 429 or a 5xx is a poll that didn't answer; any other 4xx (expired
|
|
74
|
+
// sign-in) or an unknown domain won't fix itself by waiting.
|
|
75
|
+
const isTerminal = ({ error, status }) => (status >= 400 && status < 500 && ![408, 409, 425, 429].includes(status)) || /No website found/.test(error);
|
|
76
|
+
|
|
160
77
|
// List operations answer an array, or an object holding it in `data`; their domain filter is loose.
|
|
161
78
|
const rowsFor = (data, domain) =>
|
|
162
79
|
(Array.isArray(data) ? data : data?.data ?? []).filter((r) => r.domain?.toLowerCase() === domain.toLowerCase());
|
|
@@ -174,8 +91,9 @@ const KINDS = {
|
|
|
174
91
|
fallback: 'hosting_websites_list-setups',
|
|
175
92
|
run: (mcp, { domain }, timeoutMs) =>
|
|
176
93
|
poll('setup', timeoutMs, async () => {
|
|
177
|
-
const
|
|
178
|
-
|
|
94
|
+
const reply = await mcp.execute('hosting_websites_list-setups', { domain });
|
|
95
|
+
const { data, error } = reply;
|
|
96
|
+
if (error) return { status: `list-setups: ${error}`, terminal: isTerminal(reply) };
|
|
179
97
|
const [row] = rowsFor(data, domain);
|
|
180
98
|
if (row?.status === 'completed') return { done: { ok: true, setup: row } };
|
|
181
99
|
if (row?.status === 'failed') return { done: { ok: false, setup: row } };
|
|
@@ -190,8 +108,9 @@ const KINDS = {
|
|
|
190
108
|
// The list can lag a few seconds behind the install finishing and answer [], so an empty list is a wait.
|
|
191
109
|
run: (mcp, { domain, username }, timeoutMs) =>
|
|
192
110
|
poll('cms', timeoutMs, async () => {
|
|
193
|
-
const
|
|
194
|
-
|
|
111
|
+
const reply = await mcp.execute('wordpress_installations_list', { username, domain, ownership: 'all' });
|
|
112
|
+
const { data, error } = reply;
|
|
113
|
+
if (error) return { status: `installations: ${error}`, terminal: isTerminal(reply) };
|
|
195
114
|
const rows = rowsFor(data, domain).filter((r) => r.directory === 'cms');
|
|
196
115
|
const valid = rows.find((r) => r.is_valid ?? r.valid);
|
|
197
116
|
if (valid) return { done: { ok: true, install: valid } };
|
|
@@ -206,8 +125,9 @@ const KINDS = {
|
|
|
206
125
|
run: async (mcp, { domain, buildUuid }, timeoutMs) => {
|
|
207
126
|
const started = Date.now();
|
|
208
127
|
const build = await poll('build', timeoutMs, async () => {
|
|
209
|
-
const
|
|
210
|
-
|
|
128
|
+
const reply = await mcp.execute('hosting_get-deployment', { domain, buildUuid });
|
|
129
|
+
const { data, error } = reply;
|
|
130
|
+
if (error) return { status: `get-deployment: ${error}`, terminal: isTerminal(reply) };
|
|
211
131
|
const state = data?.build?.state;
|
|
212
132
|
if (state === 'failed') {
|
|
213
133
|
const logTail = String(data.logs?.logs ?? '').split('\n').slice(-40).join('\n');
|
|
@@ -240,7 +160,7 @@ if (!spec || !domain || rest.length < spec.args.length) fail(`usage: ${usage} [-
|
|
|
240
160
|
const params = { domain, ...Object.fromEntries(spec.args.map((a, i) => [a, rest[i]])) };
|
|
241
161
|
const timeoutMs = duration(flags.timeout ?? spec.timeout);
|
|
242
162
|
fallback = spec.fallback ?? null;
|
|
243
|
-
const mcp = spec.fallback ? connect() : null;
|
|
163
|
+
const mcp = spec.fallback ? connect('headless-wait') : null;
|
|
244
164
|
const result = await spec.run(mcp, params, timeoutMs).catch((e) => fail(e.message));
|
|
245
165
|
mcp?.close();
|
|
246
166
|
finish(result.ok ? 0 : 1, { wait: kind, domain, ...result });
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The site's own WP MCP (the Hostinger AI Assistant plugin): streamable HTTP, session-based, with the
|
|
2
|
+
// installation token on every request. Used by push-content.mjs.
|
|
3
|
+
|
|
4
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
|
|
6
|
+
// A reply is plain JSON or an event stream whose data lines hold it.
|
|
7
|
+
function parseReply(text, id) {
|
|
8
|
+
if (text.trimStart().startsWith('{')) return JSON.parse(text);
|
|
9
|
+
for (const line of text.split('\n')) {
|
|
10
|
+
if (!line.startsWith('data:')) continue;
|
|
11
|
+
try {
|
|
12
|
+
const message = JSON.parse(line.slice(5));
|
|
13
|
+
if (message.id === id) return message;
|
|
14
|
+
} catch {
|
|
15
|
+
/* not a JSON data line */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`no JSON-RPC reply in: ${text.slice(0, 200)}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A tool result holds { ability, result } (structuredContent, or JSON text). A WordPress error comes back
|
|
22
|
+
// as a normal result carrying { code, message, data: { status } }; a term_exists error still names the id.
|
|
23
|
+
function unwrap(result) {
|
|
24
|
+
let body = result?.structuredContent;
|
|
25
|
+
const text = result?.content?.[0]?.text;
|
|
26
|
+
if (body === undefined) {
|
|
27
|
+
try {
|
|
28
|
+
body = JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
body = text;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const value = body?.result ?? body;
|
|
34
|
+
if (result?.isError || typeof value !== 'object' || value === null) return { error: String(text ?? value).slice(0, 300) };
|
|
35
|
+
if (value.success === false || (value.code && value.data?.status >= 400)) {
|
|
36
|
+
return { error: `${value.code ?? 'error'}: ${value.message ?? ''}`.slice(0, 300), id: value.data?.term_id ?? value.data?.resource_id };
|
|
37
|
+
}
|
|
38
|
+
return { data: value, id: value.id ?? value.data?.id };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const listOf = (data) => (Array.isArray(data) ? data : data?.data ?? []);
|
|
42
|
+
|
|
43
|
+
// Opens the session and resolves the abilities. `mint` returns { token, url }; it is called again only
|
|
44
|
+
// when the token expires. Returns { has(ability), run(ability, params) → { data, id } | { error, id? } }.
|
|
45
|
+
export async function openSite(mint) {
|
|
46
|
+
let { token, url } = await mint();
|
|
47
|
+
let session = null;
|
|
48
|
+
let nextId = 0;
|
|
49
|
+
const send = async (method, params, { notify = false } = {}) => {
|
|
50
|
+
const message = notify ? { jsonrpc: '2.0', method, params } : { jsonrpc: '2.0', id: (nextId += 1), method, params };
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'content-type': 'application/json',
|
|
55
|
+
accept: 'application/json, text/event-stream',
|
|
56
|
+
'X-Hostinger-JWT-Token': token,
|
|
57
|
+
...(session ? { 'Mcp-Session-Id': session } : {}),
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify(message),
|
|
60
|
+
signal: AbortSignal.timeout(120_000),
|
|
61
|
+
});
|
|
62
|
+
session = response.headers.get('mcp-session-id') ?? session;
|
|
63
|
+
const text = await response.text();
|
|
64
|
+
// The initialized notification can answer 404 with no body; that's harmless.
|
|
65
|
+
if (notify) return null;
|
|
66
|
+
if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`), { status: response.status });
|
|
67
|
+
const reply = parseReply(text, message.id);
|
|
68
|
+
if (reply.error) throw new Error(`${method}: ${reply.error.message}`);
|
|
69
|
+
return reply.result;
|
|
70
|
+
};
|
|
71
|
+
const open = async () => {
|
|
72
|
+
session = null;
|
|
73
|
+
await send('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'headless-push-content', version: '1' } });
|
|
74
|
+
await send('notifications/initialized', undefined, { notify: true }).catch(() => {});
|
|
75
|
+
};
|
|
76
|
+
// A 429, a 5xx or a dropped connection is retried, but a create only on a 429: it may have gone
|
|
77
|
+
// through, and the next run's lookup finds it. An expired token is re-minted once; a lost session is
|
|
78
|
+
// reopened.
|
|
79
|
+
const call = async (method, params, { retry = true } = {}) => {
|
|
80
|
+
let reminted = false;
|
|
81
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
82
|
+
try {
|
|
83
|
+
return await send(method, params);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if ((e.status === 401 || e.status === 403) && !reminted) {
|
|
86
|
+
reminted = true;
|
|
87
|
+
({ token } = await mint());
|
|
88
|
+
await open();
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const sessionLost = e.status === 404 || /Mcp-Session-Id/i.test(e.message);
|
|
92
|
+
const transient = e.status === 429 || e.status >= 500 || e.status === undefined;
|
|
93
|
+
if (attempt >= 3 || !(sessionLost || (transient && (retry || e.status === 429)))) throw e;
|
|
94
|
+
if (sessionLost) await open();
|
|
95
|
+
else await sleep(2_000 * 2 ** attempt);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
await open();
|
|
100
|
+
|
|
101
|
+
// Every ability as its own tool, or three meta-tools (discover → get-info → execute). Names carry a
|
|
102
|
+
// `hostinger-ai/` prefix (`hostinger-ai-` as a tool name); match on the rest.
|
|
103
|
+
const { tools = [] } = await call('tools/list', {});
|
|
104
|
+
const meta = (suffix) => tools.find((t) => t.name.endsWith(suffix))?.name;
|
|
105
|
+
const execute = meta('execute-ability');
|
|
106
|
+
let names = tools.map((t) => t.name);
|
|
107
|
+
if (execute) {
|
|
108
|
+
const listed = unwrap(await call('tools/call', { name: meta('discover-abilities'), arguments: {} }));
|
|
109
|
+
if (listed.error) throw new Error(`discover-abilities: ${listed.error}`);
|
|
110
|
+
names = (listed.data.abilities ?? []).map((a) => a.name);
|
|
111
|
+
}
|
|
112
|
+
const byShort = new Map(names.map((n) => [n.replace(/^hostinger[-_]ai[/_-]+/, ''), n]));
|
|
113
|
+
const run = async (ability, params) => {
|
|
114
|
+
const name = byShort.get(ability);
|
|
115
|
+
if (!name) return { error: `the site's MCP has no ${ability} ability` };
|
|
116
|
+
const retry = !/-(create|upload-from-url)$/.test(ability);
|
|
117
|
+
try {
|
|
118
|
+
const request = execute ? { name: execute, arguments: { name, parameters: params } } : { name, arguments: params };
|
|
119
|
+
return unwrap(await call('tools/call', request, { retry }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { error: e.message.slice(0, 300) };
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
return { has: (ability) => byShort.has(ability), run };
|
|
125
|
+
}
|
|
@@ -65,7 +65,7 @@ When a site breaks: stop the run, deactivate the plugin updated last with `wordp
|
|
|
65
65
|
|
|
66
66
|
- Page cache: `wordpress_litespeed-cache_show-lite-speed-status`; purge after design or content changes.
|
|
67
67
|
- Object cache: `wordpress_object-cache_show-memcached-status`, then `wordpress_object-cache_toggle-memcached` with `enabled: true` — the usual quick speed-up.
|
|
68
|
-
- PHP version: `hosting_php_get` is large (around 25 KB), so read it only when the user asks about PHP. PHP 8.x is markedly faster than 7.x; confirm plugin compatibility before `hosting_php_update
|
|
68
|
+
- PHP version: `hosting_php_get` is large (around 25 KB), so read it only when the user asks about PHP. PHP 8.x is markedly faster than 7.x; confirm plugin compatibility before switching with `hosting_php_update` (`version`). The switch resets extensions and options to their defaults, so pass the custom ones from `hosting_php_get` in the same call.
|
|
69
69
|
- wp-admin: `wordpress_login_create-links` returns temporary one-click login links. Give them to the user only; never store or reuse them.
|
|
70
70
|
- Hostinger's own plugins update with `wordpress_plugins_update-hostinger` (`slug`).
|
|
71
71
|
|
|
@@ -68,7 +68,7 @@ Batch `hosting_get-deployment` (the latest build with its log, failure analysis
|
|
|
68
68
|
|
|
69
69
|
### PHP
|
|
70
70
|
|
|
71
|
-
`hosting_php_get` returns every extension and option (around 25 KB), so call it only for PHP problems. `hosting_php_update
|
|
71
|
+
`hosting_php_get` returns every extension and option (around 25 KB), so call it only for PHP problems. `hosting_php_update` changes the version, extensions and options in one call. Switching the version resets the extensions and options to their defaults, so pass the ones to keep in the same call. Option values are silently capped at the plan maximum (`max` in `hosting_php_get`), so read the applied value back. Agency: `agency-hosting_php_list-options-for-website`, then `agency-hosting_php_replace-website-options` — a full replace, so send every custom option that should stay.
|
|
72
72
|
|
|
73
73
|
### Agency Plan sites
|
|
74
74
|
|
package/src/core/runtime.js
CHANGED
|
@@ -304,6 +304,8 @@ class MCPServer {
|
|
|
304
304
|
return await this.handleHostingWebsitesUpdateSettings(params);
|
|
305
305
|
case 'hosting_cache_clear':
|
|
306
306
|
return await this.handleHostingCacheClear(params);
|
|
307
|
+
case 'hosting_php_update':
|
|
308
|
+
return await this.handleHostingPhpUpdate(params);
|
|
307
309
|
case 'hosting_websites_list-domains':
|
|
308
310
|
return await this.handleHostingWebsitesListDomains(params);
|
|
309
311
|
case 'hosting_websites_add-domain':
|
|
@@ -1605,6 +1607,100 @@ class MCPServer {
|
|
|
1605
1607
|
return { domain, cleared: scope, directory, response };
|
|
1606
1608
|
}
|
|
1607
1609
|
|
|
1610
|
+
// Application order, and how each step maps to its operation.
|
|
1611
|
+
hosting_phpUpdate_steps = [
|
|
1612
|
+
{ setting: 'version', run: (target, value) => this.callOperation('hosting_php_update-version', { ...target, version: value }) },
|
|
1613
|
+
{ setting: 'resetExtensions', run: (target) => this.callOperation('hosting_php_reset-extensions', target) },
|
|
1614
|
+
{ setting: 'extensions', run: (target, value) => this.callOperation('hosting_php_update-extensions', { ...target, ...this.hosting_phpUpdate_extensionLists(value) }) },
|
|
1615
|
+
{ setting: 'options', run: (target, value) => this.callOperation('hosting_php_update-options', { ...target, options: value }) },
|
|
1616
|
+
];
|
|
1617
|
+
|
|
1618
|
+
// The enable and disable lists to send: only the non-empty ones.
|
|
1619
|
+
hosting_phpUpdate_extensionLists(extensions) {
|
|
1620
|
+
const lists = {};
|
|
1621
|
+
for (const key of ['enable', 'disable']) {
|
|
1622
|
+
if (Array.isArray(extensions[key]) && extensions[key].length > 0) {
|
|
1623
|
+
lists[key] = extensions[key];
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return lists;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
hosting_phpUpdate_validate(params) {
|
|
1630
|
+
// Models send null for fields they leave unset.
|
|
1631
|
+
const given = (name) => params[name] !== undefined && params[name] !== null;
|
|
1632
|
+
|
|
1633
|
+
if (given('version') && (typeof params.version !== 'string' || params.version.trim() === '')) {
|
|
1634
|
+
throw new Error('version must be a non-empty string when provided');
|
|
1635
|
+
}
|
|
1636
|
+
if (given('resetExtensions') && typeof params.resetExtensions !== 'boolean') {
|
|
1637
|
+
throw new Error('resetExtensions must be a boolean when provided');
|
|
1638
|
+
}
|
|
1639
|
+
if (given('extensions')) {
|
|
1640
|
+
const extensions = params.extensions;
|
|
1641
|
+
const list = (value) => value === undefined || value === null || (Array.isArray(value) && value.every((name) => typeof name === 'string' && name !== ''));
|
|
1642
|
+
const valid = typeof extensions === 'object' && !Array.isArray(extensions)
|
|
1643
|
+
&& list(extensions.enable) && list(extensions.disable)
|
|
1644
|
+
&& Object.keys(this.hosting_phpUpdate_extensionLists(extensions)).length > 0;
|
|
1645
|
+
if (!valid) {
|
|
1646
|
+
throw new Error('extensions must be an object with a non-empty enable or disable list of extension names');
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
if (given('options')) {
|
|
1650
|
+
const options = params.options;
|
|
1651
|
+
const valid = typeof options === 'object' && !Array.isArray(options) && Object.keys(options).length > 0
|
|
1652
|
+
&& Object.values(options).every(value => typeof value === 'string' || (typeof value === 'number' && Number.isInteger(value)));
|
|
1653
|
+
if (!valid) {
|
|
1654
|
+
throw new Error('options must be a non-empty object of option names to string or integer values');
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async handleHostingPhpUpdate(params) {
|
|
1660
|
+
const { domain: requestedDomain } = params;
|
|
1661
|
+
this.hosting_phpUpdate_validate(params);
|
|
1662
|
+
|
|
1663
|
+
// resetExtensions: false asks for nothing, like null.
|
|
1664
|
+
const requested = this.hosting_phpUpdate_steps.filter(step => step.setting === 'resetExtensions'
|
|
1665
|
+
? params.resetExtensions === true
|
|
1666
|
+
: params[step.setting] !== undefined && params[step.setting] !== null);
|
|
1667
|
+
if (requested.length === 0) {
|
|
1668
|
+
throw new Error('Pass at least one of version, resetExtensions, extensions, options');
|
|
1669
|
+
}
|
|
1670
|
+
if (!requestedDomain || typeof requestedDomain !== 'string') {
|
|
1671
|
+
throw new Error('domain is required and must be a string');
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const { username, domain } = await this.findWebsite(requestedDomain);
|
|
1675
|
+
const target = { username, domain };
|
|
1676
|
+
|
|
1677
|
+
const applied = {};
|
|
1678
|
+
let failed = null;
|
|
1679
|
+
const skipped = [];
|
|
1680
|
+
for (const step of requested) {
|
|
1681
|
+
const value = params[step.setting];
|
|
1682
|
+
if (failed) {
|
|
1683
|
+
skipped.push(step.setting);
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
await step.run(target, value);
|
|
1688
|
+
// What was sent: for extensions, only the non-empty lists.
|
|
1689
|
+
applied[step.setting] = step.setting === 'extensions' ? this.hosting_phpUpdate_extensionLists(value) : value;
|
|
1690
|
+
} catch (error) {
|
|
1691
|
+
failed = { setting: step.setting, error: this.formatToolError(error) };
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const result = { domain, applied, failed, skipped };
|
|
1696
|
+
// A failed step is a tool error carrying the same JSON, so execute reports it and a
|
|
1697
|
+
// multi-execute batch stops at this step.
|
|
1698
|
+
if (failed) {
|
|
1699
|
+
throw new Error(JSON.stringify(result));
|
|
1700
|
+
}
|
|
1701
|
+
return result;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1608
1704
|
// A 404 means the domain is not a website on the account, which fails the whole call; any other
|
|
1609
1705
|
// failure only fails its own list.
|
|
1610
1706
|
async hosting_websitesListDomains_section(request, pick) {
|
package/src/core/runtime.ts
CHANGED
|
@@ -336,6 +336,8 @@ class MCPServer {
|
|
|
336
336
|
return await this.handleHostingWebsitesUpdateSettings(params);
|
|
337
337
|
case 'hosting_cache_clear':
|
|
338
338
|
return await this.handleHostingCacheClear(params);
|
|
339
|
+
case 'hosting_php_update':
|
|
340
|
+
return await this.handleHostingPhpUpdate(params);
|
|
339
341
|
case 'hosting_websites_list-domains':
|
|
340
342
|
return await this.handleHostingWebsitesListDomains(params);
|
|
341
343
|
case 'hosting_websites_add-domain':
|
|
@@ -1643,6 +1645,100 @@ class MCPServer {
|
|
|
1643
1645
|
return { domain, cleared: scope, directory, response };
|
|
1644
1646
|
}
|
|
1645
1647
|
|
|
1648
|
+
// Application order, and how each step maps to its operation.
|
|
1649
|
+
private hosting_phpUpdate_steps: Array<{ setting: string; run: (target: { username: string; domain: string }, value: any) => Promise<any> }> = [
|
|
1650
|
+
{ setting: 'version', run: (target, value) => this.callOperation('hosting_php_update-version', { ...target, version: value }) },
|
|
1651
|
+
{ setting: 'resetExtensions', run: (target) => this.callOperation('hosting_php_reset-extensions', target) },
|
|
1652
|
+
{ setting: 'extensions', run: (target, value) => this.callOperation('hosting_php_update-extensions', { ...target, ...this.hosting_phpUpdate_extensionLists(value) }) },
|
|
1653
|
+
{ setting: 'options', run: (target, value) => this.callOperation('hosting_php_update-options', { ...target, options: value }) },
|
|
1654
|
+
];
|
|
1655
|
+
|
|
1656
|
+
// The enable and disable lists to send: only the non-empty ones.
|
|
1657
|
+
private hosting_phpUpdate_extensionLists(extensions: Record<string, any>): Record<string, string[]> {
|
|
1658
|
+
const lists: Record<string, string[]> = {};
|
|
1659
|
+
for (const key of ['enable', 'disable']) {
|
|
1660
|
+
if (Array.isArray(extensions[key]) && extensions[key].length > 0) {
|
|
1661
|
+
lists[key] = extensions[key];
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return lists;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
private hosting_phpUpdate_validate(params: Record<string, any>): void {
|
|
1668
|
+
// Models send null for fields they leave unset.
|
|
1669
|
+
const given = (name: string) => params[name] !== undefined && params[name] !== null;
|
|
1670
|
+
|
|
1671
|
+
if (given('version') && (typeof params.version !== 'string' || params.version.trim() === '')) {
|
|
1672
|
+
throw new Error('version must be a non-empty string when provided');
|
|
1673
|
+
}
|
|
1674
|
+
if (given('resetExtensions') && typeof params.resetExtensions !== 'boolean') {
|
|
1675
|
+
throw new Error('resetExtensions must be a boolean when provided');
|
|
1676
|
+
}
|
|
1677
|
+
if (given('extensions')) {
|
|
1678
|
+
const extensions = params.extensions;
|
|
1679
|
+
const list = (value: any) => value === undefined || value === null || (Array.isArray(value) && value.every((name: any) => typeof name === 'string' && name !== ''));
|
|
1680
|
+
const valid = typeof extensions === 'object' && !Array.isArray(extensions)
|
|
1681
|
+
&& list(extensions.enable) && list(extensions.disable)
|
|
1682
|
+
&& Object.keys(this.hosting_phpUpdate_extensionLists(extensions)).length > 0;
|
|
1683
|
+
if (!valid) {
|
|
1684
|
+
throw new Error('extensions must be an object with a non-empty enable or disable list of extension names');
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
if (given('options')) {
|
|
1688
|
+
const options = params.options;
|
|
1689
|
+
const valid = typeof options === 'object' && !Array.isArray(options) && Object.keys(options).length > 0
|
|
1690
|
+
&& Object.values(options).every(value => typeof value === 'string' || (typeof value === 'number' && Number.isInteger(value)));
|
|
1691
|
+
if (!valid) {
|
|
1692
|
+
throw new Error('options must be a non-empty object of option names to string or integer values');
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
private async handleHostingPhpUpdate(params: Record<string, any>): Promise<any> {
|
|
1698
|
+
const { domain: requestedDomain } = params;
|
|
1699
|
+
this.hosting_phpUpdate_validate(params);
|
|
1700
|
+
|
|
1701
|
+
// resetExtensions: false asks for nothing, like null.
|
|
1702
|
+
const requested = this.hosting_phpUpdate_steps.filter(step => step.setting === 'resetExtensions'
|
|
1703
|
+
? params.resetExtensions === true
|
|
1704
|
+
: params[step.setting] !== undefined && params[step.setting] !== null);
|
|
1705
|
+
if (requested.length === 0) {
|
|
1706
|
+
throw new Error('Pass at least one of version, resetExtensions, extensions, options');
|
|
1707
|
+
}
|
|
1708
|
+
if (!requestedDomain || typeof requestedDomain !== 'string') {
|
|
1709
|
+
throw new Error('domain is required and must be a string');
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const { username, domain } = await this.findWebsite(requestedDomain);
|
|
1713
|
+
const target = { username, domain };
|
|
1714
|
+
|
|
1715
|
+
const applied: Record<string, any> = {};
|
|
1716
|
+
let failed: { setting: string; error: string } | null = null;
|
|
1717
|
+
const skipped: string[] = [];
|
|
1718
|
+
for (const step of requested) {
|
|
1719
|
+
const value = params[step.setting];
|
|
1720
|
+
if (failed) {
|
|
1721
|
+
skipped.push(step.setting);
|
|
1722
|
+
continue;
|
|
1723
|
+
}
|
|
1724
|
+
try {
|
|
1725
|
+
await step.run(target, value);
|
|
1726
|
+
// What was sent: for extensions, only the non-empty lists.
|
|
1727
|
+
applied[step.setting] = step.setting === 'extensions' ? this.hosting_phpUpdate_extensionLists(value) : value;
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
failed = { setting: step.setting, error: this.formatToolError(error) };
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
const result = { domain, applied, failed, skipped };
|
|
1734
|
+
// A failed step is a tool error carrying the same JSON, so execute reports it and a
|
|
1735
|
+
// multi-execute batch stops at this step.
|
|
1736
|
+
if (failed) {
|
|
1737
|
+
throw new Error(JSON.stringify(result));
|
|
1738
|
+
}
|
|
1739
|
+
return result;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1646
1742
|
// A 404 means the domain is not a website on the account, which fails the whole call; any other
|
|
1647
1743
|
// failure only fails its own list.
|
|
1648
1744
|
private async hosting_websitesListDomains_section(request: Promise<any>, pick: (item: any) => any): Promise<any> {
|