WordPress + AI series · Part 3 of 3 · View all parts
← Part 2: How to make Yoast SEO fields writable via MCP
In part two of this series we built a small must-use plugin — the Yoast-MCP bridge — that makes Yoast SEO fields writable through the WordPress REST API, so Claude can set focus keyphrases and meta descriptions over MCP. It worked great for posts. Then I tried to use it on a real page, and it crashed with a 500. This article is the story of that crash, and how extending the Yoast-MCP bridge to support pages turned into two instructive bug fixes.
This is dogfooding in the truest sense: the bug below wasn’t invented for a tutorial. It surfaced while I was using my own tool to build the landing pages for this very series. Here is exactly what broke, why, and the roughly ten lines of PHP that fixed it.
Where part 2 left off
The original bridge registered a handful of Yoast meta keys with register_post_meta() and show_in_rest => true, looping over both post and page. On paper it already supported pages. In practice, nobody had actually written to a page through it yet — every test had been against blog posts. That untested path is where the trap was hiding.
The setup: why I needed pages to work
Each series on Factnetize gets a hub landing page that links all its parts together. I asked Claude to create those hub pages and fill in their Yoast SEO fields over MCP — the same workflow that had worked perfectly for posts. The first read request was a simple sanity check:
GET /wp/v2/pages/801?context=edit
Instead of the page data, the response was a flat HTTP 500. No useful message, just a server error. The exact same request shape worked fine against /wp/v2/posts/{id}. The only variable that changed was the post type.
The primary_category trap
The culprit was a single field: _yoast_wpseo_primary_category. This field stores which category Yoast treats as the canonical one for a post. It only makes sense for content that actually has categories — and pages, by default, do not have the category taxonomy at all.
Here is the precise failure. Yoast sanitizes the primary category value with absint(), which always returns an integer. But when you register the field for the REST API, the schema declares its type as string. For posts this mismatch is masked, because a real category ID round-trips cleanly. On a page, there is no category taxonomy to resolve against, and the integer-versus-string type mismatch surfaces as a hard TypeError during REST serialization — which WordPress returns as a 500.
In other words: registering _yoast_wpseo_primary_category on a post type that has no category taxonomy is the bug. The fix is to simply not register it there.
Fix 1: skip primary_category where it does not belong
WordPress has a purpose-built function for exactly this question: is_object_in_taxonomy(). It returns true if a given post type is registered with a given taxonomy. So before registering the primary category field, we check whether the current post type even supports categories, and skip it if not:
if ( '_yoast_wpseo_primary_category' === $key
&& ! is_object_in_taxonomy( $type, 'category' ) ) {
continue;
}
This is a one-line guard with zero side effects. Posts keep the primary category field exactly as before. Pages — and any custom post type without categories — quietly skip it, and the 500 disappears. Importantly, this is more correct than a hardcoded if ( $type === 'page' ) check: it works for any post type, not just pages.
The second bug: the wrong capability
With the crash gone, a subtler problem appeared. The original bridge used a single shared authorization callback for every field and every post type:
$auth = function() {
return current_user_can( 'edit_posts' );
};
The edit_posts capability governs blog posts. Pages are governed by a different capability entirely: edit_pages. A user who can edit posts but not pages would still pass this check when writing to a page — and conversely, the check is conceptually wrong even when it happens to allow the right people through. Authorization should reflect the actual object being modified, not a convenient default.
Fix 2: scope the capability per post type
The fix is to compute the correct capability for each post type and capture it in the closure with use(), so every field’s auth callback checks the capability that actually applies to it:
$required_cap = ( 'page' === $type ) ? 'edit_pages' : 'edit_posts';
$auth = function () use ( $required_cap ) {
return current_user_can( $required_cap );
};
Because the closure is created inside the foreach loop over post types, each post type gets its own callback bound to the right capability. Pages check edit_pages, posts check edit_posts, and the authorization model finally matches reality.
The complete updated mu-plugin
Here is the full Yoast-MCP bridge with both fixes applied, version 1.0.2. Replace the contents of wp-content/mu-plugins/yoast-rest-bridge.php with this — no activation step needed, mu-plugins load automatically:
<?php
/**
* Plugin Name: Yoast SEO REST Bridge
* Description: Makes Yoast SEO meta fields writable via the WordPress REST API,
* so AI tools like Claude can manage them via MCP. Posts and pages.
* Version: 1.0.2
* Author: John Lock / Factnetize
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function factnetize_yoast_fields_rest() {
$post_types = array( 'post', 'page' );
// Text fields — sanitize_text_field
$text_keys = array(
'_yoast_wpseo_title',
'_yoast_wpseo_metadesc',
'_yoast_wpseo_focuskw',
'_yoast_wpseo_meta-robots-noindex',
'_yoast_wpseo_opengraph-title',
'_yoast_wpseo_opengraph-description',
'_yoast_wpseo_twitter-title',
'_yoast_wpseo_twitter-description',
'_yoast_wpseo_primary_category',
);
// URL fields — esc_url_raw
$url_keys = array(
'_yoast_wpseo_canonical',
'_yoast_wpseo_opengraph-image',
'_yoast_wpseo_twitter-image',
);
foreach ( $post_types as $type ) {
// Fix 2: pick the capability that actually governs this post type.
// Pages need edit_pages; posts (and most CPTs) need edit_posts.
$required_cap = ( 'page' === $type ) ? 'edit_pages' : 'edit_posts';
$auth = function () use ( $required_cap ) {
return current_user_can( $required_cap );
};
foreach ( $text_keys as $key ) {
// Fix 1: the primary category field only makes sense for post
// types that actually have the 'category' taxonomy. Registering
// it for pages causes a 500 — Yoast sanitizes it with absint()
// (an int) while the REST schema declares it 'string'.
if ( '_yoast_wpseo_primary_category' === $key
&& ! is_object_in_taxonomy( $type, 'category' ) ) {
continue;
}
register_post_meta( $type, $key, array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'auth_callback' => $auth,
'sanitize_callback' => 'sanitize_text_field',
) );
}
foreach ( $url_keys as $key ) {
register_post_meta( $type, $key, array(
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'auth_callback' => $auth,
'sanitize_callback' => 'esc_url_raw',
) );
}
}
}
add_action( 'rest_api_init', 'factnetize_yoast_fields_rest' );
Verifying the fix
After saving the file, the same request that crashed before now returns clean data:
GET /wp/v2/pages/801?context=edit → 200 OK
Every Yoast meta field on the page is now readable and writable over the REST API: SEO title, meta description, Open Graph and Twitter fields all round-trip correctly. The primary category field is simply absent on pages, which is exactly right — pages have no categories to designate. The series hub pages were finished minutes later, entirely through MCP.
Why this matters
Two small lessons came out of this. First, “it loops over pages too” is not the same as “it works for pages” — an untested code path is a latent bug, and the only reliable way to find it is to actually use the thing. Second, defensive checks like is_object_in_taxonomy() and per-object capability scoping are not premature optimization; they are the difference between a tool that works on your setup and a tool that works on anyone’s.
This is the value of dogfooding. Both fixes exist because I used my own bridge for real work and it broke in my hands — not in a hypothetical. The repair shipped the same afternoon.
Conclusion and what is next
The Yoast-MCP bridge now fully supports pages. A single guard with is_object_in_taxonomy() eliminates the primary_category 500, and a per-post-type capability captured with use() makes the authorization model honest. Drop in the version 1.0.2 file above and Claude can manage SEO for both posts and pages over MCP.
There is one more gap I hit while wiring these hub pages into the site navigation — the Editor role cannot manage menus, widgets, or the customizer over REST, which blocks AI-driven navigation changes entirely. That is the subject of the next part in this series.
Featured image by Taiki Ishikawa on Unsplash.
