- 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.