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
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Manage multiple WordPress sites from a single MCP server:

All content and taxonomy tools support an optional `site_id` parameter to target specific sites.

### **Unified Content Management** (8 tools)
### **Unified Content Management** (9 tools)

Handles ALL content types (posts, pages, custom post types) with a single set of intelligent tools:

Expand All @@ -40,6 +40,7 @@ Handles ALL content types (posts, pages, custom post types) with a single set of
- `discover_content_types`: Find all available content types on your site
- `find_content_by_url`: Smart URL resolver that can find and optionally update content from any WordPress URL
- `get_content_by_slug`: Search by slug across all content types
- `get_content_summary`: Return a minimal summary (id, title, slug, status, excerpt, taxonomies, word count, Yoast SEO fields) for audit and lookup workflows. Look up by `id` or `url`.

### **Unified Taxonomy Management** (8 tools)

Expand Down Expand Up @@ -131,6 +132,58 @@ The `find_content_by_url` tool can:
- Optionally update the content in a single operation
- Works with posts, pages, and any custom post types

#### Audit & Lookup Summaries

The `get_content_summary` tool returns a minimal, fixed-shape representation of a single piece of content. Designed for audit and lookup workflows where the full WP REST response — which can exceed 50KB on recipe posts because of the rendered Recipe Maker card HTML — is overkill.

**Look up by ID** (with optional `content_type`, defaulting to `post`):

```json
{
"id": 4274,
"content_type": "post"
}
```

**Look up by URL** (content type is detected from the URL):

```json
{
"url": "https://example.com/blog/easy-smoked-asparagus/"
}
```

`id` and `url` are mutually exclusive — provide exactly one.

The response shape is fixed:

```json
{
"id": 4274,
"title": "Easy Smoked Asparagus & Hot Honey",
"slug": "easy-smoked-asparagus",
"status": "publish",
"link": "https://example.com/blog/easy-smoked-asparagus/",
"excerpt": "Smoky asparagus with hot honey.",
"date_modified": "2026-04-30T10:14:00",
"categories": [12, 7],
"tags": [33],
"featured_media": 9012,
"word_count": 875,
"yoast_focus_keyword": "smoked asparagus",
"yoast_meta_title": "Easy Smoked Asparagus | Example",
"yoast_meta_description": "Smoky charred asparagus finished with chili-lime hot honey."
}
```

Field notes:

- `title` and `excerpt` are stripped to plain text (HTML tags removed, basic entities decoded).
- `word_count` prefers `yoast_head_json.schema.@graph[].wordCount` when Yoast SEO is active; otherwise it is computed from the rendered post content with HTML stripped.
- `yoast_meta_title` and `yoast_meta_description` are read from `yoast_head_json` on the post. They are `null` when Yoast SEO is not active.
- `yoast_focus_keyword` is read from `meta._yoast_wpseo_focuskw`. WordPress core only exposes meta keys that are registered with `show_in_rest`, and Yoast SEO does not register this key by default — so this field will typically be `null` unless a companion plugin registers it (see PR #17 for context on the broader meta-key REST exposure issue).
- This tool internally bypasses the response trimming added in PR #16 so it can read `yoast_head_json`. The trim still applies to all other tools.

#### Universal Content Operations

All content operations use a single `content_type` parameter:
Expand Down
174 changes: 174 additions & 0 deletions src/tools/content-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// src/tools/content-summary.ts
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { makeWordPressRequest } from '../wordpress.js';
import { findContentByUrl, getContentEndpoint } from './unified-content.js';

const getContentSummarySchema = z.object({
id: z.coerce.number().optional().describe(
"Content ID. Mutually exclusive with `url` — provide exactly one."
),
url: z.string().optional().describe(
"Public URL of the content (e.g. https://site.com/blog/my-post/). Mutually exclusive with `id`."
),
content_type: z.string().optional().default('post').describe(
"Content type slug. Used only when looking up by `id`; when looking up by `url` the type is detected from the URL. Defaults to 'post'."
),
site_id: z.string().optional().describe("Site ID (for multi-site setups)")
});

type GetContentSummaryParams = z.infer<typeof getContentSummarySchema>;

const HTML_TAG_REGEX = /<[^>]*>/g;
const WHITESPACE_RUN_REGEX = /\s+/g;
const HTML_ENTITY_REGEX = /&(amp|lt|gt|quot|#39|nbsp);/g;

function decodeBasicEntities(s: string): string {
return s.replace(HTML_ENTITY_REGEX, (match, entity) => {
switch (entity) {
case 'amp': return '&';
case 'lt': return '<';
case 'gt': return '>';
case 'quot': return '"';
case '#39': return "'";
case 'nbsp': return ' ';
default: return match;
}
});
}

export function htmlToPlainText(input: unknown): string {
if (typeof input !== 'string' || input.length === 0) return '';
const noTags = input.replace(HTML_TAG_REGEX, ' ');
const decoded = decodeBasicEntities(noTags);
return decoded.replace(WHITESPACE_RUN_REGEX, ' ').trim();
}

export function countWords(plainText: string): number {
if (plainText.length === 0) return 0;
return plainText.split(/\s+/).filter(Boolean).length;
}

// Yoast embeds wordCount on whichever schema.org node represents the article.
// We scan the @graph rather than guessing the node type so this works on
// posts, pages, products, recipes, etc.
export function extractYoastWordCount(yoastJson: any): number | null {
const graph = yoastJson?.schema?.['@graph'];
if (!Array.isArray(graph)) return null;
for (const node of graph) {
if (node && typeof node.wordCount === 'number') return node.wordCount;
}
return null;
}

export interface ContentSummary {
id: number;
title: string;
slug: string;
status: string;
link: string;
excerpt: string;
date_modified: string;
categories: number[];
tags: number[];
featured_media: number;
word_count: number;
yoast_focus_keyword: string | null;
yoast_meta_title: string | null;
yoast_meta_description: string | null;
}

export function buildContentSummary(post: any): ContentSummary {
const yoast = post?.yoast_head_json;
const yoastWordCount = extractYoastWordCount(yoast);
const contentText = htmlToPlainText(post?.content?.rendered);
const focusKw = post?.meta?._yoast_wpseo_focuskw;

return {
id: typeof post?.id === 'number' ? post.id : 0,
title: htmlToPlainText(post?.title?.rendered),
slug: typeof post?.slug === 'string' ? post.slug : '',
status: typeof post?.status === 'string' ? post.status : '',
link: typeof post?.link === 'string' ? post.link : '',
excerpt: htmlToPlainText(post?.excerpt?.rendered),
date_modified: typeof post?.modified === 'string' ? post.modified : '',
categories: Array.isArray(post?.categories) ? post.categories : [],
tags: Array.isArray(post?.tags) ? post.tags : [],
featured_media: typeof post?.featured_media === 'number' ? post.featured_media : 0,
word_count: yoastWordCount ?? countWords(contentText),
yoast_focus_keyword: typeof focusKw === 'string' && focusKw.length > 0 ? focusKw : null,
yoast_meta_title: typeof yoast?.title === 'string' ? yoast.title : null,
yoast_meta_description: typeof yoast?.description === 'string' ? yoast.description : null,
};
}

export const contentSummaryTools: Tool[] = [
{
name: "get_content_summary",
description:
"Returns a minimal summary of a single piece of content — id, title, slug, status, link, excerpt, modified date, taxonomy IDs, featured media, word count, and Yoast SEO fields. Designed for audit and lookup workflows where the full WP REST response (which can exceed 50KB on recipe posts) is overkill. Look up by `id` (with optional `content_type`, defaulting to 'post') or by `url`.",
inputSchema: { type: "object", properties: getContentSummarySchema.shape }
}
];

export const contentSummaryHandlers = {
get_content_summary: async (params: GetContentSummaryParams) => {
try {
const hasId = params.id !== undefined && params.id !== null;
const hasUrl = typeof params.url === 'string' && params.url.length > 0;

if (hasId && hasUrl) {
throw new Error("Provide exactly one of `id` or `url`, not both.");
}
if (!hasId && !hasUrl) {
throw new Error("Provide one of `id` or `url`.");
}

let contentType = params.content_type ?? 'post';
let id: number;

if (hasUrl) {
const ref = await findContentByUrl(params.url!, params.site_id);
if (!ref) {
throw new Error(`No content found with URL: ${params.url}`);
}
contentType = ref.contentType;
id = ref.content.id;
} else {
id = params.id!;
}

const endpoint = await getContentEndpoint(contentType, params.site_id);
// Bypass response trimming so yoast_head_json reaches us — the trim
// documented in PR #16 strips it from every response by default, with
// `rawResponse: true` as the documented escape hatch for callers that
// need it.
const raw = await makeWordPressRequest('GET', `${endpoint}/${id}`, undefined, {
siteId: params.site_id,
rawResponse: true
});

const summary = buildContentSummary(raw.data);

return {
toolResult: {
content: [{
type: 'text',
text: JSON.stringify(summary, null, 2)
}],
isError: false
}
};
} catch (error: any) {
return {
toolResult: {
content: [{
type: 'text',
text: `Error getting content summary: ${error.message}`
}],
isError: true
}
};
}
}
};
9 changes: 6 additions & 3 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import { pluginRepositoryTools, pluginRepositoryHandlers } from './plugin-reposi
import { commentTools, commentHandlers } from './comments.js';
import { sqlQueryTools, sqlQueryHandlers } from './sql-query.js';
import { siteManagementTools, siteManagementHandlers } from './site-management.js';
import { contentSummaryTools, contentSummaryHandlers } from './content-summary.js';

// Combine all tools - significantly reduced from ~65 to ~42 tools
// Combine all tools
export const allTools: Tool[] = [
...unifiedContentTools, // 8 tools (replaces posts, pages, custom-post-types)
...unifiedTaxonomyTools, // 8 tools (replaces categories, custom-taxonomies)
Expand All @@ -20,7 +21,8 @@ export const allTools: Tool[] = [
...pluginRepositoryTools, // ~2 tools
...commentTools, // ~5 tools
...sqlQueryTools, // 1 tool (database queries)
...siteManagementTools // 3 tools (multi-site support)
...siteManagementTools, // 3 tools (multi-site support)
...contentSummaryTools // 1 tool (audit/lookup summary)
];

// Combine all handlers
Expand All @@ -33,5 +35,6 @@ export const toolHandlers = {
...pluginRepositoryHandlers,
...commentHandlers,
...sqlQueryHandlers,
...siteManagementHandlers
...siteManagementHandlers,
...contentSummaryHandlers
};
Loading