- 1.14.0: create-post and update-post can now write serialized block markup directly through a new content_blocks parameter, or content_blocks_base64 when the site firewall blocks the request. The markup is saved as sent, with no conversion, so custom blocks, inner-block trees, block bindings and inline formats with data attributes no longer have to be pasted in through the editor. It is validated first: it must parse to at least one block, HTML outside block comments is refused (plain HTML still goes through content_html), and every block name must be registered on the site unless allow_unregistered is true. content_md, content_html, content_blocks and content_blocks_base64 are mutually exclusive. Both abilities now report block_types (every block name in the saved content with its count, inner blocks included) and unregistered_blocks, and read the stored content back after saving, returning content_verified and a notice when WordPress altered it on save. The ability descriptions are updated to match. New error codes: freeform_content, no_blocks, unregistered_blocks, invalid_base64.
- 1.13.0: create-post and update-post now accept a post_parent of another post type where the child type allows it. Allowed parent types are set per post type through the new lh_mcp_general_abilities_return_allowed_parent_types filter: by default a hierarchical type takes a parent of its own type and a non-hierarchical type takes none, so existing behaviour is unchanged until a plugin opts a type in (for example minutes parented by their event). A parent must also exist, not be in the trash and be readable by the caller; it need not be a draft. list-post-types reports each type’s allowed parents as parent_types. Error codes post_type_not_hierarchical and parent_type_mismatch are replaced by parent_not_allowed and parent_type_not_allowed, and parent_not_readable is new.
- 1.12.0: Reading content is now one consistent model across list-posts, get-post and list-post-types, and search-users has been removed. list-posts takes arrays for post_type, status and search: leave post_type out to cover every public post type in one call, pass any registered status (custom ones such as restrict and archive included; the default is any), and pass several search phrases, which are OR-ed and matched against title, excerpt and content, so synonyms such as AGM and annual general meeting can be found together. It also gains orderby (date, modified or title) and type, parent and author fields. get-post now opens any post type, not just posts and pages, finds slugs in every status, refuses ambiguous slugs with a 409 listing the candidates, and returns the post type, parent, author, the terms of every taxonomy, and for attachments the MIME type and file URL. list-post-types reports, for each type, whether list-posts covers it by default and whether the caller can read it and create it. Access is checked per post type (the type’s own edit_posts capability) and per post (read_post) in both list-posts and get-post; list-posts reports skipped post types and a withheld count, and the readable post-type filter now sets the default set rather than limiting get-post to posts and pages. search-users is removed because it searched every user on the network, letting anyone with list_users on one site look up email addresses of users on other sites; lh-profile-page’s search-users, which only returns members of the current site, replaces it. Its class file is left in place, no longer loaded, until a follow-up deletion. Apply parts 1 to 4 (read-support, list-post-types, list-posts, get-post) before this one.
- 1.11.1: References to the pattern guide now use its new name, lh-mcp-content-transformer/get-pattern-guide, after LH MCP Block Transformer was renamed LH MCP Content Transformer. Affects the content_html and fallbacks descriptions in create-post and update-post, and the notice those abilities return when content degrades to a generic block.
- 1.11.0: Content conversion now goes through LH Content Converter directly instead of through LH MCP Block Transformer’s internal bridge class, so get-post, create-post and update-post use the same shared engine as every other LocalHero plugin. The dependency stays optional, as before: without LH Content Converter, get-post still returns content_raw with a null content_markdown and an explanatory notice, and the write abilities refuse with nothing written. get-post’s markdown_source value for a successful conversion is now lh-content-converter (previously lh-mcp-block-transformer), and the unavailable and failed notices now name LH Content Converter.
- 1.10.1: Every ability description now ends with the permission it requires, so callers know before calling whether it will be refused. Most abilities require edit_posts; search-users requires list_users; get-site-info is open to anyone. The per-object checks are named too: get-post also needs read_post on the post, update-post needs edit_post on the post, create-post needs the post type’s create_posts capability, and read-mcp-resource also needs whatever the target resource’s own permission check requires. The get-post description now also says it reads only post and page by default, and the search-users description says its results include email addresses.
- 1.10.0: Every ability now declares MCP annotations (read-only, destructive and idempotent hints), an output schema and a closed input schema, so clients can tell reads from writes and a mistyped parameter is refused instead of silently ignored. get-post, list-posts, list-post-types, list-mcp-resources, read-mcp-resource, search-users and get-site-info are read-only; create-post is a non-idempotent write; update-post is an idempotent write marked destructive, because the fields it is given replace what was there. list-post-types gains an output schema, get-post’s schema now documents the excerpt, featured media and link fields it already returned, read-mcp-resource types its content as any JSON value, and every output field is described. search-users always returns roles as a list, and get-post and list-posts always return link as a string, so results match their schemas. get-site-info stays open to anyone, as a simple connection test, and its description now says so. The shared ability base class makes annotations and output schemas mandatory, so no future ability can be registered without them. The plugin now reads its version from its header (plugin_version), adds plugin_name and the standard init filter, and declares Requires at least 6.9, the first WordPress release with the Abilities API.
- 1.9.9: Fix get-post, create-post and update-post, all broken since lh-mcp-block-transformer 1.08 moved conversion into LH_MCP_Block_Transformer_Bridge and removed LH_MCP_Block_Transformer::execute_convert_to_blocks() / execute_convert_from_blocks() (fatal: Call to undefined method). Both call sites now use LH_MCP_Block_Transformer_Bridge::convert( $content, $from, $to ), and read the output from its ‘content’ key instead of the old ‘converted_content’ / ‘serialized_blocks’ keys. The class_exists() guards now check for the bridge class, so an older transformer without it returns the existing unavailable notice instead of fataling. status, diagnostics, block_count, fallback_count and fallbacks keep the same names in the bridge result, so the rest of both methods is unchanged.
- 1.9.8: Shortened the Description header to 150 characters or fewer, the wordpress.org limit that the compiled readme.txt enforces, so the plugin’s readme compiles and it can be pushed (lh-tasks 147801, part 5). No functional change.
- 1.9.7: Correct the wording that describes the classic markdown_source value, and bump 1.9.6 to 1.9.7. Wording only: no logic, schema shape, enum value or return structure changes. The 1.6.6 classic branch was exercised for the first time on 15 September 2026, against two Princes Park Touch posts: 1226, whose content is a bare YouTube URL, and 12684, whose content is a single WooCommerce shortcode. Both correctly returned markdown_source classic with the no-action notice. The test surfaced two inaccuracies in how that value is described. First, the notice and the get-post schema said the post predates the block editor. The branch actually fires on the transformer diagnostic format_bridge_validation_failed, which means only that the content carries no serialized block comments. That is equally true of a recent post written with the Classic Editor plugin, or saved by any path that does not produce blocks, so the age claim could mislead a caller into reporting a recent post as legacy content. Second, the notice called content_raw plain HTML. In both test posts it was not HTML at all: one was a bare embed URL, the other a shortcode. Classic content can be HTML, plain text, shortcodes or embed URLs, and a caller told to edit it as HTML might wrap or escape a shortcode or URL that WordPress expects to find unwrapped. The notice now says the post has no block markup, gives the common reasons, keeps the unchanged instruction that nothing is broken and nothing needs doing, and describes content_raw as the post’s complete stored content in whatever form it was saved. The same correction is applied to the markdown_source and content_raw descriptions in the get-post output schema, and to the class docblocks in get-post and the post-read support class, so the code comments no longer contradict the behaviour. readme.txt moves its Stable tag and trimmed changelog forward per standing rule 8, dropping 1.9.4 to keep the last three versions.
- 1.9.6: Add a .gitignore excluding runtime detritus and bump 1.9.5 to 1.9.6. lh-github-publisher pushes a plugin by running git add -A over the directory exactly as it sits on the live server, with no exclusion filter of its own, so any non-source file present at push time is committed. lh-mcp-block-transformer found this the hard way at 1.06, when an error_log file was published to its repository, and fixed it at 1.07 with a .gitignore. This plugin was pushed without one. Its repository is clean today, but PHP writes error_log into whichever directory the running script lives in, so the file can appear at any time and deleting it is not a durable fix. The new .gitignore lists error_log, *.log and .DS_Store, matching the transformer minus vendor/, since this plugin has no Composer dependencies. No PHP behaviour changes: the main plugin file carries the version number and nothing else. readme.txt moves its Stable tag and trimmed changelog forward per standing rule 8, dropping 1.9.3 to keep the last three versions.
- 1.9.5: Normalise the main plugin file’s indentation and bump 1.9.4 -> 1.9.5. The entire body of the file has been indented one level deeper than it should be since 1.6.1, when the top-level if ( ! class_exists( β¦ ) ) wrapper was removed without dedenting what it had contained. Keeping the extra level was deliberate at the time and stayed deliberate through the whole one-class-per-ability refactor: while code was being moved out of this file, an unchanged indent meant every diff showed only real changes, and a dropped or misplaced line stayed visible instead of hiding inside a wholesale reformat. That reason has now expired β the migration is finished, the file holds only identity, the shared permission gate, the four ability categories and the bootstrap, and nothing further is planned for it before it goes to GitHub. So this change does nothing else, which is the condition the work was deferred under across three separate handoffs. Every line from the class docblock to the final get_instance() call loses exactly one leading tab. Not a single character of code, comment or docblock text changes, no line is added or removed, and the only non-whitespace edit anywhere in the file is the version number in the header. A reviewer can confirm correctness by checking that the diff contains nothing but whitespace and that one line. readme.txt moves its Stable tag and trimmed changelog forward to match, per standing rule 8. Plugin Check and PHPStan are both clean at 1.9.4 and are expected to stay clean; re-running them afterwards is the check that the dedent did not break the file in some way php -l would not catch.
- 1.9.4: Rewrite readme.md to the current documentation convention, roll readme.txt forward to match, and bump 1.9.3 -> 1.9.4. readme.md was 9KB, predated standing rule 2, and had drifted into being wrong rather than merely verbose. It documented list-posts as taking limit and offset when the ability actually takes per_page, page, search and fields; it omitted create-post and update-post entirely, so the two abilities that write to the database were the two the readme did not mention; it claimed WordPress 7.0 as the requirement when the real floor is 6.9, the release in which the Abilities API became a core API; and it carried a hand-maintained changelog that stopped at 1.5.0, four minor versions behind. A readme that is merely long is a nuisance, but one that describes parameters that do not exist actively misleads, and this is the file GitHub renders as the front page of the repository. Rule 2 is what it now follows: a one-paragraph description, installation steps, and links to the canonical documentation home and the changelog doc-section. The per-ability reference that used to live here is not lost β it was written into the Abilities doc-section, and the design reasoning into Architecture notes, both under the portfolio root, where they sit beside the automated changelog instead of drifting from it. That is the point of the convention: one place to update, which nobody has to remember to keep in sync, rather than three. readme.txt moves its Stable tag to 1.9.4 and its trimmed changelog forward a version, per standing rule 8, since the tag must match the plugin header exactly or wordpress.org serves the wrong files. This is a documentation-only release: no PHP behaviour, no schema, no registration argument changes.
- 1.9.3: Annotate the last Plugin Check warning and bump 1.9.2 -> 1.9.3. WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound fires on the apply_filters() call in return_plugin_debugging_status(), because the hook name is built from self::return_plugin_namespace() . ‘_return_plugin_debugging_status’ and the sniff resolves hook names statically β it cannot follow a method call, so it cannot see that the name does begin with the plugin prefix, which is the one thing it is checking for. The name is lh_mcp_general_abilities_return_plugin_debugging_status at runtime. This is the same class of false positive as NonSingularStringLiteralDomain and has the same cause and the same answer: the identity method is the single source of truth for the prefix across every LH plugin, deriving the hook name from it is the convention rather than an oversight, and the annotation carries a docblock explaining why so the next reader does not try to ‘fix’ it by inlining a literal. With this the plugin is clean under Plugin Check apart from the three readme errors, which are readme.txt rules being applied to readme.md because no readme.txt exists yet and which resolve when that file is added. Worth recording for the rest of the suite: this warning will fire in every LH plugin that goes to wordpress.org, since they all build hook names this way. This is a comment-only change β no behaviour, no hook name, no registration is altered β but standing rule 8 takes no exceptions, so it carries its own patch bump and its own changelog entry rather than riding along unrecorded.
- 1.9.2: Finish the text-domain revert that 1.9.1 began but only half-applied, starting with the two write abilities, and bump 1.9.1 -> 1.9.2. The 1.9.1 changelog entry describes reverting the i18n calls in every ability class from the string literal back to LH_MCP_General_Abilities_Plugin::return_plugin_text_domain(); in fact only the main plugin file, list-post-types, list-mcp-resources and read-mcp-resource landed, and six ability classes were left on the literal. That mixed state is functionally harmless β both forms resolve to the same string at runtime β but it is exactly the inconsistency the one-class-per-ability refactor was meant to remove before this code is published to GitHub, where the file that reads inconsistently is the artefact people see. create-post and update-post are converted here: label() and description() in each now call the identity method, with the phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralDomain annotation that the standing LH convention accepts as a false positive, matching the annotations the already-converted files carry. name() in both already used the identity method, so the two forms sat side by side in the same class. No behaviour changes: every string is byte-identical to what the literal produced, and no schema, registration argument, validation path or output shape is touched. get-post, list-posts, get-site-info and search-users are the remaining four and follow in the next changes at this same version, so the duplicate-version guard will harmlessly reject their entries β this entry is the release description for all of them.
- 1.9.1: Plugin Check remediation, and revert the nine ability classes to the LH text-domain convention. Adds the License and License URI headers, which Plugin Check reports as an error and wordpress.org requires β GPLv2 or later, matching the rest of the LH plugin set. The languages/ directory now exists with the standard index.php guard, so the Domain Path header points at a real folder instead of nothing. Reverts the i18n calls in every ability class from the string literal ‘lh-mcp-general-abilities’ back to self::return_plugin_text_domain() via LH_MCP_General_Abilities_Plugin, per the standing LH convention that identity methods are the single source of truth for the text domain and the resulting NonSingularStringLiteralDomain notice is an accepted false positive. The classes added between 1.7.0 and 1.9.0 used the literal, which was a deviation introduced without being flagged; this brings them back in line, and the phpcs:ignore annotations that the pre-existing code already carried for this rule are applied to them too. The known cost of the convention, recorded here so it is not rediscovered: wp i18n make-pot resolves the domain argument statically and cannot follow a method call, so strings in this plugin are not extractable into a .pot by the standard tooling β translation would need a different extraction path. Also fixes a real bug that PHPStan level 5 caught in list-post-types, the only error it reported across all seventeen files: rest_base was read as $type->rest_base ?? $type->name, but WP_Post_Type sets that property to false rather than null when a type declares no REST base, so the null-coalesce never fired and callers received a literal false β visible on forum, topic, lh-portfolio and product among others. WordPress routes such a type on its name, so the intended fallback now runs via an empty() check. Corrects the phpcs:ignore codes on the search-users direct query: the existing annotation named only PreparedSQL.NotPrepared, which left DirectQuery, NoCaching and UnescapedDBParameter firing, and adds the InterpolatedNotPrepared annotations for the $wpdb->users and $wpdb->usermeta table names interpolated into prepare() β trusted wpdb properties, never caller input. The three readme errors Plugin Check reports (Tested up to, License, Stable Tag) are not addressed here: they are readme.txt rules being applied to readme.md because no readme.txt exists yet, and they resolve when that file is added. Bump 1.9.0 -> 1.9.1.
- 1.9.0: Finish the migration: split LH_MCP_General_Abilities_Posts into get-post, list-post-types and list-posts as three subclasses of the per-ability base, and drop it from the bootstrap. Every ability in the plugin is now one class per ability, registered through the base’s register(), so meta.mcp.public can no longer be omitted by any individual ability β the silent failure that motivated the base, where registration succeeds but the ability never appears in the MCP tool list. The three shared helpers move to LH_MCP_General_Abilities_Post_Read_Support, the counterpart to the write-support class added in 1.7.0: return_readable_post_types() keeps its existing filter name unchanged so anything already hooking it is unaffected, and iso_datetime() and content_markdown() move verbatim, including the two WordPress behaviours their docblock records β the zeroed GMT date columns on unpublished posts, and get_post_datetime() normalising its return to the site timezone whatever $source is passed. Two small single-source-of-truth fixes in list-posts, where the available-field list and the allowed-status list were each duplicated between the input schema and execute(): both become private methods used by both, since a field added to one and not the other is exactly how those drift. list-posts deliberately still accepts any registered post type rather than being narrowed to the readable allowlist β that remains its own open decision, not something to settle while moving code. Registration args, schemas, query behaviour, error codes and output shapes are otherwise carried over unchanged across all three. The main plugin file now holds only identity, the shared permission gate, the four ability categories and bootstrap. Bump 1.8.2 -> 1.9.0, minor because the plugin’s internal structure reaches its target shape, though no external contract changes.
- 1.8.2: Split LH_MCP_General_Abilities_MCP_Utilities into one class per ability and drop it from the bootstrap. list-mcp-resources and read-mcp-resource each become their own subclass of the per-ability base, and the public-resource test they both carried β an ability is a resource if meta.mcp.public is set and meta.mcp.type is ‘resource’, defaulting to ‘tool’ when absent β moves to a single resource_meta() on the new LH_MCP_General_Abilities_MCP_Resource_Support. This is the first conversion that is a genuine split rather than a rename, and the duplicated predicate is why it needed a support class: two copies of a visibility test drift, and the failure is asymmetric. If the reader’s copy stayed broader than the lister’s, a resource would remain readable by uri after it stopped appearing in the list. Neither ability overrides permission_callback(), so this is also the first use of the base class’s default path β both keep the plugin-wide edit_posts gate, which for read-mcp-resource is deliberately coarse: it establishes the caller is an editor, and the target resource’s own permission callback makes the real decision, invoked here as check_permissions() then execute() with no arguments, mirroring WPMCPDomainResourcesMcpResource::execute() rather than reimplementing its authorisation. One signature detail worth recording: both original methods declared ?array $input = null, but the base declares execute( $input = null ) with no parameter type, and a subclass cannot narrow an untyped parameter β PHP rejects that as an incompatible signature outright rather than merely warning, so both converted methods are untyped. Registration args, schemas, lookup, error codes and output shapes are otherwise carried over unchanged. Bump 1.8.1 -> 1.8.2.
- 1.8.1: Convert search-users onto the per-ability base class and drop the old LH_MCP_General_Abilities_Users class from the bootstrap. That class held exactly one ability, so it was already one class per ability; it becomes LH_MCP_General_Abilities_Search_Users in a file named after the ability, matching get-site-info and the write abilities. It overrides permission_callback() only, to require list_users rather than the plugin-wide edit_posts gate, because the results include email addresses. The conversion carries a bug fix that the signature change forces. The old execute_search_users() was declared : array but returned a WP_Error both on empty input and from its catch block. The empty-input return threw a TypeError inside the try, which the catch swallowed, and the catch then returned a WP_Error of its own from outside the try β a second TypeError with nothing to catch it. Calling search-users with an empty or whitespace-only search string was therefore a fatal rather than the intended 400. The base class declares execute() with no return type, so the converted method carries none and both WP_Error paths now return as written. Also adds a $switched flag so restore_current_blog() runs on the exception path: previously any failure after switch_to_blog() left the entire request pointed at the wrong site, which corrupts everything running after it and is much harder to trace back than the error that caused it. Ranking logic, the UNION ALL priority SQL, the AND-then-OR pass behaviour and the output shape are carried over unchanged. The main class’s check_permissions() docblock is updated to name the new class. Bump 1.8.0 -> 1.8.1.
- 1.8.0: Begin migrating the existing abilities onto the per-ability base class, starting with get-site-info, and reorder the bootstrap so the base loads before anything that extends it. LH_MCP_General_Abilities_Site held exactly one ability and so was already one class per ability; it becomes LH_MCP_General_Abilities_Get_Site_Info in a file named after the ability, matching the write-side classes, and the old class is dropped from the bootstrap. get-site-info is deliberately first: it is the only ability in the plugin that overrides both permission_callback() (__return_true, the one unauthenticated ability here) and meta() (annotations plus show_in_rest), so converting it exercises the base class’s entire override surface before anything ordinary depends on it. The base merges meta() over the mandatory mcp.public flag rather than replacing it, which is what keeps that flag present on an ability supplying its own meta β previously untested, and the reason to prove it on the ability whose meta is most unusual rather than on one where a silent loss would go unnoticed. The non-filterable public-fields allowlist, which is what makes an unauthenticated get_bloginfo() call safe, is carried across unchanged. Bootstrap order also changes: class-lh-mcp-general-abilities-ability.php and the write-support class now load first, before every domain class, rather than midway through. Load order is load-bearing for a subclass β a class with an extends clause is not eligible for the compile-time early binding the unguarded classes here rely on, so its parent must already be in the class table when it compiles β and having the base sit in the middle of the list only worked because the two classes extending it happened to come after. Loading it first makes any future conversion order-independent. No ability’s behaviour, schema, name, or capability changes. Bump 1.7.2 -> 1.8.0, minor rather than patch because the internal structure changes even though the external contract does not.
- 1.7.2: Make get-post and list-posts actually return UTC timestamps, as their schemas claim. 1.7.1’s iso_datetime() helper converted to UTC only on the local-column fallback path, on the assumption that get_post_datetime( $post, $field, ‘gmt’ ) returns an object already in UTC. It does not: core’s get_post_datetime() ends with return $datetime->setTimezone( $wp_timezone ), so the $source argument selects which database column is read and nothing more β the returned DateTimeImmutable is always normalised to the site timezone. The result was correct instants rendered with inconsistent offsets: a published post came back as 2019-07-16T16:55:00+10:00 from the GMT column while an unpublished one came back as 2026-09-14T13:24:10+00:00 from the fallback, and a single draft could show its date in UTC and its modified in +10:00 on the same call. Confirmed against the database rather than inferred: post 147465 holds post_date_gmt 0000-00-00 00:00:00 with post_modified_gmt 2026-09-14 13:24:10, which is exactly the mixed case that made the discrepancy visible. The conversion now happens once, unconditionally, after whichever column supplied the value, so both paths format identically. No instant changes and no stored data changes β only the offset each is rendered with. The helper’s docblock records the get_post_datetime() behaviour, since the signature reads as though ‘gmt’ returns UTC and nothing at the call site suggests otherwise. Bump 1.7.1 -> 1.7.2.
- 1.7.1: Fix get-post and list-posts returning a nonsense date for any unpublished post. WordPress leaves post_date_gmt (and post_modified_gmt) as the zeroed string ‘0000-00-00 00:00:00’ until a post is first published, while post_date holds the real authored time in the site’s timezone. Both abilities guarded on truthiness β $post->post_date_gmt ? mysql2date(β¦) : null β and a zeroed datetime is a non-empty string, so it passed the guard and mysql2date() returned a timestamp in year -1 with a nonsense offset (‘-001-11-30T00:00:00+09:39′). Latent since these abilities were written, since get-post and list-posts could always return drafts; 1.7.0’s create-post made drafts the common case and surfaced it on the first call. Replaced with a private iso_datetime() helper using core’s get_post_datetime(), which returns false for a zeroed value rather than trying to parse it. When the GMT column is unusable the helper falls back to the local column and converts it to UTC, rather than returning null and discarding a date the post genuinely has β a draft’s authored time is real information, and a caller sorting drafts by date needs it. Null is now reserved for a post with no usable value in either column. Also documents date and modified in get-post’s output schema, where they were returned but never declared, and marks date/modified nullable in list-posts’ item schema to match what the code has always been able to return. Bump 1.7.0 -> 1.7.1.
- 1.7.0: Add create-post and update-post: draft-only authoring abilities for AI callers, and the first abilities in this plugin that write to the database rather than read from it. create-post creates a post or page as a draft; update-post revises an existing draft or pending post with patch semantics. Neither has a status parameter in its schema and neither reads one if passed, so no call through either path can publish, schedule, or set a member-visibility status β post_status is a literal ‘draft’ in create-post, and update-post refuses outright any post whose current status is not on the editable allowlist (draft, pending, auto-draft), pointing the caller at lh-agora/propose-changes instead. An allowlist rather than a !== ‘publish’ check, because the latter fails open on lh-membership’s restricted, confidential and logged-in statuses and on every status registered later. The one status change either makes is normalising an auto-draft to draft on save, matching the block editor, since an auto-draft is garbage-collected after seven days and would otherwise take the caller’s work with it. Writable post types are a separate filterable allowlist (post and page by default) with its own filter, distinct from the new read-side return_readable_post_types() added here β sharing one filter would mean permitting a write silently also permitted a read. Both content_md and content_html go through lh-mcp-block-transformer’s convert-to-blocks with source_format set accordingly, rather than routing Markdown through a second engine: one engine means the same document submitted either way produces the same blocks, get-post already reads through it so a read-edit-write round trip stays symmetric, and its fallbacks array names each element that degraded to a generic wrapper. There is no local fallback converter β if the transformer is unavailable the call errors and writes nothing rather than storing approximately-converted content the caller cannot tell apart from the real thing. meta mirrors core REST, refusing any key is_protected_meta() reports as protected unless it is registered with show_in_rest, so update_post_meta() cannot be used to set _wp_page_template or lh-membership’s access-control meta from an ability whose stated scope is drafting; a null value deletes a key. terms take taxonomy => slugs or IDs, validate that the taxonomy exists and is registered for the post type, check the taxonomy’s assign_terms capability, resolve every identifier to a real term (none are ever created), and replace rather than append. post_parent is validated against is_post_type_hierarchical() and errors rather than being silently dropped, since WordPress stores it and then ignores it for a non-hierarchical type; the parent must exist, match the post type, and not create a loop. Capabilities are the shared edit_posts gate plus the post type’s own create_posts on create-post and a per-post edit_post on update-post, routed through edit_post rather than an author comparison so map_meta_cap applies. Every validation runs before wp_insert_post()/wp_update_post(), since meta and terms need an ID and validating them late would leave an unwanted post behind on failure. wp_slash() before both calls β without it sanitize_post( $postarr, ‘db’ ) strips one level of literal backslashes and corrupts the unicode escapes inside block-comment JSON, the confirmed lh-portfolio pre-1.4.6 bug, silently. Structurally these are the first abilities on a new per-ability pattern: an abstract LH_MCP_General_Abilities_Ability base assembles the wp_register_ability() argument array from abstract identity and schema methods, so meta.mcp.public is set centrally and cannot be omitted by an individual ability β its omission registers successfully but leaves the ability out of the MCP tool list, surfacing much later as an ability_not_public_mcp debug-log line. The base is required before its subclasses because a class with an extends clause is not eligible for the compile-time early binding the existing classes rely on. Shared validation lives on LH_MCP_General_Abilities_Post_Write_Support rather than being duplicated or inherited. Retrofitting the four existing domain classes onto the base is deliberately left as separate work with no behaviour change. Also adds return_readable_post_types() to the posts class, replacing two identical array(‘post’,’page’) literals in execute_get_post() and a third in get-post’s input-schema enum; the default is unchanged and list-posts is deliberately left accepting any registered post type. Bump 1.6.6 -> 1.7.0.
- 1.6.6: Stop reporting classic pre-block-editor content as a conversion failure, and stop discarding the transformer’s diagnostics. content_markdown() previously collapsed every non-success result from LH MCP Block Transformer into one generic ‘reported a failed conversion’ notice, throwing away the diagnostics array that says why. Two consequences: a real failure gave the caller nothing to act on, and a post with classic HTML content β which has no serialized block comments and therefore nothing to convert β was reported as breakage when nothing is wrong. Now the diagnostics are read: the format_bridge_validation_failed code is classified as its own markdown_source value, ‘classic’, with a notice explaining that content_raw is plain HTML, nothing needs installing, and there is nothing to report; every other diagnostic has its message passed through verbatim instead of being replaced. markdown_source’s enum in get-post’s output schema gains ‘classic’, and the markdown_source and notice descriptions are corrected β notice previously claimed it always describes a missing or broken dependency, which was never true of this case. No change to the WP_Error branch or the unavailable branch. Bump 1.6.5 -> 1.6.6.
- 1.6.5: Wire in the extracted posts class and remove the originals from the main plugin class, in a single change so get-post, list-post-types and list-posts are never registered twice nor left pointing at methods that no longer exist. plugin_init() now also require_once’s includes/class-lh-mcp-general-abilities-posts.php and calls its plugin_init(). Removed from the main class: execute(), execute_list_post_types(), execute_list_posts(), the private content_markdown() helper, and all three wp_register_ability() blocks β roughly 370 lines. ability_registration_functions() and its wp_abilities_api_init hook are removed entirely rather than left empty: with posts extracted, every ability in the plugin is now registered by its own domain class, and the main class registers none. The get-post callback is renamed execute() -> execute_get_post() on the new class. check_permissions() and ability_category_functions() stay put, both being plugin-wide rather than domain-specific. Last of the planned domain extractions; no behaviour change. Bump 1.6.4 -> 1.6.5.
- 1.6.4: Wire in the extracted site-info class and remove the originals from the main plugin class, in a single change so get-site-info is never registered twice nor left pointing at a method that no longer exists. plugin_init() now also require_once’s includes/class-lh-mcp-general-abilities-site.php and calls its plugin_init(). Removed from the main class: execute_get_site_info() and the get-site-info wp_register_ability() block, including its local $public_site_fields array β roughly 63 lines. The ability keeps its __return_true permission_callback and its meta annotations + show_in_rest verbatim; the duplicated public-field allowlist is consolidated into one private public_fields() method on the new class, deliberately non-filterable since it gates an unauthenticated ability against get_bloginfo(). The site-info category stays in the main class with the other three. Third of the planned domain extractions; no behaviour change. Bump 1.6.3 -> 1.6.4.
- 1.6.3: Wire in the extracted MCP-utilities class and remove the originals from the main plugin class, in a single change so list-mcp-resources and read-mcp-resource are never registered twice nor left pointing at methods that no longer exist. plugin_init() now also require_once’s includes/class-lh-mcp-general-abilities-mcp-utilities.php and calls its plugin_init(). Removed from the main class: execute_list_mcp_resources(), execute_read_mcp_resource(), and both wp_register_ability() blocks β roughly 190 lines. Both abilities keep the shared edit_posts gate, now referenced from the new class rather than resolved via __CLASS__. Second of the planned domain extractions; no behaviour change. Bump 1.6.2 -> 1.6.3.
- 1.6.2: Wire in the extracted user-domain class and remove the originals from the main plugin class, in a single change so there is never a window with search-users registered twice or with its registration referencing a method that no longer exists. plugin_init() now require_once’s includes/class-lh-mcp-general-abilities-users.php and calls its plugin_init(), which hooks its own registration on wp_abilities_api_init. Removed from the main class: check_permissions_search_users(), execute_search_users(), and the search-users wp_register_ability() block β roughly 260 lines. The shared check_permissions() stays, since get-post, list-posts, list-post-types and the two mcp-resource abilities all use it, and so do the ability categories, which remain declared in one place. First of four planned domain extractions; no behaviour change, search-users keeps the same name, schemas, capability and output. Bump 1.6.1 -> 1.6.2.
- 1.6.1: Remove the top-level if ( ! class_exists( ‘LH_MCP_General_Abilities_Plugin’ ) ) wrapper around the class declaration and its instantiation. LH_MCP_General_Abilities_Plugin has no extends/implements, so it is eligible for PHP/OPcache compile-time early binding on this server: the class can be linked into the class table at compile time regardless of the runtime branch wrapping it, making class_exists() report true before the declaration would logically have run and skipping the entire block β no class, no instantiation, no hook registration, silently, with no fatal and nothing in the debug log. This is the confirmed root cause of the lh-tasks incident (Sept 2026) and the guard never protected anything here anyway, since WordPress include_once’s each active plugin file once per request. Body indentation is deliberately left unchanged so the diff stays reviewable; it normalises when this file is split into per-domain classes. Bump 1.6.0 -> 1.6.1.
- 1.6.0: get-post: replace the hand-rolled html_to_markdown()/safe_content_markdown() pair with a delegation to LH MCP Block Transformer’s convert-from-blocks (FormatBridge), so content_markdown is a faithful block-to-markdown conversion that can round-trip rather than a lossy regex pass over rendered HTML. Adds markdown_source and notice output fields: when lh-mcp-block-transformer is inactive or its composer vendor directory is missing, content_markdown is null and notice carries an explicit instruction to tell the user to activate it, instead of silently degrading. content_raw is unaffected in every case. Bump 1.5.2 -> 1.6.0.
- 1.5.2: Removed the temporary diagnostic filter added in 1.5.1 that direct-listed get-site-info as an MCP tool. The test it was added for is complete: lhero_local, princesparktouch_local and princesparktouch_remote all pick up newly direct-listed tools after a Claude restart; lhero_remote does not, even after a restart.
Changelog
If you like this plugin please consider making a donation to further development
