hostinger-api-mcp 3.10.4 → 3.11.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +3 -18
  3. package/gemini-extension.json +1 -1
  4. package/package.json +1 -1
  5. package/server.json +2 -2
  6. package/skills/audit-hosting/SKILL.md +1 -1
  7. package/skills/deploy-to-hosting/SKILL.md +7 -18
  8. package/skills/headless/steps/05-design/SKILL.md +43 -19
  9. package/skills/headless/steps/05-design/logo-kit.mjs +231 -6
  10. package/skills/headless/steps/08-deploy/SKILL.md +6 -1
  11. package/skills/migrate-to-hosting/SKILL.md +1 -1
  12. package/skills/troubleshoot-website/SKILL.md +2 -2
  13. package/src/core/catalog.js +2 -1
  14. package/src/core/catalog.ts +3 -2
  15. package/src/core/instructions.agency-hosting.js +1 -1
  16. package/src/core/instructions.agency-hosting.ts +1 -1
  17. package/src/core/instructions.js +1 -1
  18. package/src/core/instructions.ts +1 -1
  19. package/src/core/runtime.js +116 -27
  20. package/src/core/runtime.ts +116 -27
  21. package/src/core/skills.js +21 -21
  22. package/src/core/skills.ts +21 -21
  23. package/src/core/tools/all.js +23 -9
  24. package/src/core/tools/all.ts +23 -9
  25. package/src/core/tools/hosting.js +23 -9
  26. package/src/core/tools/hosting.ts +23 -9
  27. package/src/servers/agency-hosting.js +1 -1
  28. package/src/servers/agency-hosting.ts +1 -1
  29. package/src/servers/agent-readiness.js +1 -1
  30. package/src/servers/agent-readiness.ts +1 -1
  31. package/src/servers/all.js +1 -1
  32. package/src/servers/all.ts +1 -1
  33. package/src/servers/billing.js +1 -1
  34. package/src/servers/billing.ts +1 -1
  35. package/src/servers/dns.js +1 -1
  36. package/src/servers/dns.ts +1 -1
  37. package/src/servers/domains.js +1 -1
  38. package/src/servers/domains.ts +1 -1
  39. package/src/servers/ecommerce.js +1 -1
  40. package/src/servers/ecommerce.ts +1 -1
  41. package/src/servers/horizons.js +1 -1
  42. package/src/servers/horizons.ts +1 -1
  43. package/src/servers/hosting.js +1 -1
  44. package/src/servers/hosting.ts +1 -1
  45. package/src/servers/mail.js +1 -1
  46. package/src/servers/mail.ts +1 -1
  47. package/src/servers/reach.js +1 -1
  48. package/src/servers/reach.ts +1 -1
  49. package/src/servers/vps.js +1 -1
  50. package/src/servers/vps.ts +1 -1
  51. package/src/servers/wordpress.js +1 -1
  52. package/src/servers/wordpress.ts +1 -1
  53. package/types.d.ts +8 -8
@@ -2,7 +2,8 @@
2
2
  // multi-execute over a catalog of API operations. Pure functions only; the
3
3
  // runtime supplies the catalog and the function that performs a call.
4
4
  // Keep in sync with catalog.ts and with api-remote-mcp's PHP implementation.
5
- // `hidden` is stdio-only: the hosted server hides nothing, so the PHP side has no such flag.
5
+ // Both servers honour `hidden`: search skips the entry, execute and multi-execute still run
6
+ // it. Each server has its own list (generators.mcp.hidden and generators.remote-mcp.hidden).
6
7
 
7
8
  export const DEFAULT_SEARCH_LIMIT = 5;
8
9
  export const MAX_SEARCH_LIMIT = 20;
@@ -1,6 +1,7 @@
1
1
  // Typed mirror of catalog.js. Keep both files, and api-remote-mcp's PHP
2
- // implementation, behaviourally identical. `hidden` is stdio-only: the hosted server hides
3
- // nothing, so the PHP side has no such flag.
2
+ // implementation, behaviourally identical. Both servers honour `hidden`: search skips the
3
+ // entry, execute and multi-execute still run it. Each server has its own list
4
+ // (generators.mcp.hidden and generators.remote-mcp.hidden in the API manifest).
4
5
 
5
6
  export const DEFAULT_SEARCH_LIMIT = 5;
6
7
  export const MAX_SEARCH_LIMIT = 20;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated server instructions
2
- export default "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
2
+ export default "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- A Node.js deploy or hosting_nodejs_start-build -> poll hosting_get-deployment with the domain until the build state is completed or failed; a failed build comes back with its logs and failure analysis.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
@@ -1,3 +1,3 @@
1
1
  // Auto-generated server instructions
2
- const instructions: string = "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
2
+ const instructions: string = "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- A Node.js deploy or hosting_nodejs_start-build -> poll hosting_get-deployment with the domain until the build state is completed or failed; a failed build comes back with its logs and failure analysis.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
3
3
  export default instructions;
@@ -1,2 +1,2 @@
1
1
  // Auto-generated server instructions
2
- export default "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nDeploying a website (this server only)\n- hosting_deploy replaces the upload, archive-deploy, build, WordPress import/plugin/theme and Git operations named above. Give it a local directory (it detects static/PHP, Node.js, a WordPress site, plugin or theme and packs it itself), a local archive with `type`, or `git: { repository, branch }`. Do not run those operations by hand.\n- A Node.js deploy returns a buildUuid at once and builds on the server. Poll hosting_get-deployment with the domain, pausing longer each time, until the build state is completed or failed. A failed build comes back with its logs and analysis: fix the cause and deploy again. It replaces the build, log and settings operations named above.\n- Static, PHP, plugin and theme deploys are complete when hosting_deploy returns. A WordPress site import only starts: its files are extracted and the site is ready a few minutes later.\n- Every deploy overwrites the site's contents: confirm the domain with the user before the first deploy to a site that already has content.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
2
+ export default "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- A Node.js deploy or hosting_nodejs_start-build -> poll hosting_get-deployment with the domain until the build state is completed or failed; a failed build comes back with its logs and failure analysis.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nDeploying a website (this server only)\n- hosting_deploy replaces the upload, archive-deploy, build, WordPress import/plugin/theme and Git operations named above. Give it a local directory (it detects static/PHP, Node.js, a WordPress site, plugin or theme and packs it itself), a local archive with `type`, or `git: { repository, branch }`. Do not run those operations by hand.\n- A Node.js deploy returns a buildUuid at once and builds on the server. Poll hosting_get-deployment with the domain, pausing longer each time, until the build state is completed or failed. A failed build comes back with its logs and analysis: fix the cause and deploy again. It replaces the build, log and settings operations.\n- Static, PHP, plugin and theme deploys are complete when hosting_deploy returns. A WordPress site import only starts: its files are extracted and the site is ready a few minutes later.\n- Every deploy overwrites the site's contents: confirm the domain with the user before the first deploy to a site that already has content.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
@@ -1,3 +1,3 @@
1
1
  // Auto-generated server instructions
2
- const instructions: string = "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nDeploying a website (this server only)\n- hosting_deploy replaces the upload, archive-deploy, build, WordPress import/plugin/theme and Git operations named above. Give it a local directory (it detects static/PHP, Node.js, a WordPress site, plugin or theme and packs it itself), a local archive with `type`, or `git: { repository, branch }`. Do not run those operations by hand.\n- A Node.js deploy returns a buildUuid at once and builds on the server. Poll hosting_get-deployment with the domain, pausing longer each time, until the build state is completed or failed. A failed build comes back with its logs and analysis: fix the cause and deploy again. It replaces the build, log and settings operations named above.\n- Static, PHP, plugin and theme deploys are complete when hosting_deploy returns. A WordPress site import only starts: its files are extracted and the site is ready a few minutes later.\n- Every deploy overwrites the site's contents: confirm the domain with the user before the first deploy to a site that already has content.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
2
+ const instructions: string = "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list-setups with the domain until status is completed (a few minutes); file, deploy and database calls return 404/409 before that.\n- A Node.js deploy or hosting_nodejs_start-build -> poll hosting_get-deployment with the domain until the build state is completed or failed; a failed build comes back with its logs and failure analysis.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nDeploying a website (this server only)\n- hosting_deploy replaces the upload, archive-deploy, build, WordPress import/plugin/theme and Git operations named above. Give it a local directory (it detects static/PHP, Node.js, a WordPress site, plugin or theme and packs it itself), a local archive with `type`, or `git: { repository, branch }`. Do not run those operations by hand.\n- A Node.js deploy returns a buildUuid at once and builds on the server. Poll hosting_get-deployment with the domain, pausing longer each time, until the build state is completed or failed. A failed build comes back with its logs and analysis: fix the cause and deploy again. It replaces the build, log and settings operations.\n- Static, PHP, plugin and theme deploys are complete when hosting_deploy returns. A WordPress site import only starts: its files are extracted and the site is ready a few minutes later.\n- Every deploy overwrites the site's contents: confirm the domain with the user before the first deploy to a site that already has content.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
3
3
  export default instructions;
@@ -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);
@@ -580,15 +580,29 @@ class MCPServer {
580
580
  return this.executeApiCall(tool, params);
581
581
  }
582
582
 
583
- // The website for a domain, matched exactly (the list endpoint filters by substring).
583
+ // The website for a domain, matched exactly. The list endpoint filters by substring and pages its
584
+ // results, so a short domain can match many sites: read pages until the exact one turns up or a page
585
+ // comes back short, up to a limit.
584
586
  private async findWebsite(domain: string): Promise<any> {
585
- const response = await this.callOperation('hosting_websites_list', { domain });
586
- const sites = response && Array.isArray(response.data) ? response.data : [];
587
- const site = sites.find((website: any) => website.domain && website.domain.toLowerCase() === domain.toLowerCase());
588
- if (!site) {
589
- throw new Error('No website found for domain: ' + domain);
587
+ const perPage = 100;
588
+ const maxPages = 10;
589
+ for (let page = 1; page <= maxPages; page++) {
590
+ const response = await this.callOperation('hosting_websites_list', { domain, page, per_page: perPage });
591
+ const sites = response && Array.isArray(response.data) ? response.data : [];
592
+ const site = sites.find((website: any) => website.domain && website.domain.toLowerCase() === domain.toLowerCase());
593
+ if (site) {
594
+ // Agency Plan (U4S) websites come back without a username, and every later call would fail with
595
+ // an unclear API error.
596
+ if (typeof site.username !== 'string') {
597
+ 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.');
598
+ }
599
+ return site;
600
+ }
601
+ if (sites.length < perPage) {
602
+ break;
603
+ }
590
604
  }
591
- return site;
605
+ throw new Error('No website found for domain: ' + domain);
592
606
  }
593
607
 
594
608
  private hosting_deploy_types = ['static', 'nodejs', 'wordpress-site', 'wordpress-plugin', 'wordpress-theme'];
@@ -797,9 +811,11 @@ class MCPServer {
797
811
  // directory, and the API stores them as null, so a missing one is sent as null. Detection supplies
798
812
  // the build script but only guesses a root-level entry file, so a compiled app (TypeScript) must
799
813
  // pass entryFile.
800
- // storedSettings tells base apart: settings stored for the site (Git deploy) belong to whatever
801
- // framework it ran before, so a different appType starts from a clean build step, while settings
802
- // detected from the project itself (archive deploy) are always right to keep.
814
+ // An explicit null for outputDirectory, buildScript or entryFile clears the stored value, so an app
815
+ // can drop a build step it no longer has.
816
+ // storedSettings tells base apart: settings stored for the site or detected from a Git branch (Git
817
+ // deploy) belong to the framework they were stored or detected for, so a different appType starts
818
+ // from a clean build step, while settings detected from the archive being deployed are kept.
803
819
  private hosting_deploy_nodeSettings(base: Record<string, any> | null, params: Record<string, any>, storedSettings: boolean): Record<string, any> {
804
820
  const given = base || {};
805
821
  const appType = params.appType ?? given.app_type;
@@ -808,6 +824,7 @@ class MCPServer {
808
824
  if (appType === undefined || appType === null) {
809
825
  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.");
810
826
  }
827
+ const cleared = (name: string): boolean => name in params && params[name] === null;
811
828
  const optional: Record<string, any> = {
812
829
  entry_file: params.entryFile ?? stored.entry_file,
813
830
  package_manager: params.packageManager ?? stored.package_manager,
@@ -816,9 +833,10 @@ class MCPServer {
816
833
  node_version: params.nodeVersion ?? stored.node_version ?? 22,
817
834
  app_type: appType,
818
835
  root_directory: params.rootDirectory ?? stored.root_directory ?? '.',
819
- output_directory: params.outputDirectory ?? stored.output_directory ?? null,
820
- build_script: params.buildScript ?? stored.build_script ?? null,
836
+ output_directory: cleared('outputDirectory') ? null : params.outputDirectory ?? stored.output_directory ?? null,
837
+ build_script: cleared('buildScript') ? null : params.buildScript ?? stored.build_script ?? null,
821
838
  ...Object.fromEntries(Object.entries(optional).filter(([, value]) => value !== undefined && value !== null)),
839
+ ...(cleared('entryFile') ? { entry_file: null } : {}),
822
840
  };
823
841
  }
824
842
 
@@ -866,6 +884,9 @@ class MCPServer {
866
884
  // Read only now that git is known to be an object.
867
885
  if (git !== undefined) {
868
886
  this.hosting_deploy_gitDirectory(git);
887
+ if (git.installationUuid !== undefined && (typeof git.installationUuid !== 'string' || git.installationUuid === '')) {
888
+ throw new Error('git.installationUuid must be a non-empty string when provided');
889
+ }
869
890
  }
870
891
  }
871
892
 
@@ -1095,23 +1116,59 @@ class MCPServer {
1095
1116
  };
1096
1117
  }
1097
1118
 
1098
- // The Git installation that can reach owner/name (GitLab owners may contain slashes).
1099
- private async hosting_deploy_findRepository(fullName: string): Promise<{ installationUuid: string; owner: string; repository: string }> {
1119
+ // The Git installation that can reach owner/name (GitLab owners may contain slashes). Every active
1120
+ // installation is checked, not just until the first match: a GitHub and a GitLab mirror can share a
1121
+ // path, the installations come in no set order, and the same request must not deploy one repository
1122
+ // 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
1123
+ // repository past that cannot be found by searching it.
1124
+ private async hosting_deploy_findRepository(fullName: string, installationUuid?: string): Promise<{ installationUuid: string; owner: string; repository: string }> {
1100
1125
  if (!fullName.includes('/')) {
1101
1126
  throw new Error('git.repository must be given as owner/name');
1102
1127
  }
1128
+ if (installationUuid !== undefined) {
1129
+ const slash = fullName.lastIndexOf('/');
1130
+ const owner = fullName.slice(0, slash);
1131
+ const repository = fullName.slice(slash + 1);
1132
+ if (owner === '' || repository === '') {
1133
+ throw new Error('git.repository must be given as owner/name');
1134
+ }
1135
+ return { installationUuid, owner, repository };
1136
+ }
1103
1137
  const installations = await this.callOperation('hosting_git_list-installations', { status: 'active' });
1104
1138
  if (!Array.isArray(installations) || installations.length === 0) {
1105
1139
  throw new Error('No active Git installation. Connect GitHub or GitLab once in hPanel (Websites, Manage, Advanced, Git), then try again.');
1106
1140
  }
1141
+ // The API returns at most this many repositories per installation. A full list without the
1142
+ // repository may still hold it past the cut, so a match elsewhere cannot be trusted to be the only one.
1143
+ const repositoryLimit = 500;
1144
+ const truncated: any[] = [];
1145
+ const matches: Array<{ installation: any; installationUuid: string; owner: string; repository: string }> = [];
1107
1146
  for (const installation of installations) {
1108
- const repositories = await this.callOperation('hosting_git_list-installation-repositories', { uuid: installation.uuid });
1109
- const repo = (Array.isArray(repositories) ? repositories : []).find((candidate: any) => String(candidate.full_name).toLowerCase() === fullName.toLowerCase());
1147
+ const response = await this.callOperation('hosting_git_list-installation-repositories', { uuid: installation.uuid });
1148
+ const repositories = Array.isArray(response) ? response : [];
1149
+ const repo = repositories.find((candidate: any) => String(candidate.full_name).toLowerCase() === fullName.toLowerCase());
1110
1150
  if (repo) {
1111
- return { installationUuid: installation.uuid, owner: repo.owner, repository: repo.name };
1151
+ matches.push({ installation, installationUuid: installation.uuid, owner: repo.owner, repository: repo.name });
1152
+ } else if (repositories.length >= repositoryLimit) {
1153
+ truncated.push(installation);
1154
+ }
1155
+ }
1156
+ const describe = (installation: any): string => (typeof installation.provider === 'string' ? installation.provider : 'git') + ' account ' + (typeof installation.account_login === 'string' ? installation.account_login : '?') + ' (installation ' + installation.uuid + ')';
1157
+ if (matches.length > 1) {
1158
+ 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.');
1159
+ }
1160
+ if (matches.length === 1) {
1161
+ if (truncated.length > 0) {
1162
+ 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.');
1112
1163
  }
1164
+ const { installationUuid, owner, repository } = matches[0];
1165
+ return { installationUuid, owner, repository };
1166
+ }
1167
+ 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).';
1168
+ if (truncated.length > 0) {
1169
+ 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.');
1113
1170
  }
1114
- 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).');
1171
+ throw new Error(notFound);
1115
1172
  }
1116
1173
 
1117
1174
  // What a Git deploy is. There are no local files to look at, so guessing is dangerous: a Node.js
@@ -1138,7 +1195,15 @@ class MCPServer {
1138
1195
  if (type === 'nodejs' && directory !== '') {
1139
1196
  throw new Error('git.directory is for static deploys: a Node.js build has no target folder');
1140
1197
  }
1141
- const repo = await this.hosting_deploy_findRepository(git.repository);
1198
+ // On these sites saving auto-deployment settings clones nothing, so a static deploy would report
1199
+ // success and publish nothing.
1200
+ if (type === 'static' && website.website_type === 'nodejs') {
1201
+ 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.');
1202
+ }
1203
+ if (type === 'static' && website.website_type === 'builder') {
1204
+ throw new Error('This is a Website Builder website, which cannot deploy from Git: saving Git settings on it does not deploy anything.');
1205
+ }
1206
+ const repo = await this.hosting_deploy_findRepository(git.repository, git.installationUuid);
1142
1207
 
1143
1208
  if (type === 'static') {
1144
1209
  // Saving the settings deploys the branch now and again on every push.
@@ -1158,9 +1223,31 @@ class MCPServer {
1158
1223
  throw error;
1159
1224
  }
1160
1225
  }
1161
- const settings = this.hosting_deploy_nodeSettings(stored, params, true);
1162
- if (this.hosting_deploy_hasNodeOverrides(params)) {
1163
- // Later pushes build with the stored settings, not with this request: keep what was given.
1226
+ // The API can return all-null settings, which mean nothing is stored.
1227
+ const hasStored = stored !== null && typeof stored === 'object' && stored.app_type !== null && stored.app_type !== undefined;
1228
+ let settings: Record<string, any>;
1229
+ if (hasStored) {
1230
+ settings = this.hosting_deploy_nodeSettings(stored, params, true);
1231
+ } else {
1232
+ // A first deploy: detect the settings from the branch's package.json. The request's settings beat
1233
+ // them, and a different appType drops the build step detected for the other framework.
1234
+ let detected: any = null;
1235
+ try {
1236
+ detected = await this.callOperation('hosting_nodejs_build-settings-from-repository', {
1237
+ username, domain, owner: repo.owner, repository: repo.repository, branch: git.branch, installation_uuid: repo.installationUuid,
1238
+ });
1239
+ } catch (error) {
1240
+ if (!(error instanceof ApiExecutionError && error.status === 422)) {
1241
+ throw error;
1242
+ }
1243
+ if (params.appType === undefined || params.appType === null) {
1244
+ 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.');
1245
+ }
1246
+ }
1247
+ settings = this.hosting_deploy_nodeSettings(detected, params, true);
1248
+ }
1249
+ if (!hasStored || this.hosting_deploy_hasNodeOverrides(params)) {
1250
+ // Later pushes build with the stored settings, not with this request: keep what was given or detected.
1164
1251
  await this.callOperation('hosting_nodejs_update-build-settings', { username, domain, ...settings });
1165
1252
  }
1166
1253
  const build = await this.callOperation('hosting_nodejs_start-build', {
@@ -1178,10 +1265,11 @@ class MCPServer {
1178
1265
  // Local checks first, so a bad path fails before any request is made.
1179
1266
  const type = this.hosting_deploy_resolveType(params);
1180
1267
  const website = await this.findWebsite(domain);
1181
- return this.hosting_deploy_fromPath(website.username, domain, type, params);
1268
+ return this.hosting_deploy_fromPath(website.username, website.domain, type, params);
1182
1269
  }
1270
+ // The lookup ignores case: the API calls and the result use the website's own spelling.
1183
1271
  const website = await this.findWebsite(domain);
1184
- return this.hosting_deploy_fromGit(website, domain, git, params);
1272
+ return this.hosting_deploy_fromGit(website, website.domain, git, params);
1185
1273
  }
1186
1274
 
1187
1275
  // The failure analysis re-runs on every call and is limited to 5 a minute, so keep one request per
@@ -1227,9 +1315,9 @@ class MCPServer {
1227
1315
  }
1228
1316
 
1229
1317
  private async handleHostingGetDeployment(params: Record<string, any>): Promise<any> {
1230
- const { domain, buildUuid, fromLine } = params;
1318
+ const { domain: requestedDomain, buildUuid, fromLine } = params;
1231
1319
 
1232
- if (!domain || typeof domain !== 'string') {
1320
+ if (!requestedDomain || typeof requestedDomain !== 'string') {
1233
1321
  throw new Error('domain is required and must be a string');
1234
1322
  }
1235
1323
  if (buildUuid !== undefined && (typeof buildUuid !== 'string' || buildUuid === '')) {
@@ -1239,7 +1327,8 @@ class MCPServer {
1239
1327
  throw new Error('fromLine must be a non-negative integer when provided');
1240
1328
  }
1241
1329
 
1242
- const { username } = await this.findWebsite(domain);
1330
+ // The lookup ignores case: from here on use the website's own spelling, so one site has one analysis cache key.
1331
+ const { username, domain } = await this.findWebsite(requestedDomain);
1243
1332
  const target = { username, domain };
1244
1333
 
1245
1334
  const gitSettings = await this.callOperation('hosting_git_auto-deployment-settings', target);