hostinger-api-mcp 0.1.42 → 0.2.1
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/.env.example +4 -0
- package/README.md +40 -2
- package/package.json +1 -1
- package/src/core/oauth-error.html +90 -0
- package/src/core/oauth-success.html +77 -0
- package/src/core/oauth.js +392 -0
- package/src/core/oauth.ts +362 -0
- package/src/core/runtime.js +71 -55
- package/src/core/runtime.ts +77 -55
- 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/hosting.js +1 -1
- package/src/servers/hosting.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
|
@@ -0,0 +1,362 @@
|
|
|
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
|
+
'&': '&',
|
|
18
|
+
'<': '<',
|
|
19
|
+
'>': '>',
|
|
20
|
+
'"': '"',
|
|
21
|
+
"'": ''',
|
|
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
|
+
export class OAuthProvider {
|
|
60
|
+
private readonly issuer: string;
|
|
61
|
+
private _loginInProgress: Promise<string> | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(issuerBaseUrl?: string) {
|
|
64
|
+
this.issuer = (issuerBaseUrl || process.env['OAUTH_ISSUER'] || DEFAULT_ISSUER).replace(/\/$/, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getAccessToken(): Promise<string> {
|
|
68
|
+
const envToken = process.env['API_TOKEN'] || process.env['APITOKEN'];
|
|
69
|
+
if (envToken) {
|
|
70
|
+
return envToken;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!this._loginInProgress) {
|
|
74
|
+
this._loginInProgress = this._resolveToken().finally(() => { this._loginInProgress = null; });
|
|
75
|
+
}
|
|
76
|
+
return await this._loginInProgress;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async _resolveToken(): Promise<string> {
|
|
80
|
+
const creds = await this._load();
|
|
81
|
+
|
|
82
|
+
if (creds && creds.access_token && creds.expires_at && Date.now() < creds.expires_at) {
|
|
83
|
+
return creds.access_token;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (creds && creds.refresh_token && creds.client_id) {
|
|
87
|
+
try {
|
|
88
|
+
return await this._refresh(creds);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if ((err as { code?: string }).code !== 'OAUTH_INVALID_GRANT') {
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return await this._login();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async login(): Promise<string> {
|
|
100
|
+
if (this._loginInProgress) {
|
|
101
|
+
return await this._loginInProgress;
|
|
102
|
+
}
|
|
103
|
+
this._loginInProgress = this._login().finally(() => { this._loginInProgress = null; });
|
|
104
|
+
return await this._loginInProgress;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Force a fresh token, bypassing the cached-token fast path. Called by the
|
|
109
|
+
* runtime when the API rejects an apparently-valid token with 401. Tries the
|
|
110
|
+
* refresh grant first; if the refresh token is also dead (4xx), falls through
|
|
111
|
+
* to the full browser login flow.
|
|
112
|
+
*/
|
|
113
|
+
async reauthenticate(): Promise<string> {
|
|
114
|
+
if (!this._loginInProgress) {
|
|
115
|
+
this._loginInProgress = this._reauthenticate().finally(() => { this._loginInProgress = null; });
|
|
116
|
+
}
|
|
117
|
+
return await this._loginInProgress;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async _reauthenticate(): Promise<string> {
|
|
121
|
+
const creds = await this._load();
|
|
122
|
+
if (creds && creds.refresh_token && creds.client_id) {
|
|
123
|
+
try {
|
|
124
|
+
return await this._refresh(creds);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if ((err as { code?: string }).code !== 'OAUTH_INVALID_GRANT') {
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return await this._login();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async logout(): Promise<void> {
|
|
135
|
+
const creds = await this._load();
|
|
136
|
+
if (!creds) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (creds.access_token && creds.client_id) {
|
|
140
|
+
try {
|
|
141
|
+
const params = new URLSearchParams();
|
|
142
|
+
params.set('token', creds.access_token);
|
|
143
|
+
params.set('client_id', creds.client_id);
|
|
144
|
+
await axios.post(`${this.issuer}${REVOKE_PATH}`, params.toString(), {
|
|
145
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
146
|
+
validateStatus: () => true
|
|
147
|
+
});
|
|
148
|
+
} catch (_) {
|
|
149
|
+
// best-effort
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
await this._save({ client_id: creds.client_id });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async _login(): Promise<string> {
|
|
156
|
+
const creds: OAuthCredentials = (await this._load()) || {};
|
|
157
|
+
|
|
158
|
+
const port = await this._getFreePort();
|
|
159
|
+
const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
160
|
+
|
|
161
|
+
if (!creds.client_id) {
|
|
162
|
+
creds.client_id = await this._register(redirectUri);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { verifier, challenge } = this._generatePKCE();
|
|
166
|
+
const state = this._generateState();
|
|
167
|
+
|
|
168
|
+
const callbackPromise = this._listenForCallback(state, port);
|
|
169
|
+
|
|
170
|
+
const authorizeUrl = new URL(`${this.issuer}${AUTHORIZE_PATH}`);
|
|
171
|
+
authorizeUrl.searchParams.set('client_id', creds.client_id);
|
|
172
|
+
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
|
173
|
+
authorizeUrl.searchParams.set('state', state);
|
|
174
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
175
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
176
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
177
|
+
|
|
178
|
+
this._openBrowser(authorizeUrl.toString());
|
|
179
|
+
|
|
180
|
+
const { code } = await callbackPromise;
|
|
181
|
+
|
|
182
|
+
const params = new URLSearchParams();
|
|
183
|
+
params.set('grant_type', 'authorization_code');
|
|
184
|
+
params.set('code', code);
|
|
185
|
+
params.set('code_verifier', verifier);
|
|
186
|
+
params.set('redirect_uri', redirectUri);
|
|
187
|
+
params.set('client_id', creds.client_id);
|
|
188
|
+
|
|
189
|
+
const resp = await axios.post<OAuthTokenResponse>(`${this.issuer}${TOKEN_PATH}`, params.toString(), {
|
|
190
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
191
|
+
validateStatus: () => true
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (resp.status >= 400) {
|
|
195
|
+
throw new Error(`Token exchange failed (${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const tokens = resp.data;
|
|
199
|
+
const newCreds: OAuthCredentials = {
|
|
200
|
+
client_id: creds.client_id,
|
|
201
|
+
access_token: tokens.access_token,
|
|
202
|
+
refresh_token: tokens.refresh_token,
|
|
203
|
+
expires_at: Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000
|
|
204
|
+
};
|
|
205
|
+
await this._save(newCreds);
|
|
206
|
+
return tokens.access_token;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async _refresh(creds: OAuthCredentials): Promise<string> {
|
|
210
|
+
const params = new URLSearchParams();
|
|
211
|
+
params.set('grant_type', 'refresh_token');
|
|
212
|
+
params.set('refresh_token', creds.refresh_token as string);
|
|
213
|
+
params.set('client_id', creds.client_id as string);
|
|
214
|
+
|
|
215
|
+
const resp = await axios.post<OAuthTokenResponse>(`${this.issuer}${TOKEN_PATH}`, params.toString(), {
|
|
216
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
217
|
+
validateStatus: () => true
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (resp.status >= 400 && resp.status < 500) {
|
|
221
|
+
throw new OAuthRefreshError(`Refresh token rejected (${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (resp.status >= 500) {
|
|
225
|
+
throw new Error(`Token refresh failed (${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tokens = resp.data;
|
|
229
|
+
const newCreds: OAuthCredentials = {
|
|
230
|
+
client_id: creds.client_id,
|
|
231
|
+
access_token: tokens.access_token,
|
|
232
|
+
refresh_token: tokens.refresh_token || creds.refresh_token,
|
|
233
|
+
expires_at: Date.now() + (tokens.expires_in - EXPIRY_BUFFER_SECONDS) * 1000
|
|
234
|
+
};
|
|
235
|
+
await this._save(newCreds);
|
|
236
|
+
return tokens.access_token;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async _register(redirectUri: string): Promise<string> {
|
|
240
|
+
const resp = await axios.post<{ client_id?: string }>(`${this.issuer}${REGISTER_PATH}`, {
|
|
241
|
+
client_name: CLIENT_NAME,
|
|
242
|
+
redirect_uris: [redirectUri]
|
|
243
|
+
}, {
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
validateStatus: () => true
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
if (resp.status >= 400 || !resp.data || !resp.data.client_id) {
|
|
249
|
+
throw new Error(`Client registration failed (${resp.status}): ${JSON.stringify(resp.data)}`);
|
|
250
|
+
}
|
|
251
|
+
return resp.data.client_id;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private _generatePKCE(): { verifier: string; challenge: string } {
|
|
255
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
256
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
257
|
+
return { verifier, challenge };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private _generateState(): string {
|
|
261
|
+
return randomBytes(16).toString('hex');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private _listenForCallback(expectedState: string, port: number): Promise<{ code: string; port: number }> {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const server: Server = createServer((req, res) => {
|
|
267
|
+
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
|
268
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
269
|
+
res.writeHead(404);
|
|
270
|
+
res.end();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const code = url.searchParams.get('code');
|
|
275
|
+
const state = url.searchParams.get('state');
|
|
276
|
+
const error = url.searchParams.get('error');
|
|
277
|
+
|
|
278
|
+
const body = error
|
|
279
|
+
? ERROR_HTML_TEMPLATE.replace('{{error}}', escapeHtml(error))
|
|
280
|
+
: SUCCESS_HTML;
|
|
281
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
282
|
+
res.end(body);
|
|
283
|
+
|
|
284
|
+
setImmediate(() => server.close());
|
|
285
|
+
|
|
286
|
+
if (error) {
|
|
287
|
+
return reject(new Error(`OAuth error from authorization server: ${error}`));
|
|
288
|
+
}
|
|
289
|
+
if (!state || state !== expectedState) {
|
|
290
|
+
return reject(new Error('OAuth state mismatch'));
|
|
291
|
+
}
|
|
292
|
+
if (!code) {
|
|
293
|
+
return reject(new Error('No authorization code received'));
|
|
294
|
+
}
|
|
295
|
+
resolve({ code, port });
|
|
296
|
+
});
|
|
297
|
+
server.on('error', reject);
|
|
298
|
+
server.listen(port, '127.0.0.1');
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private _getFreePort(): Promise<number> {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
const srv = createServer();
|
|
305
|
+
srv.unref();
|
|
306
|
+
srv.on('error', reject);
|
|
307
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
308
|
+
const addr = srv.address();
|
|
309
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
310
|
+
srv.close(() => resolve(port));
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private _credentialsPath(): string {
|
|
316
|
+
if (process.platform === 'win32') {
|
|
317
|
+
const base = process.env['APPDATA'] || os.homedir();
|
|
318
|
+
return path.join(base, CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
|
|
319
|
+
}
|
|
320
|
+
return path.join(os.homedir(), '.config', CREDENTIALS_DIR_NAME, CREDENTIALS_FILE_NAME);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async _load(): Promise<OAuthCredentials | null> {
|
|
324
|
+
const p = this._credentialsPath();
|
|
325
|
+
if (!existsSync(p)) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const raw = await readFile(p, 'utf8');
|
|
330
|
+
return JSON.parse(raw) as OAuthCredentials;
|
|
331
|
+
} catch (_) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private async _save(creds: OAuthCredentials): Promise<void> {
|
|
337
|
+
const p = this._credentialsPath();
|
|
338
|
+
await mkdir(path.dirname(p), { recursive: true });
|
|
339
|
+
const writeOpts = process.platform === 'win32'
|
|
340
|
+
? { encoding: 'utf8' as const }
|
|
341
|
+
: { encoding: 'utf8' as const, mode: 0o600 };
|
|
342
|
+
await writeFile(p, JSON.stringify(creds, null, 2), writeOpts);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private _openBrowser(url: string): void {
|
|
346
|
+
process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
|
|
347
|
+
process.stderr.write('[OAuth] If the browser does not open, copy the URL above into one manually.\n\n');
|
|
348
|
+
let cmd: string;
|
|
349
|
+
if (process.platform === 'darwin') {
|
|
350
|
+
cmd = `open "${url}"`;
|
|
351
|
+
} else if (process.platform === 'win32') {
|
|
352
|
+
cmd = `start "" "${url}"`;
|
|
353
|
+
} else {
|
|
354
|
+
cmd = `xdg-open "${url}"`;
|
|
355
|
+
}
|
|
356
|
+
exec(cmd, (err) => {
|
|
357
|
+
if (err) {
|
|
358
|
+
process.stderr.write(`[OAuth] Could not auto-launch browser: ${err.message}\n`);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
package/src/core/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
ListToolsRequestSchema,
|
|
12
12
|
CallToolRequestSchema,
|
|
13
13
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
+
import { OAuthProvider } from "./oauth.js";
|
|
14
15
|
import * as tus from "tus-js-client";
|
|
15
16
|
import fs from "fs";
|
|
16
17
|
import path from "path";
|
|
@@ -41,6 +42,7 @@ class MCPServer {
|
|
|
41
42
|
this.debug = process.env.DEBUG === "true";
|
|
42
43
|
this.baseUrl = process.env.API_BASE_URL || "https://developers.hostinger.com";
|
|
43
44
|
this.headers = this.parseHeaders(process.env.API_HEADERS || "");
|
|
45
|
+
this.oauth = new OAuthProvider();
|
|
44
46
|
|
|
45
47
|
// Initialize tools map - do this before creating server
|
|
46
48
|
this.initializeTools();
|
|
@@ -74,11 +76,23 @@ class MCPServer {
|
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
const extensionUa = String(process.env.USER_AGENT ?? "")
|
|
80
|
+
.replace(/\r|\n/g, "")
|
|
81
|
+
.trim();
|
|
82
|
+
const base = `hostinger-mcp-server/${this.version}`;
|
|
83
|
+
headers["User-Agent"] = extensionUa ? `${base} (${extensionUa})` : base;
|
|
78
84
|
|
|
79
85
|
return headers;
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a bearer token. API_TOKEN env var takes precedence; otherwise the
|
|
90
|
+
* OAuth provider handles login/refresh transparently.
|
|
91
|
+
*/
|
|
92
|
+
async getAuthToken() {
|
|
93
|
+
return await this.oauth.getAccessToken();
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/**
|
|
83
97
|
* Initialize tools map from OpenAPI spec
|
|
84
98
|
* This runs before the server is connected, so don't log here
|
|
@@ -200,10 +214,7 @@ class MCPServer {
|
|
|
200
214
|
const url = new URL(`api/hosting/v1/websites?domain=${encodeURIComponent(domain)}`, baseUrl).toString();
|
|
201
215
|
|
|
202
216
|
try {
|
|
203
|
-
const bearerToken =
|
|
204
|
-
if (!bearerToken) {
|
|
205
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
206
|
-
}
|
|
217
|
+
const bearerToken = await this.getAuthToken();
|
|
207
218
|
|
|
208
219
|
const config = {
|
|
209
220
|
method: 'get',
|
|
@@ -259,10 +270,7 @@ class MCPServer {
|
|
|
259
270
|
const url = new URL('api/hosting/v1/files/upload-urls', baseUrl).toString();
|
|
260
271
|
|
|
261
272
|
try {
|
|
262
|
-
const bearerToken =
|
|
263
|
-
if (!bearerToken) {
|
|
264
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
265
|
-
}
|
|
273
|
+
const bearerToken = await this.getAuthToken();
|
|
266
274
|
|
|
267
275
|
const config = {
|
|
268
276
|
method: 'post',
|
|
@@ -456,10 +464,7 @@ class MCPServer {
|
|
|
456
464
|
const url = new URL(`api/hosting/v1/accounts/${username}/domains/${domain}/is-empty`, baseUrl).toString();
|
|
457
465
|
|
|
458
466
|
try {
|
|
459
|
-
const bearerToken =
|
|
460
|
-
if (!bearerToken) {
|
|
461
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
462
|
-
}
|
|
467
|
+
const bearerToken = await this.getAuthToken();
|
|
463
468
|
|
|
464
469
|
const config = {
|
|
465
470
|
method: 'get',
|
|
@@ -511,10 +516,7 @@ class MCPServer {
|
|
|
511
516
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/import`, baseUrl).toString();
|
|
512
517
|
|
|
513
518
|
try {
|
|
514
|
-
const bearerToken =
|
|
515
|
-
if (!bearerToken) {
|
|
516
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
517
|
-
}
|
|
519
|
+
const bearerToken = await this.getAuthToken();
|
|
518
520
|
|
|
519
521
|
const config = {
|
|
520
522
|
method: 'post',
|
|
@@ -739,10 +741,7 @@ class MCPServer {
|
|
|
739
741
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/plugins/deploy`, baseUrl).toString();
|
|
740
742
|
|
|
741
743
|
try {
|
|
742
|
-
const bearerToken =
|
|
743
|
-
if (!bearerToken) {
|
|
744
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
745
|
-
}
|
|
744
|
+
const bearerToken = await this.getAuthToken();
|
|
746
745
|
|
|
747
746
|
const config = {
|
|
748
747
|
method: 'post',
|
|
@@ -973,10 +972,7 @@ class MCPServer {
|
|
|
973
972
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/themes/deploy`, baseUrl).toString();
|
|
974
973
|
|
|
975
974
|
try {
|
|
976
|
-
const bearerToken =
|
|
977
|
-
if (!bearerToken) {
|
|
978
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
979
|
-
}
|
|
975
|
+
const bearerToken = await this.getAuthToken();
|
|
980
976
|
|
|
981
977
|
const config = {
|
|
982
978
|
method: 'post',
|
|
@@ -1210,10 +1206,7 @@ class MCPServer {
|
|
|
1210
1206
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/nodejs/builds/settings/from-archive?archive_path=${encodeURIComponent(archiveBasename)}`, baseUrl).toString();
|
|
1211
1207
|
|
|
1212
1208
|
try {
|
|
1213
|
-
const bearerToken =
|
|
1214
|
-
if (!bearerToken) {
|
|
1215
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1216
|
-
}
|
|
1209
|
+
const bearerToken = await this.getAuthToken();
|
|
1217
1210
|
|
|
1218
1211
|
const config = {
|
|
1219
1212
|
method: 'get',
|
|
@@ -1259,10 +1252,7 @@ class MCPServer {
|
|
|
1259
1252
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/nodejs/builds`, baseUrl).toString();
|
|
1260
1253
|
|
|
1261
1254
|
try {
|
|
1262
|
-
const bearerToken =
|
|
1263
|
-
if (!bearerToken) {
|
|
1264
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1265
|
-
}
|
|
1255
|
+
const bearerToken = await this.getAuthToken();
|
|
1266
1256
|
|
|
1267
1257
|
const archiveBasename = path.basename(archivePath);
|
|
1268
1258
|
const buildData = {
|
|
@@ -1510,10 +1500,7 @@ class MCPServer {
|
|
|
1510
1500
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/deploy`, baseUrl).toString();
|
|
1511
1501
|
|
|
1512
1502
|
try {
|
|
1513
|
-
const bearerToken =
|
|
1514
|
-
if (!bearerToken) {
|
|
1515
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1516
|
-
}
|
|
1503
|
+
const bearerToken = await this.getAuthToken();
|
|
1517
1504
|
|
|
1518
1505
|
const archiveBasename = path.basename(archivePath);
|
|
1519
1506
|
const deployData = {
|
|
@@ -1685,10 +1672,7 @@ class MCPServer {
|
|
|
1685
1672
|
const fullUrl = queryParams ? `${url}?${queryParams}` : url;
|
|
1686
1673
|
|
|
1687
1674
|
try {
|
|
1688
|
-
const bearerToken =
|
|
1689
|
-
if (!bearerToken) {
|
|
1690
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1691
|
-
}
|
|
1675
|
+
const bearerToken = await this.getAuthToken();
|
|
1692
1676
|
|
|
1693
1677
|
const config = {
|
|
1694
1678
|
method: 'get',
|
|
@@ -1798,10 +1782,7 @@ class MCPServer {
|
|
|
1798
1782
|
const fullUrl = queryParams ? `${url}?${queryParams}` : url;
|
|
1799
1783
|
|
|
1800
1784
|
try {
|
|
1801
|
-
const bearerToken =
|
|
1802
|
-
if (!bearerToken) {
|
|
1803
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1804
|
-
}
|
|
1785
|
+
const bearerToken = await this.getAuthToken();
|
|
1805
1786
|
|
|
1806
1787
|
const config = {
|
|
1807
1788
|
method: 'get',
|
|
@@ -1912,12 +1893,9 @@ class MCPServer {
|
|
|
1912
1893
|
}
|
|
1913
1894
|
};
|
|
1914
1895
|
|
|
1915
|
-
const
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
} else {
|
|
1919
|
-
this.log('error', `Bearer Token environment variable not found: API_TOKEN`);
|
|
1920
|
-
}
|
|
1896
|
+
const envToken = process.env['API_TOKEN'] || process.env['APITOKEN'];
|
|
1897
|
+
let bearerToken = await this.getAuthToken();
|
|
1898
|
+
config.headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
1921
1899
|
|
|
1922
1900
|
// Add parameters based on request method
|
|
1923
1901
|
if (["GET", "DELETE"].includes(method)) {
|
|
@@ -1937,9 +1915,21 @@ class MCPServer {
|
|
|
1937
1915
|
});
|
|
1938
1916
|
|
|
1939
1917
|
// Execute the request
|
|
1940
|
-
|
|
1918
|
+
let response = await axios(config);
|
|
1941
1919
|
this.log('debug', `Response status: ${response.status}`);
|
|
1942
1920
|
|
|
1921
|
+
// Reactive token recovery: a 401 means the bearer was rejected even
|
|
1922
|
+
// though our local expiry said it was fine (e.g. revoked, account
|
|
1923
|
+
// changed, clock skew). Force a re-auth via refresh-or-login and retry
|
|
1924
|
+
// once. Skipped for the env-token path — nothing to refresh.
|
|
1925
|
+
if (response.status === 401 && !envToken) {
|
|
1926
|
+
this.log('info', 'API returned 401; reauthenticating and retrying once');
|
|
1927
|
+
bearerToken = await this.oauth.reauthenticate();
|
|
1928
|
+
config.headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
1929
|
+
response = await axios(config);
|
|
1930
|
+
this.log('debug', `Retry response status: ${response.status}`);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1943
1933
|
return response.data;
|
|
1944
1934
|
|
|
1945
1935
|
} catch (error) {
|
|
@@ -2076,7 +2066,7 @@ class MCPServer {
|
|
|
2076
2066
|
export async function startServer({ name, version, tools }) {
|
|
2077
2067
|
const argv = minimist(process.argv.slice(2), {
|
|
2078
2068
|
string: ['host'],
|
|
2079
|
-
boolean: ['stdio', 'http', 'help'],
|
|
2069
|
+
boolean: ['stdio', 'http', 'help', 'login', 'logout'],
|
|
2080
2070
|
default: { host: '127.0.0.1', port: 8100, stdio: true }
|
|
2081
2071
|
});
|
|
2082
2072
|
|
|
@@ -2085,18 +2075,44 @@ export async function startServer({ name, version, tools }) {
|
|
|
2085
2075
|
${name}
|
|
2086
2076
|
Usage: ${name} [options]
|
|
2087
2077
|
Options:
|
|
2088
|
-
--http Use HTTP streaming transport
|
|
2078
|
+
--http Use HTTP streaming transport (requires API_TOKEN env var)
|
|
2089
2079
|
--stdio Use standard input/output transport (default)
|
|
2090
2080
|
--host <host> Host to bind to (default: 127.0.0.1)
|
|
2091
2081
|
--port <port> Port to bind to (default: 8100)
|
|
2082
|
+
--login Run OAuth sign-in flow and exit
|
|
2083
|
+
--logout Revoke stored OAuth credentials and exit
|
|
2092
2084
|
--help Show this help message
|
|
2093
2085
|
Environment Variables:
|
|
2094
|
-
API_TOKEN
|
|
2086
|
+
API_TOKEN Hostinger API token (overrides OAuth when set)
|
|
2087
|
+
OAUTH_ISSUER OAuth server base URL (default: https://auth.hostinger.com)
|
|
2095
2088
|
DEBUG Enable debug logging (true/false)
|
|
2096
2089
|
`);
|
|
2097
2090
|
process.exit(0);
|
|
2098
2091
|
}
|
|
2099
2092
|
|
|
2093
|
+
if (argv.login) {
|
|
2094
|
+
const provider = new OAuthProvider();
|
|
2095
|
+
console.error('[OAuth] Starting sign-in flow...');
|
|
2096
|
+
await provider.login();
|
|
2097
|
+
console.error('[OAuth] Sign-in successful. Credentials stored.');
|
|
2098
|
+
process.exit(0);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
if (argv.logout) {
|
|
2102
|
+
const provider = new OAuthProvider();
|
|
2103
|
+
await provider.logout();
|
|
2104
|
+
console.error('[OAuth] Signed out. Stored credentials revoked and cleared.');
|
|
2105
|
+
process.exit(0);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
if (argv.http) {
|
|
2109
|
+
const envToken = process.env['API_TOKEN'] || process.env['APITOKEN'];
|
|
2110
|
+
if (!envToken) {
|
|
2111
|
+
console.error('[Error] HTTP transport requires the API_TOKEN environment variable. OAuth sign-in is only supported in stdio mode.');
|
|
2112
|
+
process.exit(1);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2100
2116
|
const server = new MCPServer({ name, version, tools });
|
|
2101
2117
|
if (argv.http) {
|
|
2102
2118
|
await server.startHttp(argv.host, argv.port);
|