Security: IATO_MCP_Auth::require_cap() now actually enforces the capability argument. Previously the function checked only whether the request was authenticated and returned true regardless of the cap string passed — a documented but long-deferred limitation (the file’s own docblock acknowledged it as a v1.6 hardening item). Every existing require_cap() call site (edit_posts on write tools, manage_options on get_site_settings, upload_files on create_media, etc.) was cosmetic until this release. Now real.
Mechanism: authenticate() carries the authenticated WP_User object through to require_cap() for the Application Password path. require_cap() calls user_can($user, $cap) against that user. Bearer plugin-key authentication remains documented full-administrative-access (intentional — the key itself is the gate; security comes from key issuance being admin-only); the change applies to per-user auth paths only.
Observable contract change: any Application Password user below admin level who previously could call manage_options-gated MCP tools will now be correctly rejected. This is the security boundary tightening — semver minor, not patch, even though the surface change is “tiny” by line count. The edit_posts auth-time baseline in authenticate() is preserved (Subscribers still rejected at auth time, not per-tool time).
Audit: every require_cap() call site reviewed under enforcement. 21 sites confirmed-correct without code change. Two structural changes:
Menus (4 tools: update_menu_item, create_menu_item, delete_menu_item, update_menu_item_details) — switched from manage_options to edit_theme_options, the WP-canonical cap for nav-menu structure (matches wp-admin/nav-menus.php). Functionally identical on default WP (both admin-only); observably different for sites that grant edit_theme_options to non-admin via a role-management plugin.
Rollback (tool-rollback.php) — replaced the hand-maintained $elevated_types switch with a per-receipt-type cap map in IATO_MCP_Change_Receipt::cap_required_for(). Fail-closed default: unknown target_types are gated at manage_options until they’re explicitly added to the map, so a new receipt type can’t silently inherit a lower cap. The map lives adjacent to the existing target_type docblock so future developers see the cap requirement at the receipt-type declaration site. The ‘taxonomy’ entry is field-discriminated — assign/terms operations stay at edit_posts (match assign_term / update_taxonomy create-time caps), create_term/update_term/delete_term go to manage_categories (match the create-time caps). Convention: rollback cap === create-time cap.
Known issue (not addressed in this release): the OAuth flow at /oauth/token returns the plugin’s iato_mcp_key (admin-only MCP key) as the OAuth access_token — see class-oauth.php:486-489. This means OAuth-issued tokens are admin-key-equivalent regardless of which user originally authorized the OAuth flow, and OAuth-authenticated MCP requests flow through the Bearer plugin-key path in authenticate() (which intentionally bypasses per-user cap checks). The OAuth /oauth/authorize gate at manage_options is one-time at issuance, not ongoing per-request. Fixing this requires per-user OAuth token issuance (new token storage, per-user issuance with scoping, revocation, an alternate access-token format that encodes user identity) — substantial separate design tracked for a future cycle. Severity is bounded by the issuance-time manage_options gate (only admins can ever obtain an OAuth token in the first place); the failure mode is “OAuth-authorized client retains admin-equivalent access even if the originally-authorizing admin’s role is later downgraded.”
1.11.0
New: list_elementor_templates tool. Enumerates elementor_library posts (Theme Builder templates) with their Display Conditions surfaced both as the raw stored string (e.g. include/archive/category/1) and a structured parsed shape with resolved target labels (e.g. the category named “Build”). Closes the discovery gap that motivated Layers 1 and 2 — without this, the only way to find a template you didn’t already know the ID of was to brute-force post IDs until a revision slug leaked the parent. Parameters: template_type (filter on _elementor_template_type), status, per_page (max 100), page, include_conditions (default true — conditions ARE the listing). Returns id, title, slug, status, template_type, modified, edit_url, and the conditions array. Gated behind manage_options — enforced as of v1.10.0’s hardening (not cosmetic).
New: find_elementor_widgets gains an include_templates boolean parameter (default false). When true, the auto-scan expands from [post, page] to [post, page, elementor_library]. Opt-in not default to preserve v1.8.x BC — silently widening the scan would shift match counts unpredictably for every existing caller. Revision auto-resolution, post_status filter, contains operator, and the 500-post cap all unchanged. Has no effect when explicit post_ids are passed.
New: get_site_info gains a theme_builder_template_count integer field. Counts elementor_library posts that are actually Theme Builder templates (have a non-empty _elementor_template_type meta) — excludes saved sections, reusable blocks, and condition-less popups since those don’t shadow URLs. Returns 0 when Elementor isn’t installed. Field is always present (typed as int) so callers can rely on it.
Security: list_elementor_templates is gated behind manage_options. Justification: the tool exposes the full structural map of every Theme Builder construct on the site and its targeting rules — comparable to or greater than what get_site_settings returns, which is the existing manage_options-gated structural read. Per-resource template questions (“what renders THIS URL”) are already answered by resolve_url at the authenticated-only level; this tool exists specifically for the enumeration access pattern, which deserves a strictly higher gate than per-resource lookup. The theme_builder_template_count field in get_site_info stays ungated — a bare count is comparable to the existing plugin_count and answers “do templates exist worth querying” without disclosing structure. find_elementor_widgets.include_templates stays no-cap because it’s a filtered widget search keyed by user-supplied filters, not bare structural enumeration; conditions aren’t surfaced by it.
New: public IATO_MCP_Elementor_Router::parse_condition_string() helper that returns the structured representation of a single Elementor condition string without applying it to any URL context. Added as a sibling to the v1.8.1 matcher rather than a refactor — keeps the verified matcher untouched.
Compat: rendering_post_id semantics still UNCHANGED from v1.7.x. resolve_url‘s v1.8.0 additive contract fully preserved.
1.8.2
Fix: get_site_settings no longer corrupts permalink_structure, title, or tagline. The five-field tool was wrapping every value in sanitize_text_field(), which calls _sanitize_text_fields() — a function that repeatedly strips %[a-f0-9]{2} octets as a transport-safety measure for URL-encoded strings. Applied to fields that legitimately carry literal %xx content, that’s actively destructive. Three field-level changes follow, with deliberately distinct framing because they’re different categories of fix:
(a) permalink_structure — pure bug fix. The sanitized output was always wrong for this field. WordPress’s permalink structure legitimately carries %category%, %postname%, %year%, %monthnum%, %day%, %post_id%, and %author% as literal placeholder tokens; _sanitize_text_fields ate the %xx prefixes of those tokens (e.g. /%category%/%postname%/ came back as /tegory%/%postname%/). Now returns the raw value as WordPress stores it — matching what WP core uses internally when generating URLs.
(b) title and tagline — broader behavior change. These now return the raw stored value, not the sanitize_text_field-processed form. Practical implications beyond %xx: HTML entities, leading/trailing whitespace, line breaks, and collapsed multiple spaces in the site title or tagline now surface to the read tool instead of being stripped or collapsed. Rationale: an admin read tool’s contract is to surface what’s stored, not a display-rendered form. Most sites won’t notice the change because typical site titles are plain text; sites with unusual characters in their title/tagline will see the raw form they actually stored.
(c) admin_email and timezone — unchanged. Explicit decision: these value types don’t legitimately carry %xx in practice. PHP timezone identifiers are a controlled list with no %; admin_email‘s RFC percent-encoding form is exceedingly rare in single-mailbox use. sanitize_text_field is WP-canonical for these and stays.
Downstream: v1.8.1’s archive-detection path reads category_base/tag_base directly via get_option() inside the router (not via this MCP tool) and is unaffected. No internal callers of get_site_settings output exist in the codebase. External MCP callers now receive faithful DB values for the three fixed fields.
1.8.1
Fix: resolve_url correctly identifies Elementor Theme Builder archive templates as the renderer for Yoast-stripped category URLs (e.g. /build/ instead of /category/build/). Previously such URLs returned route_type=404 even though a Theme Builder template was rendering them. Root cause was two independent bugs:
Detect_archive_info gap. The URL classifier only recognised the default /category/<slug>/, /tag/<slug>/, and /author/<slug>/ patterns. Sites with Yoast’s “Remove the categories prefix” enabled — or with a custom category_base / tag_base configured in Settings > Permalinks — served archives at URLs that didn’t match those patterns, so the classifier returned null and the shadowing check had no archive context to evaluate against. A three-stage cascade now handles all three cases: default patterns first (zero new cost on standard sites), then configured-base patterns, then a bounded reverse lookup through get_term_link() that goes through any active term_link filter (Yoast, RankMath, etc.) and matches the resulting URL against the input.
Shadowing-dispatch bug. detect_theme_builder_shadowing treated Elementor Pro’s find_via_theme_builder_module false return (Pro loaded but get_documents_for_location matched nothing) as authoritative, returning that false directly and never reaching the find_via_conditions_meta fallback. In REST context — which is 100% of MCP traffic — get_documents_for_location is bound to the current $wp_query (the REST endpoint, not the URL being asked about) so it consistently returns nothing; the dispatch bug meant the conditions-meta scan that DOES evaluate against our explicit URL context was unreachable on every Pro-installed site. Fix is a one-line change from if ( null !== $found ) to if ( is_array( $found ) ) so both false and null fall through to the meta scan; array (a positive match) still returns immediately. Bug has existed since v1.7.x; v1.8.0’s archive plumbing landed correctly but the dispatch bug blocked it from firing.
Bonus: the dispatch fix also restores singular page shadowing detection on Pro-installed sites. get_post(id, include_shadowing:true) now correctly surfaces is_shadowed_by when an Elementor Theme Builder single template overrides the slug-based render. Same dispatch bug had been blocking this since v1.7.x.
rendering_post_id semantics UNCHANGED from v1.7.x. v1.8.0’s additive resolve_url contract fully preserved — all new fields (effective_render_id, template{}, shadowed_route_type, etc.) populated correctly by v1.8.0 already; v1.8.1 just makes the shadowing detection that fills them actually fire in REST context.
Documented limitation: plugins that strip the category base via raw .htaccess rewrites without using the term_link filter won’t be detected by Stage 3 of detect_archive_info. Rare; rely on a term_link-based stripper (Yoast, RankMath, etc.) until a workaround is needed.
1.8.0
Fix: resolve_url now resolves Elementor Theme Builder archive routes. Archive URLs served by a Theme Builder template (e.g. a category or CPT archive whose render is provided by an elementor_library document) previously returned route_type=404 because the conditions evaluator only matched include/singular/… patterns against url_to_postid()‘s post ID — which is 0 on archives. The conditions parser now evaluates include/archive/… patterns (taxonomies, terms, authors, CPT archives, in_taxonomy, post_archive) against URL-derived context, so archive shadowing is correctly detected. find_via_theme_builder_module also captures the matching location into template_type. The condition string that fired is surfaced as template.condition_matched for callers who need to see the match logic.
New: resolve_url response gains four additive fields. rendering_post_id semantics are UNCHANGED — still the canonical/slug-based post (now normalized to null instead of 0 on archives so === null checks work). The new fields are: rendering_post_type, effective_render_id (single field answering “what actually renders” — template ID when shadowed, canonical post ID otherwise, null only on a true 404), effective_render_post_type, shadowed_route_type (route the URL would have had absent the template), and a structured template{template_id,template_type,condition_matched,builder} object present when shadowing applies. rendering_template_id continues to work exactly as documented; the new fields are additive only.
New: find_elementor_widgets auto-resolves revision IDs to their parent post. Passing a revision ID via post_ids previously returned matches tagged with the revision’s own ID, leaking the parent only through the NNN-revision-vN slug — the brute-force discovery path that motivated this work. Revision IDs are now mapped to their parent via wp_is_post_revision(), deduped, and each match scanned from a revision input carries resolved_from_revision_id so callers can see the mapping. Default auto-scan also tightens post_status from any to [publish, draft, pending, private] — trash and auto-draft are no longer scanned by default.
New: find_elementor_widgets filter gains a contains operator (case-insensitive substring match against scalar settings), alongside the existing eq|ne|in|nin|exists. Useful for finding a widget by its content rather than exact-match on a heading — e.g. setting.editor.contains=”some phrase”. Scalar-only by design; never recurses into nested settings arrays. The regex operator is deferred to a future release where it can get proper backtracking-DoS guards.
Docs: get_posts, find_elementor_widgets, and resolve_url descriptions now state the current scope honestly. get_posts notes that post_type=any expands to [post, page] and does NOT return elementor_library. find_elementor_widgets notes that templates are not yet scanned. Both point at the upcoming Layer 2 work.
Note on disclosure surface: the new fields (condition_matched, template_type, effective_render_*) widen what an authenticated caller can learn about site structure on a per-URL basis. resolve_url continues to require only authentication (no capability check), unchanged from v1.7.x — Theme Builder shadowing was already disclosed per-URL since v1.5. The forthcoming template-listing tool (Layer 2) will gate full enumeration behind edit_posts.
1.7.2
Fix: create_media no longer hard-rejects payloads that omit source.type. v1.7.1 began requiring an explicit type field, breaking callers that worked under v1.6.x where the type was inferred from the presence of source.data (base64) or source.url (URL ingestion). Restored that inference — supply either field and the right mode is picked automatically. Explicit type continues to work and remains the documented preferred form.
Fix: URL ingestion no longer rejects the site’s own host. Plugins installed on example.com could not ingest https://example.com/wp-content/uploads/foo.jpg without manually adding example.com to the URL allowlist — the allowlist defends against fetching arbitrary external hosts, not the site’s own media library, and forcing every admin to allowlist their own domain was the most common papercut on this tool. The site’s home_url() and site_url() host(s) are now implicitly trusted. The SSRF IP-resolution guard (check_host_resolves_publicly) still runs in both branches, so the bypass only skips the manual allowlist; private/loopback/link-local IPs continue to be rejected.
Improved: create_media tool description rewritten to reflect real-world transport behaviour. Base64 is documented as suitable ONLY for tiny assets (favicons, sprite icons, ~4 KB of decoded image); larger JSON-RPC payloads are truncated by the MCP transport before reaching the plugin, which presents as the call hanging silently. URL ingestion is named as the path for anything bigger, with explicit warnings that share/viewer pages (Google Drive share links, Dropbox ?dl=0) return HTML rather than the file. The previous “~100 KB” guidance was wrong in practice and trained agents to attempt uploads that would silently hang.
Improved: file_too_large errors on the base64 path now mention URL ingestion as the alternative, mirroring the helpful tone of the existing url_source_disabled error. The agent gets a clear next step instead of an opaque size-cap message.
1.7.1
Fix: the four Media Uploads settings shipped in 1.7.0 (iato_mcp_media_url_source_enabled, iato_mcp_media_url_host_allowlist, iato_mcp_media_max_upload_size, iato_mcp_media_upload_rate_limit) silently failed to persist on Save. The UI rendered correctly and the fields were properly registered with register_setting(), but the General-tab form is hijacked through an admin-ajax handler (some hosts 503 on options.php POSTs due to upstream WAF/timeout rules) and that handler hardcoded the keys it persisted — anything not explicitly listed fell off the floor. 1.7.1 extends ajax_save_settings() to call the matching sanitize-and-update path for each of the four media keys, mirroring the existing iato_mcp_api_key / iato_mcp_crawl_id / iato_mcp_tools lines.
Docs: CLAUDE.md gains a “Release Checklist (adding a new admin setting)” section calling out the three places a new option must land — register_setting(), the rendered <input name=”…”>, and the AJAX persist handler. Same shape as the existing tool-release checklist; closes the structural failure mode that produced this 1.7.0 → 1.7.1 follow-up.
1.7.0
New: Settings > IATO MCP now includes a Media Uploads card. The four media settings (iato_mcp_media_url_source_enabled, iato_mcp_media_url_host_allowlist, iato_mcp_media_max_upload_size, iato_mcp_media_upload_rate_limit) were registered and enforced at runtime in 1.6.0 but never surfaced in admin UI, so the only way to enable URL-source ingestion or configure the host allowlist was via WP-CLI or a direct database edit. The url_source_disabled error message returned by create_media continues to point admins to “Settings > IATO MCP > Media uploads” — that path now exists.
New: Diagnostics page gains a “Recent media uploads” panel showing the last 100 create_media calls with their full per-phase trace, outcome badge, error code, attachment metadata (MIME, dimensions, size), and total duration. Each row expands inline via a native <details> element to show the phase-by-phase timing. Triage that previously required enabling WP_DEBUG_LOG mid-session and tailing the host’s PHP error log is now one click on the Diagnostics tab. The on-disk error_log() mirror is preserved for environments that already aggregate logs centrally.
New: backing infrastructure — IATO_MCP_Media_Phase_Log class and {prefix}iato_mcp_media_phase_log table store one ring-buffered row per create_media call. The deferred-subsizes cron path threads its req_id through wp_schedule_single_event and appends an async-subsizes-done phase to the parent row on completion, so a single Diagnostics row captures the entire end-to-end timeline of an upload — including the async tick that lands minutes later. DB writes are wrapped in try/catch so an observability hiccup cannot regress create_media itself. Table creation is dbDelta-idempotent and runs from both the activation hook and the migration gate, so upgraded installs pick it up without a reactivation.
Improved: create_media tool description now includes practical guidance on when to use base64 vs URL ingestion. Base64 is reliable for small assets (icons, badges, screenshots under ~100 KB); URL ingestion is the recommended path for production-scale photography and requires admin opt-in via the new Media Uploads settings card.
Fix: uninstall.php now drops the iato_mcp_media_phase_log table on plugin delete and cleans up iato_mcp_db_version plus the four iato_mcp_media_* options that the 1.6.0 release introduced without matching uninstall coverage.
Audit: the other four tools from the 1.6.0 batch (set_featured_image, update_post_meta, get_post_meta, set_page_settings) were audited for the same “admin-controlled toggle without UI” pattern that the create_media URL-source feature exhibited. None of them read any iato_mcp_* options at runtime — the missing-UI gap was isolated to create_media. No changes to those four were required.
1.6.4
Fix: create_media now actually accepts uploads under Bearer-token MCP auth. The v1.6.0 implementation gated the handler on current_user_can(‘upload_files’) and current_user_can(‘edit_post’, $attach_to_post), but Bearer-authenticated MCP requests don’t establish a logged-in WordPress user — wp_get_current_user() returns 0 and meta-cap checks against the empty user object always fail, so every call returned “You do not have permission to upload files.” regardless of who initiated it. Switched to IATO_MCP_Auth::require_cap(), which honors the documented “plugin key grants full administrative access” auth model — exactly the same fix shape v1.3.1 applied to update_elementor_widgets_bulk and find_elementor_widgets for the same bug class. Audited the other v1.6.0 tools (get_post_meta, update_post_meta, set_page_settings, set_featured_image) and confirmed they all use require_cap() correctly — create_media was the only regression.
1.6.3
New: create_media accepts defer_subsizes: true to skip the synchronous wp_generate_attachment_metadata call and schedule it via WP-Cron instead. The MCP response returns immediately with attachment_id and the canonical URL; intermediate sizes are generated on the next cron tick (typically within seconds). Recommended for any caller running through the Anthropic MCP gateway, which times out around 30 seconds — sites with image-optimisation pipelines (ShortPixel, Imagify, Smush, etc.) intercepting the metadata-generation hook routinely exceed that limit and produce silent hangs where the response never arrives but the attachment also never lands. The default remains synchronous so existing callers see no behaviour change.
New: per-phase diagnostic logging in the create_media handler. Every call now writes [iato-mcp create_media:<req_id>] phase=… elapsed=…s lines to PHP’s error log at each stage (entry, source resolve, MIME check, dimension check, sideload, attachment insert, subsize generation, return), including the byte counts and dimensions seen. When a call hangs or fails, the last line in wp-content/debug.log (or the host’s PHP log) identifies which phase stalled — turning the previously-silent failure mode into a one-line diagnosis. The async cron handler logs its own line on completion with attachment_id, subsize count, and duration.
Fix: update_elementor_data with inherit_settings_from now copies empty-string values from the source post instead of skipping them. WordPress’s get_post_meta returns ” for both stored-empty and absent keys, so the prior skip-empty rule turned out to silently drop meaningful Astra layout state on real cloning workflows (Astra stores per-post overrides as empty strings in some configurations, and skipping them caused targets to retain the wrong layout). The contract of inherit_settings_from is “make the target match the source”; that now happens uniformly.
Fix: the default inherit_keys list on update_elementor_data is widened from 8 keys to 14 to cover the full Astra per-post override family (site-content-style, site-sidebar-style, ast-global-header-display, ast-banner-title-visibility, ast-breadcrumbs-content, ast-featured-img). Cloning a styled post now transfers the complete layout state in a single call, not just the original 4 brief-flagged keys.
Refactor: inherited_skipped[] in the update_elementor_data response semantics changed from “source value was empty” to “source value matches target’s existing value (no-op write)”. Each entry now uses reason: ‘noop’. The new shape surfaces the case where inherit_settings_from would have written a value but the target already had it — useful diagnostic, no behavioural cost.
1.6.2
Refactor: the four hand-written version_compare migration blocks that backfill new tool names into the saved iato_mcp_tools option are replaced by a single declarative TOOL_MIGRATION_BACKFILL map on IATO_MCP_Settings plus a one-loop walker in iato_mcp_maybe_run_migrations(). Same behavior for every install that was already correctly migrated; the new shape eliminates the “remembered to add a migration block” failure mode that produced the 1.3.0 → 1.3.1 fix, the 1.4.0 → 1.4.5 fix, and the 1.6.0 → 1.6.1 fix. Adding a new tool now requires appending one line to the map alongside the TOOL_NAMES edit — colocated, hard to miss at review time.
Fix: backfills the three crawl-management tools (start_iato_crawl, get_iato_crawl_status, list_iato_crawls) on installs that originally upgraded from 1.1.x to 1.2.x with a saved iato_mcp_tools option. v1.2.0 introduced those tools but shipped without the migration to append them, so any user who had saved their per-tool toggles in 1.1.x and configured an IATO API key has had the crawl-management tools invisibly disabled for the entire 1.2 → 1.6 interval. The 1.6.2 backfill catches them automatically on first request after upgrade. No-op for installs that already have the names in the saved option (idempotent), and no-op for fresh installs (the option starts empty and every tool is enabled by default).
Fix: update_elementor_data with inherit_settings_from now returns an inherited_skipped[] array in the response listing keys that were in the configured inheritance list but absent on the source post (so the assistant can see which clone targets had no source value rather than silently getting fewer receipts than the default list implies). Each entry is { key, reason: ‘source_empty’ }. The skip-empty behavior itself is unchanged — copying an explicit empty string from a source post that never set a key would stomp the target’s existing value.
1.6.1
Fix: the five new MCP tools added in 1.6.0 (get_post_meta, update_post_meta, set_page_settings, set_featured_image, create_media) now register correctly on sites upgrading from a previous version. v1.6.0 added them to the TOOL_NAMES constant but forgot the idempotent migration that appends new tool names to the saved iato_mcp_tools per-tool toggle option — the same migration shape used for the Elementor v2 tools in 1.3.5 and for rollback in 1.4.0/1.4.5. Without it, is_tool_enabled() filtered the new names out of the registry on every upgraded install, so the tools never appeared in tools/list despite shipping in the plugin. New installs were unaffected (the option is empty on first activation and all tools are enabled by default). Single one-shot migration; no-op for installs that already have the tool names in the saved option.
1.6.0
New: get_post_meta and update_post_meta expose arbitrary post meta over MCP with a centralised security policy. A credential-shaped denylist (*_token*, *_secret*, *_api_key*, *_password*, *_credential*, _oauth_*, _jwt_*, _refresh_token_*, plus wp_capabilities and friends) is hard-rejected on writes and redacted on reads — force=true cannot override it. A known-safe allowlist of theme/builder/SEO prefixes (Astra site-/ast-, Elementor _elementor_, Yoast/RankMath/SEOPress, Kadence, GeneratePress, Genesis, plus _wp_page_template and _thumbnail_id) lets the assistant write the common cases without ceremony; anything outside both lists requires force=true. Every write emits a change_receipt rollback-able under the new target_type=post_meta. Closes the long-standing gap that left the assistant unable to touch per-post theme settings on Astra and similar themes.
New: set_page_settings is a one-call convenience wrapper for the most common page-level settings cluster on Astra + Elementor sites. Pass abstract names like hide_title: true, sidebar_layout: “no-sidebar”, content_layout: “page-builder”, disable_header, disable_footer, page_template, or elementor_page_settings and the tool maps each to the right concrete meta key for the active theme. Astra-specific keys are silently skipped on non-Astra themes and surfaced in skipped[] so the agent can report them back to the user. Returns one change_receipt per concrete meta key written, so the whole settings cluster is reversible.
New: set_featured_image finally closes the “create a post end-to-end” loop — the assistant can now set or clear _thumbnail_id directly instead of bouncing the user to wp-admin. Validates that the supplied attachment is an image, captures the previous thumbnail ID in the receipt, and rolls back via the same post_meta target_type.
New: create_media uploads new images to the media library. Two source modes: base64 (default and recommended — the WordPress server never makes an outbound HTTP request on agent input) and url (default-disabled; admins must explicitly enable it and add hosts to an allowlist before any fetch is attempted). The URL path runs full SSRF guards: DNS resolution + private/loopback/link-local/cloud-metadata IP rejection, hard timeout, redirect cap, and re-validation of every redirect destination’s resolved IP. MIME is verified against actual file bytes (never the claimed mime_type), filenames containing .php, .phtml, .phar, or .htaccess are rejected, and SVG is hard-rejected this release regardless of how the upload is presented. Size (default 10MB) and dimension (default 8000×8000) caps are configurable; per-user rate limit (default 20/min) is enforced via a transient. Successful uploads return the attachment ID, public URL, generated intermediate sizes, and a change_receipt under the new target_type=attachment — rollback fully deletes the attachment file via wp_delete_attachment(force=true).
New: update_elementor_data gains inherit_settings_from: <post_id> and optional inherit_keys parameters. When set, the tool copies a curated default set of theme + Elementor page-level meta keys (site-post-title, site-sidebar-layout, site-content-layout, ast-main-header-display, footer-sml-layout, _wp_page_template, _elementor_page_settings, _elementor_template_type) from the source post to the target in the same MCP call, returning one change_receipt per inherited key in change_receipts[]. Collapses the “clone the styling of an existing post” workflow from four or more tool calls to one — and means the new post no longer renders with the wrong theme title bar above the Elementor content.
New: four new admin settings under Settings > IATO MCP control upload behaviour — iato_mcp_media_url_source_enabled (default off), iato_mcp_media_url_host_allowlist (one host per line), iato_mcp_media_max_upload_size (bytes; default 10MB), and iato_mcp_media_upload_rate_limit (per-user per-minute; default 20). Existing installs upgrade with secure defaults — URL ingestion stays off until explicitly enabled.
1.5.0
New: update_post accepts a slug parameter to rename a post’s URL slug via MCP — previously the agent had no way to update a slug and the user had to do it manually in the WP editor. Input is strictly validated (lowercase a-z 0-9 and hyphens only, no leading/trailing/double hyphens, max 200 chars, must survive a sanitize_title() round-trip unchanged) and conflicts return a slug_conflict error with the colliding post’s ID and title rather than silently appending -2 like WordPress would. Changing the slug of a non-draft post additionally requires confirm_url_break: true since it breaks every inbound link — drafts are exempt. Slug changes are rollback-able the same way as title/content/status edits: each change emits a change_receipt and can be reversed via the rollback tool.
New: create_post and update_post responses now include a notice field on page-builder-driven sites (Elementor, Divi, WPBakery, Beaver Builder) when the call would produce content that doesn’t match the site’s existing post format. On Elementor the notice tells the agent to fetch a reference post via get_post + get_elementor_data and apply its structure via update_elementor_data; on Divi/WPBakery/Beaver it tells the agent the layout must be finished in WP admin. On Gutenberg-only sites the field is absent — vanilla installs see no spurious warnings. Closes the gap that left agents creating posts with plain HTML on Elementor sites, producing structurally orphaned drafts that looked nothing like the rest of the site.
New: the dynamic instructions injected into the MCP initialize response (added in 1.4.8) now include a NEW-POST WORKFLOW block whenever a non-Gutenberg builder is active. The block primes the agent to (1) ask the user for a reference post URL before calling create_post, (2) fetch the reference’s structure, and (3) port that structure onto the new post — so the right path happens on the first call, not after the user notices the formatting problem.
1.4.10
Fix: the JSON config snippets emitted by the plugin (setup wizard Method 3, dismissible “Ready to Connect” notice, Settings hero card) now use a unique-per-site inner mcpServers key derived from the WordPress site’s hostname (e.g. iato-garennebigby-dev, iato-dynomapper-com) instead of the hardcoded iato-wordpress. Agencies managing multiple WordPress installs from a single AI client (Claude Desktop, Claude Code, etc.) can now paste config snippets from many IATO MCP installs into the same client config file without one silently overwriting another (JSON object keys are unique, so two snippets sharing a key was a silent collision). Existing connections that were set up with the old iato-wordpress key continue to work — the inner key is a display name only, not part of any HTTP request — so no migration is needed.
1.4.9
Docs: added the plugin demo video to the top of the Description section on the WordPress.org plugin page (auto-embedded by WordPress.org’s readme renderer when a YouTube URL is on its own line). No code changes; safe to skip if you’ve already updated to 1.4.8.
1.4.8
New: dynamic page-builder-aware server instructions injected into the MCP initialize response. The plugin now detects which page-builder plugins are active on the WordPress site (Elementor, Divi, WPBakery, Beaver Builder, Gutenberg) and emits a context-specific instruction string telling the AI agent which write tools are correct for which builder, with a mandatory get_page_builder check-first rule before any content edit. Closes a class of silent-failure bug where update_post on an Elementor-built post would succeed at the database level but never reach the frontend (because Elementor stores content in _elementor_data, not post_content). Detected-but-unsupported builders (Divi, WPBakery, Beaver Builder for writes) are explicitly flagged so the agent tells the user to edit in the WP admin instead of attempting a write that won’t take effect. Uses the standard MCP instructions field added in spec rev …