@hostinger/mcp 3.4.1 → 3.5.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 +11 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/steps/02-refine/SKILL.md +7 -0
- package/skills/headless/steps/03-provision/wordpress/SKILL.md +63 -0
- package/skills/headless/steps/04-content-model/wordpress/SKILL.md +58 -5
- package/skills/headless/steps/04-content-model/wordpress/contact-form.md +227 -0
- package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +481 -0
- package/skills/headless/steps/06-frontend/SKILL.md +15 -0
- package/skills/headless/steps/07-validate/SKILL.md +13 -0
- package/skills/headless/steps/09-done/SKILL.md +5 -0
- package/src/core/skills.js +32 -18
- package/src/core/skills.ts +32 -18
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/agent-readiness.js +1 -1
- package/src/servers/agent-readiness.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,17 @@ Generated at release time from the diff between the previous and current
|
|
|
4
4
|
build. Tool changes are listed individually; everything else is summarised
|
|
5
5
|
by area.
|
|
6
6
|
|
|
7
|
+
## v3.5.0 — 2026-10-21
|
|
8
|
+
|
|
9
|
+
### Also in this release
|
|
10
|
+
|
|
11
|
+
- Bundled agent skills: 8 files
|
|
12
|
+
- Other files: `src/core/skills.js`, `src/core/skills.ts`
|
|
13
|
+
|
|
14
|
+
## v3.4.2 — 2026-10-21
|
|
15
|
+
|
|
16
|
+
No changes to the published artifact.
|
|
17
|
+
|
|
7
18
|
## v3.4.1 — 2026-10-21
|
|
8
19
|
|
|
9
20
|
### Operations
|
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": "3.
|
|
9
|
+
"version": "3.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@hostinger/mcp",
|
|
14
|
-
"version": "3.
|
|
14
|
+
"version": "3.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -109,6 +109,13 @@ If the user asks for something outside the scope (user accounts, member areas, b
|
|
|
109
109
|
calendar backend, user-generated content), say plainly that the headless flow doesn't support it yet
|
|
110
110
|
and record it under **Out of scope** — don't promise it.
|
|
111
111
|
|
|
112
|
+
> **A contact form *is* supported (WordPress backends).** If the user wants one, keep it in
|
|
113
|
+
> **Must-haves** and name it "Contact form". Provision installs **Contact Form 7** on `/cms` and the
|
|
114
|
+
> frontend wires a contact page whose form emails the site owner through WordPress's built-in
|
|
115
|
+
> `wp_mail()` — no third-party service and no API keys. It's **opt-in**: built only when the brief asks
|
|
116
|
+
> for it. (A form that needs accounts, payments or a booking calendar is still out of scope — this is a
|
|
117
|
+
> plain name/email/message enquiry form.)
|
|
118
|
+
|
|
112
119
|
### Build mode — ask once (this one *is* a user choice)
|
|
113
120
|
|
|
114
121
|
The backend is your call, but **how the frontend gets built is the user's** — because most of the time
|
|
@@ -190,6 +190,46 @@ it once `provision.status == "completed"`. The authoritative data-layer referenc
|
|
|
190
190
|
> user for payment credentials. Store currency and address (`woocommerce_*` options) likewise aren't
|
|
191
191
|
> settable headlessly — they follow the install default and the owner can change them in wp-admin.
|
|
192
192
|
|
|
193
|
+
## Step 5b — Contact form (Contact Form 7) *(only when the brief's Must-haves include a contact form — skip otherwise)*
|
|
194
|
+
|
|
195
|
+
Run this only when the brief's **Must-haves** include a contact form. It installs **Contact Form 7**
|
|
196
|
+
(`contact-form-7`) on this same `/cms` install, so the frontend can render a contact page whose form
|
|
197
|
+
emails the site owner through WordPress's built-in `wp_mail()` — no third-party service and no API keys.
|
|
198
|
+
Like Yoast/WooCommerce this is **join-half** work: run it once `provision.status == "completed"`. The form
|
|
199
|
+
itself (id + fields + recipient) — and a tiny **spam-guard plugin** that makes a honeypot work headlessly —
|
|
200
|
+
are resolved/deployed in the content-model push — see
|
|
201
|
+
[`../../04-content-model/wordpress/contact-form.md`](../../04-content-model/wordpress/contact-form.md).
|
|
202
|
+
|
|
203
|
+
> **Why CF7 and not a bespoke mailer.** A contact form is maintained, spam-hardened functionality worth
|
|
204
|
+
> reusing rather than reinventing (you *can* deploy a custom plugin to a headless `/cms` — see the
|
|
205
|
+
> content-model cookbook — but you needn't write your own mailer). CF7 is a wordpress.org-slug plugin,
|
|
206
|
+
> installs through the same management MCP as Yoast, and ships a **public, unauthenticated** submit
|
|
207
|
+
> endpoint (`POST /cms/wp-json/contact-form-7/v1/contact-forms/<id>/feedback`). The frontend and `/cms`
|
|
208
|
+
> are same-origin, so the browser posts to it directly — no proxy, no CORS, no secret on the client.
|
|
209
|
+
|
|
210
|
+
1. **Install + activate CF7** (management MCP, async — poll, never blind-retry):
|
|
211
|
+
- `wordpress_plugins_install { username, software: <install_id>, plugins: ["contact-form-7"] }`
|
|
212
|
+
- Poll `wordpress_plugins_list-installed { username, software }` until `contact-form-7` shows `active`
|
|
213
|
+
(install + activate can complete together), or activate it with
|
|
214
|
+
`wordpress_plugins_activate { username, software, plugin: "contact-form-7" }`.
|
|
215
|
+
- **Don't install the `contact-form-7-honeypot` add-on.** It only acts when a `[honeypot]` form-tag is
|
|
216
|
+
present in the form — a wp-admin edit the headless agent can't make — so on the default form it does
|
|
217
|
+
nothing. Headless spam protection instead comes from a small **spam-guard plugin deployed in the
|
|
218
|
+
content-model push** (it hooks `wpcf7_spam`); see the cookbook.
|
|
219
|
+
- **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job
|
|
220
|
+
succeeded**. Treat a 500 as "verify, don't assume": poll `list-installed` before retrying, and
|
|
221
|
+
never fire a second install blindly.
|
|
222
|
+
2. **Verify** `GET https://<domain>/cms/wp-json/` lists the **`contact-form-7/v1`** namespace, then
|
|
223
|
+
record `contact_form.enabled: true` in `site.json` (Step 6). If the install can't be confirmed after
|
|
224
|
+
one retry, record `contact_form.enabled: false`, tell the user in one line, and let the frontend
|
|
225
|
+
render the contact page without a form (graceful — not a build failure). Don't fail provision over it.
|
|
226
|
+
|
|
227
|
+
The owner's email is **CF7's default recipient** (`[_site_admin_email]`, the admin address from
|
|
228
|
+
install) — fine out of the box, so "does Hostinger set up the email?" is **yes** for basic delivery via
|
|
229
|
+
`wp_mail`. Deliverability (SPF/DKIM) is most reliable when that address is a mailbox **on the same
|
|
230
|
+
domain**; the content-model push keeps the default recipient and validate sends a test submission to
|
|
231
|
+
confirm mail actually leaves. Never ask the user for mail credentials.
|
|
232
|
+
|
|
193
233
|
## Step 6 — Record `site.json`
|
|
194
234
|
|
|
195
235
|
```json
|
|
@@ -241,6 +281,29 @@ No passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-
|
|
|
241
281
|
}
|
|
242
282
|
```
|
|
243
283
|
|
|
284
|
+
**For a brief with a contact form (CF7 installed in Step 5b), also add a `contact_form` block.**
|
|
285
|
+
Provision records only `enabled` + `plugin` here; the content-model push fills `form_id`,
|
|
286
|
+
`feedback_url`, `fields` and `honeypot_field` once it resolves the form and deploys the spam guard:
|
|
287
|
+
|
|
288
|
+
```json
|
|
289
|
+
{
|
|
290
|
+
"contact_form": {
|
|
291
|
+
"enabled": true,
|
|
292
|
+
"plugin": "contact-form-7",
|
|
293
|
+
"form_id": null,
|
|
294
|
+
"feedback_url": null,
|
|
295
|
+
"fields": [],
|
|
296
|
+
"honeypot_field": null,
|
|
297
|
+
"recipient": "site-admin-email"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`form_id` / `feedback_url` / `fields` / `honeypot_field` stay `null` / `[]` until content-model resolves the form (the
|
|
303
|
+
submit URL is `https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`).
|
|
304
|
+
`recipient` is informational — CF7's `To` is `[_site_admin_email]`; no address, token or secret is
|
|
305
|
+
stored in `site.json`.
|
|
306
|
+
|
|
244
307
|
`rest_api` stays `wp/v2` (the WordPress content contract, for the blog/pages on the same site);
|
|
245
308
|
`store_api` is the public storefront surface (browse/cart/coupons/checkout, no auth). `wc_rest_api`
|
|
246
309
|
(`wc/v3`) is recorded only as the surface the **owner's own** integrations use with a consumer key they
|
|
@@ -41,6 +41,20 @@ authenticated; the admin user context (`wp_set_current_user`) exists only *insid
|
|
|
41
41
|
> (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce
|
|
42
42
|
> catalog** section before you hand off.
|
|
43
43
|
|
|
44
|
+
> **Needs more than posts and pages? See [`custom-plugins.md`](custom-plugins.md).** The per-site MCP can
|
|
45
|
+
> create content *data* but can't register a **custom post type**, a **custom taxonomy**, or a **global
|
|
46
|
+
> settings page** (footer text, phone, social links the frontend reads). When the content model calls for
|
|
47
|
+
> those, author a small custom plugin and deploy it into the headless `/cms` with
|
|
48
|
+
> `hosting_deploy { type: "wordpress-plugin", headless: true }`, exposing everything over REST for the
|
|
49
|
+
> frontend. **Deploy the plugin first** (the schema — the deploy activates it), then the steps below populate
|
|
50
|
+
> its entries through the MCP's native `cpt-create` ability (custom-taxonomy terms are seeded in the plugin — see the cookbook).
|
|
51
|
+
|
|
52
|
+
> **A contact form on this site? See [`contact-form.md`](contact-form.md).** When the brief's
|
|
53
|
+
> **Must-haves** include a contact form (`site.json → contact_form.enabled: true`), provision installed
|
|
54
|
+
> **Contact Form 7**; this push resolves the form's numeric id + field names (in the **same session**)
|
|
55
|
+
> and records them into `site.json` so the Step 6 frontend can submit to CF7's public feedback endpoint.
|
|
56
|
+
> Run the **Contact form** section after the WordPress content below.
|
|
57
|
+
|
|
44
58
|
## Step 1 — Open the authenticated MCP session
|
|
45
59
|
|
|
46
60
|
Mint a short-lived token through the Hostinger connector:
|
|
@@ -75,9 +89,10 @@ what's there; with discovery, list abilities, then execute by ability name. The
|
|
|
75
89
|
cover this step (exact names come from discovery — don't hardcode them):
|
|
76
90
|
|
|
77
91
|
`SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·
|
|
78
|
-
`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
`PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`, plus **`cpt-*`** for custom post types. There
|
|
93
|
+
is **no** generic `wp/v2` passthrough tool — every write goes through a dedicated ability, so a `wp/v2`
|
|
94
|
+
route with no matching ability (e.g. the terms of a *custom* taxonomy) isn't reachable from the MCP;
|
|
95
|
+
register and seed those in a custom plugin instead (see the backend's custom-plugins cookbook).
|
|
81
96
|
|
|
82
97
|
Seen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are
|
|
83
98
|
`hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and
|
|
@@ -92,12 +107,18 @@ create answers **`200`** (not `201`) and the new object's `id` is **inside** `re
|
|
|
92
107
|
## Step 3 — Idempotency rule (makes resume safe)
|
|
93
108
|
|
|
94
109
|
Every create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.
|
|
95
|
-
Use the matching read ability (
|
|
96
|
-
reuse its `id`; empty → create. Capture each returned `id` and record the id map back into
|
|
110
|
+
Use the matching read ability (`posts-search`, `pages-search`, `categories-list`, `tags-list`,
|
|
111
|
+
`cpt-search`, `media-search`, …): a hit → reuse its `id`; empty → create. Capture each returned `id` and record the id map back into
|
|
97
112
|
`content-model.md` so later references resolve without re-querying.
|
|
98
113
|
|
|
99
114
|
## Step 4 — Create in dependency order
|
|
100
115
|
|
|
116
|
+
> **Custom types/settings? Deploy the plugin before this list.** If the content model needs a custom post
|
|
117
|
+
> type, taxonomy or global settings page, deploy that plugin **first** (the deploy activates it — see
|
|
118
|
+
> [`custom-plugins.md`](custom-plugins.md)) so its REST routes exist — then the steps below populate its
|
|
119
|
+
> entries through the MCP's native `cpt-create` ability, as they do core posts and pages (custom-taxonomy
|
|
120
|
+
> terms are seeded in the plugin — the MCP has no taxonomy-term path; see the cookbook).
|
|
121
|
+
|
|
101
122
|
0. **Remove WordPress's default content first.** A fresh install ships a **"Hello world!" post**
|
|
102
123
|
(id 1), a **"Sample Page"**, and a default comment — they will otherwise show up on the live site.
|
|
103
124
|
Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page
|
|
@@ -216,6 +237,38 @@ the storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.
|
|
|
216
237
|
> finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call
|
|
217
238
|
> `/checkout` or block on it. See the cookbook §4.
|
|
218
239
|
|
|
240
|
+
## Contact form (Contact Form 7) *(only when `site.json → contact_form.enabled` is true — skip otherwise)*
|
|
241
|
+
|
|
242
|
+
Provision installed Contact Form 7. Here, resolve the form the frontend submits to, deploy a tiny
|
|
243
|
+
spam-guard plugin (a headless honeypot), and record both into `site.json`. Full recipe:
|
|
244
|
+
[`contact-form.md`](contact-form.md). In short:
|
|
245
|
+
|
|
246
|
+
1. **Find the form id (idempotent).** The per-site MCP has **no generic REST ability** and
|
|
247
|
+
`wpcf7_contact_form` is not a public CPT, so read the id by **probing the public feedback endpoint**:
|
|
248
|
+
a **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with
|
|
249
|
+
`_wpcf7_unit_tag=wpcf7-f<id>-o1`, for `id` in `1`‒`30`. The hit is the id whose `200` JSON reply carries
|
|
250
|
+
`"contact_form_id": <id>` (plus a CF7 `"status"`) — the default **"Contact form 1"** (fields `your-name`,
|
|
251
|
+
`your-email`, `your-subject`, `your-message`); `404`/`wpcf7_not_found` is a miss, and anything else
|
|
252
|
+
(`415`, `403`, `429`, `5xx`) is inconclusive, never a hit. **The content type matters:** CF7 answers
|
|
253
|
+
`415` to a non-multipart body *before* it looks up the id, so a bare POST would wrongly pick id 1. Reuse
|
|
254
|
+
it on re-run. CF7's default mail already sends to `[_site_admin_email]`, so no recipient change is needed.
|
|
255
|
+
*(Fallback if nothing answers: hand the owner the one-click wp-admin login — `wordpress_login_create-links`
|
|
256
|
+
— to read the id from Contact → Contact Forms, the WooCommerce-checkout-gate owner-handoff pattern.)* See
|
|
257
|
+
the cookbook for the full method.
|
|
258
|
+
2. **Deploy the spam guard (honeypot).** The `contact-form-7-honeypot` add-on can't work headlessly (it
|
|
259
|
+
needs a `[honeypot]` form-tag only an owner can add), so deploy a ~15-line `wpcf7_spam` plugin to `/cms`
|
|
260
|
+
with `hosting_deploy { type: "wordpress-plugin", slug: "cf7-headless-spamguard", headless: true }` — it
|
|
261
|
+
flags a submission as `spam` (no mail) when a fixed decoy field (`your_website`) is filled. Cookbook §2b.
|
|
262
|
+
3. **Record into `site.json`** — fill the `contact_form` block provision started: `form_id`,
|
|
263
|
+
`feedback_url` (`{urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`), `fields`
|
|
264
|
+
(the form's input names, e.g. `["your-name","your-email","your-subject","your-message"]`) and
|
|
265
|
+
`honeypot_field` (`"your_website"`, the decoy the frontend renders empty). Note the form id in
|
|
266
|
+
`content-model.md` under `## Created in WordPress`.
|
|
267
|
+
|
|
268
|
+
> **Don't send a real test here.** Resolving the form id + fields is the whole job for this stage; the
|
|
269
|
+
> throwaway `mail_sent` test belongs to **validate** (Step 7), so one dummy email reaches the owner, not
|
|
270
|
+
> several. See the cookbook for the feedback contract and the hidden `_wpcf7*` fields the frontend needs.
|
|
271
|
+
|
|
219
272
|
## Step 6 — Hand off
|
|
220
273
|
|
|
221
274
|
Update `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# headless — Contact Form 7 cookbook (contact form on headless WordPress)
|
|
2
|
+
|
|
3
|
+
A companion recipe to `SKILL.md` (WordPress content model). Use it whenever the brief's **Must-haves**
|
|
4
|
+
include a contact form (`site.json → contact_form.enabled: true`). The goal: a contact page whose form,
|
|
5
|
+
submitted from the **decoupled frontend**, sends the enquiry to the site owner by email using
|
|
6
|
+
WordPress's built-in `wp_mail()` — no third-party form service, no API keys, no secret on the client.
|
|
7
|
+
|
|
8
|
+
**Contact Form 7 is not a separate backend.** It is the `contact-form-7` plugin on the same headless
|
|
9
|
+
WordPress install at `/cms` (installed in the provision step). This cookbook is the authoritative home for
|
|
10
|
+
the contact-form data layer: how to **resolve the form** (content-model push), **deploy a spam guard**,
|
|
11
|
+
what the **frontend submits** (Step 6), and how **validate** proves mail actually leaves.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1 — Why CF7, and why it works headlessly
|
|
16
|
+
|
|
17
|
+
You **could** deploy a custom plugin into the headless `/cms` and write a bespoke mailer (see
|
|
18
|
+
[`custom-plugins.md`](custom-plugins.md)), but a contact form is exactly the kind of maintained,
|
|
19
|
+
spam-hardened functionality you should **reuse rather than reinvent**. CF7 is the right choice because:
|
|
20
|
+
|
|
21
|
+
- It is a **wordpress.org-slug plugin** → installs through the same management MCP (`wordpress_plugins_install`)
|
|
22
|
+
as Yoast and WooCommerce. No custom code to deploy.
|
|
23
|
+
- It ships a **public, unauthenticated submit endpoint**:
|
|
24
|
+
`POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`. Submissions don't need
|
|
25
|
+
the JWT or any key.
|
|
26
|
+
- The frontend (apex domain) and `/cms` are **same-origin**, so the browser posts straight to that
|
|
27
|
+
endpoint — **no proxy route, no CORS, no credentials client-side.**
|
|
28
|
+
- On submit CF7 runs its mail template through `wp_mail()`, which on Hostinger routes through the
|
|
29
|
+
server's mailer. The default form's `To` is `[_site_admin_email]` — the admin address set at install —
|
|
30
|
+
so **basic email delivery works out of the box.**
|
|
31
|
+
|
|
32
|
+
**Spam protection comes from a tiny deployed plugin, not the Honeypot add-on.** CF7's own
|
|
33
|
+
`contact-form-7-honeypot` add-on only acts when a `[honeypot]` form-tag is present in the form — a wp-admin
|
|
34
|
+
edit the headless agent can't make — so on the default form it does **nothing**. Instead, deploy a
|
|
35
|
+
~15-line `wpcf7_spam` guard into `/cms` (the capability from [`custom-plugins.md`](custom-plugins.md)): it
|
|
36
|
+
rejects a submission whose decoy field is filled, with no owner step and no keys. See *§2b — Spam
|
|
37
|
+
protection* below. (reCAPTCHA is the alternative but needs owner-provided Google keys set in wp-admin —
|
|
38
|
+
out of reach for the headless agent.)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 2 — Content-model push: resolve the form and record it
|
|
43
|
+
|
|
44
|
+
A fresh CF7 install ships one form, **"Contact form 1"**, with the default fields `your-name`,
|
|
45
|
+
`your-email`, `your-subject`, `your-message` and a submit, and mails `[_site_admin_email]`. You need
|
|
46
|
+
its **numeric form id** (and, if you tailored the form, its field names) to build `feedback_url`.
|
|
47
|
+
|
|
48
|
+
> **Reality check — how to read the id.** The per-site WP MCP (Hostinger AI Assistant, discovery mode)
|
|
49
|
+
> exposes **no generic REST/`RestEndpointTool` ability**, and `wpcf7_contact_form` is **not a public CPT**
|
|
50
|
+
> (it is absent from `cpt-list-types` and from `wp/v2/types`), so the MCP cannot read the form, and CF7's
|
|
51
|
+
> own `GET /contact-form-7/v1/contact-forms` is capability-gated (no public read). The reliable,
|
|
52
|
+
> tool-free way is to **probe the public feedback endpoint**, which needs no auth.
|
|
53
|
+
|
|
54
|
+
1. **Find the form id by probing the feedback endpoint (idempotent).** For `id` in a small range
|
|
55
|
+
(`1`‒`30` covers a fresh install — the default form lands a few ids after the sample content), send a
|
|
56
|
+
**`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with
|
|
57
|
+
a single field `_wpcf7_unit_tag=wpcf7-f<id>-o1`. **The content type matters:** CF7 checks it *before*
|
|
58
|
+
it looks up the id and answers **415** to any non-multipart body, so a bare POST returns non-404 for
|
|
59
|
+
*every* id and would wrongly pick id 1.
|
|
60
|
+
- **Hit** → `200` whose JSON body carries `"contact_form_id": <id>` (matching the id you probed) and a
|
|
61
|
+
CF7 `"status"` (e.g. `validation_failed`). **This id is the form.** On a fresh single-form install
|
|
62
|
+
that is the only hit; reuse it on a re-run.
|
|
63
|
+
- **Miss** → `404` with `"code": "wpcf7_not_found"`. Keep going.
|
|
64
|
+
- **Inconclusive** → anything else (`415` from a wrong content type, `403`, `429`, `5xx`) is **not** a
|
|
65
|
+
hit — never select such an id. Fix the request or retry, widen the range, and fall back to the owner
|
|
66
|
+
handoff (step 2) if nothing resolves.
|
|
67
|
+
|
|
68
|
+
The default field names are `your-name`, `your-email`, `your-subject`, `your-message`. (A full test
|
|
69
|
+
submission that confirms both the id and the field names — and that mail actually sends — belongs to
|
|
70
|
+
**validate**, so you send just one real email; see §4.)
|
|
71
|
+
|
|
72
|
+
2. **(Optional) tailor the form — owner-side.** The headless tooling can't edit the CF7 form (no REST
|
|
73
|
+
ability reaches it). If the design needs different fields, keep the default form for the build and note
|
|
74
|
+
it; the owner edits fields/recipient in wp-admin (Contact → Contact Forms) via the one-click login.
|
|
75
|
+
The default form is enough for a name/email/subject/message enquiry, and its recipient
|
|
76
|
+
(`[_site_admin_email]`) is already correct.
|
|
77
|
+
|
|
78
|
+
3. **Record into `site.json`** — fill the `contact_form` block provision started:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"contact_form": {
|
|
83
|
+
"enabled": true,
|
|
84
|
+
"plugin": "contact-form-7",
|
|
85
|
+
"form_id": 4,
|
|
86
|
+
"feedback_url": "https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/4/feedback",
|
|
87
|
+
"fields": ["your-name", "your-email", "your-subject", "your-message"],
|
|
88
|
+
"cf7_version": "6.2.2",
|
|
89
|
+
"honeypot_field": "your_website",
|
|
90
|
+
"recipient": "site-admin-email"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`cf7_version` is the installed CF7 version (from `wordpress_plugins_list-installed`) — the frontend
|
|
96
|
+
sends it as `_wpcf7_version`. `honeypot_field` is the decoy the spam guard checks (§2b) — the frontend
|
|
97
|
+
renders it empty and the server rejects a filled one. Also note the form id in `content-model.md`. No
|
|
98
|
+
address, token or secret goes into `site.json`.
|
|
99
|
+
|
|
100
|
+
> **Fallback if the probe finds nothing** (an unusual install, or the default form was removed): mint a
|
|
101
|
+
> one-click wp-admin link (`wordpress_login_create-links { username, software }`) and ask the owner to
|
|
102
|
+
> read the form id from **Contact → Contact Forms** (the shortcode shows `id="…"`). Record it into
|
|
103
|
+
> `site.json` and continue — the same owner-handoff used for the WooCommerce checkout gate, not a build
|
|
104
|
+
> failure.
|
|
105
|
+
|
|
106
|
+
> **Aside — Hostinger's own shortcode.** The per-site MCP's `pages-create` ability suggests
|
|
107
|
+
> `[hostinger_contact_form]` for a contact form *inside a WordPress page*. That is server-rendered
|
|
108
|
+
> shortcode output, not useful to a decoupled React frontend that reads REST JSON — this flow uses CF7's
|
|
109
|
+
> JSON feedback endpoint instead. Mentioned only so the shortcode hint isn't mistaken for the headless path.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 2b — Spam protection: deploy the honeypot guard
|
|
114
|
+
|
|
115
|
+
CF7's default form has **no spam protection**, and the `contact-form-7-honeypot` add-on can't help
|
|
116
|
+
headlessly (it needs a `[honeypot]` form-tag only an owner can add — see §1). Instead, deploy a tiny
|
|
117
|
+
plugin that enforces a honeypot **server-side**, on the same REST feedback path the frontend uses. This
|
|
118
|
+
reuses the custom-plugin deploy from [`custom-plugins.md`](custom-plugins.md) — the only moving part is one
|
|
119
|
+
~15-line file.
|
|
120
|
+
|
|
121
|
+
**Why it works.** CF7's `wpcf7_spam` filter runs inside `submit()`, which the REST feedback endpoint
|
|
122
|
+
calls, so returning `true` sets the submission `status` to `spam` and **suppresses the mail**. The hook can
|
|
123
|
+
read any posted field via `WPCF7_Submission::get_posted_data()` — including a decoy field that isn't a CF7
|
|
124
|
+
form-tag, **as long as its name doesn't start with `_`** (CF7 strips `_`-prefixed keys). So the frontend
|
|
125
|
+
sends a decoy field (empty for humans), and the guard flags the submission as spam when a bot fills it.
|
|
126
|
+
|
|
127
|
+
1. **Write the guard** to `~/.hostinger/<domain>/plugins/cf7-headless-spamguard/cf7-headless-spamguard.php`:
|
|
128
|
+
|
|
129
|
+
```php
|
|
130
|
+
<?php
|
|
131
|
+
/**
|
|
132
|
+
* Plugin Name: CF7 Headless Spam Guard
|
|
133
|
+
* Description: Server-side honeypot for Contact Form 7 submissions from a headless frontend.
|
|
134
|
+
* Version: 1.0.0
|
|
135
|
+
* Requires PHP: 7.4
|
|
136
|
+
* License: GPL-2.0-or-later
|
|
137
|
+
*/
|
|
138
|
+
if ( ! defined( 'ABSPATH' ) ) { exit; }
|
|
139
|
+
|
|
140
|
+
// Reject a submission whose decoy field is filled. The name is a fixed convention the frontend
|
|
141
|
+
// renders empty (site.json → contact_form.honeypot_field); it must NOT start with "_".
|
|
142
|
+
add_filter( 'wpcf7_spam', function ( $spam, $submission ) {
|
|
143
|
+
if ( $spam ) { return $spam; } // keep any earlier spam verdict
|
|
144
|
+
$decoy = $submission->get_posted_data( 'your_website' );
|
|
145
|
+
if ( is_string( $decoy ) && trim( $decoy ) !== '' ) {
|
|
146
|
+
$spam = true; // bot filled it → status 'spam', no mail
|
|
147
|
+
}
|
|
148
|
+
return $spam;
|
|
149
|
+
}, 10, 2 );
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
2. **Deploy it** into the headless `/cms` (activates on install; idempotent — re-deploy to update):
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{ "operation": "hosting_deploy",
|
|
156
|
+
"params": {
|
|
157
|
+
"domain": "<domain>",
|
|
158
|
+
"type": "wordpress-plugin",
|
|
159
|
+
"path": "~/.hostinger/<domain>/plugins/cf7-headless-spamguard",
|
|
160
|
+
"slug": "cf7-headless-spamguard",
|
|
161
|
+
"headless": true } }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
3. **Record** `contact_form.honeypot_field: "your_website"` in `site.json` (done in §2 step 3) so the
|
|
165
|
+
frontend knows which decoy to render. Keep the plugin **generic** (no site-specific values) — it's one
|
|
166
|
+
reusable artifact, not per-site code.
|
|
167
|
+
|
|
168
|
+
> **Nonce note.** CF7's built-in spam check also flags nonce failure, but nonce verification is **inactive
|
|
169
|
+
> for logged-out visitors by default** — which is why a plain headless submission returns `mail_sent`, not
|
|
170
|
+
> `spam`. The guard adds the honeypot on top. Confirm end-to-end in validate (§4).
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 3 — What the frontend submits (Step 6 reference)
|
|
175
|
+
|
|
176
|
+
The feedback endpoint expects `multipart/form-data` with the form's own fields **plus** CF7's hidden
|
|
177
|
+
control fields. The frontend builds this in a `submitContactForm(data)` helper in the one data module:
|
|
178
|
+
|
|
179
|
+
| Field | Value |
|
|
180
|
+
|---|---|
|
|
181
|
+
| each name in `contact_form.fields` | the user's input (`your-name`, `your-email`, …) |
|
|
182
|
+
| `_wpcf7` | `contact_form.form_id` (number) |
|
|
183
|
+
| `_wpcf7_version` | CF7 version string, e.g. `"5.9.8"` (not strictly validated) |
|
|
184
|
+
| `_wpcf7_locale` | e.g. `"en_US"` |
|
|
185
|
+
| `_wpcf7_unit_tag` | any stable string, e.g. `"wpcf7-f<form_id>-o1"` |
|
|
186
|
+
| `_wpcf7_container_post` | `"0"` |
|
|
187
|
+
| `contact_form.honeypot_field` (the decoy, e.g. `your_website`) | **empty** — rendered hidden/off-screen, `autocomplete="off"`, `tabindex="-1"`; the §2b guard rejects it as spam if filled |
|
|
188
|
+
|
|
189
|
+
`POST` to `contact_form.feedback_url` and **do not set `Content-Type`** — let the browser add the
|
|
190
|
+
multipart boundary. Same-origin, so no CORS and no secret.
|
|
191
|
+
|
|
192
|
+
**Response** is JSON:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{ "contact_form_id": 42, "status": "mail_sent", "message": "Thank you for your message. It has been sent.",
|
|
196
|
+
"invalid_fields": [], "posted_data_hash": "…" }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Branch on `status`:
|
|
200
|
+
|
|
201
|
+
- `mail_sent` → success state (clear the form, show the thank-you message).
|
|
202
|
+
- `validation_failed` → map each `invalid_fields[].field` to its input and show the message inline.
|
|
203
|
+
- `spam` / `aborted` / `mail_failed` / `acceptance_missing` → a generic "couldn't send, try again" error.
|
|
204
|
+
|
|
205
|
+
A user validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is
|
|
206
|
+
`false` or absent, the frontend renders the contact page with plain contact details and **no form**.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 4 — Email delivery & the `wp_mail` caveat (what to tell the owner)
|
|
211
|
+
|
|
212
|
+
CF7's default form mails `[_site_admin_email]` via `wp_mail()`, so **Hostinger does "set up" basic email
|
|
213
|
+
by default** — nothing else to configure for the form to send. Two caveats worth surfacing:
|
|
214
|
+
|
|
215
|
+
- **Deliverability.** `wp_mail` from a shared host can land in spam for external recipients without
|
|
216
|
+
SPF/DKIM. The most reliable recipient is a **Hostinger mailbox on the same domain**. Keep CF7's admin
|
|
217
|
+
recipient; if the owner wants a different inbox, point them to a same-domain mailbox.
|
|
218
|
+
- **Prove it, don't assume it.** Validate (Step 7) POSTs a throwaway test submission and asserts
|
|
219
|
+
`status == "mail_sent"`. A `mail_failed` is a real finding — report it (CF7 is wired, but the mailer
|
|
220
|
+
needs attention), don't pass silently.
|
|
221
|
+
- **Check the honeypot too.** Send one more throwaway submission with the decoy
|
|
222
|
+
(`contact_form.honeypot_field`) **filled** — it must come back `status: "spam"` with **no** mail. If it
|
|
223
|
+
returns `mail_sent`, the §2b guard plugin isn't active (re-check its deploy). The normal empty-decoy
|
|
224
|
+
submission still returns `mail_sent`, so this costs one extra throwaway email at most.
|
|
225
|
+
|
|
226
|
+
Don't install an SMTP plugin or ask for mail credentials as part of this flow — the default `wp_mail`
|
|
227
|
+
path is the intended behaviour; SMTP is an owner decision in wp-admin if deliverability needs hardening.
|