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.
@@ -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
+ '&': '&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
+ 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
+ }
@@ -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
- headers['User-Agent'] = `hostinger-mcp-server/${this.version}`;
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 bearerToken = process.env['API_TOKEN'] || process.env['APITOKEN']; // APITOKEN for backwards compatibility
1916
- if (bearerToken) {
1917
- config.headers['Authorization'] = `Bearer ${bearerToken}`;
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
- const response = await axios(config);
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 Your Hostinger API token (required)
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);