hostinger-api-mcp 3.20.0 → 3.20.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ Generated at release time from the diff between the previous and current
4
4
  build. Tool changes are listed individually; everything else is summarised
5
5
  by area.
6
6
 
7
+ ## v3.20.3 — 2026-10-28
8
+
9
+ ### Operations
10
+
11
+ - Changed the input schema of `hosting_deploy`
12
+ - Changed the input schema of `hosting_deploy-wordpress-plugin`
13
+ - Changed the input schema of `hosting_deploy-wordpress-theme`
14
+
15
+ ### Also in this release
16
+
17
+ - Request handling and authentication: `src/core/runtime.js`, `src/core/runtime.ts`
18
+ - Build and packaging: `types.d.ts`
19
+
20
+ ## v3.20.2 — 2026-10-28
21
+
22
+ ### Also in this release
23
+
24
+ - OAuth sign-in flow: `src/core/oauth.js`, `src/core/oauth.ts`
25
+
26
+ ## v3.20.1 — 2026-10-28
27
+
28
+ ### Also in this release
29
+
30
+ - Request handling and authentication: `src/core/runtime.js`, `src/core/runtime.ts`
31
+
7
32
  ## v3.20.0 — 2026-10-28
8
33
 
9
34
  ### Operations
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.20.0",
3
+ "version": "3.20.3",
4
4
  "description": "Hostinger API — manage domains, DNS, hosting, VPS, billing and more from Gemini CLI.",
5
5
  "mcpServers": {
6
6
  "hostinger": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.20.0",
3
+ "version": "3.20.3",
4
4
  "mcpName": "io.github.hostinger/hostinger-api-mcp",
5
5
  "description": "MCP server for Hostinger API",
6
6
  "repository": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/hostinger/api-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.20.0",
9
+ "version": "3.20.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.20.0",
14
+ "version": "3.20.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
package/src/core/oauth.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "crypto";
2
2
  import { createServer } from "http";
3
3
  import { readFile, writeFile, mkdir } from "fs/promises";
4
4
  import { existsSync, readFileSync } from "fs";
5
- import { exec } from "child_process";
5
+ import { execFile } from "child_process";
6
6
  import { fileURLToPath } from "url";
7
7
  import path from "path";
8
8
  import os from "os";
@@ -55,13 +55,41 @@ export function getEnvToken() {
55
55
  );
56
56
  }
57
57
 
58
+ /**
59
+ * The issuer must be a bare origin: https, or http only on loopback. It ends up
60
+ * in token requests and in the browser launch, so a path, query or userinfo is
61
+ * refused instead of carried along.
62
+ */
63
+ function parseIssuer(raw) {
64
+ let u;
65
+ try {
66
+ u = new URL(raw);
67
+ } catch {
68
+ throw new Error(`Invalid OAUTH_ISSUER: ${raw}`);
69
+ }
70
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname);
71
+ const schemeOk =
72
+ u.protocol === "https:" || (u.protocol === "http:" && loopback);
73
+ if (
74
+ !schemeOk ||
75
+ u.username ||
76
+ u.password ||
77
+ u.pathname !== "/" ||
78
+ u.search ||
79
+ u.hash
80
+ ) {
81
+ throw new Error(
82
+ `Invalid OAUTH_ISSUER: ${raw} (expected an https origin such as ${DEFAULT_ISSUER})`,
83
+ );
84
+ }
85
+ return u.origin;
86
+ }
87
+
58
88
  export class OAuthProvider {
59
89
  constructor(issuerBaseUrl) {
60
- this.issuer = (
61
- issuerBaseUrl ||
62
- process.env["OAUTH_ISSUER"] ||
63
- DEFAULT_ISSUER
64
- ).replace(/\/$/, "");
90
+ this.issuer = parseIssuer(
91
+ issuerBaseUrl || process.env["OAUTH_ISSUER"] || DEFAULT_ISSUER,
92
+ );
65
93
  this._loginInProgress = null;
66
94
  }
67
95
 
@@ -387,15 +415,14 @@ export class OAuthProvider {
387
415
  process.stderr.write(
388
416
  "[OAuth] If the browser does not open, copy the URL above into one manually.\n\n",
389
417
  );
390
- let cmd;
391
- if (process.platform === "darwin") {
392
- cmd = `open "${url}"`;
393
- } else if (process.platform === "win32") {
394
- cmd = `start "" "${url}"`;
395
- } else {
396
- cmd = `xdg-open "${url}"`;
397
- }
398
- exec(cmd, (err) => {
418
+ // No shell: the URL is a single argv entry, never parsed by sh or cmd.
419
+ const [cmd, args] =
420
+ process.platform === "darwin"
421
+ ? ["open", [url]]
422
+ : process.platform === "win32"
423
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
424
+ : ["xdg-open", [url]];
425
+ execFile(cmd, args, (err) => {
399
426
  if (err) {
400
427
  process.stderr.write(
401
428
  `[OAuth] Could not auto-launch browser: ${err.message}\n`,
package/src/core/oauth.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'crypto';
2
2
  import { createServer, Server } from 'http';
3
3
  import { readFile, writeFile, mkdir } from 'fs/promises';
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { exec } from 'child_process';
5
+ import { execFile } from 'child_process';
6
6
  import { fileURLToPath } from 'url';
7
7
  import path from 'path';
8
8
  import os from 'os';
@@ -68,12 +68,32 @@ export function getEnvToken(): string | undefined {
68
68
  || process.env['APITOKEN'];
69
69
  }
70
70
 
71
+ /**
72
+ * The issuer must be a bare origin: https, or http only on loopback. It ends up
73
+ * in token requests and in the browser launch, so a path, query or userinfo is
74
+ * refused instead of carried along.
75
+ */
76
+ function parseIssuer(raw: string): string {
77
+ let u: URL;
78
+ try {
79
+ u = new URL(raw);
80
+ } catch {
81
+ throw new Error(`Invalid OAUTH_ISSUER: ${raw}`);
82
+ }
83
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
84
+ const schemeOk = u.protocol === 'https:' || (u.protocol === 'http:' && loopback);
85
+ if (!schemeOk || u.username || u.password || u.pathname !== '/' || u.search || u.hash) {
86
+ throw new Error(`Invalid OAUTH_ISSUER: ${raw} (expected an https origin such as ${DEFAULT_ISSUER})`);
87
+ }
88
+ return u.origin;
89
+ }
90
+
71
91
  export class OAuthProvider {
72
92
  private readonly issuer: string;
73
93
  private _loginInProgress: Promise<string> | null = null;
74
94
 
75
95
  constructor(issuerBaseUrl?: string) {
76
- this.issuer = (issuerBaseUrl || process.env['OAUTH_ISSUER'] || DEFAULT_ISSUER).replace(/\/$/, '');
96
+ this.issuer = parseIssuer(issuerBaseUrl || process.env['OAUTH_ISSUER'] || DEFAULT_ISSUER);
77
97
  }
78
98
 
79
99
  async getAccessToken(): Promise<string> {
@@ -357,15 +377,13 @@ export class OAuthProvider {
357
377
  private _openBrowser(url: string): void {
358
378
  process.stderr.write(`\n[OAuth] Opening browser for sign-in:\n ${url}\n`);
359
379
  process.stderr.write('[OAuth] If the browser does not open, copy the URL above into one manually.\n\n');
360
- let cmd: string;
361
- if (process.platform === 'darwin') {
362
- cmd = `open "${url}"`;
363
- } else if (process.platform === 'win32') {
364
- cmd = `start "" "${url}"`;
365
- } else {
366
- cmd = `xdg-open "${url}"`;
367
- }
368
- exec(cmd, (err) => {
380
+ // No shell: the URL is a single argv entry, never parsed by sh or cmd.
381
+ const [cmd, args]: [string, string[]] = process.platform === 'darwin'
382
+ ? ['open', [url]]
383
+ : process.platform === 'win32'
384
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
385
+ : ['xdg-open', [url]];
386
+ execFile(cmd, args, (err) => {
369
387
  if (err) {
370
388
  process.stderr.write(`[OAuth] Could not auto-launch browser: ${err.message}\n`);
371
389
  }
@@ -66,7 +66,7 @@ const GetSkillRequestSchema = z.object({
66
66
 
67
67
  /**
68
68
  * MCP Server for Hostinger API
69
- * Generated from OpenAPI spec version 2.5.0
69
+ * Generated from OpenAPI spec version 2.5.1
70
70
  */
71
71
  class MCPServer {
72
72
  constructor({ name, version, tools, instructions, skills = [] }) {
@@ -348,6 +348,93 @@ class MCPServer {
348
348
  }
349
349
  }
350
350
 
351
+ // Directories that hold credentials, the server's own token store (hostinger-mcp) among them.
352
+ credentialDirectories = ['.ssh', '.aws', '.gnupg', '.config', '.kube', '.docker', '.azure', 'hostinger-mcp'];
353
+
354
+ // System directories. /var as a whole stays deployable: sites live in /var/www, and macOS keeps its temp dir there.
355
+ systemDirectories = ['/etc', '/root', '/usr', '/bin', '/sbin', '/boot', '/proc', '/sys', '/dev', '/var/lib', '/var/log', '/var/db', '/var/root', '/private/etc', '/private/var/lib', '/private/var/log', '/private/var/db', '/private/var/root', '/System', '/Library'];
356
+
357
+ // A deploy publishes what it reads, and the path comes from the model, so prompt-injected arguments could
358
+ // publish any readable file. Refused, before anything is read or sent: the home directory and every directory
359
+ // above it (they contain the credentials), other users' home directories, system directories, a path in a
360
+ // credential directory, and a secret file. Symlinks are resolved first.
361
+ // os is imported by deploy.template: the JS generator dedupes import blocks by their whole text, and this block
362
+ // must stay identical to agency-hosting/shared's, or tus and fs are imported twice.
363
+ assertDeployablePath(localPath) {
364
+ let real;
365
+ try {
366
+ real = fs.realpathSync(localPath);
367
+ } catch (error) {
368
+ throw new Error('Path not found: ' + localPath);
369
+ }
370
+ let home = os.homedir();
371
+ try {
372
+ home = fs.realpathSync(home);
373
+ } catch (error) {
374
+ // a missing home directory has nothing in it to protect
375
+ }
376
+ const inside = (dir, file) => {
377
+ const relative = path.relative(dir, file);
378
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
379
+ };
380
+ // A home in the filesystem root (HOME=/ in a container run as a user without a passwd entry) exempts nothing.
381
+ const inHome = path.dirname(home) !== home && inside(home, real);
382
+ // The directory that holds the home directories (/Users, /home); not when home sits in the root (/root).
383
+ const homes = path.dirname(home);
384
+ const otherHome = !inHome && path.dirname(homes) !== homes && inside(homes, real);
385
+ const inSystem = !inHome && this.systemDirectories.some((dir) => inside(dir, real));
386
+ const inCredentials = real.split(path.sep).some((segment) => this.credentialDirectories.includes(segment.toLowerCase()));
387
+ if (inside(real, home) || otherHome || inSystem || inCredentials || (fs.statSync(real).isFile() && this.isSecretFile(path.basename(real)))) {
388
+ throw new Error('Refusing to deploy ' + localPath + ': it is, or contains, a home, system or credentials location. Pass the project directory or archive.');
389
+ }
390
+ }
391
+
392
+ // Env files (examples such as .env.example are kept), credentials and private keys: a deploy would serve them
393
+ // publicly. Matches the CLI's isSecretFile, plus .npmrc and the Hostinger credential files.
394
+ isSecretFile(name) {
395
+ if (['.env', '.envrc', '.netrc', '.git-credentials', '.pgpass', '.npmrc', '.hostinger.yaml', 'credentials.json'].includes(name)) {
396
+ return true;
397
+ }
398
+ if (['id_rsa', 'id_dsa', 'id_ecdsa', 'id_ed25519'].some((key) => name.startsWith(key))) {
399
+ return true;
400
+ }
401
+ if (name.endsWith('.pem') || name.endsWith('.key')) {
402
+ return true;
403
+ }
404
+ return name.startsWith('.env.') && !['.env.example', '.env.sample', '.env.template', '.env.dist'].includes(name);
405
+ }
406
+
407
+ // The files to ship, as sorted forward-slash paths relative to dir, and the secret files left out.
408
+ // node_modules, .git and OS junk are skipped; symlinks are not followed. .gitignore is not read:
409
+ // keep build output out of the directory.
410
+ // exclude is a file that is uploaded on its own (the database dump) and must not also travel inside
411
+ // the archive, where extracting it would leave a second, downloadable copy.
412
+ listDeployFiles(dir, exclude) {
413
+ const files = [];
414
+ const skipped = [];
415
+ const walk = (relativeDir) => {
416
+ for (const entry of fs.readdirSync(path.join(dir, relativeDir), { withFileTypes: true })) {
417
+ if (entry.isSymbolicLink()) {
418
+ continue;
419
+ }
420
+ const relativePath = relativeDir ? relativeDir + '/' + entry.name : entry.name;
421
+ if (entry.isDirectory()) {
422
+ if (entry.name !== 'node_modules' && entry.name !== '.git') {
423
+ walk(relativePath);
424
+ }
425
+ } else if (entry.isFile() && entry.name !== '.DS_Store' && path.resolve(dir, relativePath) !== exclude) {
426
+ if (this.isSecretFile(entry.name)) {
427
+ skipped.push(relativePath);
428
+ } else {
429
+ files.push(relativePath);
430
+ }
431
+ }
432
+ }
433
+ };
434
+ walk('');
435
+ return { files: files.sort(), skipped: skipped.sort() };
436
+ }
437
+
351
438
  async resolveUsername(domain) {
352
439
  const baseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`;
353
440
  const url = new URL(`api/hosting/v1/websites?domain=${encodeURIComponent(domain)}`, baseUrl).toString();
@@ -680,43 +767,6 @@ class MCPServer {
680
767
  return slug;
681
768
  }
682
769
 
683
- // .env and .env.* hold secrets, and one in a document root can be fetched over HTTP; the platform
684
- // takes variables from its own settings, so they are never packed. Examples are kept.
685
- hosting_deploy_isEnvFile(name) {
686
- return name === '.env' || (name.startsWith('.env.') && !['.env.example', '.env.sample', '.env.template', '.env.dist'].includes(name));
687
- }
688
-
689
- // The files to ship, as sorted forward-slash paths relative to dir, and the .env files left out.
690
- // node_modules, .git and OS junk are skipped; symlinks are not followed. .gitignore is not read:
691
- // keep build output out of the directory.
692
- // exclude is a file that is uploaded on its own (the database dump) and must not also travel inside
693
- // the archive, where extracting it would leave a second, downloadable copy.
694
- hosting_deploy_listFiles(dir, exclude) {
695
- const files = [];
696
- const skipped = [];
697
- const walk = (relativeDir) => {
698
- for (const entry of fs.readdirSync(path.join(dir, relativeDir), { withFileTypes: true })) {
699
- if (entry.isSymbolicLink()) {
700
- continue;
701
- }
702
- const relativePath = relativeDir ? relativeDir + '/' + entry.name : entry.name;
703
- if (entry.isDirectory()) {
704
- if (entry.name !== 'node_modules' && entry.name !== '.git') {
705
- walk(relativePath);
706
- }
707
- } else if (entry.isFile() && entry.name !== '.DS_Store' && path.resolve(dir, relativePath) !== exclude) {
708
- if (this.hosting_deploy_isEnvFile(entry.name)) {
709
- skipped.push(relativePath);
710
- } else {
711
- files.push(relativePath);
712
- }
713
- }
714
- }
715
- };
716
- walk('');
717
- return { files: files.sort(), skipped: skipped.sort() };
718
- }
719
-
720
770
  // Adds what was left out to a result, so it is never silent.
721
771
  hosting_deploy_withSkipped(result, skipped) {
722
772
  if (skipped.length === 0) {
@@ -725,7 +775,7 @@ class MCPServer {
725
775
  return {
726
776
  ...result,
727
777
  skippedFiles: skipped,
728
- skippedNote: 'These .env files were not uploaded. Set their values with hosting_nodejs_replace-environment-variables.',
778
+ skippedNote: 'These files hold secrets (env files, keys, credentials) and were not uploaded. Set environment values with hosting_nodejs_replace-environment-variables.',
729
779
  };
730
780
  }
731
781
 
@@ -926,7 +976,7 @@ class MCPServer {
926
976
  return { archivePath: resolved, skipped: [] };
927
977
  }
928
978
 
929
- const { files, skipped } = this.hosting_deploy_listFiles(resolved, exclude);
979
+ const { files, skipped } = this.listDeployFiles(resolved, exclude);
930
980
  if (files.length === 0) {
931
981
  throw new Error('No files to deploy in ' + resolved);
932
982
  }
@@ -977,7 +1027,7 @@ class MCPServer {
977
1027
  const isPlugin = type === 'wordpress-plugin';
978
1028
  const directory = path.resolve(params.path);
979
1029
  const slug = params.slug !== undefined ? params.slug : this.hosting_deploy_slug(directory);
980
- const { files, skipped } = this.hosting_deploy_listFiles(directory);
1030
+ const { files, skipped } = this.listDeployFiles(directory);
981
1031
  if (files.length === 0) {
982
1032
  throw new Error('No files found in directory: ' + directory);
983
1033
  }
@@ -1236,6 +1286,10 @@ class MCPServer {
1236
1286
 
1237
1287
  if (git === undefined) {
1238
1288
  // Local checks first, so a bad path fails before any request is made.
1289
+ this.assertDeployablePath(params.path);
1290
+ if (params.databaseDump !== undefined) {
1291
+ this.assertDeployablePath(params.databaseDump);
1292
+ }
1239
1293
  const type = this.hosting_deploy_resolveType(params);
1240
1294
  const website = await this.findWebsite(domain);
1241
1295
  return this.hosting_deploy_fromPath(website.username, website.domain, type, params);
@@ -1512,6 +1566,7 @@ class MCPServer {
1512
1566
  }
1513
1567
 
1514
1568
  hosting_importWordpressWebsite_validateArchiveFile(archivePath) {
1569
+ this.assertDeployablePath(archivePath);
1515
1570
  if (!fs.existsSync(archivePath)) {
1516
1571
  throw new Error(`Archive file not found: ${archivePath}`);
1517
1572
  }
@@ -1527,6 +1582,7 @@ class MCPServer {
1527
1582
  }
1528
1583
 
1529
1584
  hosting_importWordpressWebsite_validateDatabaseFile(databaseDump) {
1585
+ this.assertDeployablePath(databaseDump);
1530
1586
  if (!fs.existsSync(databaseDump)) {
1531
1587
  throw new Error(`Database dump file not found: ${databaseDump}`);
1532
1588
  }
@@ -1782,6 +1838,7 @@ class MCPServer {
1782
1838
  }
1783
1839
 
1784
1840
  hosting_deployWordpressPlugin_validatePluginDirectory(pluginPath) {
1841
+ this.assertDeployablePath(pluginPath);
1785
1842
  if (!fs.existsSync(pluginPath)) {
1786
1843
  throw new Error(`Plugin directory not found: ${pluginPath}`);
1787
1844
  }
@@ -1792,32 +1849,6 @@ class MCPServer {
1792
1849
  }
1793
1850
  }
1794
1851
 
1795
- hosting_deployWordpressPlugin_scanDirectory(dirPath, basePath = dirPath) {
1796
- const files = [];
1797
-
1798
- const scanDir = (currentPath) => {
1799
- const items = fs.readdirSync(currentPath);
1800
-
1801
- for (const item of items) {
1802
- const itemPath = path.join(currentPath, item);
1803
- const stats = fs.statSync(itemPath);
1804
-
1805
- if (stats.isDirectory()) {
1806
- scanDir(itemPath);
1807
- } else if (stats.isFile()) {
1808
- const relativePath = path.relative(basePath, itemPath);
1809
- files.push({
1810
- absolutePath: itemPath,
1811
- relativePath: relativePath
1812
- });
1813
- }
1814
- }
1815
- };
1816
-
1817
- scanDir(dirPath);
1818
- return files;
1819
- }
1820
-
1821
1852
  async hosting_deployWordpressPlugin_deployPlugin(username, domain, slug, pluginPath) {
1822
1853
  const baseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`;
1823
1854
  const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/plugins/deploy`, baseUrl).toString();
@@ -1882,7 +1913,8 @@ class MCPServer {
1882
1913
  const uploadDirName = `${slug}-${randomSuffix}`;
1883
1914
 
1884
1915
  this.log('info', `Scanning plugin directory: ${pluginPath}`);
1885
- const pluginFiles = this.hosting_deployWordpressPlugin_scanDirectory(pluginPath);
1916
+ const { files, skipped } = this.listDeployFiles(pluginPath);
1917
+ const pluginFiles = files.map((relativePath) => ({ absolutePath: path.join(pluginPath, relativePath), relativePath }));
1886
1918
 
1887
1919
  if (pluginFiles.length === 0) {
1888
1920
  throw new Error(`No files found in plugin directory: ${pluginPath}`);
@@ -1970,6 +2002,7 @@ class MCPServer {
1970
2002
  failed: failureCount
1971
2003
  },
1972
2004
  results,
2005
+ ...(skipped.length > 0 ? { skippedFiles: skipped } : {}),
1973
2006
  deploymentError: errorMessage
1974
2007
  };
1975
2008
  }
@@ -1983,6 +2016,7 @@ class MCPServer {
1983
2016
  failed: failureCount
1984
2017
  },
1985
2018
  results,
2019
+ ...(skipped.length > 0 ? { skippedFiles: skipped } : {}),
1986
2020
  uploadDirName
1987
2021
  };
1988
2022
  }
@@ -2013,6 +2047,7 @@ class MCPServer {
2013
2047
  }
2014
2048
 
2015
2049
  hosting_deployWordpressTheme_validateThemeDirectory(themePath) {
2050
+ this.assertDeployablePath(themePath);
2016
2051
  if (!fs.existsSync(themePath)) {
2017
2052
  throw new Error(`Theme directory not found: ${themePath}`);
2018
2053
  }
@@ -2023,32 +2058,6 @@ class MCPServer {
2023
2058
  }
2024
2059
  }
2025
2060
 
2026
- hosting_deployWordpressTheme_scanDirectory(dirPath, basePath = dirPath) {
2027
- const files = [];
2028
-
2029
- const scanDir = (currentPath) => {
2030
- const items = fs.readdirSync(currentPath);
2031
-
2032
- for (const item of items) {
2033
- const itemPath = path.join(currentPath, item);
2034
- const stats = fs.statSync(itemPath);
2035
-
2036
- if (stats.isDirectory()) {
2037
- scanDir(itemPath);
2038
- } else if (stats.isFile()) {
2039
- const relativePath = path.relative(basePath, itemPath);
2040
- files.push({
2041
- absolutePath: itemPath,
2042
- relativePath: relativePath
2043
- });
2044
- }
2045
- }
2046
- };
2047
-
2048
- scanDir(dirPath);
2049
- return files;
2050
- }
2051
-
2052
2061
  async hosting_deployWordpressTheme_deployTheme(username, domain, slug, themePath, activate = false) {
2053
2062
  const baseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`;
2054
2063
  const url = new URL(`api/hosting/v1/accounts/${username}/websites/${domain}/wordpress/themes/deploy`, baseUrl).toString();
@@ -2114,7 +2123,8 @@ class MCPServer {
2114
2123
  const uploadDirName = `${slug}-${randomSuffix}`;
2115
2124
 
2116
2125
  this.log('info', `Scanning theme directory: ${themePath}`);
2117
- const themeFiles = this.hosting_deployWordpressTheme_scanDirectory(themePath);
2126
+ const { files, skipped } = this.listDeployFiles(themePath);
2127
+ const themeFiles = files.map((relativePath) => ({ absolutePath: path.join(themePath, relativePath), relativePath }));
2118
2128
 
2119
2129
  if (themeFiles.length === 0) {
2120
2130
  throw new Error(`No files found in theme directory: ${themePath}`);
@@ -2202,6 +2212,7 @@ class MCPServer {
2202
2212
  failed: failureCount
2203
2213
  },
2204
2214
  results,
2215
+ ...(skipped.length > 0 ? { skippedFiles: skipped } : {}),
2205
2216
  deploymentError: errorMessage
2206
2217
  };
2207
2218
  }
@@ -2215,6 +2226,7 @@ class MCPServer {
2215
2226
  failed: failureCount
2216
2227
  },
2217
2228
  results,
2229
+ ...(skipped.length > 0 ? { skippedFiles: skipped } : {}),
2218
2230
  uploadDirName,
2219
2231
  activated: activate
2220
2232
  };
@@ -2268,6 +2280,7 @@ class MCPServer {
2268
2280
  }
2269
2281
 
2270
2282
  hosting_deployJsApplication_validateArchiveFile(archivePath) {
2283
+ this.assertDeployablePath(archivePath);
2271
2284
  if (!fs.existsSync(archivePath)) {
2272
2285
  throw new Error(`Archive file not found: ${archivePath}`);
2273
2286
  }
@@ -2563,6 +2576,7 @@ class MCPServer {
2563
2576
  }
2564
2577
 
2565
2578
  hosting_deployStaticWebsite_validateArchiveFile(archivePath) {
2579
+ this.assertDeployablePath(archivePath);
2566
2580
  if (!fs.existsSync(archivePath)) {
2567
2581
  throw new Error(`Archive file not found: ${archivePath}`);
2568
2582
  }
@@ -3014,6 +3028,7 @@ class MCPServer {
3014
3028
  }
3015
3029
 
3016
3030
  agencyHosting_validateArchiveFile(archivePath) {
3031
+ this.assertDeployablePath(archivePath);
3017
3032
  if (!fs.existsSync(archivePath)) {
3018
3033
  throw new Error(`Archive file not found: ${archivePath}`);
3019
3034
  }
@@ -3022,6 +3037,12 @@ class MCPServer {
3022
3037
  if (!archiveStats.isFile()) {
3023
3038
  throw new Error(`Archive path is not a file: ${archivePath}`);
3024
3039
  }
3040
+
3041
+ // Only an archive is uploaded (and, with removeArchive, deleted afterwards), never any other file.
3042
+ const name = path.basename(archivePath).toLowerCase();
3043
+ if (!['.zip', '.tar', '.tar.gz', '.tgz', '.7z', '.gz', '.gzip'].some((extension) => name.endsWith(extension))) {
3044
+ throw new Error('Invalid archive format. Supported formats: zip, tar, tar.gz, tgz, 7z, gz, gzip');
3045
+ }
3025
3046
  }
3026
3047
 
3027
3048
  agencyHosting_removeArchive(archivePath, removeArchive) {