One class per ability
Every ability is its own class extending a shared abstract base, which assembles the entire wp_register_ability() argument array from the subclass’s identity and schema methods. That is the base’s whole purpose: meta.mcp.public is set centrally and cannot be omitted by an individual ability. Forgetting it is a silent failure — registration succeeds, and the only symptom is the ability never appearing in the MCP tool list, surfacing much later as an ability_not_public_mcp line in the debug log.
The plugin arrived here by stages: one god class, then four domain classes, then this. The domain split was an improvement that stopped short — a class holding three abilities still lets one of them diverge from the other two unnoticed.
Two allowlists, deliberately not one
Readable and writable post types are separate filterable methods with separate filter names, and that separation is the point. Sharing one filter would mean that adding a post type so it could be written would silently also change what could be read — coupling two decisions with very different risk profiles.
Both are inclusion allowlists rather than exclusion lists. This network has nineteen public post types, most with a purpose-built ability enforcing invariants of their own. An exclusion list defaults open: every new CPT would be writable through here until somebody remembered to add it.
Status allowlist, not a publish check
update-post proceeds only when the post’s current status is draft, pending or auto-draft. The tempting shorthand — refuse if status is publish — fails open on lh-membership‘s restricted, confidential and logged-in statuses, all of which are live member-visible content, and on every status registered after the check was written. Naming what is permitted means an unanticipated status is refused rather than accepted.
The one status change either ability makes is normalising auto-draft to draft on save. An auto-draft is WordPress’s placeholder for a post opened but never saved, and it is garbage-collected after seven days, so writing real content into one and leaving it would quietly schedule that work for deletion. The block editor does the same on first save.
One conversion engine, both directions
Markdown and HTML both route through LH MCP Block Transformer, which accepts a source format, rather than Markdown going through a separate converter. The same document submitted as Markdown or as HTML would otherwise produce different blocks. It also means get-post reads through the same engine that update-post writes through, so a read, edit and write-back round-trips rather than drifting.
The dependency is optional, and the two directions treat its absence differently on purpose. On read, a missing transformer yields a null content_markdown with an explanatory notice — content_raw is still complete, so the call is still useful. On write there is no degraded result worth having, so conversion failure is a hard error and the message says explicitly that nothing was written, rather than leaving a caller to guess whether to retry.
Validate everything, then write once
Meta and terms need a post ID and so can only be applied after the insert. Validating them at that point would leave a created post behind whenever one failed — an empty draft the caller did not ask for and might not notice. So all validation runs first, the insert happens once, and meta and terms are applied after. A rejected term slug on a call that also carried a title change leaves the title unwritten too.
Meta mirrors core REST: any key is_protected_meta() reports is refused unless it is registered and exposed via show_in_rest. Without that, update_post_meta() would happily write _wp_page_template or lh-membership‘s access-control meta. Terms replace rather than append, and the taxonomy must be registered for that post type.
Two WordPress behaviours worth knowing
wp_slash() is applied before every insert and update. wp_insert_post() runs the array through sanitize_post( $postarr, 'db' ), which strips one level of literal backslashes — corrupting the unicode escapes inside block-comment JSON, which is exactly what post_content holds here. It fails silently: the post saves, and the damage only appears later in the editor.
Dates are normalised to UTC unconditionally. WordPress leaves post_date_gmt as a zeroed datetime until a post is first published, and get_post_datetime() returns a site-timezone object whatever source column is requested — so converting only on the fallback path yields correct instants carrying inconsistent offsets, with published posts and drafts disagreeing in the same response.