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": "\n

Update: 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": "\n

Old price

\n", + "value": "\n

New 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;