IATO MCP

Descripción

WordPress.com has a built-in MCP server. Now self-hosted WordPress does too.

IATO MCP connects your WordPress site to Claude Desktop and other MCP-enabled AI clients. Once connected, you can ask Claude to audit your site and fix SEO issues, identify orphan pages, clean up broken links, and more, all in a single conversation.

How it works

  1. Install and activate the plugin
  2. Follow the setup wizard — copy the config into Claude Desktop, or use “Add Custom Connector” with your site URL
  3. Connect your IATO account for AI-powered analysis (free trial up to 500 pages)

What Claude can do

Without an IATO account (45 WordPress tools):

  • Read and edit posts, pages, and media
  • Create new posts and pages with excerpt support
  • Update SEO titles and meta descriptions (Yoast SEO, RankMath, SEOPress)
  • Update canonical URLs
  • Update image alt text
  • Upload new images to the media library (base64 by default; URL ingestion optional with admin allowlist + full SSRF protection)
  • Set and clear the featured image on any post
  • Read and write arbitrary post meta with a credential-key denylist and a known-safe theme/builder/SEO allowlist (force=true required outside the allowlist)
  • Set per-post theme + builder page settings (hide title, sidebar layout, content layout, etc.) on Astra, Kadence, GeneratePress, and Elementor in one call
  • Read and edit navigation menus
  • Manage categories, tags, and taxonomy terms
  • Manage JSON-LD structured data
  • Manage redirect rules
  • Read and write Elementor page builder data
  • Widget-grained Elementor edits with optimistic concurrency, idempotency, and bulk operations
  • Fix heading levels, text, links and image alt text on Elementor Atomic Editor (V4) elements (Elementor 4.3.0 or later), converted and validated with Elementor’s own code, each change with its own rollback
  • Clone the styling of an existing Elementor post in one call via update_elementor_data(..., inherit_settings_from: <id>)
  • Resolve URLs to their rendering post (Theme Builder shadowing detection)
  • Search content across the site
  • Read site info and settings
  • Read and filter comments
  • One-call rollback for any tracked write — every change emits a receipt with a stable change_id; pass it back to the rollback tool and the original value is restored

With an IATO account (12 bridge tools — full analyze-and-fix pipeline):

  • Start a new crawl of your site directly from Claude (admin only)
  • Check crawl status and list recent crawl jobs
  • Run a full SEO audit and fix title, meta description, and alt text issues automatically
  • Identify orphan pages not linked from any navigation menu
  • Audit navigation menus for gaps and missing sections
  • Surface thin content with specific improvement recommendations
  • Map broken links to source posts for direct editing
  • Analyze site taxonomy and suggest consolidations
  • Get AI-prioritized suggestions across all areas
  • Flag slow pages with contributing performance factors

Supported SEO plugins

  • Yoast SEO
  • RankMath
  • SEOPress
  • Falls back to native WordPress title if none detected

Example prompts

“Crawl my site and fix all missing meta descriptions”

“Show me pages that aren’t in any navigation menu and add them to the right place”

“What are the most impactful improvements I can make to my site right now?”

“Find all broken links and tell me which posts contain them”

“Audit my categories and tags and suggest consolidations”

“Set every H2 heading in these Elementor posts to H1”

“Find all button widgets on the site and change their color to #ff0000”

External Services

This plugin connects to the following external service when configured:

IATO API (https://iato.ai) — When you enter an IATO API key in the plugin settings, the plugin sends requests to https://iato.ai/api to retrieve crawl data, SEO audit results, sitemap information, and AI-generated improvement suggestions. No data is sent to IATO until you configure an API key. Your public page URLs (as crawled by IATO) and crawl analysis results are transmitted.

The plugin also implements an OAuth 2.0 authorization server on your WordPress site so that MCP clients like Claude Desktop can authenticate via the standard “Add Custom Connector” flow. This communication stays between the MCP client and your WordPress site — no data is sent to third parties during authentication.

Capturas

Instalación

  1. Upload the plugin files to /wp-content/plugins/iato-mcp/ or install via the WordPress plugin directory
  2. Activate the plugin via the Plugins menu in WordPress
  3. Follow the setup wizard that appears — it provides the JSON config for Claude Desktop
  4. In Claude Desktop, either paste the JSON config or use “Add Custom Connector” and enter your site URL
  5. Optionally, go to Settings > IATO MCP to enter your IATO API key for the full analysis pipeline. Use a separate IATO API key for each client site: a key gives access to every crawl in its IATO account, so a key shared between sites lets one site’s administrators see the others’ crawl data.

For detailed setup instructions, see the IATO MCP documentation.

Preguntas frecuentes

Do I need an IATO account?

No. The plugin works standalone for reading and editing WordPress content with 40 built-in tools. An IATO account (free trial up to 500 pages) unlocks 12 additional bridge tools: start/list/status crawl management, SEO audit, broken links, content gaps, orphan pages, navigation audit, taxonomy analysis, AI suggestions, and performance reports. They are available to administrators. If you manage several client sites, give each site its own IATO API key: a key can read every crawl in its account.

Which WordPress version is required?

WordPress 6.2 or higher with PHP 8.0+. The plugin uses the WordPress REST API and implements OAuth 2.0 for secure authentication with AI clients.

Does this work on shared hosting?

Yes. The plugin uses standard HTTP requests (one per MCP call) rather than long-lived connections, so it works on all hosting environments including shared hosting.

Which AI clients are supported?

Any MCP-enabled client: Claude Desktop, Cursor, VS Code with GitHub Copilot, and any client that supports the Streamable HTTP MCP transport.

How does authentication work?

The plugin generates a secure API key on activation. You can authenticate in two ways: paste the provided Bearer token config into your AI client, or use Claude’s “Add custom connector” flow, which handles OAuth 2.1 with PKCE automatically. Each OAuth connection gets its own expiring token, acts as the administrator who approved it, and can be revoked on its own under Settings > IATO MCP > Connected OAuth clients.

Why does the plugin support two auth methods?

AI clients like Claude Desktop authenticate via a WordPress Application Password (or the OAuth 2.0 / PKCE flow), which is the WordPress-native pattern most users will use. The plugin also accepts the plugin-generated Bearer token at the same MCP endpoint — that path is used by the IATO platform’s own integrations (for example, the dashboard’s “Sync pages, posts, menus, and taxonomy from WordPress” feature, which composes the plugin’s read tools to pull content into IATO). Both methods land at /wp-json/iato-mcp/v1/message and are validated by class-auth.php. You don’t have to choose — paste your Bearer token into the IATO platform connection, generate an Application Password for Claude Desktop, and the same plugin handles both.

Is my content sent to IATO or Anthropic?

WordPress content (post titles, meta descriptions, etc.) is never sent to IATO. IATO crawls your public URLs the same way a search engine would. Claude processes content within your AI client session only. The IATO API is only called when you use bridge tools, and only crawl analysis data (not your content) is transmitted.

A page lost an embed or script after an IATO edit. How do I repair it?

Open the page in Elementor, go to History, then Revisions, and apply the last revision saved by a person before the first IATO edit. Then re-apply the IATO changes you want to keep. If revisions are disabled on the site, there is no earlier copy to restore.

A page edited with IATO shows a missing link or image, or Elementor will not save it

IATO 1.13.0 and earlier could store a plain value on an Elementor Atomic Editor (V4) element, such as a heading, paragraph, image or button built with the Atomic Editor, where Elementor expects a typed value. Elementor then drops a link or image it cannot read, and saving the page in Elementor fails with “Settings validation failed”.

To find the affected elements, read the page with get_elementor_data, get_elementor_widget, list_elementor_widgets or find_elementor_widgets. Since 1.13.1 they flag the element with stored_value_invalid: true, and invalid_keys names the settings to repair; get_elementor_data also lists them under invalid_elements.

To repair an element, send each listed setting again with update_elementor_widget, set_widget_setting or set_heading_level. From 1.14.0 (with Elementor 4.3.0 or later) the plain value works, for example {“tag”: “h2”} or {“link”: {“destination”: “https://example.com/”}}: IATO stores it in Elementor’s format and the element is repaired. With 1.13.1, or on a site without Elementor 4.3.0, send it as a typed value, in the shape get_elementor_widget shows under settings for the element, for example {“tag”: {“$$type”: “string”, “value”: “h2”}}. When several elements on a page are affected, repair them one at a time; Elementor saves the page normally again once the last one is repaired. A damaged element cannot be copied (update_elementor_patch refuses the copy): repair the source first, then copy it. You can also restore a revision saved before the IATO edit, under History, then Revisions, in Elementor.

Can IATO edit Elementor Atomic Editor (V4) elements?

Yes, from 1.14.0, with Elementor 4.3.0 or later and the Atomic Editor turned on. set_heading_level, set_widget_setting, update_elementor_widget, update_elementor_widgets_bulk and update_elementor_patch take plain values for atomic headings, paragraphs, buttons, images and containers, and update_alt_text takes post_id and element_id for an atomic image. IATO converts and validates each value with Elementor’s own code and keeps every setting the call does not name as it was stored. Changes go live, like edits to classic widgets, and each one can be rolled back on its own.

The alt text of an image from the Media Library belongs to the attachment, so IATO changes it there, and the change applies everywhere that image is used. Global classes and component instances are not edited by IATO yet. On sites with an older Elementor, IATO accepts only typed values for atomic elements and says what is missing. While the Atomic Editor is off, IATO does not change atomic elements at all, because Elementor cannot check them; the rest of the page can still be edited.

Can I control which tools are available?

Yes. Go to Settings > IATO MCP to enable or disable individual tools. You can turn off any tool you don’t want AI clients to access.

Can AI clients upload arbitrary files to my media library?

Only images, and only when the calling user has the upload_files capability. The create_media tool enforces an image-only MIME allowlist (JPEG, PNG, GIF, WebP, AVIF) verified against actual file bytes — the claimed mime_type is never trusted. SVG uploads are not supported in this release. Files exceeding the size cap (default 10MB) or the dimension cap (default 8000×8000) are rejected, as are filenames containing .php, .phtml, or .htaccess. URL-source ingestion is disabled by default; admins who enable it must also configure a host allowlist, and private/loopback/cloud-metadata IPs are rejected even for allowlisted hosts. Each upload counts against a per-user rate limit (default 20/min) and emits a change_receipt — rolling back fully deletes the attachment file. All four limits are configurable from Settings > IATO MCP.

Reseñas

No hay reseñas para este plugin.

Colaboradores y desarrolladores

“IATO MCP” es un software de código abierto. Las siguientes personas han colaborado con este plugin.

Colaboradores

Traduce “IATO MCP” a tu idioma.

¿Interesado en el desarrollo?

Revisa el código , echa un vistazo al repositorio SVN , o suscríbete al log de desarrollo por RSS .

Registro de cambios

1.14.0

  • New: Heading level, text, link and external-image alt fixes now work on Elementor Atomic Editor (V4) elements through the existing tools (set_heading_level, set_widget_setting, update_elementor_widget, update_elementor_widgets_bulk, update_elementor_patch, update_alt_text). IATO converts and validates values with Elementor’s own code (Elementor 4.3.0 or later), and edits go live like classic edits.
  • New: Each atomic change records a receipt per element and can be rolled back on its own, with the conflict check and dry_run.
  • New: Results say how a change was applied (applied_via), that it is live (state), which values were sanitised, and which caches were cleared.
  • New: update_alt_text accepts post_id and element_id to fix the alt text of an atomic image that uses an external URL. For a Media Library image it changes the attachment’s alt text, as before. update_alt_text also accepts dry_run.
  • Change: Plain values sent to atomic elements, refused since 1.13.1, are now converted and validated with Elementor’s code and stored in Elementor’s format. On sites without Elementor 4.3.0 or later, the 1.13.1 rule stays: only typed values are accepted, and the refusal (now atomic_write_unavailable) says what is missing.
  • Change: While Elementor cannot load an atomic element’s type (the Atomic Editor is off, or the plugin that provides it is inactive), IATO no longer changes that element, because nothing can validate it; typed values were stored unchecked until now. Other elements on the page can still be edited.
  • Change: In update_elementor_widgets_bulk, atomic rows for the same page are applied in one save, so several rows that send the same if_revision now succeed together. Before this change, every row after the first failed with revision_conflict. Classic rows are unchanged.
  • New: get_iato_seo_fixes lists issues it cannot act on under skipped, with a reason: a URL that is not a WordPress post on this site, or a URL on another domain.
  • Fix: An error thrown inside a tool or another plugin now returns a clear error instead of a PHP fatal error.
  • Fix: When Elementor’s own save fails and IATO stores the page itself, IATO clears that page’s CSS and element cache and says so in the result.
  • Fix: Sending a setting again to an atomic element that an earlier IATO version stored as a plain value repairs that element.
  • Tweak: Atomic edits clear only the edited page’s caches, not every page’s.
  • Tweak: Wording of the plugin description.

1.13.1

  • Fix: plain values sent to Elementor Atomic Editor (V4) elements are now refused instead of being stored in a form that could remove links or images and block later saves.
  • Fix: IATO no longer stores new settings for an Atomic Editor (V4) element that Elementor’s own validation rejects, whichever credential is used, and dry runs report the same refusal. Settings an atomic element does not have (such as header_size on an atomic heading) are refused instead of being reported as applied.
  • Fix: editing a page that contains elements from a deactivated addon, or atomic elements while the Atomic Editor is off, no longer removes those elements.
  • New: the Elementor read tools (get_elementor_data, get_elementor_widget, list_elementor_widgets, find_elementor_widgets) flag an atomic element whose stored settings Elementor cannot read with stored_value_invalid and invalid_keys, and show those values as stored. See the FAQ for how to repair such an element.

1.13.0

  • Security: OAuth connections (the Claude connector, Claude Code, Cursor and other MCP clients) now receive their own tokens. The authorization code is random and single use (5 minutes); the access token expires after 1 hour and is renewed with a rotating refresh token (30 days); a connection expires after 180 days unless it is approved again (filter: iato_mcp_oauth_grant_lifetime). Tokens are stored hashed. Before this release the OAuth code and the access token were the site’s API key itself.
  • Security: each OAuth connection acts as the administrator who approved it and is re-checked on every request. Deleting or demoting that user revokes the connection. Approving a connection no longer assigns the site’s API key to the approving user.
  • New: Settings > IATO MCP lists connected OAuth clients (application, approving user, connected, last used) with a Revoke button per connection and a Disconnect all button, and the plugin serves a token revocation endpoint at /oauth/revoke. Revoking connections does not affect the API key.
  • Security: dynamic client registration accepts https redirect URIs only (plain http only to localhost, 127.0.0.1 or [::1], any port), stores the application name, is rate-limited (10 per IP per 10 minutes, 100 per site per hour; filter: iato_mcp_oauth_registration_limits), and removes registrations that never complete an authorization after 24 hours or have had no live connection for 30 days.
  • Security: PKCE (S256) is required at /oauth/authorize; the resource parameter (RFC 8707) is checked; the authorization response carries iss (RFC 9207); the consent screen shows the application name, the host it will send you back to, and the user the connection will act as, and its approval is bound to that exact request. Logged-in users who are not administrators get a clear “administrators only” page. Follows the MCP authorization specification revision 2026-07-28. Client ID Metadata Documents (“Use Claude’s published identity”) are not supported yet; Claude falls back to dynamic registration on its own.
  • Fix: database changes are now tracked by a schema version separate from the plugin version, so a site that ran a pre-release build still receives every later table change.
  • Security: update_post_meta and inherit_keys no longer write Elementor builder data. Every elementor key is refused except _elementor_page_settings (whose values are sanitised), _elementor_edit_mode and _elementor_template_type; the error names the Elementor tool to use. Element trees stored as arrays by earlier versions are still read.
  • Security: when Elementor’s own save refuses, throws (some addon packs) or Elementor is not loaded, the plugin now sanitises the values the request changed before storing the tree, by field type: URL fields through esc_url_raw, custom_css through sanitize_textarea_field, strings containing markup through wp_kses_post; untouched values, plain text, numbers and booleans are stored verbatim. Elementor’s save runs first and the raw tree is never written. Responses carry sanitized: true and the changed setting paths when a value was altered; dry runs report the same.
  • Fix: the 1.12.2 fix (edits change only the values they set) now also holds when Elementor’s own save runs, which it does for a key bound to an owner and for OAuth connections; what a person authored elsewhere on the page is stored as it was.
  • Security: content written through MCP with an Application Password is now filtered like a non-administrator’s (the same kses rules that already applied to the API key and OAuth), so scripts and embeds in Elementor widgets or post content are stripped even for administrators. A developer who needs raw HTML through MCP can opt out with the iato_mcp_allow_unfiltered_html filter (receives the user ID and the credential kind; honoured only for Application Password users who hold unfiltered_html).
  • Security: every change receipt records who made it: the WordPress user, the credential (site key, OAuth connection or Application Password) and the OAuth application. Only that user or an administrator can roll the change back; changes recorded before 1.13.0 keep the earlier rules. Rollback responses and the Diagnostics call log show who made and who undid each change.
  • New: rollback refuses to overwrite a value that has changed since the change was made (a later edit in WordPress or another change) and shows the current and expected values; pass force: true to roll back anyway, or dry_run: true to check first.
  • Fix: Elementor edits can be rolled back exactly (the receipt now keeps the page as it was before the edit). Elementor receipts from earlier versions held only a revision hash; their rollback now says so instead of failing.
  • New: those page copies are removed automatically after 90 days by a daily task; the receipt stays as a record of who changed what, and older edits are restored from Elementor’s revisions instead. Developers can change the period with the iato_mcp_receipt_retention_days filter (0 keeps them). Diagnostics shows when the cleanup last ran.
  • Fix: backslashes are kept. Post and page titles, content and excerpts, post meta (including Elementor page settings such as custom CSS escapes like \201C), SEO titles and descriptions, alt text, media titles and captions, menu item titles and term names and descriptions written through MCP used to lose every backslash, and rollback could not restore them.
  • Fix: rolling back a tag added to a post that had no tags no longer removes the post’s categories, and rolling back a term edit restores the term (it did nothing before).
  • Security: the IATO bridge tools (crawls, SEO audit, broken links, content gaps, orphan pages, navigation audit, taxonomy, suggestions, performance, sitemap) now require an administrator (manage_options) through MCP, as they already did as WordPress abilities. They return the IATO account’s crawl data, which can include other sites. The iato_mcp_ability_capability filter lowers the requirement for both surfaces.
  • Security: get_media requires upload_files (Author and above), the same as the Media Library.
  • Security: get_comments returns only comments on posts the caller can read (password-protected posts need edit rights), and held, spam or trashed comments require moderate_comments. An unknown status is now an error instead of an empty list.
  • Security: get_menu_items leaves out items that link to posts the caller cannot read, such as drafts and private posts, and reports how many it left out.
  • Security: get_site_info returns the active plugin count and the Theme Builder template count to administrators only; other users see them listed under restricted_fields.
  • Security: resolve_url answers with a 404 for posts the caller cannot read, as WordPress does for visitors.
  • Security: writing _elementor_template_type with update_post_meta requires an administrator; update_elementor_data’s inherit step skips it for other users and says why.
  • Security: in array values written to other post meta keys (custom fields, theme and SEO plugin settings), text containing HTML that the write changes is filtered with wp_kses_post, like Elementor values; values already stored are kept as they are.
  • Note on upgrade: OAuth connections made before 1.13.0 keep working (they hold the API key) but cannot be revoked individually. An admin notice explains how to move them to their own tokens (remove and re-add the connector in the AI client) and that regenerating the API key disconnects all of them; Settings and Diagnostics keep pointing this out until the key is regenerated. An authorization that was in progress during the upgrade has to be started again.

1.12.2

  • Fixed: editing an Elementor page with an IATO site key or OAuth connection removed iframes, scripts and other HTML from widgets the edit didn’t change, and turned & into & in text. Edits now change only the values they set. Affects 1.0.0 to 1.12.1. Connections using an Application Password of a user who may publish unfiltered HTML (administrators and editors on a single site, super admins on multisite) were not affected.
  • Fixed: rolling back Elementor content now restores it exactly.

1.12.1

  • Security: write tools now check permission on the specific post, page, term, or attachment being changed, and publishing requires publish rights.
  • Security: read tools now follow WordPress permissions for drafts, private posts, password-protected content (including revisions), and Theme Builder templates. get_post_meta requires edit access to the post.
  • Security: hardened structured data. update_structured_data now accepts only JSON objects or arrays, rejects script and comment markup, and stores values with HTML-sensitive characters escaped (for example \u003C). Code that reads the stored meta directly will see the escaped form.
  • Security: the OAuth consent screen can no longer be embedded in other sites. Token exchange is stricter: only the S256 PKCE method is accepted, and a client that sent a code challenge must send the matching code verifier.
  • Security: rollback requires the same permissions as the original change.
  • Fix: base64 media uploads no longer fail on some sites.
  • Note for integrations using an Application Password: requests now get exactly the access that user’s WordPress role allows. Contributors and Authors can no longer read or change other users’ unpublished content, and Contributors can no longer create pages. get_posts may report a hidden count. find_elementor_widgets lists explicitly requested posts it could not read in skipped_unreadable, reports only a count for posts found by its automatic scan, and requires administrator rights for include_templates. Connections using the site key or OAuth are unaffected.

1.12.0

  • Elementor Atomic Editor (V4) read support. list_elementor_widgets, get_elementor_data (summary), get_elementor_widget and find_elementor_widgets now return correct content for atomic elements (e-heading, e-paragraph, e-image, e-button, e-flexbox, e-div-block, …) and for pages mixing classic and atomic elements, in document order. Every node carries schema: “classic” | “atomic”; atomic nodes are normalised to the classic peek keys (title, header_size, editor, text, link) plus tag, link_new_tab, image_url, image_id, image_alt, image_alt_source.
  • get_elementor_widget returns settings_plain (typed envelopes unwrapped, unsaved schema defaults filled and listed in defaulted_keys) alongside the raw settings. get_page_builder returns elementor_schema: classic | atomic | mixed | empty. initialize advertises capabilities.elementor.atomic_read.
  • find_elementor_widgets setting filters match atomic nodes on unwrapped values, so setting.title.contains and setting.header_size.eq work on both schemas; type accepts atomic names.
  • Reads parse _elementor_data directly with no call into Elementor code, so they work on Elementor 3.x, 4.0-4.2, with the Elementor MCP module off, and with Elementor deactivated. Classic-only pages return the same output as 1.11.0 apart from the added schema key.
  • Writes to atomic elements are not yet supported (planned next release).

1.10.0

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