Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions scripts/manual-test-meta-warning.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Manual end-to-end test for the dropped-meta-key warning behavior.
//
// Usage:
// 1. Configure .env with credentials for a real WP install (Yoast SEO
// active is the canonical case, but any install will do — the test
// just needs a key that WP will silently drop).
// 2. From the repo root: node scripts/manual-test-meta-warning.mjs
//
// What it does:
// - Initializes the WP client via the same code path the MCP server uses.
// - Creates a draft post.
// - Calls update_content with three deliberately-unregistered meta keys
// (`_yoast_wpseo_focuskw`, `_yoast_wpseo_metadesc`, `_yoast_wpseo_title`).
// - Asserts the warning content block appears in the toolResult.
// - Deletes the draft post (force) on cleanup.
//
// Exits 0 on success, non-zero on any assertion failure.

import 'dotenv/config';
import { initWordPress, makeWordPressRequest } from '../build/wordpress.js';
import { unifiedContentHandlers } from '../build/tools/unified-content.js';

const SCRATCH = {
title: 'meta warning manual test (delete me)',
status: 'draft',
content: 'created by scripts/manual-test-meta-warning.mjs',
};

const SENT_META = {
_yoast_wpseo_focuskw: 'manual-test',
_yoast_wpseo_metadesc: 'manual test description',
_yoast_wpseo_title: 'Manual Test Title',
};

let createdId = null;
let exitCode = 0;

function fail(msg) {
console.error('FAIL:', msg);
exitCode = 1;
}

function pass(msg) {
console.log('PASS:', msg);
}

try {
await initWordPress();

const created = await makeWordPressRequest('POST', 'posts', SCRATCH);
createdId = created.id;
console.log(`Created scratch post id=${createdId}`);

const result = await unifiedContentHandlers.update_content({
content_type: 'post',
id: createdId,
meta: SENT_META,
});

const contentBlocks = result?.toolResult?.content;
if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) {
fail('toolResult.content was empty or not an array');
} else {
const warningBlock = contentBlocks.find(
(b) => b && typeof b.text === 'string' && b.text.startsWith('Warning:'),
);

if (!warningBlock) {
fail('expected a Warning: content block, none found');
console.error('Got content blocks:', JSON.stringify(contentBlocks, null, 2));
} else {
pass('warning content block present');
for (const key of Object.keys(SENT_META)) {
if (!warningBlock.text.includes(key)) {
fail(`warning text missing key: ${key}`);
} else {
pass(`warning mentions ${key}`);
}
}
}
}
} catch (err) {
fail(`unhandled error: ${err.message}`);
console.error(err);
} finally {
if (createdId) {
try {
await makeWordPressRequest('DELETE', `posts/${createdId}`, { force: true });
console.log(`Cleaned up scratch post id=${createdId}`);
} catch (cleanupErr) {
console.warn(`Cleanup failed for id=${createdId}: ${cleanupErr.message}`);
}
}
}

process.exit(exitCode);
132 changes: 97 additions & 35 deletions src/tools/unified-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,44 @@ async function processContent(
return htmlContent;
}

// Return the meta keys that were sent in the request but don't appear in
// the WP response's `meta` object. WordPress silently drops unregistered
// meta keys on writes to /wp/v2/{type}/{id}, so absence in the echoed
// response is the signal that a key wasn't persisted. The `responseData`
// is the parsed WP REST response; we look for `responseData.meta` as the
// echoed object. If the response shape is unexpected (no meta object,
// or meta returned as an array rather than the usual keyed object), we
// treat every sent key as dropped — conservative, but matches the
// underlying "we can't confirm it stuck" signal.
export function detectDroppedMetaKeys(
sent: Record<string, unknown> | undefined,
responseData: unknown
): string[] {
if (!sent) return [];
const sentKeys = Object.keys(sent);
if (sentKeys.length === 0) return [];
if (!responseData || typeof responseData !== 'object' || Array.isArray(responseData)) {
return sentKeys;
}
const returnedMeta = (responseData as Record<string, unknown>).meta;
if (!returnedMeta || typeof returnedMeta !== 'object' || Array.isArray(returnedMeta)) {
return sentKeys;
}
const returnedKeys = new Set(Object.keys(returnedMeta as Record<string, unknown>));
return sentKeys.filter(k => !returnedKeys.has(k));
}

export function buildDroppedMetaWarning(droppedKeys: string[]): string {
return (
`Warning: WordPress did not persist these meta keys: ${droppedKeys.join(', ')}. ` +
`This usually means they are not registered for REST exposure via ` +
`register_post_meta(..., show_in_rest => true). Common culprits are SEO ` +
`plugin keys (Yoast _yoast_wpseo_*, Rank Math rank_math_*, AIOSEO _aioseo_*) ` +
`which the plugins do not expose on the core /wp/v2/ endpoints by default. ` +
`See README "Meta field limitations" for context.`
);
}

// Schema definitions
const listContentSchema = z.object({
content_type: z.string().describe("The content type slug (e.g., 'post', 'page', 'product', 'documentation')"),
Expand Down Expand Up @@ -682,13 +720,19 @@ export const unifiedContentHandlers = {
});

const response = await makeWordPressRequest('POST', endpoint, contentData, { siteId: params.site_id });


const responseContent: any[] = [{
type: 'text',
text: JSON.stringify(response, null, 2)
}];
const droppedMeta = detectDroppedMetaKeys(params.meta, response);
if (droppedMeta.length > 0) {
responseContent.unshift({ type: 'text', text: buildDroppedMetaWarning(droppedMeta) });
}

return {
toolResult: {
content: [{
type: 'text',
text: JSON.stringify(response, null, 2)
}],
content: responseContent,
isError: false
}
};
Expand Down Expand Up @@ -742,22 +786,28 @@ export const unifiedContentHandlers = {
}

const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData, { siteId: params.site_id });


const responseContent: any[] = [{
type: 'text',
text: JSON.stringify(response, null, 2)
}];
const droppedMeta = detectDroppedMetaKeys(params.meta, response);
if (droppedMeta.length > 0) {
responseContent.unshift({ type: 'text', text: buildDroppedMetaWarning(droppedMeta) });
}

return {
toolResult: {
content: [{
type: 'text',
text: JSON.stringify(response, null, 2)
}],
content: responseContent,
isError: false
}
};
} catch (error: any) {
return {
toolResult: {
content: [{
type: 'text',
text: `Error updating content: ${error.message}`
content: [{
type: 'text',
text: `Error updating content: ${error.message}`
}],
isError: true
}
Expand Down Expand Up @@ -906,19 +956,25 @@ export const unifiedContentHandlers = {

const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });

const responseContent: any[] = [{
type: 'text',
text: JSON.stringify({
found: true,
content_type: contentType,
content_id: content.id,
original_url: params.url,
updated: true,
content: updatedContent
}, null, 2)
}];
const droppedMeta = detectDroppedMetaKeys(params.update_fields.meta, updatedContent);
if (droppedMeta.length > 0) {
responseContent.unshift({ type: 'text', text: buildDroppedMetaWarning(droppedMeta) });
}

return {
toolResult: {
content: [{
type: 'text',
text: JSON.stringify({
found: true,
content_type: contentType,
content_id: content.id,
original_url: params.url,
updated: true,
content: updatedContent
}, null, 2)
}],
content: responseContent,
isError: false
}
};
Expand Down Expand Up @@ -965,19 +1021,25 @@ export const unifiedContentHandlers = {

const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });

const responseContent: any[] = [{
type: 'text',
text: JSON.stringify({
found: true,
content_type: contentType,
content_id: content.id,
original_url: params.url,
updated: true,
content: updatedContent
}, null, 2)
}];
const droppedMeta = detectDroppedMetaKeys(params.update_fields.meta, updatedContent);
if (droppedMeta.length > 0) {
responseContent.unshift({ type: 'text', text: buildDroppedMetaWarning(droppedMeta) });
}

return {
toolResult: {
content: [{
type: 'text',
text: JSON.stringify({
found: true,
content_type: contentType,
content_id: content.id,
original_url: params.url,
updated: true,
content: updatedContent
}, null, 2)
}],
content: responseContent,
isError: false
}
};
Expand Down