hostinger-api-mcp 1.63.4 → 2.0.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.
- package/CHANGELOG.md +10 -0
- package/README.md +28 -20
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +5 -5
- package/skills/headless/references/DATABASE.md +2 -2
- package/skills/headless/references/DEPLOYMENT.md +1 -1
- package/skills/headless/references/SETUP.md +3 -3
- package/skills/headless/references/STORE.md +2 -2
- package/skills/headless/references/WORDPRESS.md +1 -1
- package/src/core/catalog.js +183 -0
- package/src/core/catalog.ts +207 -0
- package/src/core/instructions.js +1 -1
- package/src/core/instructions.ts +1 -1
- package/src/core/runtime.js +61 -85
- package/src/core/runtime.ts +52 -87
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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
|
+
## v2.0.0 — 2026-09-28
|
|
8
|
+
|
|
9
|
+
### Also in this release
|
|
10
|
+
|
|
11
|
+
- Documentation: `README.md`
|
|
12
|
+
- Dependencies: `package-lock.json`
|
|
13
|
+
- Bundled agent skills: 6 files
|
|
14
|
+
- Server instructions: `src/core/instructions.js`, `src/core/instructions.ts`
|
|
15
|
+
- Request handling and authentication: 4 files
|
|
16
|
+
|
|
7
17
|
## v1.63.4 — 2026-09-25
|
|
8
18
|
|
|
9
19
|
### Tools
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ https://mcp.hostinger.com
|
|
|
16
16
|
claude mcp add --transport http hostinger https://mcp.hostinger.com
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
This opens a browser window to authorize via OAuth. Once approved,
|
|
19
|
+
This opens a browser window to authorize via OAuth. Once approved, the operations below are reachable through `execute` in your session.
|
|
20
20
|
|
|
21
21
|
### Other MCP-compatible clients
|
|
22
22
|
|
|
@@ -69,20 +69,20 @@ 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
|
|
73
|
-
- `hostinger-agency-hosting-mcp` — 42
|
|
74
|
-
- `hostinger-billing-mcp` — 9
|
|
75
|
-
- `hostinger-dns-mcp` — 8
|
|
76
|
-
- `hostinger-domains-mcp` — 41
|
|
77
|
-
- `hostinger-ecommerce-mcp` — 29
|
|
78
|
-
- `hostinger-horizons-mcp` — 6
|
|
79
|
-
- `hostinger-hosting-mcp` — 74
|
|
80
|
-
- `hostinger-mail-mcp` — 38
|
|
81
|
-
- `hostinger-reach-mcp` — 52
|
|
82
|
-
- `hostinger-vps-mcp` — 64
|
|
83
|
-
- `hostinger-wordpress-mcp` — 38
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
- `hostinger-api-mcp` — unified server over every operation (401 total)
|
|
73
|
+
- `hostinger-agency-hosting-mcp` — 42 operations for agency-hosting
|
|
74
|
+
- `hostinger-billing-mcp` — 9 operations for billing
|
|
75
|
+
- `hostinger-dns-mcp` — 8 operations for dns
|
|
76
|
+
- `hostinger-domains-mcp` — 41 operations for domains
|
|
77
|
+
- `hostinger-ecommerce-mcp` — 29 operations for ecommerce
|
|
78
|
+
- `hostinger-horizons-mcp` — 6 operations for horizons
|
|
79
|
+
- `hostinger-hosting-mcp` — 74 operations for hosting
|
|
80
|
+
- `hostinger-mail-mcp` — 38 operations for mail
|
|
81
|
+
- `hostinger-reach-mcp` — 52 operations for reach
|
|
82
|
+
- `hostinger-vps-mcp` — 64 operations for vps
|
|
83
|
+
- `hostinger-wordpress-mcp` — 38 operations for wordpress
|
|
84
|
+
|
|
85
|
+
Every binary exposes the same three tools; a scoped binary only searches and executes its own group's operations. `hostinger-api-mcp` remains the backwards-compatible default.
|
|
86
86
|
|
|
87
87
|
## Configuration
|
|
88
88
|
|
|
@@ -210,17 +210,25 @@ await client.connect(transport);
|
|
|
210
210
|
const { tools } = await client.listTools();
|
|
211
211
|
console.log("Available tools:", tools);
|
|
212
212
|
|
|
213
|
-
//
|
|
213
|
+
// Execute an operation
|
|
214
214
|
const result = await client.callTool({
|
|
215
|
-
name: "
|
|
216
|
-
arguments: { category: "DOMAIN" }
|
|
215
|
+
name: "execute",
|
|
216
|
+
arguments: { operation: "billing_getCatalogItemListV1", params: { category: "DOMAIN" } }
|
|
217
217
|
});
|
|
218
218
|
console.log("Tool result:", result);
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
##
|
|
221
|
+
## Tools
|
|
222
222
|
|
|
223
|
-
|
|
223
|
+
Every server exposes three tools:
|
|
224
|
+
|
|
225
|
+
- `search` — find operations by keyword; returns each match with its `inputSchema`.
|
|
226
|
+
- `execute` — run one operation: `{ "operation": "<name>", "params": { ... } }`.
|
|
227
|
+
- `multi-execute` — run up to 20 operations in order, stopping at the first failure. A params value that is exactly `"$steps.<i>.<path>"` is replaced by that value from an earlier step's result.
|
|
228
|
+
|
|
229
|
+
## Operations
|
|
230
|
+
|
|
231
|
+
The operations reachable through `execute`, by binary:
|
|
224
232
|
|
|
225
233
|
### `hostinger-agency-hosting-mcp`
|
|
226
234
|
|
package/gemini-extension.json
CHANGED
package/package.json
CHANGED
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": "
|
|
9
|
+
"version": "2.0.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@hostinger/mcp",
|
|
14
|
-
"version": "
|
|
14
|
+
"version": "2.0.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/skills/headless/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ The frontend is built ad-hoc to the user's intent — there is no template libra
|
|
|
12
12
|
## Preconditions
|
|
13
13
|
|
|
14
14
|
1. An authenticated Hostinger MCP session — the entry skill's bootstrap handled this. If MCP tools fail with auth errors, send the user back through `entry/skill.md`.
|
|
15
|
-
2. The Hostinger MCP
|
|
15
|
+
2. The Hostinger MCP operations for **hosting** (plus **ecommerce** when the run involves a store, and **wordpress** when it involves a content backend). Operation availability varies per user — product groups can be toggled in the Hostinger Connector. When search cannot find a needed operation, ask the user to enable that product group (or configure the scoped binary, e.g. `hostinger-hosting-mcp`) rather than improvising around it.
|
|
16
16
|
3. An active hosting plan (checked in Setup §1). Hostinger hosting is a paid product: if the account has no usable plan, inform the user plainly that a subscription is required, link them to https://www.hostinger.com/web-hosting, and pause until they confirm the purchase. Do not treat this as an error — it is a normal step for new accounts. Everything that doesn't need hosting (planning, building the frontend locally) can proceed while they decide.
|
|
17
17
|
|
|
18
18
|
## Resolving the operation
|
|
@@ -32,7 +32,7 @@ If signals conflict, ask the user — don't guess.
|
|
|
32
32
|
3. **Store** (`references/STORE.md`, only when commerce is needed) — resolve or create the store and its `custom` sales channel, seed products/shipping/payment, and note the `sales_channel_id` for the frontend.
|
|
33
33
|
4. **Content backend** (`references/WORDPRESS.md`, only when owner-managed content is needed) — install WordPress on a dedicated subdomain, wait until it's ready, and hand the owner a wp-admin login link. The frontend reads it through the public WP REST API.
|
|
34
34
|
5. **Build** — create or wire the frontend. For store runs, follow the frontend contract in `references/STORE.md` (catalog fetched at runtime from the public Storefront API, cart in `localStorage`, client-side checkout — never embed an API token in the site). For content runs, follow the frontend contract in `references/WORDPRESS.md` (posts fetched at runtime from the WP REST API, rendered HTML bodies, graceful empty states). When the app needs MySQL, follow `references/DATABASE.md` (which host each runtime connects to, credentials via env vars or a server-side config file, never in the frontend).
|
|
35
|
-
6. **Deploy** (`references/DEPLOYMENT.md`) — archive and deploy via the matching hosting
|
|
35
|
+
6. **Deploy** (`references/DEPLOYMENT.md`) — archive and deploy via the matching hosting operation, polling build logs for Node.js runs.
|
|
36
36
|
7. **Verify** — curl the live URL for a 200 and a piece of real page copy; for store runs, confirm a checkout POST returns a redirect URL; for content runs, confirm the WP REST API returns 200 and the frontend renders posts. Show the user the live URL and where to manage things (hPanel: https://hpanel.hostinger.com, the store dashboard for commerce, wp-admin for content).
|
|
37
37
|
8. **Record** — write `.hostinger/site.json` into the project: `{ "domain", "username", "type": "static"|"nodejs", "sales_channel_id"?, "store_id"?, "cms_domain"? }`. This is what makes future runs resolve as `iterate`.
|
|
38
38
|
|
|
@@ -50,7 +50,7 @@ Run non-interactively wherever possible. The exceptions that must involve the us
|
|
|
50
50
|
|
|
51
51
|
## Where the how comes from
|
|
52
52
|
|
|
53
|
-
The
|
|
53
|
+
The inputSchema returned by search is authoritative for request shapes — read it before calling. Two live sources supersede anything written here when they disagree:
|
|
54
54
|
|
|
55
|
-
- The ecommerce
|
|
56
|
-
- The Hostinger API reference at https://developers.hostinger.com describes every endpoint behind the
|
|
55
|
+
- The ecommerce operation `ecommerce_getCustomStorefrontSetupInstructionsV1` returns the current storefront integration guide from the server — execute it at the start of any store run.
|
|
56
|
+
- The Hostinger API reference at https://developers.hostinger.com describes every endpoint behind the operations.
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Database and runtime — MySQL hosts, env vars, and what to poll
|
|
2
2
|
|
|
3
|
-
Use this when the app needs MySQL, or when a deploy "succeeded" but the app cannot reach its database. Everything here uses the **hosting** MCP
|
|
3
|
+
Use this when the app needs MySQL, or when a deploy "succeeded" but the app cannot reach its database. Everything here uses the **hosting** MCP operations; Agency Plan differences are at the end.
|
|
4
4
|
|
|
5
5
|
## 1. Create the database
|
|
6
6
|
|
|
7
7
|
1. `hosting_createAccountDatabaseV1` on the site's `username` with a database name, a user and a password you generate — name and user are prefixed with the account username automatically.
|
|
8
|
-
2. Read the full, prefixed `name` and `user` back from `hosting_listAccountDatabasesV1`; every other database
|
|
8
|
+
2. Read the full, prefixed `name` and `user` back from `hosting_listAccountDatabasesV1`; every other database operation wants the full name.
|
|
9
9
|
3. Do not print the password to the user once it is stored, and never commit it.
|
|
10
10
|
|
|
11
11
|
## 2. Which host the app connects to
|
|
@@ -18,7 +18,7 @@ For anything that needs a server or a server-side build (Express, Next.js, NestJ
|
|
|
18
18
|
- Hard cap: **50 MB** archive.
|
|
19
19
|
- The upload starts a build. Track it with `hosting_listJsDeployments`; on failure pull `hosting_showJsDeploymentLogs`, fix, and redeploy. Poll with backoff — builds take minutes, not seconds.
|
|
20
20
|
|
|
21
|
-
## WordPress → `hosting_importWordpressWebsite` / plugin & theme
|
|
21
|
+
## WordPress → `hosting_importWordpressWebsite` / plugin & theme operations
|
|
22
22
|
|
|
23
23
|
Only when the user explicitly wants a WordPress *site* migrated or themed. Site imports take an archive plus a `.sql` dump and can run for several minutes. (WordPress as a headless content backend behind an agent-built frontend is a different flow — that's `WORDPRESS.md`, installed fresh via the API, not imported here.)
|
|
24
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Setup — plan check and website provisioning
|
|
2
2
|
|
|
3
|
-
Everything here uses the **hosting** MCP
|
|
3
|
+
Everything here uses the **hosting** MCP operations. If they're missing, ask the user to enable the Websites product group in the Hostinger Connector (or use the `hostinger-hosting-mcp` scoped binary).
|
|
4
4
|
|
|
5
5
|
## 1. Plan check (gate — run this first)
|
|
6
6
|
|
|
@@ -24,11 +24,11 @@ Generating a subdomain does **not** create a website — deploying straight to i
|
|
|
24
24
|
|
|
25
25
|
1. `hosting_createWebsiteV1 { domain, order_id }` — `datacenter_code` is required only for the first website on a brand-new plan (pick the first entry from `hosting_listAvailableDatacentersV1`).
|
|
26
26
|
2. **Poll** `hosting_listWebsitesV1` filtered by the domain until the site appears. Creation takes up to a few minutes — poll with backoff, don't fail fast.
|
|
27
|
-
3. Note the site's `username` — deployment and database
|
|
27
|
+
3. Note the site's `username` — deployment and database operations are keyed on it.
|
|
28
28
|
|
|
29
29
|
If the domain already has a website (an `iterate` run, or the user pointed at an existing site), skip creation entirely.
|
|
30
30
|
|
|
31
31
|
## 4. Optional extras (only when the run needs them)
|
|
32
32
|
|
|
33
33
|
- **Database:** when the app needs MySQL, follow `DATABASE.md` — creating it, which host each runtime connects to (Node.js must use `127.0.0.1`), and handing the credentials to the app without hard-coding them.
|
|
34
|
-
- **DNS records:** the DNS
|
|
34
|
+
- **DNS records:** the DNS operations (`DNS_updateDNSRecordsV1` etc.) for custom-domain records; take a snapshot (`DNS_getDNSSnapshotListV1` context) before destructive changes.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Store — Hostinger Ecommerce with a custom storefront
|
|
2
2
|
|
|
3
|
-
**Primary source:**
|
|
3
|
+
**Primary source:** execute `ecommerce_getCustomStorefrontSetupInstructionsV1` at the start of every store run — it returns the current, server-maintained integration guide, and it supersedes this file wherever they disagree. This file carries the essentials so the run can be planned before that call, and a fallback when the ecommerce operations aren't enabled.
|
|
4
4
|
|
|
5
5
|
## Mental model
|
|
6
6
|
|
|
@@ -21,7 +21,7 @@ Two API surfaces — don't mix them:
|
|
|
21
21
|
2. For checkout to work the store needs all three: ≥ 1 product, ≥ 1 payment method, ≥ 1 shipping zone. Payment, shipping, and currency are store-level — check `ecommerce_getStoreMetadataV1` (`has_payment_methods` and `has_shipping` must be `true`; the same response carries `default_currency` with `code`, `decimal_digits`, `template` — read it here rather than guessing). Products are **per sales channel** — verify by listing products for the resolved channel on the Storefront API and confirming the list is non-empty. Seed what's missing with `ecommerce_createPhysicalProductV1` / `ecommerce_createDigitalProductV1`, `ecommerce_enableManualPaymentMethodV1`, and `ecommerce_setStoreShippingV1` (price `0` = free shipping) — confirming with the user before writing to a store that already has real data.
|
|
22
22
|
3. After deploy, point the channel at the live site: `ecommerce_updateSalesChannelV1 { store_id, sales_channel_id, url }`.
|
|
23
23
|
|
|
24
|
-
If the ecommerce
|
|
24
|
+
If the ecommerce operations aren't available, ask the user to enable the Ecommerce product group in the Hostinger Connector — or to provide the `sales_channel_id` directly, after which the public endpoints suffice for the frontend work.
|
|
25
25
|
|
|
26
26
|
## Frontend contract
|
|
27
27
|
|
|
@@ -4,7 +4,7 @@ Use WordPress when the site has **owner-managed content**: a blog, news, article
|
|
|
4
4
|
|
|
5
5
|
**When NOT to use this:** static copy that rarely changes (an about page, a services list, a brochure site) — bake that into the frontend. Installing WordPress for content the owner will never edit adds a moving part for nothing. Any buy/sell intent is `STORE.md`, not this file.
|
|
6
6
|
|
|
7
|
-
Everything in Setup uses the **hosting** and **wordpress** MCP
|
|
7
|
+
Everything in Setup uses the **hosting** and **wordpress** MCP operation groups; ask the user to enable them in the Hostinger Connector if operations are missing.
|
|
8
8
|
|
|
9
9
|
## Mental model
|
|
10
10
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Meta-tool logic for the consolidated MCP surface: search, execute and
|
|
2
|
+
// multi-execute over a catalog of API operations. Pure functions only; the
|
|
3
|
+
// runtime supplies the catalog and the function that performs a call.
|
|
4
|
+
// Keep in sync with catalog.ts and with api-remote-mcp's PHP implementation.
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SEARCH_LIMIT = 5;
|
|
7
|
+
export const MAX_SEARCH_LIMIT = 20;
|
|
8
|
+
export const MAX_STEPS = 20;
|
|
9
|
+
|
|
10
|
+
const STEP_REF = /^\$steps\.(\d+)((?:\.[^.]+)*)$/;
|
|
11
|
+
|
|
12
|
+
/** Bad input from the model: reported as an isError result, never a protocol error. */
|
|
13
|
+
export class ToolInputError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'ToolInputError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const meta = (name, title, description, inputSchema, readOnly) => ({
|
|
21
|
+
name,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
inputSchema,
|
|
25
|
+
annotations: { title, readOnlyHint: readOnly, destructiveHint: !readOnly },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const META_TOOLS = [
|
|
29
|
+
meta('search', 'Search API operations',
|
|
30
|
+
"Find API operations by keyword. Returns the best matches, each with its operation name, hints and inputSchema. Pass a result's operation and params matching its inputSchema to execute or multi-execute.",
|
|
31
|
+
{
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
query: { type: 'string', description: 'Keywords, e.g. "list vps" or "dns records".' },
|
|
35
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_LIMIT, default: DEFAULT_SEARCH_LIMIT, description: 'Maximum number of results.' },
|
|
36
|
+
},
|
|
37
|
+
required: ['query'],
|
|
38
|
+
},
|
|
39
|
+
true),
|
|
40
|
+
meta('execute', 'Execute an API operation',
|
|
41
|
+
"Run one API operation found with search. params is a flat object holding the operation's path, query and body parameters as described by its inputSchema.",
|
|
42
|
+
{
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
46
|
+
params: { type: 'object', description: 'Parameters matching the operation inputSchema.' },
|
|
47
|
+
},
|
|
48
|
+
required: ['operation'],
|
|
49
|
+
},
|
|
50
|
+
false),
|
|
51
|
+
meta('multi-execute', 'Execute API operations in sequence',
|
|
52
|
+
'Run up to 20 API operations in order, stopping at the first failure. A params value that is exactly "$steps.<i>.<path>" is replaced by that value from the result of earlier step <i> (dot-separated keys; numbers index arrays), e.g. "$steps.0.0.id".',
|
|
53
|
+
{
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
steps: {
|
|
57
|
+
type: 'array',
|
|
58
|
+
minItems: 1,
|
|
59
|
+
maxItems: MAX_STEPS,
|
|
60
|
+
items: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
64
|
+
params: { type: 'object', description: 'Parameters; may contain "$steps.<i>.<path>" references.' },
|
|
65
|
+
},
|
|
66
|
+
required: ['operation'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ['steps'],
|
|
71
|
+
},
|
|
72
|
+
false),
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
const byteOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
76
|
+
|
|
77
|
+
function toSummary(op) {
|
|
78
|
+
return {
|
|
79
|
+
operation: op.name,
|
|
80
|
+
title: op.title ?? op.name,
|
|
81
|
+
description: op.description ?? '',
|
|
82
|
+
group: op.group ?? null,
|
|
83
|
+
readOnly: op.annotations?.readOnlyHint === true,
|
|
84
|
+
destructive: op.annotations?.destructiveHint === true,
|
|
85
|
+
inputSchema: op.inputSchema,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function searchOperations(operations, query, limit = DEFAULT_SEARCH_LIMIT) {
|
|
90
|
+
if (typeof query !== 'string') throw new ToolInputError('query must be a string');
|
|
91
|
+
const tokens = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
92
|
+
if (tokens.length === 0) throw new ToolInputError('query must contain at least one letter or digit');
|
|
93
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
|
|
94
|
+
throw new ToolInputError(`limit must be an integer from 1 to ${MAX_SEARCH_LIMIT}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return operations
|
|
98
|
+
.map(op => {
|
|
99
|
+
const name = op.name.toLowerCase();
|
|
100
|
+
const group = (op.group ?? '').toLowerCase();
|
|
101
|
+
const title = (op.title ?? '').toLowerCase();
|
|
102
|
+
const description = (op.description ?? '').toLowerCase();
|
|
103
|
+
let score = 0;
|
|
104
|
+
for (const t of tokens) {
|
|
105
|
+
if (name.includes(t)) score += 3;
|
|
106
|
+
if (group.includes(t)) score += 2;
|
|
107
|
+
if (title.includes(t)) score += 2;
|
|
108
|
+
if (description.includes(t)) score += 1;
|
|
109
|
+
}
|
|
110
|
+
return { op, score };
|
|
111
|
+
})
|
|
112
|
+
.filter(s => s.score > 0)
|
|
113
|
+
.sort((a, b) => b.score - a.score || byteOrder(a.op.name, b.op.name))
|
|
114
|
+
.slice(0, limit)
|
|
115
|
+
.map(s => toSummary(s.op));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderSearch(query, results) {
|
|
119
|
+
return results.length > 0
|
|
120
|
+
? JSON.stringify(results)
|
|
121
|
+
: `No operations matched "${query}". Try broader or different terms.`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function findOperation(operations, name, params) {
|
|
125
|
+
if (typeof name !== 'string' || !operations.has(name)) {
|
|
126
|
+
throw new ToolInputError(`Unknown operation "${name}". Use search to find operations.`);
|
|
127
|
+
}
|
|
128
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
129
|
+
throw new ToolInputError('params must be an object');
|
|
130
|
+
}
|
|
131
|
+
const op = operations.get(name);
|
|
132
|
+
const missing = (op.inputSchema?.required ?? []).filter(k => params[k] === undefined || params[k] === null);
|
|
133
|
+
if (missing.length > 0) {
|
|
134
|
+
throw new ToolInputError(`Missing required parameter(s) for ${name}: ${missing.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
return op;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveRefs(value, results, stepIndex) {
|
|
140
|
+
if (typeof value === 'string') {
|
|
141
|
+
const m = STEP_REF.exec(value);
|
|
142
|
+
if (!m) return value;
|
|
143
|
+
const i = Number(m[1]);
|
|
144
|
+
if (i >= stepIndex) {
|
|
145
|
+
throw new ToolInputError(`${value}: references must point to an earlier step`);
|
|
146
|
+
}
|
|
147
|
+
let current = results[i];
|
|
148
|
+
for (const segment of m[2].split('.').slice(1)) {
|
|
149
|
+
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, segment)) {
|
|
150
|
+
throw new ToolInputError(`${value}: path not found in the result of step ${i}`);
|
|
151
|
+
}
|
|
152
|
+
current = current[segment];
|
|
153
|
+
}
|
|
154
|
+
return current;
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(value)) return value.map(v => resolveRefs(v, results, stepIndex));
|
|
157
|
+
if (value !== null && typeof value === 'object') {
|
|
158
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveRefs(v, results, stepIndex)]));
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runSteps(steps, runOne, formatError) {
|
|
164
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > MAX_STEPS) {
|
|
165
|
+
throw new ToolInputError(`steps must be an array of 1 to ${MAX_STEPS} items`);
|
|
166
|
+
}
|
|
167
|
+
const raw = [];
|
|
168
|
+
const results = [];
|
|
169
|
+
for (const [i, step] of steps.entries()) {
|
|
170
|
+
try {
|
|
171
|
+
if (step === null || typeof step !== 'object' || typeof step.operation !== 'string') {
|
|
172
|
+
throw new ToolInputError(`step ${i}: operation must be a string`);
|
|
173
|
+
}
|
|
174
|
+
const params = resolveRefs(step.params ?? {}, raw, i);
|
|
175
|
+
const result = await runOne(step.operation, params);
|
|
176
|
+
raw.push(result);
|
|
177
|
+
results.push({ step: i, operation: step.operation, result });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { results, error: { step: i, message: formatError(error) } };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { results };
|
|
183
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Typed mirror of catalog.js. Keep both files, and api-remote-mcp's PHP
|
|
2
|
+
// implementation, behaviourally identical.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SEARCH_LIMIT = 5;
|
|
5
|
+
export const MAX_SEARCH_LIMIT = 20;
|
|
6
|
+
export const MAX_STEPS = 20;
|
|
7
|
+
|
|
8
|
+
const STEP_REF = /^\$steps\.(\d+)((?:\.[^.]+)*)$/;
|
|
9
|
+
|
|
10
|
+
export interface CatalogOperation {
|
|
11
|
+
name: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
group?: string;
|
|
15
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; [key: string]: unknown };
|
|
16
|
+
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[]; [key: string]: unknown };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OperationSummary {
|
|
20
|
+
operation: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
group: string | null;
|
|
24
|
+
readOnly: boolean;
|
|
25
|
+
destructive: boolean;
|
|
26
|
+
inputSchema: CatalogOperation['inputSchema'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StepResult { step: number; operation: string; result: unknown }
|
|
30
|
+
export interface BatchResult { results: StepResult[]; error?: { step: number; message: string } }
|
|
31
|
+
|
|
32
|
+
export class ToolInputError extends Error {
|
|
33
|
+
constructor(message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'ToolInputError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const meta = (name: string, title: string, description: string, inputSchema: CatalogOperation['inputSchema'], readOnly: boolean) => ({
|
|
40
|
+
name,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
inputSchema,
|
|
44
|
+
annotations: { title, readOnlyHint: readOnly, destructiveHint: !readOnly },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const META_TOOLS = [
|
|
48
|
+
meta('search', 'Search API operations',
|
|
49
|
+
"Find API operations by keyword. Returns the best matches, each with its operation name, hints and inputSchema. Pass a result's operation and params matching its inputSchema to execute or multi-execute.",
|
|
50
|
+
{
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
query: { type: 'string', description: 'Keywords, e.g. "list vps" or "dns records".' },
|
|
54
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_LIMIT, default: DEFAULT_SEARCH_LIMIT, description: 'Maximum number of results.' },
|
|
55
|
+
},
|
|
56
|
+
required: ['query'],
|
|
57
|
+
},
|
|
58
|
+
true),
|
|
59
|
+
meta('execute', 'Execute an API operation',
|
|
60
|
+
"Run one API operation found with search. params is a flat object holding the operation's path, query and body parameters as described by its inputSchema.",
|
|
61
|
+
{
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
65
|
+
params: { type: 'object', description: 'Parameters matching the operation inputSchema.' },
|
|
66
|
+
},
|
|
67
|
+
required: ['operation'],
|
|
68
|
+
},
|
|
69
|
+
false),
|
|
70
|
+
meta('multi-execute', 'Execute API operations in sequence',
|
|
71
|
+
'Run up to 20 API operations in order, stopping at the first failure. A params value that is exactly "$steps.<i>.<path>" is replaced by that value from the result of earlier step <i> (dot-separated keys; numbers index arrays), e.g. "$steps.0.0.id".',
|
|
72
|
+
{
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
steps: {
|
|
76
|
+
type: 'array',
|
|
77
|
+
minItems: 1,
|
|
78
|
+
maxItems: MAX_STEPS,
|
|
79
|
+
items: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
83
|
+
params: { type: 'object', description: 'Parameters; may contain "$steps.<i>.<path>" references.' },
|
|
84
|
+
},
|
|
85
|
+
required: ['operation'],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ['steps'],
|
|
90
|
+
},
|
|
91
|
+
false),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const byteOrder = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
95
|
+
|
|
96
|
+
function toSummary(op: CatalogOperation): OperationSummary {
|
|
97
|
+
return {
|
|
98
|
+
operation: op.name,
|
|
99
|
+
title: op.title ?? op.name,
|
|
100
|
+
description: op.description ?? '',
|
|
101
|
+
group: op.group ?? null,
|
|
102
|
+
readOnly: op.annotations?.readOnlyHint === true,
|
|
103
|
+
destructive: op.annotations?.destructiveHint === true,
|
|
104
|
+
inputSchema: op.inputSchema,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function searchOperations(operations: CatalogOperation[], query: unknown, limit: unknown = DEFAULT_SEARCH_LIMIT): OperationSummary[] {
|
|
109
|
+
if (typeof query !== 'string') throw new ToolInputError('query must be a string');
|
|
110
|
+
const tokens = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
111
|
+
if (tokens.length === 0) throw new ToolInputError('query must contain at least one letter or digit');
|
|
112
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
|
|
113
|
+
throw new ToolInputError(`limit must be an integer from 1 to ${MAX_SEARCH_LIMIT}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return operations
|
|
117
|
+
.map(op => {
|
|
118
|
+
const name = op.name.toLowerCase();
|
|
119
|
+
const group = (op.group ?? '').toLowerCase();
|
|
120
|
+
const title = (op.title ?? '').toLowerCase();
|
|
121
|
+
const description = (op.description ?? '').toLowerCase();
|
|
122
|
+
let score = 0;
|
|
123
|
+
for (const t of tokens) {
|
|
124
|
+
if (name.includes(t)) score += 3;
|
|
125
|
+
if (group.includes(t)) score += 2;
|
|
126
|
+
if (title.includes(t)) score += 2;
|
|
127
|
+
if (description.includes(t)) score += 1;
|
|
128
|
+
}
|
|
129
|
+
return { op, score };
|
|
130
|
+
})
|
|
131
|
+
.filter(s => s.score > 0)
|
|
132
|
+
.sort((a, b) => b.score - a.score || byteOrder(a.op.name, b.op.name))
|
|
133
|
+
.slice(0, limit)
|
|
134
|
+
.map(s => toSummary(s.op));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderSearch(query: string, results: OperationSummary[]): string {
|
|
138
|
+
return results.length > 0
|
|
139
|
+
? JSON.stringify(results)
|
|
140
|
+
: `No operations matched "${query}". Try broader or different terms.`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findOperation<T extends CatalogOperation>(operations: Map<string, T>, name: unknown, params: unknown): T {
|
|
144
|
+
const op = typeof name === 'string' ? operations.get(name) : undefined;
|
|
145
|
+
if (!op) {
|
|
146
|
+
throw new ToolInputError(`Unknown operation "${String(name)}". Use search to find operations.`);
|
|
147
|
+
}
|
|
148
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
149
|
+
throw new ToolInputError('params must be an object');
|
|
150
|
+
}
|
|
151
|
+
const given = params as Record<string, unknown>;
|
|
152
|
+
const missing = (op.inputSchema?.required ?? []).filter(k => given[k] === undefined || given[k] === null);
|
|
153
|
+
if (missing.length > 0) {
|
|
154
|
+
throw new ToolInputError(`Missing required parameter(s) for ${op.name}: ${missing.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
return op;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveRefs(value: unknown, results: unknown[], stepIndex: number): unknown {
|
|
160
|
+
if (typeof value === 'string') {
|
|
161
|
+
const m = STEP_REF.exec(value);
|
|
162
|
+
if (!m) return value;
|
|
163
|
+
const i = Number(m[1]);
|
|
164
|
+
if (i >= stepIndex) {
|
|
165
|
+
throw new ToolInputError(`${value}: references must point to an earlier step`);
|
|
166
|
+
}
|
|
167
|
+
let current: unknown = results[i];
|
|
168
|
+
for (const segment of m[2].split('.').slice(1)) {
|
|
169
|
+
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, segment)) {
|
|
170
|
+
throw new ToolInputError(`${value}: path not found in the result of step ${i}`);
|
|
171
|
+
}
|
|
172
|
+
current = (current as Record<string, unknown>)[segment];
|
|
173
|
+
}
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(value)) return value.map(v => resolveRefs(v, results, stepIndex));
|
|
177
|
+
if (value !== null && typeof value === 'object') {
|
|
178
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveRefs(v, results, stepIndex)]));
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function runSteps(
|
|
184
|
+
steps: unknown,
|
|
185
|
+
runOne: (operation: string, params: Record<string, unknown>) => Promise<unknown>,
|
|
186
|
+
formatError: (error: unknown) => string,
|
|
187
|
+
): Promise<BatchResult> {
|
|
188
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > MAX_STEPS) {
|
|
189
|
+
throw new ToolInputError(`steps must be an array of 1 to ${MAX_STEPS} items`);
|
|
190
|
+
}
|
|
191
|
+
const raw: unknown[] = [];
|
|
192
|
+
const results: StepResult[] = [];
|
|
193
|
+
for (const [i, step] of steps.entries()) {
|
|
194
|
+
try {
|
|
195
|
+
if (step === null || typeof step !== 'object' || typeof step.operation !== 'string') {
|
|
196
|
+
throw new ToolInputError(`step ${i}: operation must be a string`);
|
|
197
|
+
}
|
|
198
|
+
const params = resolveRefs(step.params ?? {}, raw, i) as Record<string, unknown>;
|
|
199
|
+
const result = await runOne(step.operation, params);
|
|
200
|
+
raw.push(result);
|
|
201
|
+
results.push({ step: i, operation: step.operation, result });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { results, error: { step: i, message: formatError(error) } };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { results };
|
|
207
|
+
}
|
package/src/core/instructions.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated server instructions
|
|
2
|
-
export default "Hostinger API notes for agents.
|
|
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_listAccountDatabasesV1 (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_createDatabaseRemoteConnectionV1. 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_createWebsiteV1 -> poll hosting_listWebsitesV1 until the domain appears (a few minutes).\n- hosting_startNode_jsBuildV1 -> hosting_getNode_jsBuildDetailsV1; logs via hosting_getNodeJSBuildLogsV1; on failure hosting_analyseFailedNode_jsBuildV1.\n- hosting_installWordPressV1 and WordPress plugin/theme/core jobs -> poll hosting_listWordPressInstallationsV1.\n- agency-hosting_createANewWebsiteV1 -> poll agency-hosting_getWebsiteSetupStatusV1 with setup_uuid; other Agency Plan jobs -> agency-hosting_listWebsiteProcessesV1.\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_replaceNode_jsEnvironmentVariablesV1 (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";
|