hostinger-api-mcp 3.11.0 → 3.12.0

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +101 -22
  3. package/gemini-extension.json +1 -1
  4. package/package.json +1 -1
  5. package/server.json +2 -2
  6. package/skills/deploy-to-hosting/SKILL.md +1 -1
  7. package/src/core/runtime.js +117 -28
  8. package/src/core/runtime.ts +117 -28
  9. package/src/core/skills.js +3 -3
  10. package/src/core/skills.ts +3 -3
  11. package/src/core/tools/all.js +253 -11
  12. package/src/core/tools/all.ts +253 -11
  13. package/src/core/tools/hosting.js +23 -9
  14. package/src/core/tools/hosting.ts +23 -9
  15. package/src/core/tools/reach.js +230 -2
  16. package/src/core/tools/reach.ts +230 -2
  17. package/src/servers/agency-hosting.js +1 -1
  18. package/src/servers/agency-hosting.ts +1 -1
  19. package/src/servers/agent-readiness.js +1 -1
  20. package/src/servers/agent-readiness.ts +1 -1
  21. package/src/servers/all.js +1 -1
  22. package/src/servers/all.ts +1 -1
  23. package/src/servers/billing.js +1 -1
  24. package/src/servers/billing.ts +1 -1
  25. package/src/servers/dns.js +1 -1
  26. package/src/servers/dns.ts +1 -1
  27. package/src/servers/domains.js +1 -1
  28. package/src/servers/domains.ts +1 -1
  29. package/src/servers/ecommerce.js +1 -1
  30. package/src/servers/ecommerce.ts +1 -1
  31. package/src/servers/horizons.js +1 -1
  32. package/src/servers/horizons.ts +1 -1
  33. package/src/servers/hosting.js +1 -1
  34. package/src/servers/hosting.ts +1 -1
  35. package/src/servers/mail.js +1 -1
  36. package/src/servers/mail.ts +1 -1
  37. package/src/servers/reach.js +1 -1
  38. package/src/servers/reach.ts +1 -1
  39. package/src/servers/vps.js +1 -1
  40. package/src/servers/vps.ts +1 -1
  41. package/src/servers/wordpress.js +1 -1
  42. package/src/servers/wordpress.ts +1 -1
  43. package/types.d.ts +159 -11
package/CHANGELOG.md CHANGED
@@ -4,6 +4,41 @@ 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.12.0 — 2026-10-27
8
+
9
+ ### Operations
10
+
11
+ - Added `reach_integrations_disconnect-ecommerce-store` — Disconnect Ecommerce store
12
+ - Added `reach_integrations_list-ecommerce-stores` — List Ecommerce stores
13
+ - Added `reach_templates_email` — Get email template
14
+ - Added `reach_integrations_list-connected-websites` — List connected websites
15
+ - Added `reach_integrations_connect-ai-website-builder-site` — Connect AI Website Builder site
16
+ - Added `reach_integrations_disconnect-ai-website-builder-site` — Disconnect AI Website Builder site
17
+ - Added `reach_integrations_disconnect-wordpress-site` — Disconnect WordPress site
18
+ - Changed the description of `reach_campaigns_get`
19
+ - Changed the description of `reach_templates_list-email`
20
+
21
+ ### Also in this release
22
+
23
+ - Documentation: `README.md`
24
+ - Request handling and authentication: `src/core/runtime.js`, `src/core/runtime.ts`
25
+ - Build and packaging: `types.d.ts`
26
+
27
+ ## v3.11.1 — 2026-10-27
28
+
29
+ ### Operations
30
+
31
+ - Hid 1 operation from search (it still runs through `execute`): `hosting_nodejs_build-settings-from-repository`
32
+ - Changed the description and input schema of `hosting_deploy`
33
+
34
+ ### Also in this release
35
+
36
+ - Documentation: `README.md`
37
+ - Bundled agent skills: `skills/deploy-to-hosting/SKILL.md`
38
+ - Request handling and authentication: `src/core/runtime.js`, `src/core/runtime.ts`
39
+ - Other files: `src/core/skills.js`, `src/core/skills.ts`
40
+ - Build and packaging: `types.d.ts`
41
+
7
42
  ## v3.11.0 — 2026-10-27
8
43
 
9
44
  ### Also in this release
package/README.md CHANGED
@@ -69,7 +69,7 @@ pnpm update -g @hostinger/mcp
69
69
 
70
70
  This package installs the following MCP server commands:
71
71
 
72
- - `hostinger-api-mcp` — unified server over every operation (410 total)
72
+ - `hostinger-api-mcp` — unified server over every operation (416 total)
73
73
  - `hostinger-agency-hosting-mcp` — 42 operations for agency-hosting
74
74
  - `hostinger-agent-readiness-mcp` — 3 operations for agent-readiness
75
75
  - `hostinger-billing-mcp` — 9 operations for billing
@@ -77,9 +77,9 @@ This package installs the following MCP server commands:
77
77
  - `hostinger-domains-mcp` — 42 operations for domains
78
78
  - `hostinger-ecommerce-mcp` — 29 operations for ecommerce
79
79
  - `hostinger-horizons-mcp` — 6 operations for horizons
80
- - `hostinger-hosting-mcp` — 67 operations for hosting
80
+ - `hostinger-hosting-mcp` — 66 operations for hosting
81
81
  - `hostinger-mail-mcp` — 38 operations for mail
82
- - `hostinger-reach-mcp` — 62 operations for reach
82
+ - `hostinger-reach-mcp` — 69 operations for reach
83
83
  - `hostinger-vps-mcp` — 67 operations for vps
84
84
  - `hostinger-wordpress-mcp` — 40 operations for wordpress
85
85
 
@@ -1816,7 +1816,7 @@ interface in the provided website URL.
1816
1816
 
1817
1817
  #### hosting_deploy
1818
1818
 
1819
- Deploy, publish or upload a website to Hostinger web hosting (Shared, Cloud): static HTML, PHP, a nodejs (Node.js) app, a whole WordPress site, or a WordPress plugin or theme, from a local directory, an archive or a GitHub or GitLab repository. It detects what the project is and does the upload, build and Git setup itself: do not call upload or build operations, and do not archive the directory yourself. WARNING: it overwrites the website's contents and cannot be undone; confirm the domain with the user before deploying to a site that already has content. A Node.js build runs on the server: this returns a buildUuid at once, then call hosting_get-deployment until the build state is completed or failed. A plugin or theme goes into the site's WordPress installation, also when WordPress is in a folder of the document root (headless). Static, PHP, plugin and theme deploys are done when this returns; a WordPress site import only starts, and its files are extracted a few minutes later. From Git, pass type (static or nodejs) unless the site already runs a Node.js app or you pass Node.js settings; a static or PHP site deploys the branch now and again on every push; a Node.js site builds the branch now and on every later push, with the settings stored for the site (when none are stored, pass appType and nodeVersion from the project's engines, plus outputDirectory and buildScript if the app has a build step). Git needs GitHub or GitLab connected once in hPanel.
1819
+ Deploy, publish or upload a website to Hostinger web hosting (Shared, Cloud): static HTML, PHP, a nodejs (Node.js) app, a whole WordPress site, or a WordPress plugin or theme, from a local directory, an archive or a GitHub or GitLab repository. It detects what the project is and does the upload, build and Git setup itself: do not call upload or build operations, and do not archive the directory yourself. WARNING: it overwrites the website's contents and cannot be undone; confirm the domain with the user before deploying to a site that already has content. A Node.js build runs on the server: this returns a buildUuid at once, then call hosting_get-deployment until the build state is completed or failed. A plugin or theme goes into the site's WordPress installation, also when WordPress is in a folder of the document root (headless). Static, PHP, plugin and theme deploys are done when this returns; a WordPress site import only starts, and its files are extracted a few minutes later. From Git, pass type (static or nodejs) unless the site already runs a Node.js app or you pass Node.js settings; a static or PHP site deploys the branch now and again on every push; a Node.js site builds the branch now and on every later push, with the settings stored for the site (when none are stored, they are detected from the branch's package.json; pass appType, nodeVersion, outputDirectory, buildScript or entryFile to override them). Git needs GitHub or GitLab connected once in hPanel.
1820
1820
 
1821
1821
  - **Method**: `custom`
1822
1822
  - **Path**: `custom`
@@ -2248,21 +2248,6 @@ to it.
2248
2248
  - **Method**: `POST`
2249
2249
  - **Path**: `/api/hosting/v1/headless/agent-reports`
2250
2250
 
2251
- #### hosting_nodejs_build-settings-from-repository
2252
-
2253
- Determine the build settings for a Node.js application from the package.json file of a branch
2254
- in a Git repository. Use before starting a Git-source build to find out the auto-detected
2255
- settings instead of guessing them.
2256
-
2257
- The repository is read through a Git installation: take `installation_uuid` from
2258
- `List Git installations` and `owner` and `repository` from `List Git installation repositories`.
2259
-
2260
- Returns 422 when the installation is not found, the repository or branch is not found, or
2261
- the branch has no package.json.
2262
-
2263
- - **Method**: `GET`
2264
- - **Path**: `/api/hosting/v1/accounts/{username}/websites/{domain}/nodejs/builds/settings/from-repository`
2265
-
2266
2251
  #### hosting_nodejs_list-environment-variables
2267
2252
 
2268
2253
  Lists the Node.js environment variables currently set for the website. Values are always
@@ -3050,7 +3035,8 @@ Get a single campaign with its sender, subject, template reference, targeting an
3050
3035
  progress.
3051
3036
 
3052
3037
  This describes how the campaign was set up and how far it has got. For opens, clicks and
3053
- unsubscribes use the campaign statistics endpoint.
3038
+ unsubscribes use the campaign statistics endpoint. The email content is not included: pass
3039
+ `template_uuid` to the get email template endpoint to read its HTML.
3054
3040
 
3055
3041
  - **Method**: `GET`
3056
3042
  - **Path**: `/api/reach/v1/profiles/{profileUuid}/campaigns/{campaignUuid}`
@@ -3705,13 +3691,41 @@ a connected domain cannot be changed here. Returns the settings after the change
3705
3691
  - **Method**: `PATCH`
3706
3692
  - **Path**: `/api/reach/v1/profiles/{profileUuid}/settings`
3707
3693
 
3694
+ #### reach_integrations_disconnect-ecommerce-store
3695
+
3696
+ Disconnect an eCommerce store from the profile.
3697
+
3698
+ Reach stops receiving the store's customers and orders, and its products and coupons are
3699
+ removed from Reach; contacts and past orders stay. Take `store_id` from the list eCommerce
3700
+ stores endpoint; for a store that is `connected_to_other_profile`, call this with that
3701
+ profile. Also succeeds when the store is not connected to this profile, so success is not
3702
+ proof that anything was disconnected. Confirm the store and the plan with the customer
3703
+ before calling.
3704
+
3705
+ - **Method**: `DELETE`
3706
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/ecommerce/stores/{storeId}`
3707
+
3708
+ #### reach_integrations_list-ecommerce-stores
3709
+
3710
+ Get all of the customer's Hostinger eCommerce stores, each with its `store_id` and
3711
+ `connection_status` for the profile in the path.
3712
+
3713
+ A store is connected to at most one profile. `connected_to_other_profile` does not name that
3714
+ profile - call this for each profile to find it. The list is empty, not an error, when the
3715
+ eCommerce service cannot be reached. Pass `store_id` to the disconnect eCommerce store
3716
+ endpoint. Connecting an AI Website Builder site connects the store selling on it.
3717
+
3718
+ - **Method**: `GET`
3719
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/ecommerce/stores`
3720
+
3708
3721
  #### reach_templates_list-email
3709
3722
 
3710
3723
  Get a list of the email templates in a profile, most recently updated first.
3711
3724
 
3712
3725
  Templates are the reusable email bodies a campaign is built from. The list is not paginated
3713
- and only the metadata is returned - the template content itself is not exposed. Use the
3714
- `uuid` of a template as the `template_uuid` when creating a campaign.
3726
+ and only the metadata is returned, without the HTML content. Use the `uuid` of a template
3727
+ as the `template_uuid` when creating a campaign. To read one template's HTML use the get
3728
+ email template endpoint, and only when the content itself is needed.
3715
3729
 
3716
3730
  - **Method**: `GET`
3717
3731
  - **Path**: `/api/reach/v1/profiles/{profileUuid}/templates`
@@ -3727,6 +3741,71 @@ reference it as the `template_uuid` of a campaign.
3727
3741
  - **Method**: `POST`
3728
3742
  - **Path**: `/api/reach/v1/profiles/{profileUuid}/templates`
3729
3743
 
3744
+ #### reach_templates_email
3745
+
3746
+ Get one email template of the profile with its full HTML in `template_content`.
3747
+
3748
+ A campaign's email content is its template: to read what a campaign sends, get the campaign
3749
+ and pass its `template_uuid` here. The HTML can be large, so fetch it only when the content
3750
+ itself is needed, never to browse or pick templates - the list email templates endpoint does
3751
+ that without content. Personalization tags such as `{{name}}` come back unrendered.
3752
+
3753
+ - **Method**: `GET`
3754
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/templates/{templateUuid}`
3755
+
3756
+ #### reach_integrations_list-connected-websites
3757
+
3758
+ Get the websites connected to the profile, each with its `domain` and `platform_type`.
3759
+
3760
+ A domain is connected to at most one profile, so to find which plan a site is on, call this
3761
+ for each profile. Only connected sites are listed, not every website the customer owns, and
3762
+ eCommerce stores are not included - use the list eCommerce stores endpoint for those. Pass
3763
+ `domain` to the disconnect WordPress site or disconnect AI Website Builder site endpoint.
3764
+
3765
+ - **Method**: `GET`
3766
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/websites`
3767
+
3768
+ #### reach_integrations_connect-ai-website-builder-site
3769
+
3770
+ Connect an AI Website Builder site the customer built manually to the profile, so its
3771
+ subscribers flow into Reach.
3772
+
3773
+ Fails with not found when the customer owns no AI Website Builder site with that domain.
3774
+ Fails with `DOMAIN_ALREADY_EXISTS` when the domain is connected to any profile - to move a
3775
+ site to another plan, disconnect it from the old profile first - and with
3776
+ `WEBSITE_CONNECTION_ALREADY_EXISTS` when this profile already has an AI Website Builder site.
3777
+ The eCommerce store selling on the site, if it is not connected anywhere, is connected too.
3778
+ WordPress sites and sites made with the agentic AI Website Builder cannot be connected here.
3779
+
3780
+ - **Method**: `POST`
3781
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/websites/website-builder`
3782
+
3783
+ #### reach_integrations_disconnect-ai-website-builder-site
3784
+
3785
+ Disconnect an AI Website Builder site from the profile; the site stops sending subscribers
3786
+ to Reach.
3787
+
3788
+ Take `domain` from the list connected websites endpoint. Works for a site that was already
3789
+ deleted in the builder. Fails with not found when the domain is not connected to this
3790
+ profile. Contacts and any connected eCommerce store stay. The site stays disconnected until
3791
+ it is connected again, so confirm the site and the plan with the customer before calling.
3792
+
3793
+ - **Method**: `DELETE`
3794
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/websites/website-builder`
3795
+
3796
+ #### reach_integrations_disconnect-wordpress-site
3797
+
3798
+ Disconnect a WordPress site from the profile.
3799
+
3800
+ The Reach plugin on the site stops sending subscribers and store data, and the site's
3801
+ products and coupons are removed from Reach; contacts and past orders stay. Take `domain`
3802
+ from the list connected websites endpoint. Fails with not found when the domain is not
3803
+ connected to this profile. This cannot be undone through the API - the customer reconnects
3804
+ from the WordPress plugin - so confirm the site and the plan with the customer before calling.
3805
+
3806
+ - **Method**: `DELETE`
3807
+ - **Path**: `/api/reach/v1/profiles/{profileUuid}/websites/wordpress`
3808
+
3730
3809
  ### `hostinger-vps-mcp`
3731
3810
 
3732
3811
  #### vps_data-centers_list
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
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.11.0",
3
+ "version": "3.12.0",
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.11.0",
9
+ "version": "3.12.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.11.0",
14
+ "version": "3.12.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -86,7 +86,7 @@ Local files on the hosted server, deployed from an upload (step 2):
86
86
 
87
87
  On both the local and the hosted server, `hosting_deploy` with `git: { repository: "owner/name", branch }` does all of it: it finds the Git installation that can reach the repository and deploys it as the `type` you give. Always pass `type: "static"` or `"nodejs"` unless the site already runs a Node.js app (or you pass Node.js settings such as `appType`): a new site is not detected as Node.js, and a Node.js repository deployed as static would publish its source. Then:
88
88
  - **PHP and static sites:** saves the auto-deployment settings, which deploys the branch immediately and again on every push;
89
- - **Node.js sites:** starts a build of the branch with the stored build settings; later pushes build the same way. A Git deploy has nothing to detect from, so when none are stored pass `appType` and `nodeVersion` (from `engines` in `package.json`), plus `outputDirectory` and `buildScript` if the app has a build step; they are stored for those pushes.
89
+ - **Node.js sites:** starts a build of the branch with the stored build settings; later pushes build the same way. When none are stored, they are detected from the branch's `package.json` and stored for those pushes; pass `appType`, `nodeVersion`, `outputDirectory`, `buildScript` or `entryFile` to override them (a compiled app still needs `entryFile`).
90
90
 
91
91
  With no active installation it stops and says so: the user connects GitHub or GitLab once in hPanel under Websites → Manage → Advanced → Git; there is no API for that step. Confirm with `hosting_get-deployment`, which returns the stored `gitAutoDeployment`.
92
92
 
@@ -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.3.0
69
+ * Generated from OpenAPI spec version 2.4.0
70
70
  */
71
71
  class MCPServer {
72
72
  constructor({ name, version, tools, instructions, skills = [] }) {
@@ -543,15 +543,29 @@ class MCPServer {
543
543
  return this.executeApiCall(tool, params);
544
544
  }
545
545
 
546
- // The website for a domain, matched exactly (the list endpoint filters by substring).
546
+ // The website for a domain, matched exactly. The list endpoint filters by substring and pages its
547
+ // results, so a short domain can match many sites: read pages until the exact one turns up or a page
548
+ // comes back short, up to a limit.
547
549
  async findWebsite(domain) {
548
- const response = await this.callOperation('hosting_websites_list', { domain });
549
- const sites = response && Array.isArray(response.data) ? response.data : [];
550
- const site = sites.find((website) => website.domain && website.domain.toLowerCase() === domain.toLowerCase());
551
- if (!site) {
552
- throw new Error('No website found for domain: ' + domain);
550
+ const perPage = 100;
551
+ const maxPages = 10;
552
+ for (let page = 1; page <= maxPages; page++) {
553
+ const response = await this.callOperation('hosting_websites_list', { domain, page, per_page: perPage });
554
+ const sites = response && Array.isArray(response.data) ? response.data : [];
555
+ const site = sites.find((website) => website.domain && website.domain.toLowerCase() === domain.toLowerCase());
556
+ if (site) {
557
+ // Agency Plan (U4S) websites come back without a username, and every later call would fail with
558
+ // an unclear API error.
559
+ if (typeof site.username !== 'string') {
560
+ throw new Error(site.domain + ' is an Agency Plan website: Git deploys and build status here work only for Shared and Cloud hosting websites. Use the agency-hosting operations for it.');
561
+ }
562
+ return site;
563
+ }
564
+ if (sites.length < perPage) {
565
+ break;
566
+ }
553
567
  }
554
- return site;
568
+ throw new Error('No website found for domain: ' + domain);
555
569
  }
556
570
 
557
571
  hosting_deploy_types = ['static', 'nodejs', 'wordpress-site', 'wordpress-plugin', 'wordpress-theme'];
@@ -760,9 +774,11 @@ class MCPServer {
760
774
  // directory, and the API stores them as null, so a missing one is sent as null. Detection supplies
761
775
  // the build script but only guesses a root-level entry file, so a compiled app (TypeScript) must
762
776
  // pass entryFile.
763
- // storedSettings tells base apart: settings stored for the site (Git deploy) belong to whatever
764
- // framework it ran before, so a different appType starts from a clean build step, while settings
765
- // detected from the project itself (archive deploy) are always right to keep.
777
+ // An explicit null for outputDirectory, buildScript or entryFile clears the stored value, so an app
778
+ // can drop a build step it no longer has.
779
+ // storedSettings tells base apart: settings stored for the site or detected from a Git branch (Git
780
+ // deploy) belong to the framework they were stored or detected for, so a different appType starts
781
+ // from a clean build step, while settings detected from the archive being deployed are kept.
766
782
  hosting_deploy_nodeSettings(base, params, storedSettings) {
767
783
  const given = base || {};
768
784
  const appType = params.appType ?? given.app_type;
@@ -771,6 +787,7 @@ class MCPServer {
771
787
  if (appType === undefined || appType === null) {
772
788
  throw new Error("Missing Node.js build setting: appType. Pass it to hosting_deploy, with outputDirectory and buildScript when the app has a build step; read them from the project's package.json.");
773
789
  }
790
+ const cleared = (name) => name in params && params[name] === null;
774
791
  const optional = {
775
792
  entry_file: params.entryFile ?? stored.entry_file,
776
793
  package_manager: params.packageManager ?? stored.package_manager,
@@ -779,9 +796,10 @@ class MCPServer {
779
796
  node_version: params.nodeVersion ?? stored.node_version ?? 22,
780
797
  app_type: appType,
781
798
  root_directory: params.rootDirectory ?? stored.root_directory ?? '.',
782
- output_directory: params.outputDirectory ?? stored.output_directory ?? null,
783
- build_script: params.buildScript ?? stored.build_script ?? null,
799
+ output_directory: cleared('outputDirectory') ? null : params.outputDirectory ?? stored.output_directory ?? null,
800
+ build_script: cleared('buildScript') ? null : params.buildScript ?? stored.build_script ?? null,
784
801
  ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined && value !== null)),
802
+ ...(cleared('entryFile') ? { entry_file: null } : {}),
785
803
  };
786
804
  }
787
805
 
@@ -829,6 +847,9 @@ class MCPServer {
829
847
  // Read only now that git is known to be an object.
830
848
  if (git !== undefined) {
831
849
  this.hosting_deploy_gitDirectory(git);
850
+ if (git.installationUuid !== undefined && (typeof git.installationUuid !== 'string' || git.installationUuid === '')) {
851
+ throw new Error('git.installationUuid must be a non-empty string when provided');
852
+ }
832
853
  }
833
854
  }
834
855
 
@@ -1058,23 +1079,59 @@ class MCPServer {
1058
1079
  };
1059
1080
  }
1060
1081
 
1061
- // The Git installation that can reach owner/name (GitLab owners may contain slashes).
1062
- async hosting_deploy_findRepository(fullName) {
1082
+ // The Git installation that can reach owner/name (GitLab owners may contain slashes). Every active
1083
+ // installation is checked, not just until the first match: a GitHub and a GitLab mirror can share a
1084
+ // path, the installations come in no set order, and the same request must not deploy one repository
1085
+ // one time and the other the next, overwriting the site. With an installation given, the name is taken as it is: the repository list stops at 500 entries, so a
1086
+ // repository past that cannot be found by searching it.
1087
+ async hosting_deploy_findRepository(fullName, installationUuid) {
1063
1088
  if (!fullName.includes('/')) {
1064
1089
  throw new Error('git.repository must be given as owner/name');
1065
1090
  }
1091
+ if (installationUuid !== undefined) {
1092
+ const slash = fullName.lastIndexOf('/');
1093
+ const owner = fullName.slice(0, slash);
1094
+ const repository = fullName.slice(slash + 1);
1095
+ if (owner === '' || repository === '') {
1096
+ throw new Error('git.repository must be given as owner/name');
1097
+ }
1098
+ return { installationUuid, owner, repository };
1099
+ }
1066
1100
  const installations = await this.callOperation('hosting_git_list-installations', { status: 'active' });
1067
1101
  if (!Array.isArray(installations) || installations.length === 0) {
1068
1102
  throw new Error('No active Git installation. Connect GitHub or GitLab once in hPanel (Websites, Manage, Advanced, Git), then try again.');
1069
1103
  }
1104
+ // The API returns at most this many repositories per installation. A full list without the
1105
+ // repository may still hold it past the cut, so a match elsewhere cannot be trusted to be the only one.
1106
+ const repositoryLimit = 500;
1107
+ const truncated = [];
1108
+ const matches = [];
1070
1109
  for (const installation of installations) {
1071
- const repositories = await this.callOperation('hosting_git_list-installation-repositories', { uuid: installation.uuid });
1072
- const repo = (Array.isArray(repositories) ? repositories : []).find((candidate) => String(candidate.full_name).toLowerCase() === fullName.toLowerCase());
1110
+ const response = await this.callOperation('hosting_git_list-installation-repositories', { uuid: installation.uuid });
1111
+ const repositories = Array.isArray(response) ? response : [];
1112
+ const repo = repositories.find((candidate) => String(candidate.full_name).toLowerCase() === fullName.toLowerCase());
1073
1113
  if (repo) {
1074
- return { installationUuid: installation.uuid, owner: repo.owner, repository: repo.name };
1114
+ matches.push({ installation, installationUuid: installation.uuid, owner: repo.owner, repository: repo.name });
1115
+ } else if (repositories.length >= repositoryLimit) {
1116
+ truncated.push(installation);
1117
+ }
1118
+ }
1119
+ const describe = (installation) => (typeof installation.provider === 'string' ? installation.provider : 'git') + ' account ' + (typeof installation.account_login === 'string' ? installation.account_login : '?') + ' (installation ' + installation.uuid + ')';
1120
+ if (matches.length > 1) {
1121
+ throw new Error('Repository ' + fullName + ' is in ' + matches.length + ' of your Git installations: ' + matches.map((match) => describe(match.installation)).join('; ') + '. Pass git.installationUuid to say which one to deploy.');
1122
+ }
1123
+ if (matches.length === 1) {
1124
+ if (truncated.length > 0) {
1125
+ throw new Error('Repository ' + fullName + ' is in ' + describe(matches[0].installation) + ', but it may also be in ' + truncated.map(describe).join('; ') + ': repository lists stop at the first ' + repositoryLimit + '. Pass git.installationUuid to say which one to deploy.');
1075
1126
  }
1127
+ const { installationUuid, owner, repository } = matches[0];
1128
+ return { installationUuid, owner, repository };
1129
+ }
1130
+ const notFound = 'Repository ' + fullName + ' was not found in your ' + installations.length + ' active Git installation(s). Check the name, or give the Git provider access to it in hPanel (Websites, Manage, Advanced, Git).';
1131
+ if (truncated.length > 0) {
1132
+ throw new Error(notFound + ' An installation lists only its first ' + repositoryLimit + ' repositories: if it has more, pass git.installationUuid (from hosting_git_list-installations) to name the repository directly.');
1076
1133
  }
1077
- throw new Error('Repository ' + fullName + ' was not found in your ' + installations.length + ' active Git installation(s). Check the name, or give the Git provider access to it in hPanel (Websites, Manage, Advanced, Git).');
1134
+ throw new Error(notFound);
1078
1135
  }
1079
1136
 
1080
1137
  // What a Git deploy is. There are no local files to look at, so guessing is dangerous: a Node.js
@@ -1101,7 +1158,15 @@ class MCPServer {
1101
1158
  if (type === 'nodejs' && directory !== '') {
1102
1159
  throw new Error('git.directory is for static deploys: a Node.js build has no target folder');
1103
1160
  }
1104
- const repo = await this.hosting_deploy_findRepository(git.repository);
1161
+ // On these sites saving auto-deployment settings clones nothing, so a static deploy would report
1162
+ // success and publish nothing.
1163
+ if (type === 'static' && website.website_type === 'nodejs') {
1164
+ throw new Error('This website runs a Node.js app: deploy it with type nodejs, or leave type out. Saving Git settings on a Node.js website does not deploy anything.');
1165
+ }
1166
+ if (type === 'static' && website.website_type === 'builder') {
1167
+ throw new Error('This is a Website Builder website, which cannot deploy from Git: saving Git settings on it does not deploy anything.');
1168
+ }
1169
+ const repo = await this.hosting_deploy_findRepository(git.repository, git.installationUuid);
1105
1170
 
1106
1171
  if (type === 'static') {
1107
1172
  // Saving the settings deploys the branch now and again on every push.
@@ -1121,9 +1186,31 @@ class MCPServer {
1121
1186
  throw error;
1122
1187
  }
1123
1188
  }
1124
- const settings = this.hosting_deploy_nodeSettings(stored, params, true);
1125
- if (this.hosting_deploy_hasNodeOverrides(params)) {
1126
- // Later pushes build with the stored settings, not with this request: keep what was given.
1189
+ // The API can return all-null settings, which mean nothing is stored.
1190
+ const hasStored = stored !== null && typeof stored === 'object' && stored.app_type !== null && stored.app_type !== undefined;
1191
+ let settings;
1192
+ if (hasStored) {
1193
+ settings = this.hosting_deploy_nodeSettings(stored, params, true);
1194
+ } else {
1195
+ // A first deploy: detect the settings from the branch's package.json. The request's settings beat
1196
+ // them, and a different appType drops the build step detected for the other framework.
1197
+ let detected = null;
1198
+ try {
1199
+ detected = await this.callOperation('hosting_nodejs_build-settings-from-repository', {
1200
+ username, domain, owner: repo.owner, repository: repo.repository, branch: git.branch, installation_uuid: repo.installationUuid,
1201
+ });
1202
+ } catch (error) {
1203
+ if (!(error instanceof ApiExecutionError && error.status === 422)) {
1204
+ throw error;
1205
+ }
1206
+ if (params.appType === undefined || params.appType === null) {
1207
+ throw new Error('No Node.js build settings could be detected from the ' + git.branch + ' branch (' + this.formatToolError(error) + '). Pass appType, with outputDirectory and buildScript when the app has a build step, or deploy with type static if the branch has no Node.js app.');
1208
+ }
1209
+ }
1210
+ settings = this.hosting_deploy_nodeSettings(detected, params, true);
1211
+ }
1212
+ if (!hasStored || this.hosting_deploy_hasNodeOverrides(params)) {
1213
+ // Later pushes build with the stored settings, not with this request: keep what was given or detected.
1127
1214
  await this.callOperation('hosting_nodejs_update-build-settings', { username, domain, ...settings });
1128
1215
  }
1129
1216
  const build = await this.callOperation('hosting_nodejs_start-build', {
@@ -1141,10 +1228,11 @@ class MCPServer {
1141
1228
  // Local checks first, so a bad path fails before any request is made.
1142
1229
  const type = this.hosting_deploy_resolveType(params);
1143
1230
  const website = await this.findWebsite(domain);
1144
- return this.hosting_deploy_fromPath(website.username, domain, type, params);
1231
+ return this.hosting_deploy_fromPath(website.username, website.domain, type, params);
1145
1232
  }
1233
+ // The lookup ignores case: the API calls and the result use the website's own spelling.
1146
1234
  const website = await this.findWebsite(domain);
1147
- return this.hosting_deploy_fromGit(website, domain, git, params);
1235
+ return this.hosting_deploy_fromGit(website, website.domain, git, params);
1148
1236
  }
1149
1237
 
1150
1238
  // The failure analysis re-runs on every call and is limited to 5 a minute, so keep one request per
@@ -1190,9 +1278,9 @@ class MCPServer {
1190
1278
  }
1191
1279
 
1192
1280
  async handleHostingGetDeployment(params) {
1193
- const { domain, buildUuid, fromLine } = params;
1281
+ const { domain: requestedDomain, buildUuid, fromLine } = params;
1194
1282
 
1195
- if (!domain || typeof domain !== 'string') {
1283
+ if (!requestedDomain || typeof requestedDomain !== 'string') {
1196
1284
  throw new Error('domain is required and must be a string');
1197
1285
  }
1198
1286
  if (buildUuid !== undefined && (typeof buildUuid !== 'string' || buildUuid === '')) {
@@ -1202,7 +1290,8 @@ class MCPServer {
1202
1290
  throw new Error('fromLine must be a non-negative integer when provided');
1203
1291
  }
1204
1292
 
1205
- const { username } = await this.findWebsite(domain);
1293
+ // The lookup ignores case: from here on use the website's own spelling, so one site has one analysis cache key.
1294
+ const { username, domain } = await this.findWebsite(requestedDomain);
1206
1295
  const target = { username, domain };
1207
1296
 
1208
1297
  const gitSettings = await this.callOperation('hosting_git_auto-deployment-settings', target);