hostinger-api-mcp 0.1.43 → 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 +66 -54
- package/src/core/runtime.ts +70 -52
- 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();
|
|
@@ -83,6 +85,14 @@ class MCPServer {
|
|
|
83
85
|
return headers;
|
|
84
86
|
}
|
|
85
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
|
+
|
|
86
96
|
/**
|
|
87
97
|
* Initialize tools map from OpenAPI spec
|
|
88
98
|
* This runs before the server is connected, so don't log here
|
|
@@ -204,10 +214,7 @@ class MCPServer {
|
|
|
204
214
|
const url = new URL(`api/hosting/v1/websites?domain=${encodeURIComponent(domain)}`, baseUrl).toString();
|
|
205
215
|
|
|
206
216
|
try {
|
|
207
|
-
const bearerToken =
|
|
208
|
-
if (!bearerToken) {
|
|
209
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
210
|
-
}
|
|
217
|
+
const bearerToken = await this.getAuthToken();
|
|
211
218
|
|
|
212
219
|
const config = {
|
|
213
220
|
method: 'get',
|
|
@@ -263,10 +270,7 @@ class MCPServer {
|
|
|
263
270
|
const url = new URL('api/hosting/v1/files/upload-urls', baseUrl).toString();
|
|
264
271
|
|
|
265
272
|
try {
|
|
266
|
-
const bearerToken =
|
|
267
|
-
if (!bearerToken) {
|
|
268
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
269
|
-
}
|
|
273
|
+
const bearerToken = await this.getAuthToken();
|
|
270
274
|
|
|
271
275
|
const config = {
|
|
272
276
|
method: 'post',
|
|
@@ -460,10 +464,7 @@ class MCPServer {
|
|
|
460
464
|
const url = new URL(`api/hosting/v1/accounts/${username}/domains/${domain}/is-empty`, baseUrl).toString();
|
|
461
465
|
|
|
462
466
|
try {
|
|
463
|
-
const bearerToken =
|
|
464
|
-
if (!bearerToken) {
|
|
465
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
466
|
-
}
|
|
467
|
+
const bearerToken = await this.getAuthToken();
|
|
467
468
|
|
|
468
469
|
const config = {
|
|
469
470
|
method: 'get',
|
|
@@ -515,10 +516,7 @@ class MCPServer {
|
|
|
515
516
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/import`, baseUrl).toString();
|
|
516
517
|
|
|
517
518
|
try {
|
|
518
|
-
const bearerToken =
|
|
519
|
-
if (!bearerToken) {
|
|
520
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
521
|
-
}
|
|
519
|
+
const bearerToken = await this.getAuthToken();
|
|
522
520
|
|
|
523
521
|
const config = {
|
|
524
522
|
method: 'post',
|
|
@@ -743,10 +741,7 @@ class MCPServer {
|
|
|
743
741
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/plugins/deploy`, baseUrl).toString();
|
|
744
742
|
|
|
745
743
|
try {
|
|
746
|
-
const bearerToken =
|
|
747
|
-
if (!bearerToken) {
|
|
748
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
749
|
-
}
|
|
744
|
+
const bearerToken = await this.getAuthToken();
|
|
750
745
|
|
|
751
746
|
const config = {
|
|
752
747
|
method: 'post',
|
|
@@ -977,10 +972,7 @@ class MCPServer {
|
|
|
977
972
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/themes/deploy`, baseUrl).toString();
|
|
978
973
|
|
|
979
974
|
try {
|
|
980
|
-
const bearerToken =
|
|
981
|
-
if (!bearerToken) {
|
|
982
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
983
|
-
}
|
|
975
|
+
const bearerToken = await this.getAuthToken();
|
|
984
976
|
|
|
985
977
|
const config = {
|
|
986
978
|
method: 'post',
|
|
@@ -1214,10 +1206,7 @@ class MCPServer {
|
|
|
1214
1206
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/nodejs/builds/settings/from-archive?archive_path=${encodeURIComponent(archiveBasename)}`, baseUrl).toString();
|
|
1215
1207
|
|
|
1216
1208
|
try {
|
|
1217
|
-
const bearerToken =
|
|
1218
|
-
if (!bearerToken) {
|
|
1219
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1220
|
-
}
|
|
1209
|
+
const bearerToken = await this.getAuthToken();
|
|
1221
1210
|
|
|
1222
1211
|
const config = {
|
|
1223
1212
|
method: 'get',
|
|
@@ -1263,10 +1252,7 @@ class MCPServer {
|
|
|
1263
1252
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/nodejs/builds`, baseUrl).toString();
|
|
1264
1253
|
|
|
1265
1254
|
try {
|
|
1266
|
-
const bearerToken =
|
|
1267
|
-
if (!bearerToken) {
|
|
1268
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1269
|
-
}
|
|
1255
|
+
const bearerToken = await this.getAuthToken();
|
|
1270
1256
|
|
|
1271
1257
|
const archiveBasename = path.basename(archivePath);
|
|
1272
1258
|
const buildData = {
|
|
@@ -1514,10 +1500,7 @@ class MCPServer {
|
|
|
1514
1500
|
const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/deploy`, baseUrl).toString();
|
|
1515
1501
|
|
|
1516
1502
|
try {
|
|
1517
|
-
const bearerToken =
|
|
1518
|
-
if (!bearerToken) {
|
|
1519
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1520
|
-
}
|
|
1503
|
+
const bearerToken = await this.getAuthToken();
|
|
1521
1504
|
|
|
1522
1505
|
const archiveBasename = path.basename(archivePath);
|
|
1523
1506
|
const deployData = {
|
|
@@ -1689,10 +1672,7 @@ class MCPServer {
|
|
|
1689
1672
|
const fullUrl = queryParams ? `${url}?${queryParams}` : url;
|
|
1690
1673
|
|
|
1691
1674
|
try {
|
|
1692
|
-
const bearerToken =
|
|
1693
|
-
if (!bearerToken) {
|
|
1694
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1695
|
-
}
|
|
1675
|
+
const bearerToken = await this.getAuthToken();
|
|
1696
1676
|
|
|
1697
1677
|
const config = {
|
|
1698
1678
|
method: 'get',
|
|
@@ -1802,10 +1782,7 @@ class MCPServer {
|
|
|
1802
1782
|
const fullUrl = queryParams ? `${url}?${queryParams}` : url;
|
|
1803
1783
|
|
|
1804
1784
|
try {
|
|
1805
|
-
const bearerToken =
|
|
1806
|
-
if (!bearerToken) {
|
|
1807
|
-
throw new Error('API_TOKEN environment variable not found');
|
|
1808
|
-
}
|
|
1785
|
+
const bearerToken = await this.getAuthToken();
|
|
1809
1786
|
|
|
1810
1787
|
const config = {
|
|
1811
1788
|
method: 'get',
|
|
@@ -1916,12 +1893,9 @@ class MCPServer {
|
|
|
1916
1893
|
}
|
|
1917
1894
|
};
|
|
1918
1895
|
|
|
1919
|
-
const
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
} else {
|
|
1923
|
-
this.log('error', `Bearer Token environment variable not found: API_TOKEN`);
|
|
1924
|
-
}
|
|
1896
|
+
const envToken = process.env['API_TOKEN'] || process.env['APITOKEN'];
|
|
1897
|
+
let bearerToken = await this.getAuthToken();
|
|
1898
|
+
config.headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
1925
1899
|
|
|
1926
1900
|
// Add parameters based on request method
|
|
1927
1901
|
if (["GET", "DELETE"].includes(method)) {
|
|
@@ -1941,9 +1915,21 @@ class MCPServer {
|
|
|
1941
1915
|
});
|
|
1942
1916
|
|
|
1943
1917
|
// Execute the request
|
|
1944
|
-
|
|
1918
|
+
let response = await axios(config);
|
|
1945
1919
|
this.log('debug', `Response status: ${response.status}`);
|
|
1946
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
|
+
|
|
1947
1933
|
return response.data;
|
|
1948
1934
|
|
|
1949
1935
|
} catch (error) {
|
|
@@ -2080,7 +2066,7 @@ class MCPServer {
|
|
|
2080
2066
|
export async function startServer({ name, version, tools }) {
|
|
2081
2067
|
const argv = minimist(process.argv.slice(2), {
|
|
2082
2068
|
string: ['host'],
|
|
2083
|
-
boolean: ['stdio', 'http', 'help'],
|
|
2069
|
+
boolean: ['stdio', 'http', 'help', 'login', 'logout'],
|
|
2084
2070
|
default: { host: '127.0.0.1', port: 8100, stdio: true }
|
|
2085
2071
|
});
|
|
2086
2072
|
|
|
@@ -2089,18 +2075,44 @@ export async function startServer({ name, version, tools }) {
|
|
|
2089
2075
|
${name}
|
|
2090
2076
|
Usage: ${name} [options]
|
|
2091
2077
|
Options:
|
|
2092
|
-
--http Use HTTP streaming transport
|
|
2078
|
+
--http Use HTTP streaming transport (requires API_TOKEN env var)
|
|
2093
2079
|
--stdio Use standard input/output transport (default)
|
|
2094
2080
|
--host <host> Host to bind to (default: 127.0.0.1)
|
|
2095
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
|
|
2096
2084
|
--help Show this help message
|
|
2097
2085
|
Environment Variables:
|
|
2098
|
-
API_TOKEN
|
|
2086
|
+
API_TOKEN Hostinger API token (overrides OAuth when set)
|
|
2087
|
+
OAUTH_ISSUER OAuth server base URL (default: https://auth.hostinger.com)
|
|
2099
2088
|
DEBUG Enable debug logging (true/false)
|
|
2100
2089
|
`);
|
|
2101
2090
|
process.exit(0);
|
|
2102
2091
|
}
|
|
2103
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
|
+
|
|
2104
2116
|
const server = new MCPServer({ name, version, tools });
|
|
2105
2117
|
if (argv.http) {
|
|
2106
2118
|
await server.startHttp(argv.host, argv.port);
|