Skip to main content
Glama
index.ts42 kB
/** * Content Tools Registration * * Handles: Pages, Posts, Media, Comments (CRUD operations) * * @package WP_Navigator_Pro * @since 1.3.0 */ import { toolRegistry, ToolCategory } from '../../tool-registry/index.js'; import { validateRequired, validatePagination, validateId, buildQueryString, extractSummary } from '../../tool-registry/utils.js'; import { applyContentChanges, applyContentCreation } from '../../safety.js'; import fetch from 'cross-fetch'; import FormData from 'form-data'; /** * Register content management tools (pages, posts, media, comments) */ export function registerContentTools() { // ============================================================================ // PAGES // ============================================================================ toolRegistry.register({ definition: { name: 'wpnav_list_pages', description: 'List WordPress pages with optional filtering. Returns page ID, title, status, and last modified date.', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number for pagination (default: 1)' }, per_page: { type: 'number', description: 'Number of pages to return (default: 10, max: 100)' }, status: { type: 'string', enum: ['publish', 'draft', 'private', 'any'], description: 'Filter by status (default: publish)' }, search: { type: 'string', description: 'Search term to filter pages by title or content' }, }, required: [], }, }, handler: async (args, context) => { const { page, per_page } = validatePagination(args); const status = args.status || 'publish'; const qs = buildQueryString({ page, per_page, status, search: args.search }); const pages = await context.wpRequest(`/wp/v2/pages?${qs}`); const summary = pages.map((p: any) => extractSummary(p, ['id', 'title.rendered', 'status', 'modified', 'link'])); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_get_page', description: 'Get a single WordPress page by ID. Returns full page content, metadata, and edit history.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress page ID' }, }, required: ['id'], }, }, handler: async (args, context) => { validateRequired(args, ['id']); const id = validateId(args.id, 'Page'); const page = await context.wpRequest(`/wp/v2/pages/${id}`); const summary = extractSummary(page, [ 'id', 'title.rendered', 'content.rendered', 'status', 'author', 'modified', 'link', 'template', 'parent', 'menu_order', ]); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_update_page', description: 'Update a WordPress page. Requires page ID and at least one field to update (title, content, or status). Changes are logged in audit trail.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress page ID' }, title: { type: 'string', description: 'New page title' }, content: { type: 'string', description: 'New page content (HTML). WordPress will auto-save revisions.' }, status: { type: 'string', enum: ['publish', 'draft', 'private'], description: 'Page status: publish, draft, private' }, force: { type: 'boolean', description: 'Skip safety validation', default: false }, }, required: ['id'], }, }, handler: async (args, context) => { try { validateRequired(args, ['id']); const id = validateId(args.id, 'Page'); const ops: any[] = []; if (args.title != null) ops.push({ op: 'replace', path: '/title', value: String(args.title) }); if (args.content != null) ops.push({ op: 'replace', path: '/content', value: String(args.content) }); if (args.status != null) ops.push({ op: 'replace', path: '/status', value: String(args.status) }); if (!ops.length) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'validation_failed', code: 'VALIDATION_FAILED', message: 'At least one of title, content, or status must be provided', context: { resource_type: 'page', resource_id: id }, }, null, 2), }], isError: true, }; } const result = await applyContentChanges(context.wpRequest as any, context.config, { postId: id, operations: ops, force: !!args.force, }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(result, null, 2)) }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'UPDATE_FAILED', message: errorMessage, context: { resource_type: 'page', resource_id: args.id, suggestion: 'Use wpnav_get_page to verify page exists' }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_create_page', description: 'Create a new WordPress page. Requires title and optional content. Changes are logged in audit trail.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Page title' }, content: { type: 'string', description: 'Page content (HTML). Optional.' }, status: { type: 'string', enum: ['publish', 'draft', 'private'], description: 'Page status: publish, draft, private (default: draft)', default: 'draft' }, }, required: ['title'], }, }, handler: async (args, context) => { try { validateRequired(args, ['title']); const result = await applyContentCreation(context.wpRequest as any, context.config, { postType: 'page', title: String(args.title), content: args.content ? String(args.content) : undefined, status: (args.status as 'draft' | 'publish' | 'private') || 'draft', }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ success: true, post_id: result.apply.post_id, title: args.title, status: args.status || 'draft', plan_id: result.plan.plan_id, message: 'Page created successfully', }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'CREATE_FAILED', message: errorMessage, context: { resource_type: 'page', title: args.title }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_delete_page', description: 'Delete a WordPress page by ID. Changes are logged in audit trail. WARNING: This action cannot be undone (page moves to trash by default).', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress page ID' }, force: { type: 'boolean', description: 'Force permanent deletion (skip trash). Default: false', default: false }, }, required: ['id'], }, }, handler: async (args) => { return { content: [{ type: 'text', text: JSON.stringify({ error: 'not_supported', code: 'NOT_SUPPORTED', message: 'Page delete via safe plan/apply is not available in v1.1. Use update operations or wait for v1.2.', context: { resource_type: 'page', resource_id: args.id, suggestion: 'Use wpnav_update_page to change status to trash instead' }, }, null, 2), }], isError: true, }; }, category: ToolCategory.CONTENT, }); // ============================================================================ // wpnav_snapshot_page - Comprehensive page snapshot for AI agents // ============================================================================ toolRegistry.register({ definition: { name: 'wpnav_snapshot_page', description: 'Create a comprehensive snapshot of a WordPress page including metadata, Gutenberg blocks, featured image, and SEO data. Designed for AI agents to understand full page structure.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress page ID' }, include_raw_content: { type: 'boolean', description: 'Include raw HTML content (default: false)' }, }, required: ['id'], }, }, handler: async (args, context) => { validateRequired(args, ['id']); const id = validateId(args.id, 'Page'); const includeRawContent = args.include_raw_content === true; // Fetch page with all fields const page = await context.wpRequest(`/wp/v2/pages/${id}?context=edit`); // Fetch featured image if set let featuredImage = null; if (page.featured_media && page.featured_media > 0) { try { const media = await context.wpRequest(`/wp/v2/media/${page.featured_media}`); featuredImage = { id: media.id, url: media.source_url, alt: media.alt_text || '', title: media.title?.rendered || '', width: media.media_details?.width, height: media.media_details?.height, }; } catch { featuredImage = { id: page.featured_media, error: 'not_accessible' }; } } // Fetch author info let author = null; if (page.author) { try { const authorData = await context.wpRequest(`/wp/v2/users/${page.author}?_fields=id,name,slug`); author = { id: authorData.id, name: authorData.name, slug: authorData.slug, }; } catch { author = { id: page.author, error: 'not_accessible' }; } } // Parse Gutenberg blocks from raw content const blocks: Array<{ type: string; attrs: Record<string, unknown> }> = []; const rawContent = page.content?.raw || page.content?.rendered || ''; // Simple block parser - extracts block comments from content const blockRegex = /<!-- wp:([a-z0-9-]+\/)?([a-z0-9-]+)(\s+(\{[^}]*\}))?\s*(\/)?-->/g; let match; while ((match = blockRegex.exec(rawContent)) !== null) { const namespace = match[1] ? match[1].slice(0, -1) : 'core'; const blockName = match[2]; const attrsJson = match[4]; let attrs: Record<string, unknown> = {}; if (attrsJson) { try { attrs = JSON.parse(attrsJson); } catch { // Invalid JSON, skip attrs } } blocks.push({ type: `${namespace}/${blockName}`, attrs, }); } // Check for SEO plugins (Yoast or RankMath) let seo = null; if (page.yoast_head_json) { seo = { source: 'yoast', title: page.yoast_head_json.title, description: page.yoast_head_json.description, og_title: page.yoast_head_json.og_title, og_description: page.yoast_head_json.og_description, }; } else if (page.rank_math) { seo = { source: 'rankmath', title: page.rank_math.title, description: page.rank_math.description, }; } // Build snapshot const snapshot: Record<string, unknown> = { id: page.id, metadata: { title: page.title?.rendered || page.title?.raw || '', slug: page.slug, status: page.status, visibility: page.status === 'private' ? 'private' : 'public', template: page.template || '', parent: page.parent || 0, menu_order: page.menu_order || 0, link: page.link, modified: page.modified, modified_gmt: page.modified_gmt, }, author, featured_image: featuredImage, blocks: blocks.length > 0 ? blocks : null, block_count: blocks.length, seo, excerpt: page.excerpt?.rendered || '', }; if (includeRawContent) { snapshot.raw_content = rawContent; } return { content: [{ type: 'text', text: JSON.stringify(snapshot, null, 2) }], }; }, category: ToolCategory.CONTENT, }); // ============================================================================ // POSTS // ============================================================================ toolRegistry.register({ definition: { name: 'wpnav_list_posts', description: 'List WordPress blog posts with optional filtering. Returns post ID, title, status, and last modified date.', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number for pagination (default: 1)' }, per_page: { type: 'number', description: 'Number of posts to return (default: 10, max: 100)' }, status: { type: 'string', enum: ['publish', 'draft', 'private', 'any'], description: 'Filter by status (default: publish)' }, search: { type: 'string', description: 'Search term to filter posts by title or content' }, }, required: [], }, }, handler: async (args, context) => { const { page, per_page } = validatePagination(args); const status = args.status || 'publish'; const qs = buildQueryString({ page, per_page, status, search: args.search }); const posts = await context.wpRequest(`/wp/v2/posts?${qs}`); const summary = posts.map((p: any) => extractSummary(p, ['id', 'title.rendered', 'status', 'modified', 'link'])); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_get_post', description: 'Get a single WordPress post by ID. Returns full post content, metadata, categories, and tags.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress post ID' }, }, required: ['id'], }, }, handler: async (args, context) => { validateRequired(args, ['id']); const id = validateId(args.id, 'Post'); const post = await context.wpRequest(`/wp/v2/posts/${id}`); const summary = extractSummary(post, [ 'id', 'title.rendered', 'content.rendered', 'excerpt.rendered', 'status', 'author', 'modified', 'link', 'categories', 'tags', ]); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_update_post', description: 'Update a WordPress post. Requires post ID and at least one field to update. Changes are logged in audit trail.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress post ID' }, title: { type: 'string', description: 'New post title' }, content: { type: 'string', description: 'New post content (HTML).' }, excerpt: { type: 'string', description: 'Post excerpt' }, status: { type: 'string', enum: ['publish', 'draft', 'private'], description: 'Post status: publish, draft, private' }, force: { type: 'boolean', description: 'Skip safety validation', default: false }, }, required: ['id'], }, }, handler: async (args, context) => { try { validateRequired(args, ['id']); const id = validateId(args.id, 'Post'); const ops: any[] = []; if (args.title != null) ops.push({ op: 'replace', path: '/title', value: String(args.title) }); if (args.content != null) ops.push({ op: 'replace', path: '/content', value: String(args.content) }); if (args.excerpt != null) ops.push({ op: 'replace', path: '/excerpt', value: String(args.excerpt) }); if (args.status != null) ops.push({ op: 'replace', path: '/status', value: String(args.status) }); if (!ops.length) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'validation_failed', code: 'VALIDATION_FAILED', message: 'At least one of title, content, excerpt, or status must be provided', context: { resource_type: 'post', resource_id: id }, }, null, 2), }], isError: true, }; } const result = await applyContentChanges(context.wpRequest as any, context.config, { postId: id, operations: ops, force: !!args.force, }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(result, null, 2)) }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'UPDATE_FAILED', message: errorMessage, context: { resource_type: 'post', resource_id: args.id, suggestion: 'Use wpnav_get_post to verify post exists' }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_create_post', description: 'Create a new WordPress blog post. Requires title and optional content. Changes are logged in audit trail.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Post title' }, content: { type: 'string', description: 'Post content (HTML). Optional.' }, excerpt: { type: 'string', description: 'Post excerpt. Optional.' }, status: { type: 'string', enum: ['publish', 'draft', 'private'], description: 'Post status: publish, draft, private (default: draft)', default: 'draft' }, }, required: ['title'], }, }, handler: async (args, context) => { try { validateRequired(args, ['title']); const result = await applyContentCreation(context.wpRequest as any, context.config, { postType: 'post', title: String(args.title), content: args.content ? String(args.content) : undefined, excerpt: args.excerpt ? String(args.excerpt) : undefined, status: (args.status as 'draft' | 'publish' | 'private') || 'draft', }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ success: true, post_id: result.apply.post_id, title: args.title, status: args.status || 'draft', plan_id: result.plan.plan_id, message: 'Post created successfully', }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'CREATE_FAILED', message: errorMessage, context: { resource_type: 'post', title: args.title }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_create_post_with_blocks', description: 'Create a new post with Gutenberg blocks in a single atomic operation. Safer than creating empty post then adding blocks.', inputSchema: { type: 'object', properties: { title: { type: 'string', description: 'Post title' }, status: { type: 'string', enum: ['draft', 'publish', 'private'], description: 'Post status (default: draft)', default: 'draft', }, blocks: { type: 'array', description: 'Array of Gutenberg blocks to insert', items: { type: 'object', properties: { block_type: { type: 'string', description: 'Gutenberg block type (e.g., "core/paragraph", "core/heading")' }, attrs: { type: 'object', additionalProperties: true, description: 'Block attributes (e.g., {level: 2, content: "Hello"})' }, }, required: ['block_type', 'attrs'], }, }, }, required: ['title', 'blocks'], }, }, handler: async (args, context) => { try { validateRequired(args, ['title', 'blocks']); // Convert blocks to IR format const gutenbergBlocks = args.blocks.map((b: any) => ({ blockName: b.block_type, attrs: b.attrs, innerBlocks: [], })); const result = await applyContentCreation(context.wpRequest as any, context.config, { postType: 'post', title: String(args.title), status: (args.status as 'draft' | 'publish' | 'private') || 'draft', blocks: gutenbergBlocks, }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ success: true, post_id: result.apply.post_id, title: args.title, block_count: args.blocks.length, plan_id: result.plan.plan_id, message: `Post created with ${args.blocks.length} Gutenberg blocks`, }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'CREATE_FAILED', message: errorMessage, context: { resource_type: 'post', title: args.title, block_count: args.blocks?.length }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_delete_post', description: 'Delete a WordPress post by ID. Changes are logged in audit trail. WARNING: This action cannot be undone (post moves to trash by default).', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress post ID' }, force: { type: 'boolean', description: 'Force permanent deletion (skip trash). Default: false', default: false }, }, required: ['id'], }, }, handler: async (args) => { return { content: [{ type: 'text', text: JSON.stringify({ error: 'not_supported', code: 'NOT_SUPPORTED', message: 'Post delete via safe plan/apply is not available in v1.1. Use update operations or wait for v1.2.', context: { resource_type: 'post', resource_id: args.id, suggestion: 'Use wpnav_update_post to change status to trash instead' }, }, null, 2), }], isError: true, }; }, category: ToolCategory.CONTENT, }); // ============================================================================ // MEDIA // ============================================================================ toolRegistry.register({ definition: { name: 'wpnav_list_media', description: 'List WordPress media library items. Returns media ID, title, URL, and mime type.', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number for pagination (default: 1)' }, per_page: { type: 'number', description: 'Number of media items to return (default: 10, max: 100)' }, media_type: { type: 'string', description: 'Filter by media type: image, video, application, etc.' }, search: { type: 'string', description: 'Search term to filter media by title or filename' }, }, required: [], }, }, handler: async (args, context) => { const { page, per_page } = validatePagination(args); const qs = buildQueryString({ page, per_page, media_type: args.media_type, search: args.search }); const media = await context.wpRequest(`/wp/v2/media?${qs}`); const summary = media.map((item: any) => extractSummary(item, ['id', 'title.rendered', 'source_url', 'mime_type', 'modified'])); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_get_media', description: 'Get a single media item by ID. Returns full metadata including URL, dimensions, and file info.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress media ID' }, }, required: ['id'], }, }, handler: async (args, context) => { validateRequired(args, ['id']); const id = validateId(args.id, 'Media'); const media = await context.wpRequest(`/wp/v2/media/${id}`); const summary = extractSummary(media, [ 'id', 'title.rendered', 'source_url', 'mime_type', 'alt_text', 'caption.rendered', 'description.rendered', 'media_details', ]); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_delete_media', description: 'Delete a media item by ID. WARNING: This permanently deletes the file from the server.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress media ID' }, force: { type: 'boolean', description: 'Force permanent deletion. Default: true for media', default: true }, }, required: ['id'], }, }, handler: async (args, context) => { try { validateRequired(args, ['id']); const id = validateId(args.id, 'Media'); const params = new URLSearchParams(); if (args.force !== false) { params.append('force', 'true'); } const result = await context.wpRequest(`/wp/v2/media/${id}?${params.toString()}`, { method: 'DELETE', }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ id: result.id, message: 'Media item permanently deleted' }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; return { content: [{ type: 'text', text: JSON.stringify({ error: 'operation_failed', code: 'DELETE_FAILED', message: errorMessage, context: { resource_type: 'media', resource_id: args.id, suggestion: 'Use wpnav_get_media to verify media exists' }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); /** * Upload Media from URL * * Downloads an image from a URL and uploads it to WordPress media library. * Part of Phase 2: 95% Test Automation Plan */ toolRegistry.register({ definition: { name: 'wpnav_upload_media_from_url', description: 'Upload media from URL (server-side download). Downloads an image from a URL and uploads it to WordPress media library without sending binary data over MCP.', inputSchema: { type: 'object', properties: { url: { type: 'string', format: 'uri', description: 'Image URL to download and upload' }, title: { type: 'string', description: 'Media title' }, alt_text: { type: 'string', description: 'Alt text for accessibility (optional)' }, caption: { type: 'string', description: 'Media caption (optional)' }, }, required: ['url', 'title'], }, }, handler: async (args, context) => { try { validateRequired(args, ['url', 'title']); // Use the WP Navigator server-side sideload endpoint. // This avoids DNS resolution issues when the MCP client can't resolve external domains. // WordPress server downloads the URL instead of the local MCP client. const sideloadData: Record<string, string> = { url: args.url, title: args.title, }; if (args.alt_text) sideloadData.alt_text = args.alt_text; if (args.caption) sideloadData.caption = args.caption; const result = await context.wpRequest('/wpnav/v1/media/sideload', { method: 'POST', body: JSON.stringify(sideloadData), }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(result, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; return { content: [{ type: 'text', text: JSON.stringify({ error: 'operation_failed', code: 'UPLOAD_FAILED', message: errorMessage, context: { resource_type: 'media', url: args.url, title: args.title, suggestion: 'Check URL is accessible and returns valid image' }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); // ============================================================================ // COMMENTS // ============================================================================ toolRegistry.register({ definition: { name: 'wpnav_list_comments', description: 'List WordPress comments with optional filtering. Returns comment ID, author, content, and status.', inputSchema: { type: 'object', properties: { page: { type: 'number', description: 'Page number for pagination (default: 1)' }, per_page: { type: 'number', description: 'Number of comments to return (default: 10, max: 100)' }, status: { type: 'string', enum: ['approve', 'hold', 'spam', 'trash', 'any'], description: 'Filter by status: approve, hold, spam, trash, any' }, post: { type: 'number', description: 'Filter by post ID' }, }, required: [], }, }, handler: async (args, context) => { const { page, per_page } = validatePagination(args); const qs = buildQueryString({ page, per_page, status: args.status, post: args.post }); const comments = await context.wpRequest(`/wp/v2/comments?${qs}`); const summary = comments.map((c: any) => extractSummary(c, ['id', 'author_name', 'content.rendered', 'status', 'post', 'date'])); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_get_comment', description: 'Get a single comment by ID. Returns full comment details including author info and content.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress comment ID' }, }, required: ['id'], }, }, handler: async (args, context) => { validateRequired(args, ['id']); const id = validateId(args.id, 'Comment'); const comment = await context.wpRequest(`/wp/v2/comments/${id}`); const summary = extractSummary(comment, [ 'id', 'author_name', 'author_email', 'author_url', 'content.rendered', 'status', 'post', 'parent', 'date', ]); return { content: [{ type: 'text', text: context.clampText(JSON.stringify(summary, null, 2)) }], }; }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_update_comment', description: 'Update a comment. Can change status (approve/hold/spam) or content. Changes are logged in audit trail.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress comment ID' }, status: { type: 'string', enum: ['approve', 'hold', 'spam', 'trash'], description: 'Comment status: approve, hold, spam, trash' }, content: { type: 'string', description: 'Comment content' }, }, required: ['id'], }, }, handler: async (args, context) => { try { validateRequired(args, ['id']); const id = validateId(args.id, 'Comment'); const updateData: any = {}; if (args.status) updateData.status = args.status; if (args.content) updateData.content = args.content; if (Object.keys(updateData).length === 0) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'validation_failed', code: 'VALIDATION_FAILED', message: 'At least one field (status or content) must be provided', context: { resource_type: 'comment', resource_id: id }, }, null, 2), }], isError: true, }; } const result = await context.wpRequest(`/wp/v2/comments/${id}`, { method: 'POST', body: JSON.stringify(updateData), }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ id: result.id, status: result.status, message: 'Comment updated successfully' }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; return { content: [{ type: 'text', text: JSON.stringify({ error: 'operation_failed', code: 'UPDATE_FAILED', message: errorMessage, context: { resource_type: 'comment', resource_id: args.id, suggestion: 'Use wpnav_get_comment to verify comment exists' }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); toolRegistry.register({ definition: { name: 'wpnav_delete_comment', description: 'Delete a comment by ID. WARNING: This action cannot be undone.', inputSchema: { type: 'object', properties: { id: { type: 'number', description: 'WordPress comment ID' }, force: { type: 'boolean', description: 'Force permanent deletion (skip trash). Default: false', default: false }, }, required: ['id'], }, }, handler: async (args, context) => { try { validateRequired(args, ['id']); const id = validateId(args.id, 'Comment'); const params = new URLSearchParams(); if (args.force) { params.append('force', 'true'); } const result = await context.wpRequest(`/wp/v2/comments/${id}?${params.toString()}`, { method: 'DELETE', }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ id: result.id, message: args.force ? 'Comment permanently deleted' : 'Comment moved to trash', }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; const isWritesDisabled = errorMessage.includes('WRITES_DISABLED'); return { content: [{ type: 'text', text: JSON.stringify({ error: isWritesDisabled ? 'writes_disabled' : 'operation_failed', code: isWritesDisabled ? 'WRITES_DISABLED' : 'DELETE_FAILED', message: errorMessage, context: { resource_type: 'comment', resource_id: args.id, suggestion: isWritesDisabled ? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)' : 'Check comment ID exists with wpnav_get_comment', }, }, null, 2), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); /** * Create Comment via REST API * * Uses WordPress REST API to create comments on posts. * Works with any WordPress site (local, Hetzner, production). * Part of Phase 1: 95% Test Automation Plan */ toolRegistry.register({ definition: { name: 'wpnav_create_comment', description: 'Create a new comment on a post. Uses REST API - works with any WordPress instance.', inputSchema: { type: 'object', properties: { post_id: { type: 'number', description: 'WordPress post ID to comment on' }, content: { type: 'string', description: 'Comment content' }, author_name: { type: 'string', description: 'Comment author name (default: Test User)' }, author_email: { type: 'string', description: 'Comment author email (default: test@example.com)' }, status: { type: 'string', enum: ['approved', 'hold', 'spam'], description: 'Comment status: approved (default), hold, or spam', default: 'approved' }, }, required: ['post_id', 'content'], }, }, handler: async (args, context) => { validateRequired(args, ['post_id', 'content']); const postId = validateId(args.post_id, 'Post'); const authorName = args.author_name || 'Test User'; const authorEmail = args.author_email || 'test@example.com'; // Map 'approved' to 'approve' for REST API compatibility const status = args.status === 'approved' ? 'approve' : (args.status || 'approve'); try { // Use REST API to create comment const result = await context.wpRequest('/wp/v2/comments', { method: 'POST', body: JSON.stringify({ post: postId, content: args.content, author_name: authorName, author_email: authorEmail, status: status, }), }); return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ id: result.id, post_id: postId, author_name: result.author_name, author_email: authorEmail, status: result.status, message: 'Comment created successfully via REST API', }, null, 2)), }], }; } catch (error: any) { const errorMessage = error.message || 'Unknown error'; return { content: [{ type: 'text', text: context.clampText(JSON.stringify({ error: 'create_failed', code: 'CREATE_FAILED', message: `Comment creation failed: ${errorMessage}`, context: { resource_type: 'comment', post_id: postId, suggestion: 'Verify the post exists and accepts comments using wpnav_get_post' } }, null, 2)), }], isError: true, }; } }, category: ToolCategory.CONTENT, }); }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server