@hostinger/mcp 1.52.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 (75) hide show
  1. package/.env.example +14 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +36 -0
  3. package/.github/workflows/build-release.yaml +163 -0
  4. package/CODEOWNERS +1 -0
  5. package/Dockerfile +15 -0
  6. package/LICENSE +21 -0
  7. package/README.md +4343 -0
  8. package/build.js +60 -0
  9. package/gemini-extension.json +25 -0
  10. package/glama.json +7 -0
  11. package/package.json +59 -0
  12. package/server.json +29 -0
  13. package/skills/headless/SKILL.md +55 -0
  14. package/skills/headless/entry/bootstrap.mjs +109 -0
  15. package/skills/headless/entry/skill.md +79 -0
  16. package/skills/headless/references/DEPLOYMENT.md +34 -0
  17. package/skills/headless/references/SETUP.md +34 -0
  18. package/skills/headless/references/STORE.md +52 -0
  19. package/skills/headless/references/WORDPRESS.md +51 -0
  20. package/src/core/oauth-error.html +90 -0
  21. package/src/core/oauth-success.html +77 -0
  22. package/src/core/oauth.js +406 -0
  23. package/src/core/oauth.ts +374 -0
  24. package/src/core/runtime.js +2673 -0
  25. package/src/core/runtime.ts +2724 -0
  26. package/src/core/tools/agency-hosting.js +1535 -0
  27. package/src/core/tools/agency-hosting.ts +1550 -0
  28. package/src/core/tools/all.js +15361 -0
  29. package/src/core/tools/all.ts +15376 -0
  30. package/src/core/tools/billing.js +310 -0
  31. package/src/core/tools/billing.ts +325 -0
  32. package/src/core/tools/dns.js +393 -0
  33. package/src/core/tools/dns.ts +408 -0
  34. package/src/core/tools/domains.js +1435 -0
  35. package/src/core/tools/domains.ts +1450 -0
  36. package/src/core/tools/ecommerce.js +1426 -0
  37. package/src/core/tools/ecommerce.ts +1441 -0
  38. package/src/core/tools/horizons.js +84 -0
  39. package/src/core/tools/horizons.ts +99 -0
  40. package/src/core/tools/hosting.js +2479 -0
  41. package/src/core/tools/hosting.ts +2494 -0
  42. package/src/core/tools/mail.js +1641 -0
  43. package/src/core/tools/mail.ts +1656 -0
  44. package/src/core/tools/reach.js +2270 -0
  45. package/src/core/tools/reach.ts +2285 -0
  46. package/src/core/tools/vps.js +2248 -0
  47. package/src/core/tools/vps.ts +2263 -0
  48. package/src/core/tools/wordpress.js +1570 -0
  49. package/src/core/tools/wordpress.ts +1585 -0
  50. package/src/servers/agency-hosting.js +6 -0
  51. package/src/servers/agency-hosting.ts +6 -0
  52. package/src/servers/all.js +6 -0
  53. package/src/servers/all.ts +6 -0
  54. package/src/servers/billing.js +6 -0
  55. package/src/servers/billing.ts +6 -0
  56. package/src/servers/dns.js +6 -0
  57. package/src/servers/dns.ts +6 -0
  58. package/src/servers/domains.js +6 -0
  59. package/src/servers/domains.ts +6 -0
  60. package/src/servers/ecommerce.js +6 -0
  61. package/src/servers/ecommerce.ts +6 -0
  62. package/src/servers/horizons.js +6 -0
  63. package/src/servers/horizons.ts +6 -0
  64. package/src/servers/hosting.js +6 -0
  65. package/src/servers/hosting.ts +6 -0
  66. package/src/servers/mail.js +6 -0
  67. package/src/servers/mail.ts +6 -0
  68. package/src/servers/reach.js +6 -0
  69. package/src/servers/reach.ts +6 -0
  70. package/src/servers/vps.js +6 -0
  71. package/src/servers/vps.ts +6 -0
  72. package/src/servers/wordpress.js +6 -0
  73. package/src/servers/wordpress.ts +6 -0
  74. package/tsconfig.json +15 -0
  75. package/types.d.ts +8836 -0
@@ -0,0 +1,374 @@
1
+ import { createHash, randomBytes } from 'crypto';
2
+ import { createServer, Server } from 'http';
3
+ import { readFile, writeFile, mkdir } from 'fs/promises';
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { exec } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+ import path from 'path';
8
+ import os from 'os';
9
+ import axios from 'axios';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const SUCCESS_HTML: string = readFileSync(path.join(__dirname, 'oauth-success.html'), 'utf8');
13
+ const ERROR_HTML_TEMPLATE: string = readFileSync(path.join(__dirname, 'oauth-error.html'), 'utf8');
14
+
15
+ function escapeHtml(s: string): string {
16
+ return String(s).replace(/[&<>"']/g, (ch) => ({
17
+ '&': '&amp;',
18
+ '<': '&lt;',
19
+ '>': '&gt;',
20
+ '"': '&quot;',
21
+ "'": '&#39;',
22
+ })[ch] as string);
23
+ }
24
+
25
+ const DEFAULT_ISSUER = 'https://auth.hostinger.com';
26
+ const REGISTER_PATH = '/api/external/v1/oauth-server/register';
27
+ const AUTHORIZE_PATH = '/api/external/v1/oauth-server/authorize';
28
+ const TOKEN_PATH = '/api/external/v1/oauth-server/token';
29
+ const REVOKE_PATH = '/api/external/v1/oauth-server/token/revoke';
30
+ const CLIENT_NAME = 'hostinger-mcp';
31
+ const CALLBACK_PATH = '/oauth/callback';
32
+ const CREDENTIALS_DIR_NAME = 'hostinger-mcp';
33
+ const CREDENTIALS_FILE_NAME = 'credentials.json';
34
+ const EXPIRY_BUFFER_SECONDS = 60;
35
+
36
+ export interface OAuthCredentials {
37
+ client_id?: string;
38
+ access_token?: string;
39
+ refresh_token?: string;
40
+ expires_at?: number;
41
+ }
42
+
43
+ interface OAuthTokenResponse {
44
+ access_token: string;
45
+ refresh_token?: string;
46
+ expires_in: number;
47
+ token_type: string;
48
+ scope?: string;
49
+ }
50
+
51
+ export class OAuthRefreshError extends Error {
52
+ public readonly code = 'OAUTH_INVALID_GRANT';
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = 'OAuthRefreshError';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve the bearer token from the environment. HOSTINGER_API_TOKEN is the
61
+ * preferred name; API_TOKEN and APITOKEN are kept as backwards-compatible
62
+ * aliases (API_TOKEN is deprecated and will be removed in a future version).
63
+ * Empty values fall through, matching the previous `||` behavior.
64
+ */
65
+ export function getEnvToken(): string | undefined {
66
+ return process.env['HOSTINGER_API_TOKEN']
67
+ || process.env['API_TOKEN']
68
+ || process.env['APITOKEN'];
69
+ }
70
+
71
+ export class OAuthProvider {
72
+ private readonly issuer: string;
73
+ private _loginInProgress: Promise<string> | null = null;
74
+
75
+ constructor(issuerBaseUrl?: string) {
76
+ this.issuer = (issuerBaseUrl || process.env['OAUTH_ISSUER'] || DEFAULT_ISSUER).replace(/\/$/, '');
77
+ }
78
+
79
+ async getAccessToken(): Promise<string> {
80
+ const envToken = getEnvToken();
81
+ if (envToken) {
82
+ return envToken;
83
+ }
84
+
85
+ if (!this._loginInProgress) {
86
+ this._loginInProgress = this._resolveToken().finally(() => { this._loginInProgress = null; });
87
+ }
88
+ return await this._loginInProgress;
89
+ }
90
+
91
+ private async _resolveToken(): Promise<string> {
92
+ const creds = await this._load();
93
+
94
+ if (creds && creds.access_token && creds.expires_at && Date.now() < creds.expires_at) {
95
+ return creds.access_token;
96
+ }
97
+
98
+ if (creds && creds.refresh_token && creds.client_id) {
99
+ try {
100
+ return await this._refresh(creds);
101
+ } catch (err) {
102
+ if ((err as { code?: string }).code !== 'OAUTH_INVALID_GRANT') {
103
+ throw err;
104
+ }
105
+ }
106
+ }
107
+
108
+ return await this._login();
109
+ }
110
+
111
+ async login(): Promise<string> {
112
+ if (this._loginInProgress) {
113
+ return await this._loginInProgress;
114
+ }
115
+ this._loginInProgress = this._login().finally(() => { this._loginInProgress = null; });
116
+ return await this._loginInProgress;
117
+ }
118
+
119
+ /**
120
+ * Force a fresh token, bypassing the cached-token fast path. Called by the
121
+ * runtime when the API rejects an apparently-valid token with 401. Tries the
122
+ * refresh grant first; if the refresh token is also dead (4xx), falls through
123
+ * to the full browser login flow.
124
+ */
125
+ async reauthenticate(): Promise<string> {
126
+ if (!this._loginInProgress) {
127
+ this._loginInProgress = this._reauthenticate().finally(() => { this._loginInProgress = null; });
128
+ }
129
+ return await this._loginInProgress;
130
+ }
131
+
132
+ private async _reauthenticate(): Promise<string> {
133
+ const creds = await this._load();
134
+ if (creds && creds.refresh_token && creds.client_id) {
135
+ try {
136
+ return await this._refresh(creds);
137
+ } catch (err) {
138
+ if ((err as { code?: string }).code !== 'OAUTH_INVALID_GRANT') {
139
+ throw err;
140
+ }
141
+ }
142
+ }
143
+ return await this._login();
144
+ }
145
+
146
+ async logout(): Promise<void> {
147
+ const creds = await this._load();
148
+ if (!creds) {
149
+ return;
150
+ }
151
+ if (creds.access_token && creds.client_id) {
152
+ try {
153
+ const params = new URLSearchParams();
154
+ params.set('token', creds.access_token);
155
+ params.set('client_id', creds.client_id);
156
+ await axios.post(`${this.issuer}${REVOKE_PATH}`, params.toString(), {
157
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
158
+ validateStatus: () => true
159
+ });
160
+ } catch (_) {
161
+ // best-effort
162
+ }
163
+ }
164
+ await this._save({ client_id: creds.client_id });
165
+ }
166
+
167
+ private async _login(): Promise<string> {
168
+ const creds: OAuthCredentials = (await this._load()) || {};
169
+
170
+ const port = await this._getFreePort();
171
+ const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
172
+
173
+ if (!creds.client_id) {
174
+ creds.client_id = await this._register(redirectUri);
175
+ }
176
+
177
+ const { verifier, challenge } = this._generatePKCE();
178
+ const state = this._generateState();
179
+
180
+ const callbackPromise = this._listenForCallback(state, port);
181
+
182
+ const authorizeUrl = new URL(`${this.issuer}${AUTHORIZE_PATH}`);
183
+ authorizeUrl.searchParams.set('client_id', creds.client_id);
184
+ authorizeUrl.searchParams.set('redirect_uri', redirectUri);
185
+ authorizeUrl.searchParams.set('state', state);
186
+ authorizeUrl.searchParams.set('code_challenge', challenge);
187
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256');
188
+ authorizeUrl.searchParams.set('response_type', 'code');
189
+
190
+ this._openBrowser(authorizeUrl.toString());
191
+
192
+ const { code } = await callbackPromise;
193
+
194
+ const params = new URLSearchParams();
195
+ params.set('grant_type', 'authorization_code');
196
+ params.set('code', code);
197
+ params.set('code_verifier', verifier);
198
+ params.set('redirect_uri', redirectUri);
199
+ params.set('client_id', creds.client_id);
200
+
201
+ const resp = await axios.post<OAuthTokenResponse>(`${this.issuer}${TOKEN_PATH}`, params.toString(), {
202
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
203
+ validateStatus: () => true
204
+ });
205
+
206
+ if (resp.status >= 400) {
207
+ throw new Error(`Token exchange failed (${resp.status}): ${JSON.stringify(resp.data)}`);
208
+ }
209
+
210
+ const tokens = resp.data;
211
+ const newCreds: OAuthCredentials = {
212
+ client_id: creds.client_id,
213
+ access_token: tokens.access_token,
214
+ refresh_token: tokens.refresh_token,
215
+ expires_at: Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000
216
+ };
217
+ await this._save(newCreds);
218
+ return tokens.access_token;
219
+ }
220
+
221
+ private async _refresh(creds: OAuthCredentials): Promise<string> {
222
+ const params = new URLSearchParams();
223
+ params.set('grant_type', 'refresh_token');
224
+ params.set('refresh_token', creds.refresh_token as string);
225
+ params.set('client_id', creds.client_id as string);
226
+
227
+ const resp = await axios.post<OAuthTokenResponse>(`${this.issuer}${TOKEN_PATH}`, params.toString(), {
228
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
229
+ validateStatus: () => true
230
+ });
231
+
232
+ if (resp.status >= 400 && resp.status < 500) {
233
+ throw new OAuthRefreshError(`Refresh token rejected (${resp.status}): ${JSON.stringify(resp.data)}`);
234
+ }
235
+
236
+ if (resp.status >= 500) {
237
+ throw new Error(`Token refresh failed (${resp.status}): ${JSON.stringify(resp.data)}`);
238
+ }
239
+
240
+ const tokens = resp.data;
241
+ const newCreds: OAuthCredentials = {
242
+ client_id: creds.client_id,
243
+ access_token: tokens.access_token,
244
+ refresh_token: tokens.refresh_token || creds.refresh_token,
245
+ expires_at: Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000
246
+ };
247
+ await this._save(newCreds);
248
+ return tokens.access_token;
249
+ }
250
+
251
+ private async _register(redirectUri: string): Promise<string> {
252
+ const resp = await axios.post<{ client_id?: string }>(`${this.issuer}${REGISTER_PATH}`, {
253
+ client_name: CLIENT_NAME,
254
+ redirect_uris: [redirectUri]
255
+ }, {
256
+ headers: { 'Content-Type': 'application/json' },
257
+ validateStatus: () => true
258
+ });
259
+
260
+ if (resp.status >= 400 || !resp.data || !resp.data.client_id) {
261
+ throw new Error(`Client registration failed (${resp.status}): ${JSON.stringify(resp.data)}`);
262
+ }
263
+ return resp.data.client_id;
264
+ }
265
+
266
+ private _generatePKCE(): { verifier: string; challenge: string } {
267
+ const verifier = randomBytes(32).toString('base64url');
268
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
269
+ return { verifier, challenge };
270
+ }
271
+
272
+ private _generateState(): string {
273
+ return randomBytes(16).toString('hex');
274
+ }
275
+
276
+ private _listenForCallback(expectedState: string, port: number): Promise<{ code: string; port: number }> {
277
+ return new Promise((resolve, reject) => {
278
+ const server: Server = createServer((req, res) => {
279
+ const url = new URL(req.url || '/', `http://127.0.0.1:${port}`);
280
+ if (url.pathname !== CALLBACK_PATH) {
281
+ res.writeHead(404);
282
+ res.end();
283
+ return;
284
+ }
285
+
286
+ const code = url.searchParams.get('code');
287
+ const state = url.searchParams.get('state');
288
+ const error = url.searchParams.get('error');
289
+
290
+ const body = error
291
+ ? ERROR_HTML_TEMPLATE.replace('{{error}}', escapeHtml(error))
292
+ : SUCCESS_HTML;
293
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
294
+ res.end(body);
295
+
296
+ setImmediate(() => server.close());
297
+
298
+ if (error) {
299
+ return reject(new Error(`OAuth error from authorization server: ${error}`));
300
+ }
301
+ if (!state || state !== expectedState) {
302
+ return reject(new Error('OAuth state mismatch'));
303
+ }
304
+ if (!code) {
305
+ return reject(new Error('No authorization code received'));
306
+ }
307
+ resolve({ code, port });
308
+ });
309
+ server.on('error', reject);
310
+ server.listen(port, '127.0.0.1');
311
+ });
312
+ }
313
+
314
+ private _getFreePort(): Promise<number> {
315
+ return new Promise((resolve, reject) => {
316
+ const srv = createServer();
317
+ srv.unref();
318
+ srv.on('error', reject);
319
+ srv.listen(0, '127.0.0.1', () => {
320
+ const addr = srv.address();
321
+ const port = typeof addr === 'object' && addr ? addr.port : 0;
322
+ srv.close(() => resolve(port));
323
+ });
324
+ });
325
+ }
326
+
327
+ private _credentialsPath(): string {
328
+ if (process.platform === 'win32') {
329
+ const base = process.env['APPDATA'] || os.homedir();
330
+ return path.join(base, CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
331
+ }
332
+ return path.join(os.homedir(), '.config', CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
333
+ }
334
+
335
+ private async _load(): Promise<OAuthCredentials | null> {
336
+ const p = this._credentialsPath();
337
+ if (!existsSync(p)) {
338
+ return null;
339
+ }
340
+ try {
341
+ const raw = await readFile(p, 'utf8');
342
+ return JSON.parse(raw) as OAuthCredentials;
343
+ } catch (_) {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ private async _save(creds: OAuthCredentials): Promise<void> {
349
+ const p = this._credentialsPath();
350
+ await mkdir(path.dirname(p), { recursive: true });
351
+ const writeOpts = process.platform === 'win32'
352
+ ? { encoding: 'utf8' as const }
353
+ : { encoding: 'utf8' as const, mode: 0o600 };
354
+ await writeFile(p, JSON.stringify(creds, null, 2), writeOpts);
355
+ }
356
+
357
+ private _openBrowser(url: string): void {
358
+ process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
359
+ process.stderr.write('[OAuth] If the browser does not open, copy the URL above into one manually.\n\n');
360
+ let cmd: string;
361
+ if (process.platform === 'darwin') {
362
+ cmd = `open "${url}"`;
363
+ } else if (process.platform === 'win32') {
364
+ cmd = `start "" "${url}"`;
365
+ } else {
366
+ cmd = `xdg-open "${url}"`;
367
+ }
368
+ exec(cmd, (err) => {
369
+ if (err) {
370
+ process.stderr.write(`[OAuth] Could not auto-launch browser: ${err.message}\n`);
371
+ }
372
+ });
373
+ }
374
+ }