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.
@@ -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();
@@ -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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 = process.env['API_TOKEN'] || process.env['APITOKEN'];
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 bearerToken = process.env['API_TOKEN'] || process.env['APITOKEN']; // APITOKEN for backwards compatibility
1920
- if (bearerToken) {
1921
- config.headers['Authorization'] = `Bearer ${bearerToken}`;
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
- const response = await axios(config);
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 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)
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);