hostinger-api-mcp 3.28.5 → 3.29.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 (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/SKILL.md +1 -0
  6. package/skills/headless/VERSION +3 -3
  7. package/skills/headless/connect/SKILL.md +25 -17
  8. package/skills/headless/connect/bootstrap.mjs +2 -2
  9. package/skills/headless/connect/tools/antigravity.md +2 -2
  10. package/skills/headless/connect/tools/claude-code.md +2 -2
  11. package/skills/headless/connect/tools/codex.md +2 -2
  12. package/skills/headless/connect/tools/cursor.md +2 -2
  13. package/skills/headless/connect/tools/devin.md +2 -2
  14. package/skills/headless/connect/tools/vscode.md +2 -2
  15. package/skills/headless/hostinger-mcp.mjs +88 -0
  16. package/skills/headless/push-content.mjs +530 -0
  17. package/skills/headless/steps/04-content-model/SKILL.md +9 -9
  18. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +83 -252
  19. package/skills/headless/steps/04-content-model/wordpress/by-hand.md +31 -0
  20. package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +2 -2
  21. package/skills/headless/steps/04-content-model/wordpress/woocommerce.md +21 -49
  22. package/skills/headless/steps/05-design/SKILL.md +1 -1
  23. package/skills/headless/wait.mjs +15 -94
  24. package/skills/headless/wp-mcp.mjs +125 -0
  25. package/src/core/skills.js +73 -45
  26. package/src/core/skills.ts +73 -45
  27. package/src/servers/agency-hosting.js +1 -1
  28. package/src/servers/agency-hosting.ts +1 -1
  29. package/src/servers/agent-readiness.js +1 -1
  30. package/src/servers/agent-readiness.ts +1 -1
  31. package/src/servers/all.js +1 -1
  32. package/src/servers/all.ts +1 -1
  33. package/src/servers/billing.js +1 -1
  34. package/src/servers/billing.ts +1 -1
  35. package/src/servers/dns.js +1 -1
  36. package/src/servers/dns.ts +1 -1
  37. package/src/servers/domains.js +1 -1
  38. package/src/servers/domains.ts +1 -1
  39. package/src/servers/ecommerce.js +1 -1
  40. package/src/servers/ecommerce.ts +1 -1
  41. package/src/servers/gpu.js +1 -1
  42. package/src/servers/gpu.ts +1 -1
  43. package/src/servers/horizons.js +1 -1
  44. package/src/servers/horizons.ts +1 -1
  45. package/src/servers/hosting.js +1 -1
  46. package/src/servers/hosting.ts +1 -1
  47. package/src/servers/mail.js +1 -1
  48. package/src/servers/mail.ts +1 -1
  49. package/src/servers/reach.js +1 -1
  50. package/src/servers/reach.ts +1 -1
  51. package/src/servers/vps.js +1 -1
  52. package/src/servers/vps.ts +1 -1
  53. package/src/servers/wordpress.js +1 -1
  54. package/src/servers/wordpress.ts +1 -1
@@ -13,16 +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 { spawn } from 'node:child_process';
17
-
18
- // ── platform-safe binary names (npm/npx are .cmd on Windows), as in connect/bootstrap.mjs ──────────
19
- const isWin = process.platform === 'win32';
20
- const bin = (name) => (isWin ? `${name}.cmd` : name);
21
- const MCP = [bin('npx'), '-y', '--package=@hostinger/mcp@latest', 'hostinger-api-mcp'];
22
- // Node >= 18.20.2 / 20.12.2 refuses to spawn a .cmd/.bat without a shell
23
- // (CVE-2024-27980), so npx.cmd needs shell:true on Windows. Our args are all
24
- // fixed literals, so enabling the shell introduces no injection risk.
25
- const spawnShell = isWin;
16
+ import { connect } from './hostinger-mcp.mjs';
26
17
 
27
18
  const log = (message) => process.stderr.write(`wait: ${message}\n`);
28
19
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -52,83 +43,6 @@ function duration(text) {
52
43
  return Number(m[1]) * { s: 1e3, m: 6e4, h: 36e5 }[m[2]];
53
44
  }
54
45
 
55
- // One MCP server for the whole wait, spoken to over newline-delimited JSON-RPC on stdio. Once the
56
- // server dies every pending and later call rejects, so the wait ends with exit 2 rather than hanging.
57
- function connect() {
58
- const child = spawn(MCP[0], MCP.slice(1), { stdio: ['pipe', 'pipe', 'pipe'], shell: spawnShell });
59
- const pending = new Map();
60
- let buffer = '';
61
- let diag = '';
62
- let dead = null;
63
- let nextId = 0;
64
- const die = (reason) => {
65
- dead ??= new Error(`the Hostinger MCP server stopped (${reason}): ${diag.trim().slice(-500)}`);
66
- for (const { reject } of pending.values()) reject(dead);
67
- pending.clear();
68
- };
69
- child.on('error', (e) => die(e.message));
70
- // 'close' comes after stderr is drained, so the reason is in diag. A write to a dying server fails
71
- // with EPIPE before that; 'close' still follows, so the EPIPE itself is ignored.
72
- child.on('close', (code) => die(`exit code ${code}`));
73
- child.stdin.on('error', () => {});
74
- child.stderr.on('data', (chunk) => (diag = (diag + chunk).slice(-2000)));
75
- child.stdout.on('data', (chunk) => {
76
- buffer += chunk;
77
- let i;
78
- while ((i = buffer.indexOf('\n')) >= 0) {
79
- const line = buffer.slice(0, i);
80
- buffer = buffer.slice(i + 1);
81
- try {
82
- const message = JSON.parse(line);
83
- pending.get(message.id)?.resolve(message);
84
- pending.delete(message.id);
85
- } catch {
86
- /* not a JSON-RPC line */
87
- }
88
- }
89
- });
90
- // A call can stall on a live server (a stuck upstream request), so each one has its own deadline; past
91
- // it, it answers like an error reply. A stalled poll is retried; a stalled start ends the wait.
92
- const rpc = (method, params) =>
93
- new Promise((resolve, reject) => {
94
- if (dead) return reject(dead);
95
- const id = (nextId += 1);
96
- const timer = setTimeout(() => {
97
- pending.delete(id);
98
- resolve({ error: { message: `no reply to ${method} in 60 s` } });
99
- }, 60_000);
100
- const settle = (fn) => (value) => {
101
- clearTimeout(timer);
102
- fn(value);
103
- };
104
- pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
105
- child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
106
- });
107
- const ready = rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'headless-wait', version: '1' } })
108
- .then((reply) => {
109
- if (reply.error) throw new Error(`the Hostinger MCP server didn't start: ${reply.error.message}`);
110
- child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`);
111
- });
112
- // The server keeps "HTTP <status>" in its error text. A 409 while the setup runs, a 429 or a 5xx is a
113
- // poll that didn't answer; any other 4xx (expired sign-in) or an unknown domain won't fix itself by waiting.
114
- const execute = async (operation, params) => {
115
- await ready;
116
- const reply = await rpc('tools/call', { name: 'execute', arguments: { operation, params } });
117
- const text = reply.result?.content?.[0]?.text ?? reply.error?.message ?? 'unknown error';
118
- if (reply.error || reply.result?.isError) {
119
- const status = Number(text.match(/HTTP (\d{3})/)?.[1]);
120
- const terminal = (status >= 400 && status < 500 && ![408, 409, 425, 429].includes(status)) || /No website found/.test(text);
121
- return { error: text.slice(0, 300), terminal };
122
- }
123
- try {
124
- return { data: JSON.parse(text) };
125
- } catch {
126
- return { error: text.slice(0, 300), terminal: false };
127
- }
128
- };
129
- return { execute, close: () => child.kill() };
130
- }
131
-
132
46
  async function httpStatus(url) {
133
47
  try {
134
48
  const response = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(30_000) });
@@ -156,6 +70,10 @@ async function poll(label, timeoutMs, check) {
156
70
  }
157
71
  }
158
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
+
159
77
  // List operations answer an array, or an object holding it in `data`; their domain filter is loose.
160
78
  const rowsFor = (data, domain) =>
161
79
  (Array.isArray(data) ? data : data?.data ?? []).filter((r) => r.domain?.toLowerCase() === domain.toLowerCase());
@@ -173,8 +91,9 @@ const KINDS = {
173
91
  fallback: 'hosting_websites_list-setups',
174
92
  run: (mcp, { domain }, timeoutMs) =>
175
93
  poll('setup', timeoutMs, async () => {
176
- const { data, error, terminal } = await mcp.execute('hosting_websites_list-setups', { domain });
177
- if (error) return { status: `list-setups: ${error}`, terminal };
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) };
178
97
  const [row] = rowsFor(data, domain);
179
98
  if (row?.status === 'completed') return { done: { ok: true, setup: row } };
180
99
  if (row?.status === 'failed') return { done: { ok: false, setup: row } };
@@ -189,8 +108,9 @@ const KINDS = {
189
108
  // The list can lag a few seconds behind the install finishing and answer [], so an empty list is a wait.
190
109
  run: (mcp, { domain, username }, timeoutMs) =>
191
110
  poll('cms', timeoutMs, async () => {
192
- const { data, error, terminal } = await mcp.execute('wordpress_installations_list', { username, domain, ownership: 'all' });
193
- if (error) return { status: `installations: ${error}`, terminal };
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) };
194
114
  const rows = rowsFor(data, domain).filter((r) => r.directory === 'cms');
195
115
  const valid = rows.find((r) => r.is_valid ?? r.valid);
196
116
  if (valid) return { done: { ok: true, install: valid } };
@@ -205,8 +125,9 @@ const KINDS = {
205
125
  run: async (mcp, { domain, buildUuid }, timeoutMs) => {
206
126
  const started = Date.now();
207
127
  const build = await poll('build', timeoutMs, async () => {
208
- const { data, error, terminal } = await mcp.execute('hosting_get-deployment', { domain, buildUuid });
209
- if (error) return { status: `get-deployment: ${error}`, terminal };
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) };
210
131
  const state = data?.build?.state;
211
132
  if (state === 'failed') {
212
133
  const logTail = String(data.logs?.logs ?? '').split('\n').slice(-40).join('\n');
@@ -239,7 +160,7 @@ if (!spec || !domain || rest.length < spec.args.length) fail(`usage: ${usage} [-
239
160
  const params = { domain, ...Object.fromEntries(spec.args.map((a, i) => [a, rest[i]])) };
240
161
  const timeoutMs = duration(flags.timeout ?? spec.timeout);
241
162
  fallback = spec.fallback ?? null;
242
- const mcp = spec.fallback ? connect() : null;
163
+ const mcp = spec.fallback ? connect('headless-wait') : null;
243
164
  const result = await spec.run(mcp, params, timeoutMs).catch((e) => fail(e.message));
244
165
  mcp?.close();
245
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
+ }