diff --git a/CLAUDE.md b/CLAUDE.md
index 5e02ba6..e6a0d55 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,10 +128,10 @@ Handles ALL content types (posts, pages, custom post types) with a single set of
- `list_content` - List any content type with filtering and pagination
- `get_content` - Get specific content by ID and type
- `create_content` - Create new content of any type
-- `update_content` - Update existing content of any type
+- `update_content` - Update existing content of any type, including targeted partial edits
- `delete_content` - Delete content of any type
- `discover_content_types` - Find all available content types
-- `find_content_by_url` - Smart URL resolver with optional update
+- `find_content_by_url` - Smart URL resolver with optional full or targeted update
- `get_content_by_slug` - Search by slug across content types
#### **Unified Taxonomy Tools** (`unified-taxonomies.ts`) - 8 tools
@@ -185,6 +185,26 @@ All content operations use a single `content_type` parameter:
}
```
+Targeted content edits are also supported through `content_edit` on `update_content` and `find_content_by_url.update_fields`:
+
+```json
+{
+ "content_type": "post",
+ "id": 42,
+ "content_edit": {
+ "operation": "append",
+ "value": "\n
Update: Early access is now open.
",
+ "content_format": "html"
+ }
+}
+```
+
+To retrieve an exact target string for those edits, `get_content` and `find_content_by_url` also support `include_raw_content: true`, which fetches the item with WordPress edit context and returns a top-level `content_raw` field.
+
+Rendered WordPress HTML can differ from `content.raw`, so exact partial-edit targeting should use the raw value rather than copied rendered markup.
+
+For targeted operations, `target_text` must match the stored raw WordPress content exactly. If it appears multiple times, provide `occurrence` to choose the 1-based match.
+
#### Unified Taxonomy Management
All taxonomy operations use a single `taxonomy` parameter:
```json
diff --git a/README.md b/README.md
index e9ae91d..1cdb6b6 100644
--- a/README.md
+++ b/README.md
@@ -35,10 +35,10 @@ Handles ALL content types (posts, pages, custom post types) with a single set of
- `list_content`: List any content type with filtering and pagination
- `get_content`: Get specific content by ID and type
- `create_content`: Create new content of any type
-- `update_content`: Update existing content of any type
+- `update_content`: Update existing content of any type, including targeted partial edits
- `delete_content`: Delete content of any type
- `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
+- `find_content_by_url`: Smart URL resolver that can find and optionally update content from any WordPress URL, including targeted partial edits
- `get_content_by_slug`: Search by slug across all content types
### **Unified Taxonomy Management** (8 tools)
@@ -144,6 +144,56 @@ All content operations use a single `content_type` parameter:
}
```
+#### Targeted Content Edits
+
+`update_content` and `find_content_by_url.update_fields` can patch the existing raw WordPress content without resending the full document.
+
+To make exact matching easier, `get_content` and `find_content_by_url` both accept `include_raw_content: true`. When enabled, the response is fetched with WordPress edit context and includes a top-level `content_raw` field that matches what `content_edit.target_text` needs.
+
+```json
+{
+ "content_type": "page",
+ "id": 7,
+ "include_raw_content": true
+}
+```
+
+Append a short release note to the end of a post:
+
+```json
+{
+ "content_type": "post",
+ "id": 42,
+ "content_edit": {
+ "operation": "append",
+ "value": "\nUpdate: Early access is now open.
",
+ "content_format": "html"
+ }
+}
+```
+
+Replace a unique HTML fragment or marker comment in place:
+
+```json
+{
+ "content_type": "page",
+ "id": 7,
+ "content_edit": {
+ "operation": "replace",
+ "target_text": "\nOld price
\n",
+ "value": "\nNew price
\n",
+ "content_format": "html"
+ }
+}
+```
+
+Notes:
+
+- Rendered WordPress HTML can differ from `content.raw` because entities may be escaped and markup may be expanded, so use `include_raw_content` when you need an exact `target_text`.
+- `target_text` matches the stored raw WordPress content exactly.
+- If the same `target_text` appears multiple times, pass `occurrence` to choose the 1-based match.
+- For posts stored as Gutenberg blocks, set `content_edit.convert_to_blocks` when inserting Markdown or HTML that should become blocks.
+
#### Universal Taxonomy Operations
All taxonomy operations use a single `taxonomy` parameter:
diff --git a/src/tools/sql-query.ts b/src/tools/sql-query.ts
index 37da1fa..f198c48 100644
--- a/src/tools/sql-query.ts
+++ b/src/tools/sql-query.ts
@@ -1,26 +1,42 @@
// src/tools/sql-query.ts
import { z } from 'zod';
import { Tool } from '@modelcontextprotocol/sdk/types.js';
-import { makeWordPressRequest } from '../wordpress.js';
+import axios from 'axios';
+import { siteManager } from '../config/site-manager.js';
// Schema for SQL query execution
const executeSqlQuerySchema = z.object({
- query: z.string().describe('SQL query to execute (read-only queries: SELECT, WITH...SELECT, EXPLAIN only)')
+ query: z.string().describe('SQL query to execute (read-only queries: SELECT, WITH...SELECT, EXPLAIN only)'),
+ site_id: z.string().optional().describe('Site ID (for multi-site setups, e.g. "wp-fusion")')
});
// Type definition
type ExecuteSqlQueryParams = z.infer;
+const DANGEROUS_PATTERNS = [
+ /DROP\s+/i,
+ /DELETE\s+/i,
+ /UPDATE\s+/i,
+ /INSERT\s+/i,
+ /TRUNCATE\s+/i,
+ /ALTER\s+/i,
+ /CREATE\s+/i,
+ /GRANT\s+/i,
+ /REVOKE\s+/i
+];
+
// Tools
export const sqlQueryTools: Tool[] = [
{
name: 'execute_sql_query',
description: 'Execute a SQL query against the WordPress database. For safety, only SELECT queries are allowed. Requires the WP Fusion Database Query endpoint to be enabled.',
+ // server.ts rebuilds the schema with z.object(inputSchema.properties), so
+ // properties must be zod shapes like every other tool — raw JSON Schema
+ // here collapses the published schema to {} and clients strip all params.
inputSchema: {
type: 'object',
- properties: executeSqlQuerySchema.shape,
- required: ['query']
- }
+ properties: executeSqlQuerySchema.shape
+ } as unknown as Tool['inputSchema']
}
];
@@ -30,94 +46,78 @@ export const sqlQueryHandlers = {
try {
const query = params.query.trim();
const trimmedQuery = query.toUpperCase();
-
+
// Validate that it's a read-only query
const isSelect = trimmedQuery.startsWith('SELECT');
const isWithSelect = trimmedQuery.startsWith('WITH ');
- const isExplainSelect = trimmedQuery.startsWith('EXPLAIN SELECT') || trimmedQuery.startsWith('EXPLAIN ');
-
- if (!(isSelect || isWithSelect || isExplainSelect)) {
+ const isExplain = trimmedQuery.startsWith('EXPLAIN ');
+
+ if (!(isSelect || isWithSelect || isExplain)) {
return {
toolResult: {
- content: [{
- type: 'text' as const,
- text: 'Error: Only read-only queries are allowed (SELECT, WITH...SELECT, EXPLAIN SELECT). Please use a valid read-only statement.'
- }],
+ content: [{ type: 'text' as const, text: 'Error: Only read-only queries are allowed (SELECT, WITH...SELECT, EXPLAIN). Please use a valid read-only statement.' }],
isError: true
}
};
}
- // Disallow multiple statements (semicolon followed by non-whitespace)
- // Remove quoted strings first to avoid false positives
+ // Disallow multiple statements — strip quoted strings first to avoid false positives
const queryWithoutStrings = query.replace(/(['"]).*?\1/g, '');
if (/;\s*\S/.test(queryWithoutStrings)) {
return {
toolResult: {
- content: [{
- type: 'text' as const,
- text: 'Error: Multiple SQL statements are not allowed. Please execute one query at a time.'
- }],
+ content: [{ type: 'text' as const, text: 'Error: Multiple SQL statements are not allowed. Please execute one query at a time.' }],
isError: true
}
};
}
- // Check for dangerous patterns
- const dangerousPatterns = [
- /DROP\s+/i,
- /DELETE\s+/i,
- /UPDATE\s+/i,
- /INSERT\s+/i,
- /TRUNCATE\s+/i,
- /ALTER\s+/i,
- /CREATE\s+/i,
- /GRANT\s+/i,
- /REVOKE\s+/i
- ];
-
- for (const pattern of dangerousPatterns) {
+ for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(query)) {
return {
toolResult: {
- content: [{
- type: 'text' as const,
- text: `Error: Query contains potentially dangerous SQL statement. Only read-only queries are allowed.`
- }],
+ content: [{ type: 'text' as const, text: 'Error: Query contains potentially dangerous SQL statement. Only read-only queries are allowed.' }],
isError: true
}
};
}
}
- // Execute the query via the custom endpoint
- // Use environment variable or default to /mcp/v1/query
- const sqlEndpoint = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';
- const response = await makeWordPressRequest(
- 'POST',
- sqlEndpoint,
- { query },
- { headers: { 'Content-Type': 'application/json' } }
- );
+ // Build absolute URL directly from site config.
+ // makeWordPressRequest prepends /wp-json/wp/v2/ to all paths, so it cannot
+ // be used for the SQL endpoint which lives at /wp-json/mcp/v1/query.
+ const site = siteManager.getSite(params.site_id);
+ const sqlPath = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';
+ const siteBase = site.url.replace(/\/$/, '');
+ const url = `${siteBase}/wp-json${sqlPath}`;
+
+ const auth = Buffer.from(`${site.username}:${site.password}`).toString('base64');
+
+ const response = await axios.post(url, { query }, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Basic ${auth}`,
+ 'User-Agent': 'Mozilla/5.0'
+ },
+ timeout: 30000
+ });
// Handle large result sets
- const text = JSON.stringify(response, null, 2);
+ const text = JSON.stringify(response.data, null, 2);
const MAX_LENGTH = 50000;
- const resultText = text.length > MAX_LENGTH
+ const resultText = text.length > MAX_LENGTH
? text.slice(0, MAX_LENGTH) + '\n\n...(truncated - result too large)'
: text;
return {
toolResult: {
- content: [{
- type: 'text' as const,
- text: resultText
- }]
+ content: [{ type: 'text' as const, text: resultText }]
}
};
} catch (error: any) {
- // Check if it's a 404 error (endpoint not found)
+ const sqlPath = process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query';
+
if (error.response?.status === 404) {
return {
toolResult: {
@@ -127,7 +127,7 @@ export const sqlQueryHandlers = {
To enable this feature, see the setup instructions in README.md under "Enabling SQL Query Tool (Optional)".
-Expected endpoint: ${process.env.WORDPRESS_SQL_ENDPOINT || '/mcp/v1/query'}
+Expected endpoint: ${sqlPath}
You can customize this by setting the WORDPRESS_SQL_ENDPOINT environment variable.`
}],
isError: true
@@ -137,10 +137,7 @@ You can customize this by setting the WORDPRESS_SQL_ENDPOINT environment variabl
return {
toolResult: {
- content: [{
- type: 'text' as const,
- text: `Error executing SQL query: ${error.message}`
- }],
+ content: [{ type: 'text' as const, text: `Error executing SQL query: ${error.message}` }],
isError: true
}
};
diff --git a/src/tools/unified-content.ts b/src/tools/unified-content.ts
index 3bb4302..1e0f92f 100644
--- a/src/tools/unified-content.ts
+++ b/src/tools/unified-content.ts
@@ -211,6 +211,35 @@ async function findContentAcrossTypes(slug: string, contentTypes?: string[], sit
// Content format types
type ContentFormat = 'auto' | 'markdown' | 'html' | 'blocks';
type DetectedFormat = 'blocks' | 'html' | 'markdown' | 'text';
+const CONTENT_EDIT_OPERATIONS = ['append', 'prepend', 'insert_before', 'insert_after', 'replace'] as const;
+type ContentEditOperation = typeof CONTENT_EDIT_OPERATIONS[number];
+type ContentEditParams = {
+ operation: ContentEditOperation;
+ value: string;
+ target_text?: string;
+ occurrence?: number;
+ content_format?: ContentFormat;
+ convert_to_blocks?: boolean;
+};
+type ContentUpdateInput = {
+ title?: string;
+ content?: string;
+ content_format?: ContentFormat;
+ convert_to_blocks?: boolean;
+ content_edit?: ContentEditParams;
+ status?: string;
+ excerpt?: string;
+ slug?: string;
+ author?: number;
+ parent?: number;
+ categories?: number[];
+ tags?: number[];
+ featured_media?: number;
+ format?: string;
+ menu_order?: number;
+ meta?: Record;
+ custom_fields?: Record;
+};
/**
* Detects the format of content based on its structure
@@ -405,6 +434,185 @@ async function processContent(
return htmlContent;
}
+function validateContentEdit(edit: ContentEditParams) {
+ const targetedOperations = new Set(['insert_before', 'insert_after', 'replace']);
+
+ if (targetedOperations.has(edit.operation) && !edit.target_text) {
+ throw new Error(`content_edit.target_text is required for ${edit.operation}`);
+ }
+}
+
+function getTargetMatchIndex(content: string, targetText: string, occurrence?: number): number {
+ const matches: number[] = [];
+ let fromIndex = 0;
+
+ while (true) {
+ const matchIndex = content.indexOf(targetText, fromIndex);
+ if (matchIndex === -1) {
+ break;
+ }
+
+ matches.push(matchIndex);
+ fromIndex = matchIndex + targetText.length;
+ }
+
+ if (matches.length === 0) {
+ throw new Error('content_edit.target_text was not found in the existing content');
+ }
+
+ if (occurrence === undefined) {
+ if (matches.length > 1) {
+ throw new Error(`content_edit.target_text matched ${matches.length} locations. Provide content_edit.occurrence to disambiguate.`);
+ }
+ return matches[0];
+ }
+
+ if (!Number.isInteger(occurrence) || occurrence < 1) {
+ throw new Error('content_edit.occurrence must be a positive integer');
+ }
+
+ const resolvedIndex = matches[occurrence - 1];
+ if (resolvedIndex === undefined) {
+ throw new Error(`content_edit.occurrence ${occurrence} is out of range for ${matches.length} matches`);
+ }
+
+ return resolvedIndex;
+}
+
+function applyContentEdit(existingContent: string, edit: ContentEditParams): string {
+ validateContentEdit(edit);
+
+ switch (edit.operation) {
+ case 'append':
+ return `${existingContent}${edit.value}`;
+ case 'prepend':
+ return `${edit.value}${existingContent}`;
+ case 'insert_before': {
+ const targetText = edit.target_text as string;
+ const targetIndex = getTargetMatchIndex(existingContent, targetText, edit.occurrence);
+ return `${existingContent.slice(0, targetIndex)}${edit.value}${existingContent.slice(targetIndex)}`;
+ }
+ case 'insert_after': {
+ const targetText = edit.target_text as string;
+ const targetIndex = getTargetMatchIndex(existingContent, targetText, edit.occurrence) + targetText.length;
+ return `${existingContent.slice(0, targetIndex)}${edit.value}${existingContent.slice(targetIndex)}`;
+ }
+ case 'replace': {
+ const targetText = edit.target_text as string;
+ const targetIndex = getTargetMatchIndex(existingContent, targetText, edit.occurrence);
+ return `${existingContent.slice(0, targetIndex)}${edit.value}${existingContent.slice(targetIndex + targetText.length)}`;
+ }
+ }
+}
+
+async function getEditableRawContent(endpoint: string, id: number, siteId?: string): Promise {
+ const response = await fetchContentById(endpoint, id, siteId, true);
+
+ const rawContent = response?.content?.raw;
+ if (typeof rawContent !== 'string') {
+ throw new Error('Partial content edits require WordPress edit access and a REST response that includes content.raw');
+ }
+
+ return rawContent;
+}
+
+function withContentRawAlias>(response: T): T & { content_raw?: string } {
+ const rawContent = response?.content?.raw;
+ if (typeof rawContent !== 'string') {
+ return response;
+ }
+
+ return {
+ ...response,
+ content_raw: rawContent
+ };
+}
+
+async function fetchContentById(
+ endpoint: string,
+ id: number,
+ siteId?: string,
+ includeRawContent: boolean = false
+) {
+ const response = await makeWordPressRequest(
+ 'GET',
+ `${endpoint}/${id}`,
+ includeRawContent ? { context: 'edit' } : undefined,
+ { siteId }
+ );
+
+ return includeRawContent ? withContentRawAlias(response) : response;
+}
+
+async function resolveUpdatedContent(
+ input: ContentUpdateInput,
+ endpoint: string,
+ id: number,
+ siteId?: string
+): Promise {
+ if (input.content !== undefined && input.content_edit !== undefined) {
+ throw new Error('Provide either content or content_edit, not both');
+ }
+
+ if (input.content !== undefined) {
+ return processContent(
+ input.content,
+ input.content_format || 'auto',
+ input.convert_to_blocks || false
+ );
+ }
+
+ if (input.content_edit !== undefined) {
+ validateContentEdit(input.content_edit);
+
+ const existingContent = await getEditableRawContent(endpoint, id, siteId);
+ const processedFragment = await processContent(
+ input.content_edit.value,
+ input.content_edit.content_format || 'auto',
+ input.content_edit.convert_to_blocks || false
+ );
+
+ return applyContentEdit(existingContent, {
+ ...input.content_edit,
+ value: processedFragment
+ });
+ }
+
+ return undefined;
+}
+
+async function buildContentUpdateData(
+ input: ContentUpdateInput,
+ endpoint: string,
+ id: number,
+ siteId?: string
+) {
+ const updateData: any = {};
+
+ if (input.title !== undefined) updateData.title = input.title;
+
+ const updatedContent = await resolveUpdatedContent(input, endpoint, id, siteId);
+ if (updatedContent !== undefined) updateData.content = updatedContent;
+
+ if (input.status !== undefined) updateData.status = input.status;
+ if (input.excerpt !== undefined) updateData.excerpt = input.excerpt;
+ if (input.slug !== undefined) updateData.slug = input.slug;
+ if (input.author !== undefined) updateData.author = input.author;
+ if (input.parent !== undefined) updateData.parent = input.parent;
+ if (input.featured_media !== undefined) updateData.featured_media = input.featured_media;
+ if (input.format !== undefined) updateData.format = input.format;
+ if (input.menu_order !== undefined) updateData.menu_order = input.menu_order;
+ if (input.categories !== undefined) updateData.categories = input.categories;
+ if (input.tags !== undefined) updateData.tags = input.tags;
+ if (input.meta !== undefined) updateData.meta = input.meta;
+
+ if (input.custom_fields) {
+ Object.assign(updateData, input.custom_fields);
+ }
+
+ return updateData;
+}
+
// Schema definitions
const listContentSchema = z.object({
content_type: z.string().describe("The content type slug (e.g., 'post', 'page', 'product', 'documentation')"),
@@ -427,7 +635,10 @@ const listContentSchema = z.object({
const getContentSchema = z.object({
content_type: z.string().describe("The content type slug"),
id: z.coerce.number().describe("Content ID"),
- site_id: z.string().optional().describe("Site ID (for multi-site setups)")
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
+ include_raw_content: z.boolean().optional().default(false).describe(
+ "Fetch the content with WordPress edit context and include a top-level content_raw field for exact matching"
+ )
});
const createContentSchema = z.object({
@@ -458,7 +669,37 @@ const createContentSchema = z.object({
custom_fields: z.record(z.any()).optional().describe("Custom fields specific to this content type")
});
-const updateContentSchema = z.object({
+const contentEditSchema = z.object({
+ operation: z.enum(CONTENT_EDIT_OPERATIONS).describe(
+ "Partial content edit operation: append, prepend, insert_before, insert_after, or replace"
+ ),
+ value: z.string().describe(
+ "Content fragment to insert or use as the replacement. Accepts Gutenberg blocks, HTML, or Markdown."
+ ),
+ target_text: z.string().optional().describe(
+ "Exact raw content fragment to target for insert_before, insert_after, or replace"
+ ),
+ occurrence: z.number().int().positive().optional().describe(
+ "Optional 1-based occurrence to target when target_text appears multiple times"
+ ),
+ content_format: z.enum(['auto', 'markdown', 'html', 'blocks']).optional().default('auto').describe(
+ "Format hint for the content_edit value"
+ ),
+ convert_to_blocks: z.boolean().optional().default(false).describe(
+ "Convert the content_edit value to Gutenberg blocks before applying it"
+ )
+}).superRefine((value, ctx) => {
+ const targetedOperations = new Set(['insert_before', 'insert_after', 'replace']);
+ if (targetedOperations.has(value.operation) && !value.target_text) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `target_text is required for ${value.operation}`,
+ path: ['target_text']
+ });
+ }
+});
+
+const updateContentSchemaShape = {
content_type: z.string().describe("The content type slug"),
id: z.coerce.number().describe("Content ID"),
site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
@@ -473,6 +714,9 @@ const updateContentSchema = z.object({
convert_to_blocks: z.boolean().optional().default(false).describe(
"Convert content to Gutenberg blocks. Recommended for sites using block editor."
),
+ content_edit: contentEditSchema.optional().describe(
+ "Apply a targeted edit to the existing raw content instead of replacing the whole document"
+ ),
status: z.string().optional().describe("Content status"),
excerpt: z.string().optional().describe("Content excerpt"),
slug: z.string().optional().describe("Content slug"),
@@ -485,6 +729,16 @@ const updateContentSchema = z.object({
menu_order: z.number().optional().describe("Menu order"),
meta: z.record(z.any()).optional().describe("Meta fields"),
custom_fields: z.record(z.any()).optional().describe("Custom fields")
+};
+
+const updateContentSchema = z.object(updateContentSchemaShape).superRefine((value, ctx) => {
+ if (value.content !== undefined && value.content_edit !== undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Provide either content or content_edit, not both",
+ path: ['content_edit']
+ });
+ }
});
const deleteContentSchema = z.object({
@@ -499,19 +753,35 @@ const discoverContentTypesSchema = z.object({
site_id: z.string().optional().describe("Site ID (for multi-site setups)")
});
+const findContentByUrlUpdateFieldsShape = {
+ title: z.string().optional(),
+ content: z.string().optional().describe(
+ "Content body. Accepts Gutenberg blocks, HTML, or Markdown (auto-converted to HTML)."
+ ),
+ content_format: z.enum(['auto', 'markdown', 'html', 'blocks']).optional().default('auto'),
+ convert_to_blocks: z.boolean().optional().default(false),
+ content_edit: contentEditSchema.optional().describe(
+ "Apply a targeted edit to the existing raw content instead of replacing the whole document"
+ ),
+ status: z.string().optional(),
+ meta: z.record(z.any()).optional(),
+ custom_fields: z.record(z.any()).optional()
+};
+
const findContentByUrlSchema = z.object({
url: z.string().describe("The full URL of the content to find"),
site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
- update_fields: z.object({
- title: z.string().optional(),
- content: z.string().optional().describe(
- "Content body. Accepts Gutenberg blocks, HTML, or Markdown (auto-converted to HTML)."
- ),
- content_format: z.enum(['auto', 'markdown', 'html', 'blocks']).optional().default('auto'),
- convert_to_blocks: z.boolean().optional().default(false),
- status: z.string().optional(),
- meta: z.record(z.any()).optional(),
- custom_fields: z.record(z.any()).optional()
+ include_raw_content: z.boolean().optional().default(false).describe(
+ "Fetch the matched content with WordPress edit context and include a top-level content_raw field for exact matching"
+ ),
+ update_fields: z.object(findContentByUrlUpdateFieldsShape).superRefine((value, ctx) => {
+ if (value.content !== undefined && value.content_edit !== undefined) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Provide either content or content_edit, not both",
+ path: ['content_edit']
+ });
+ }
}).optional().describe("Optional fields to update after finding the content")
});
@@ -550,7 +820,7 @@ export const unifiedContentTools: Tool[] = [
{
name: "update_content",
description: "Updates existing content of any type",
- inputSchema: { type: "object", properties: updateContentSchema.shape }
+ inputSchema: { type: "object", properties: updateContentSchemaShape }
},
{
name: "delete_content",
@@ -565,7 +835,14 @@ export const unifiedContentTools: Tool[] = [
{
name: "find_content_by_url",
description: "Finds content by its URL, automatically detecting the content type, and optionally updates it",
- inputSchema: { type: "object", properties: findContentByUrlSchema.shape }
+ inputSchema: { type: "object", properties: {
+ url: z.string().describe("The full URL of the content to find"),
+ site_id: z.string().optional().describe("Site ID (for multi-site setups)"),
+ include_raw_content: z.boolean().optional().default(false).describe(
+ "Fetch the matched content with WordPress edit context and include a top-level content_raw field for exact matching"
+ ),
+ update_fields: z.object(findContentByUrlUpdateFieldsShape).optional().describe("Optional fields to update after finding the content")
+ } }
},
{
name: "get_content_by_slug",
@@ -611,7 +888,12 @@ export const unifiedContentHandlers = {
get_content: async (params: GetContentParams) => {
try {
const endpoint = await getContentEndpoint(params.content_type, params.site_id);
- const response = await makeWordPressRequest('GET', `${endpoint}/${params.id}`, undefined, { siteId: params.site_id });
+ const response = await fetchContentById(
+ endpoint,
+ params.id,
+ params.site_id,
+ params.include_raw_content || false
+ );
return {
toolResult: {
@@ -711,35 +993,7 @@ export const unifiedContentHandlers = {
update_content: async (params: UpdateContentParams) => {
try {
const endpoint = await getContentEndpoint(params.content_type, params.site_id);
-
- const updateData: any = {};
-
- // Only include defined fields
- if (params.title !== undefined) updateData.title = params.title;
- if (params.content !== undefined) {
- // Process content format (markdown -> HTML, optional block conversion)
- updateData.content = await processContent(
- params.content,
- params.content_format || 'auto',
- params.convert_to_blocks || false
- );
- }
- if (params.status !== undefined) updateData.status = params.status;
- if (params.excerpt !== undefined) updateData.excerpt = params.excerpt;
- if (params.slug !== undefined) updateData.slug = params.slug;
- if (params.author !== undefined) updateData.author = params.author;
- if (params.parent !== undefined) updateData.parent = params.parent;
- if (params.featured_media !== undefined) updateData.featured_media = params.featured_media;
- if (params.format !== undefined) updateData.format = params.format;
- if (params.menu_order !== undefined) updateData.menu_order = params.menu_order;
- if (params.categories !== undefined) updateData.categories = params.categories;
- if (params.tags !== undefined) updateData.tags = params.tags;
- if (params.meta !== undefined) updateData.meta = params.meta;
-
- // Add custom fields
- if (params.custom_fields) {
- Object.assign(updateData, params.custom_fields);
- }
+ const updateData = await buildContentUpdateData(params, endpoint, params.id, params.site_id);
const response = await makeWordPressRequest('POST', `${endpoint}/${params.id}`, updateData, { siteId: params.site_id });
@@ -887,24 +1141,17 @@ export const unifiedContentHandlers = {
// Update if requested
if (params.update_fields) {
const endpoint = await getContentEndpoint(contentType, params.site_id);
+ const updateData = await buildContentUpdateData(
+ params.update_fields,
+ endpoint,
+ content.id,
+ params.site_id
+ );
- const updateData: any = {};
- if (params.update_fields.title !== undefined) updateData.title = params.update_fields.title;
- if (params.update_fields.content !== undefined) {
- // Process content format (markdown -> HTML, optional block conversion)
- updateData.content = await processContent(
- params.update_fields.content,
- params.update_fields.content_format || 'auto',
- params.update_fields.convert_to_blocks || false
- );
- }
- if (params.update_fields.status !== undefined) updateData.status = params.update_fields.status;
- if (params.update_fields.meta !== undefined) updateData.meta = params.update_fields.meta;
- if (params.update_fields.custom_fields !== undefined) {
- Object.assign(updateData, params.update_fields.custom_fields);
- }
-
- const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });
+ await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });
+ const responseContent = params.include_raw_content
+ ? await fetchContentById(endpoint, content.id, params.site_id, true)
+ : await fetchContentById(endpoint, content.id, params.site_id, false);
return {
toolResult: {
@@ -916,7 +1163,8 @@ export const unifiedContentHandlers = {
content_id: content.id,
original_url: params.url,
updated: true,
- content: updatedContent
+ content: responseContent,
+ content_raw: params.include_raw_content ? responseContent.content_raw : undefined
}, null, 2)
}],
isError: false
@@ -924,6 +1172,10 @@ export const unifiedContentHandlers = {
};
}
+ const responseContent = params.include_raw_content
+ ? await fetchContentById(await getContentEndpoint(contentType, params.site_id), content.id, params.site_id, true)
+ : content;
+
return {
toolResult: {
content: [{
@@ -933,7 +1185,8 @@ export const unifiedContentHandlers = {
content_type: contentType,
content_id: content.id,
original_url: params.url,
- content: content
+ content: responseContent,
+ content_raw: params.include_raw_content ? responseContent.content_raw : undefined
}, null, 2)
}],
isError: false
@@ -946,24 +1199,17 @@ export const unifiedContentHandlers = {
// Update if requested
if (params.update_fields) {
const endpoint = await getContentEndpoint(contentType, params.site_id);
+ const updateData = await buildContentUpdateData(
+ params.update_fields,
+ endpoint,
+ content.id,
+ params.site_id
+ );
- const updateData: any = {};
- if (params.update_fields.title !== undefined) updateData.title = params.update_fields.title;
- if (params.update_fields.content !== undefined) {
- // Process content format (markdown -> HTML, optional block conversion)
- updateData.content = await processContent(
- params.update_fields.content,
- params.update_fields.content_format || 'auto',
- params.update_fields.convert_to_blocks || false
- );
- }
- if (params.update_fields.status !== undefined) updateData.status = params.update_fields.status;
- if (params.update_fields.meta !== undefined) updateData.meta = params.update_fields.meta;
- if (params.update_fields.custom_fields !== undefined) {
- Object.assign(updateData, params.update_fields.custom_fields);
- }
-
- const updatedContent = await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });
+ await makeWordPressRequest('POST', `${endpoint}/${content.id}`, updateData, { siteId: params.site_id });
+ const responseContent = params.include_raw_content
+ ? await fetchContentById(endpoint, content.id, params.site_id, true)
+ : await fetchContentById(endpoint, content.id, params.site_id, false);
return {
toolResult: {
@@ -975,7 +1221,8 @@ export const unifiedContentHandlers = {
content_id: content.id,
original_url: params.url,
updated: true,
- content: updatedContent
+ content: responseContent,
+ content_raw: params.include_raw_content ? responseContent.content_raw : undefined
}, null, 2)
}],
isError: false
@@ -983,6 +1230,10 @@ export const unifiedContentHandlers = {
};
}
+ const responseContent = params.include_raw_content
+ ? await fetchContentById(await getContentEndpoint(contentType, params.site_id), content.id, params.site_id, true)
+ : content;
+
return {
toolResult: {
content: [{
@@ -992,7 +1243,8 @@ export const unifiedContentHandlers = {
content_type: contentType,
content_id: content.id,
original_url: params.url,
- content: content
+ content: responseContent,
+ content_raw: params.include_raw_content ? responseContent.content_raw : undefined
}, null, 2)
}],
isError: false
@@ -1044,4 +1296,4 @@ export const unifiedContentHandlers = {
};
}
}
-};
\ No newline at end of file
+};
diff --git a/src/types/wordpress-types.ts b/src/types/wordpress-types.ts
index 48cd872..721d34f 100644
--- a/src/types/wordpress-types.ts
+++ b/src/types/wordpress-types.ts
@@ -16,13 +16,16 @@ interface WPContent {
link: string;
title: {
rendered: string;
+ raw?: string;
};
content: {
rendered: string;
+ raw?: string;
protected: boolean;
};
excerpt: {
rendered: string;
+ raw?: string;
protected: boolean;
};
author: number;