From e0d0c16892af2ef6c5f5f3e263ad876fb3e630d7 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Sat, 16 May 2026 22:27:25 +0200 Subject: [PATCH] Initial Codex blog abilities plugin --- .gitignore | 5 + LICENSE | 11 + README.md | 77 +++ codex-blog-abilities.php | 1327 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1420 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 codex-blog-abilities.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22e9be7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +Thumbs.db +*.zip +vendor/ +node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3582e62 --- /dev/null +++ b/LICENSE @@ -0,0 +1,11 @@ +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 2 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. + +You should have received a copy of the GNU General Public License along with +this program. If not, see https://www.gnu.org/licenses/gpl-2.0.html. diff --git a/README.md b/README.md new file mode 100644 index 0000000..013635e --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# Codex Blog Abilities + +Codex Blog Abilities is a small WordPress plugin that exposes guarded blog +administration actions through the WordPress MCP Adapter. + +It is intended for a trusted MCP client, such as Codex, that authenticates with a +WordPress Application Password. + +## Requirements + +- WordPress 6.9 or newer +- PHP 7.4 or newer +- The WordPress MCP Adapter plugin +- A WordPress user with the capabilities required for the requested action + +## Abilities + +The plugin registers public MCP abilities under the `codex-blog/` namespace: + +- `get-site-info` +- `list-post-types` +- `list-posts` +- `get-post` +- `create-post` +- `update-post` +- `delete-post` +- `list-terms` +- `create-term` +- `update-term` +- `delete-term` +- `list-comments` +- `update-comment` +- `delete-comment` +- `list-media` +- `list-plugins` +- `activate-plugin` +- `deactivate-plugin` +- `get-options` +- `update-options` + +## Safety Model + +The plugin relies on native WordPress capabilities before executing actions. +For example, post edits require `edit_post`, plugin management requires +`activate_plugins`, and option changes require `manage_options`. + +The option update ability is intentionally restricted to a small allowlist of +common site settings. It does not expose arbitrary `update_option()` access. + +Deactivation of `mcp-adapter` and this plugin is blocked by default so an MCP +client does not accidentally remove its own control plane. + +## Installation + +Copy this directory to: + +```text +wp-content/plugins/codex-blog-abilities +``` + +Then activate it: + +```bash +wp plugin activate codex-blog-abilities +``` + +## Development + +Run a PHP syntax check before deploying: + +```bash +php -l codex-blog-abilities.php +``` + +## License + +GPL-2.0-or-later. diff --git a/codex-blog-abilities.php b/codex-blog-abilities.php new file mode 100644 index 0000000..c9ba1e8 --- /dev/null +++ b/codex-blog-abilities.php @@ -0,0 +1,1327 @@ + __( 'Codex Blog Admin', 'codex-blog-abilities' ), + 'description' => __( 'Administrative blog operations exposed to trusted MCP clients.', 'codex-blog-abilities' ), + ) + ); + } +); + +add_action( + 'wp_abilities_api_init', + static function() { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + codex_blog_register_ability( + 'get-site-info', + 'Get Site Info', + 'Read basic WordPress site, theme, and current authenticated user information.', + null, + 'codex_blog_get_site_info', + static function() { + return current_user_can( 'read' ); + }, + true + ); + + codex_blog_register_ability( + 'list-post-types', + 'List Post Types', + 'List registered post types and their key capabilities.', + codex_blog_schema( + array( + 'include_non_public' => array( + 'type' => 'boolean', + 'description' => 'Whether to include non-public post types.', + ), + ) + ), + 'codex_blog_list_post_types', + static function() { + return current_user_can( 'edit_posts' ); + }, + true + ); + + codex_blog_register_ability( + 'list-posts', + 'List Posts', + 'Query posts, pages, or custom post types with pagination and basic filters.', + codex_blog_schema( + array( + 'post_type' => array( + 'type' => 'string', + 'description' => 'Post type slug. Defaults to post.', + ), + 'status' => array( + 'type' => 'string', + 'description' => 'Post status such as publish, draft, pending, private, trash, or any.', + ), + 'search' => array( + 'type' => 'string', + 'description' => 'Search phrase.', + ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Page number. Defaults to 1.', + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + 'description' => 'Results per page. Defaults to 10.', + ), + 'orderby' => array( + 'type' => 'string', + 'description' => 'Order by field. Common values: date, modified, title, ID, menu_order.', + ), + 'order' => array( + 'type' => 'string', + 'enum' => array( 'ASC', 'DESC', 'asc', 'desc' ), + 'description' => 'Sort order.', + ), + ) + ), + 'codex_blog_list_posts', + static function( $input ) { + $args = codex_blog_input( $input ); + $post_type = codex_blog_post_type_from_input( $args ); + $type_obj = get_post_type_object( $post_type ); + return $type_obj && current_user_can( $type_obj->cap->edit_posts ); + }, + true + ); + + codex_blog_register_ability( + 'get-post', + 'Get Post', + 'Read one post, page, attachment, or custom post type item by ID.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Post ID.', + ), + ), + array( 'id' ) + ), + 'codex_blog_get_post', + static function( $input ) { + $args = codex_blog_input( $input ); + $post = get_post( (int) $args['id'] ); + return $post && current_user_can( 'edit_post', $post->ID ); + }, + true + ); + + codex_blog_register_ability( + 'create-post', + 'Create Post', + 'Create a post, page, or custom post type item. Publishing requires the relevant publish capability.', + codex_blog_schema( + array( + 'post_type' => array( 'type' => 'string' ), + 'title' => array( 'type' => 'string' ), + 'content' => array( 'type' => 'string' ), + 'excerpt' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'draft', 'pending', 'publish', 'private' ), + ), + 'slug' => array( 'type' => 'string' ), + 'parent_id' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'terms' => array( + 'type' => 'object', + 'description' => 'Object keyed by taxonomy slug. Values are arrays of term IDs, slugs, or names.', + 'additionalProperties' => array( + 'type' => 'array', + 'items' => array( + 'type' => array( 'integer', 'string' ), + ), + ), + ), + ), + array( 'title' ) + ), + 'codex_blog_create_post', + 'codex_blog_can_create_post', + false + ); + + codex_blog_register_ability( + 'update-post', + 'Update Post', + 'Update fields and taxonomy terms for an existing post, page, or custom post type item.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'title' => array( 'type' => 'string' ), + 'content' => array( 'type' => 'string' ), + 'excerpt' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'draft', 'pending', 'publish', 'private', 'trash' ), + ), + 'slug' => array( 'type' => 'string' ), + 'parent_id' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'terms' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'array', + 'items' => array( + 'type' => array( 'integer', 'string' ), + ), + ), + ), + ), + array( 'id' ) + ), + 'codex_blog_update_post', + 'codex_blog_can_update_post', + false + ); + + codex_blog_register_ability( + 'delete-post', + 'Delete Post', + 'Trash or permanently delete a post, page, attachment, or custom post type item.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'If true, permanently delete. Otherwise move to trash when possible.', + ), + ), + array( 'id' ) + ), + 'codex_blog_delete_post', + static function( $input ) { + $args = codex_blog_input( $input ); + return ! empty( $args['id'] ) && current_user_can( 'delete_post', (int) $args['id'] ); + }, + false, + true + ); + + codex_blog_register_ability( + 'list-terms', + 'List Terms', + 'List taxonomy terms.', + codex_blog_schema( + array( + 'taxonomy' => array( 'type' => 'string' ), + 'hide_empty' => array( 'type' => 'boolean' ), + 'search' => array( 'type' => 'string' ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + ), + ), + array( 'taxonomy' ) + ), + 'codex_blog_list_terms', + static function() { + return current_user_can( 'edit_posts' ); + }, + true + ); + + codex_blog_register_ability( + 'create-term', + 'Create Term', + 'Create a taxonomy term.', + codex_blog_schema( + array( + 'taxonomy' => array( 'type' => 'string' ), + 'name' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + 'parent_id' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + ), + array( 'taxonomy', 'name' ) + ), + 'codex_blog_create_term', + 'codex_blog_can_manage_terms', + false + ); + + codex_blog_register_ability( + 'update-term', + 'Update Term', + 'Update a taxonomy term.', + codex_blog_schema( + array( + 'taxonomy' => array( 'type' => 'string' ), + 'term_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'name' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'description' => array( 'type' => 'string' ), + 'parent_id' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + ), + array( 'taxonomy', 'term_id' ) + ), + 'codex_blog_update_term', + 'codex_blog_can_manage_terms', + false + ); + + codex_blog_register_ability( + 'delete-term', + 'Delete Term', + 'Delete a taxonomy term.', + codex_blog_schema( + array( + 'taxonomy' => array( 'type' => 'string' ), + 'term_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + array( 'taxonomy', 'term_id' ) + ), + 'codex_blog_delete_term', + 'codex_blog_can_manage_terms', + false, + true + ); + + codex_blog_register_ability( + 'list-comments', + 'List Comments', + 'List comments with moderation status filters.', + codex_blog_schema( + array( + 'status' => array( + 'type' => 'string', + 'description' => 'Comment status: all, approve, hold, spam, trash.', + ), + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + ), + ) + ), + 'codex_blog_list_comments', + static function() { + return current_user_can( 'moderate_comments' ); + }, + true + ); + + codex_blog_register_ability( + 'update-comment', + 'Update Comment', + 'Update a comment moderation status or content.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'status' => array( + 'type' => 'string', + 'enum' => array( 'approve', 'hold', 'spam', 'trash' ), + ), + 'content' => array( 'type' => 'string' ), + ), + array( 'id' ) + ), + 'codex_blog_update_comment', + static function() { + return current_user_can( 'moderate_comments' ); + }, + false + ); + + codex_blog_register_ability( + 'delete-comment', + 'Delete Comment', + 'Trash or permanently delete a comment.', + codex_blog_schema( + array( + 'id' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'force' => array( 'type' => 'boolean' ), + ), + array( 'id' ) + ), + 'codex_blog_delete_comment', + static function() { + return current_user_can( 'moderate_comments' ); + }, + false, + true + ); + + codex_blog_register_ability( + 'list-media', + 'List Media', + 'List media library attachments.', + codex_blog_schema( + array( + 'search' => array( 'type' => 'string' ), + 'mime_type' => array( 'type' => 'string' ), + 'page' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + 'per_page' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 100, + ), + ) + ), + 'codex_blog_list_media', + static function() { + return current_user_can( 'upload_files' ); + }, + true + ); + + codex_blog_register_ability( + 'list-plugins', + 'List Plugins', + 'List installed plugins and activation status.', + null, + 'codex_blog_list_plugins', + static function() { + return current_user_can( 'activate_plugins' ); + }, + true + ); + + codex_blog_register_ability( + 'activate-plugin', + 'Activate Plugin', + 'Activate an installed plugin by plugin file path.', + codex_blog_schema( + array( + 'plugin_file' => array( + 'type' => 'string', + 'description' => 'Plugin file path, for example akismet/akismet.php.', + ), + ), + array( 'plugin_file' ) + ), + 'codex_blog_activate_plugin', + static function() { + return current_user_can( 'activate_plugins' ); + }, + false + ); + + codex_blog_register_ability( + 'deactivate-plugin', + 'Deactivate Plugin', + 'Deactivate an installed plugin by plugin file path. Blocks deactivating MCP-critical plugins unless allow_critical is true.', + codex_blog_schema( + array( + 'plugin_file' => array( 'type' => 'string' ), + 'allow_critical' => array( + 'type' => 'boolean', + 'description' => 'Allow deactivating mcp-adapter or codex-blog-abilities.', + ), + ), + array( 'plugin_file' ) + ), + 'codex_blog_deactivate_plugin', + static function() { + return current_user_can( 'activate_plugins' ); + }, + false + ); + + codex_blog_register_ability( + 'get-options', + 'Get Options', + 'Read selected safe WordPress options. If names is omitted, reads a common admin allowlist.', + codex_blog_schema( + array( + 'names' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ) + ), + 'codex_blog_get_options', + static function() { + return current_user_can( 'manage_options' ); + }, + true + ); + + codex_blog_register_ability( + 'update-options', + 'Update Options', + 'Update selected safe WordPress options from a strict allowlist.', + codex_blog_schema( + array( + 'updates' => array( + 'type' => 'object', + 'additionalProperties' => true, + ), + ), + array( 'updates' ) + ), + 'codex_blog_update_options', + static function() { + return current_user_can( 'manage_options' ); + }, + false + ); + } +); + +function codex_blog_register_ability( $slug, $label, $description, $input_schema, $callback, $permission_callback, $readonly, $destructive = false ) { + $args = array( + 'label' => __( $label, 'codex-blog-abilities' ), + 'description' => __( $description, 'codex-blog-abilities' ), + 'category' => 'codex-blog-admin', + 'output_schema' => codex_blog_output_schema(), + 'execute_callback' => $callback, + 'permission_callback' => $permission_callback, + 'meta' => array( + 'mcp' => array( + 'public' => true, + ), + 'annotations' => array( + 'readonly' => (bool) $readonly, + 'destructive' => (bool) $destructive, + 'idempotent' => false, + ), + ), + ); + + if ( null !== $input_schema ) { + $args['input_schema'] = $input_schema; + } + + wp_register_ability( 'codex-blog/' . $slug, $args ); +} + +function codex_blog_schema( $properties, $required = array() ) { + return array( + 'type' => 'object', + 'properties' => $properties, + 'required' => $required, + 'additionalProperties' => false, + ); +} + +function codex_blog_output_schema() { + return array( + 'type' => 'object', + 'additionalProperties' => true, + ); +} + +function codex_blog_input( $input ) { + if ( is_object( $input ) ) { + $input = get_object_vars( $input ); + } + + if ( ! is_array( $input ) ) { + return array(); + } + + foreach ( $input as $key => $value ) { + if ( is_object( $value ) ) { + $input[ $key ] = codex_blog_input( $value ); + } elseif ( is_array( $value ) ) { + $input[ $key ] = codex_blog_deep_normalize( $value ); + } + } + + return $input; +} + +function codex_blog_deep_normalize( $value ) { + if ( is_object( $value ) ) { + return codex_blog_input( $value ); + } + + if ( ! is_array( $value ) ) { + return $value; + } + + foreach ( $value as $key => $child ) { + $value[ $key ] = codex_blog_deep_normalize( $child ); + } + + return $value; +} + +function codex_blog_limit( $value, $default, $min, $max ) { + $value = (int) $value; + if ( $value < $min ) { + $value = $default; + } + + return min( $max, max( $min, $value ) ); +} + +function codex_blog_post_type_from_input( $args ) { + $post_type = isset( $args['post_type'] ) ? sanitize_key( $args['post_type'] ) : 'post'; + return $post_type ? $post_type : 'post'; +} + +function codex_blog_post_to_array( WP_Post $post, $include_content = false ) { + $data = array( + 'id' => (int) $post->ID, + 'post_type' => $post->post_type, + 'status' => $post->post_status, + 'title' => get_the_title( $post ), + 'slug' => $post->post_name, + 'author_id' => (int) $post->post_author, + 'date' => get_post_time( DATE_ATOM, false, $post ), + 'modified' => get_post_modified_time( DATE_ATOM, false, $post ), + 'link' => get_permalink( $post ), + 'parent_id' => (int) $post->post_parent, + 'comment_count' => (int) $post->comment_count, + ); + + if ( $include_content ) { + $data['content'] = $post->post_content; + $data['excerpt'] = $post->post_excerpt; + $data['terms'] = codex_blog_get_post_terms( $post->ID ); + } + + return $data; +} + +function codex_blog_get_post_terms( $post_id ) { + $result = array(); + $taxonomies = get_object_taxonomies( get_post_type( $post_id ) ); + + foreach ( $taxonomies as $taxonomy ) { + $terms = get_the_terms( $post_id, $taxonomy ); + if ( is_wp_error( $terms ) || empty( $terms ) ) { + $result[ $taxonomy ] = array(); + continue; + } + + $result[ $taxonomy ] = array_map( + static function( WP_Term $term ) { + return array( + 'id' => (int) $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + }, + $terms + ); + } + + return $result; +} + +function codex_blog_get_site_info() { + $current_user = wp_get_current_user(); + $theme = wp_get_theme(); + + return array( + 'name' => get_bloginfo( 'name' ), + 'description' => get_bloginfo( 'description' ), + 'url' => site_url(), + 'home' => home_url(), + 'wp_version' => get_bloginfo( 'version' ), + 'active_theme' => array( + 'name' => $theme->get( 'Name' ), + 'version' => $theme->get( 'Version' ), + ), + 'current_user' => array( + 'id' => (int) $current_user->ID, + 'login' => $current_user->user_login, + 'display' => $current_user->display_name, + 'roles' => $current_user->roles, + 'capability' => array( + 'edit_posts' => current_user_can( 'edit_posts' ), + 'manage_options' => current_user_can( 'manage_options' ), + 'activate_plugins' => current_user_can( 'activate_plugins' ), + ), + ), + ); +} + +function codex_blog_list_post_types( $input ) { + $args = codex_blog_input( $input ); + $include_non_public = ! empty( $args['include_non_public'] ); + $post_types = get_post_types( array(), 'objects' ); + $result = array(); + + foreach ( $post_types as $post_type => $type_obj ) { + if ( ! $include_non_public && ! $type_obj->public ) { + continue; + } + + $result[] = array( + 'name' => $post_type, + 'label' => $type_obj->label, + 'public' => (bool) $type_obj->public, + 'show_ui' => (bool) $type_obj->show_ui, + 'hierarchical' => (bool) $type_obj->hierarchical, + 'capabilities' => array( + 'edit_posts' => $type_obj->cap->edit_posts, + 'publish_posts' => $type_obj->cap->publish_posts, + 'delete_posts' => $type_obj->cap->delete_posts, + ), + ); + } + + return array( 'post_types' => $result ); +} + +function codex_blog_list_posts( $input ) { + $args = codex_blog_input( $input ); + $post_type = codex_blog_post_type_from_input( $args ); + $status = isset( $args['status'] ) ? sanitize_key( $args['status'] ) : 'any'; + $page = codex_blog_limit( $args['page'] ?? 1, 1, 1, 100000 ); + $per_page = codex_blog_limit( $args['per_page'] ?? 10, 10, 1, 100 ); + + $query_args = array( + 'post_type' => $post_type, + 'post_status' => $status ? $status : 'any', + 'paged' => $page, + 'posts_per_page' => $per_page, + 'orderby' => isset( $args['orderby'] ) ? sanitize_key( $args['orderby'] ) : 'date', + 'order' => isset( $args['order'] ) && 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC', + ); + + if ( ! empty( $args['search'] ) ) { + $query_args['s'] = sanitize_text_field( $args['search'] ); + } + + $query = new WP_Query( $query_args ); + + return array( + 'page' => $page, + 'per_page' => $per_page, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + 'posts' => array_map( + static function( WP_Post $post ) { + return codex_blog_post_to_array( $post, false ); + }, + $query->posts + ), + ); +} + +function codex_blog_get_post( $input ) { + $args = codex_blog_input( $input ); + $post = get_post( (int) $args['id'] ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + return array( 'post' => codex_blog_post_to_array( $post, true ) ); +} + +function codex_blog_can_create_post( $input ) { + $args = codex_blog_input( $input ); + $post_type = codex_blog_post_type_from_input( $args ); + $type_obj = get_post_type_object( $post_type ); + + if ( ! $type_obj || ! current_user_can( $type_obj->cap->edit_posts ) ) { + return false; + } + + $status = isset( $args['status'] ) ? sanitize_key( $args['status'] ) : 'draft'; + if ( in_array( $status, array( 'publish', 'private' ), true ) && ! current_user_can( $type_obj->cap->publish_posts ) ) { + return false; + } + + return true; +} + +function codex_blog_create_post( $input ) { + $args = codex_blog_input( $input ); + $post_type = codex_blog_post_type_from_input( $args ); + $status = isset( $args['status'] ) ? sanitize_key( $args['status'] ) : 'draft'; + + $postarr = array( + 'post_type' => $post_type, + 'post_status' => $status, + 'post_title' => sanitize_text_field( $args['title'] ?? '' ), + 'post_content' => isset( $args['content'] ) ? wp_kses_post( $args['content'] ) : '', + 'post_excerpt' => isset( $args['excerpt'] ) ? wp_kses_post( $args['excerpt'] ) : '', + ); + + if ( isset( $args['slug'] ) ) { + $postarr['post_name'] = sanitize_title( $args['slug'] ); + } + + if ( isset( $args['parent_id'] ) ) { + $postarr['post_parent'] = (int) $args['parent_id']; + } + + $post_id = wp_insert_post( $postarr, true ); + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $terms_result = codex_blog_apply_terms( $post_id, $args['terms'] ?? array() ); + if ( is_wp_error( $terms_result ) ) { + return $terms_result; + } + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( $post_id ), true ), + ); +} + +function codex_blog_can_update_post( $input ) { + $args = codex_blog_input( $input ); + if ( empty( $args['id'] ) || ! current_user_can( 'edit_post', (int) $args['id'] ) ) { + return false; + } + + if ( ! empty( $args['status'] ) && in_array( sanitize_key( $args['status'] ), array( 'publish', 'private' ), true ) ) { + $post = get_post( (int) $args['id'] ); + $type_obj = $post ? get_post_type_object( $post->post_type ) : null; + return $type_obj && current_user_can( $type_obj->cap->publish_posts ); + } + + return true; +} + +function codex_blog_update_post( $input ) { + $args = codex_blog_input( $input ); + $post = get_post( (int) $args['id'] ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + $postarr = array( 'ID' => $post->ID ); + if ( array_key_exists( 'title', $args ) ) { + $postarr['post_title'] = sanitize_text_field( $args['title'] ); + } + if ( array_key_exists( 'content', $args ) ) { + $postarr['post_content'] = wp_kses_post( $args['content'] ); + } + if ( array_key_exists( 'excerpt', $args ) ) { + $postarr['post_excerpt'] = wp_kses_post( $args['excerpt'] ); + } + if ( array_key_exists( 'status', $args ) ) { + $postarr['post_status'] = sanitize_key( $args['status'] ); + } + if ( array_key_exists( 'slug', $args ) ) { + $postarr['post_name'] = sanitize_title( $args['slug'] ); + } + if ( array_key_exists( 'parent_id', $args ) ) { + $postarr['post_parent'] = (int) $args['parent_id']; + } + + $result = wp_update_post( $postarr, true ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $terms_result = codex_blog_apply_terms( $post->ID, $args['terms'] ?? array() ); + if ( is_wp_error( $terms_result ) ) { + return $terms_result; + } + + return array( + 'success' => true, + 'post' => codex_blog_post_to_array( get_post( $post->ID ), true ), + ); +} + +function codex_blog_delete_post( $input ) { + $args = codex_blog_input( $input ); + $id = (int) $args['id']; + $force = ! empty( $args['force'] ); + $post = get_post( $id ); + + if ( ! $post ) { + return new WP_Error( 'codex_blog_not_found', 'Post not found.' ); + } + + $result = $force ? wp_delete_post( $id, true ) : wp_trash_post( $id ); + if ( ! $result ) { + return new WP_Error( 'codex_blog_delete_failed', 'The post could not be deleted.' ); + } + + return array( + 'success' => true, + 'id' => $id, + 'previous_status' => $post->post_status, + 'force' => $force, + ); +} + +function codex_blog_apply_terms( $post_id, $terms_by_taxonomy ) { + if ( empty( $terms_by_taxonomy ) ) { + return true; + } + + if ( ! is_array( $terms_by_taxonomy ) ) { + return new WP_Error( 'codex_blog_invalid_terms', 'Terms must be an object keyed by taxonomy.' ); + } + + foreach ( $terms_by_taxonomy as $taxonomy => $terms ) { + $taxonomy = sanitize_key( $taxonomy ); + if ( ! taxonomy_exists( $taxonomy ) ) { + return new WP_Error( 'codex_blog_invalid_taxonomy', 'Invalid taxonomy: ' . $taxonomy ); + } + + $tax_obj = get_taxonomy( $taxonomy ); + if ( ! current_user_can( $tax_obj->cap->assign_terms ) ) { + return new WP_Error( 'codex_blog_forbidden_terms', 'Current user cannot assign terms for taxonomy: ' . $taxonomy ); + } + + $result = wp_set_object_terms( $post_id, $terms, $taxonomy, false ); + if ( is_wp_error( $result ) ) { + return $result; + } + } + + return true; +} + +function codex_blog_list_terms( $input ) { + $args = codex_blog_input( $input ); + $taxonomy = sanitize_key( $args['taxonomy'] ); + if ( ! taxonomy_exists( $taxonomy ) ) { + return new WP_Error( 'codex_blog_invalid_taxonomy', 'Invalid taxonomy.' ); + } + + $page = codex_blog_limit( $args['page'] ?? 1, 1, 1, 100000 ); + $per_page = codex_blog_limit( $args['per_page'] ?? 50, 50, 1, 100 ); + $query = array( + 'taxonomy' => $taxonomy, + 'hide_empty' => ! empty( $args['hide_empty'] ), + 'number' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + ); + + if ( ! empty( $args['search'] ) ) { + $query['search'] = sanitize_text_field( $args['search'] ); + } + + $terms = get_terms( $query ); + if ( is_wp_error( $terms ) ) { + return $terms; + } + + return array( + 'terms' => array_map( + static function( WP_Term $term ) { + return array( + 'id' => (int) $term->term_id, + 'taxonomy' => $term->taxonomy, + 'name' => $term->name, + 'slug' => $term->slug, + 'description' => $term->description, + 'parent_id' => (int) $term->parent, + 'count' => (int) $term->count, + ); + }, + $terms + ), + ); +} + +function codex_blog_can_manage_terms( $input ) { + $args = codex_blog_input( $input ); + $taxonomy = isset( $args['taxonomy'] ) ? sanitize_key( $args['taxonomy'] ) : ''; + $tax_obj = $taxonomy ? get_taxonomy( $taxonomy ) : null; + return $tax_obj && current_user_can( $tax_obj->cap->manage_terms ); +} + +function codex_blog_create_term( $input ) { + $args = codex_blog_input( $input ); + $taxonomy = sanitize_key( $args['taxonomy'] ); + $termargs = array(); + + foreach ( array( 'slug', 'description' ) as $field ) { + if ( isset( $args[ $field ] ) ) { + $termargs[ $field ] = 'slug' === $field ? sanitize_title( $args[ $field ] ) : sanitize_text_field( $args[ $field ] ); + } + } + + if ( isset( $args['parent_id'] ) ) { + $termargs['parent'] = (int) $args['parent_id']; + } + + $result = wp_insert_term( sanitize_text_field( $args['name'] ), $taxonomy, $termargs ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'term_id' => (int) $result['term_id'], + ); +} + +function codex_blog_update_term( $input ) { + $args = codex_blog_input( $input ); + $taxonomy = sanitize_key( $args['taxonomy'] ); + $termargs = array(); + + foreach ( array( 'name', 'slug', 'description' ) as $field ) { + if ( isset( $args[ $field ] ) ) { + $termargs[ $field ] = 'slug' === $field ? sanitize_title( $args[ $field ] ) : sanitize_text_field( $args[ $field ] ); + } + } + + if ( isset( $args['parent_id'] ) ) { + $termargs['parent'] = (int) $args['parent_id']; + } + + $result = wp_update_term( (int) $args['term_id'], $taxonomy, $termargs ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'term_id' => (int) $result['term_id'], + ); +} + +function codex_blog_delete_term( $input ) { + $args = codex_blog_input( $input ); + $result = wp_delete_term( (int) $args['term_id'], sanitize_key( $args['taxonomy'] ) ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => (bool) $result, + 'deleted' => (bool) $result, + ); +} + +function codex_blog_list_comments( $input ) { + $args = codex_blog_input( $input ); + $page = codex_blog_limit( $args['page'] ?? 1, 1, 1, 100000 ); + $per_page = codex_blog_limit( $args['per_page'] ?? 20, 20, 1, 100 ); + + $query = array( + 'status' => isset( $args['status'] ) ? sanitize_key( $args['status'] ) : 'all', + 'number' => $per_page, + 'offset' => ( $page - 1 ) * $per_page, + ); + + if ( ! empty( $args['post_id'] ) ) { + $query['post_id'] = (int) $args['post_id']; + } + + $comments = get_comments( $query ); + + return array( + 'comments' => array_map( + static function( WP_Comment $comment ) { + return array( + 'id' => (int) $comment->comment_ID, + 'post_id' => (int) $comment->comment_post_ID, + 'author' => $comment->comment_author, + 'author_url' => $comment->comment_author_url, + 'date' => mysql2date( DATE_ATOM, $comment->comment_date_gmt, false ), + 'status' => wp_get_comment_status( $comment ), + 'content' => $comment->comment_content, + ); + }, + $comments + ), + ); +} + +function codex_blog_update_comment( $input ) { + $args = codex_blog_input( $input ); + $id = (int) $args['id']; + + if ( isset( $args['status'] ) ) { + wp_set_comment_status( $id, sanitize_key( $args['status'] ) ); + } + + if ( isset( $args['content'] ) ) { + $result = wp_update_comment( + array( + 'comment_ID' => $id, + 'comment_content' => wp_kses_post( $args['content'] ), + ) + ); + + if ( false === $result ) { + return new WP_Error( 'codex_blog_comment_update_failed', 'Comment update failed.' ); + } + } + + return array( + 'success' => true, + 'comment' => get_comment( $id, ARRAY_A ), + ); +} + +function codex_blog_delete_comment( $input ) { + $args = codex_blog_input( $input ); + $result = wp_delete_comment( (int) $args['id'], ! empty( $args['force'] ) ); + + return array( + 'success' => (bool) $result, + 'deleted' => (bool) $result, + ); +} + +function codex_blog_list_media( $input ) { + $args = codex_blog_input( $input ); + $page = codex_blog_limit( $args['page'] ?? 1, 1, 1, 100000 ); + $per_page = codex_blog_limit( $args['per_page'] ?? 20, 20, 1, 100 ); + + $query_args = array( + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'paged' => $page, + 'posts_per_page' => $per_page, + ); + + if ( ! empty( $args['search'] ) ) { + $query_args['s'] = sanitize_text_field( $args['search'] ); + } + + if ( ! empty( $args['mime_type'] ) ) { + $query_args['post_mime_type'] = sanitize_mime_type( $args['mime_type'] ); + } + + $query = new WP_Query( $query_args ); + + return array( + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + 'media' => array_map( + static function( WP_Post $post ) { + return array( + 'id' => (int) $post->ID, + 'title' => get_the_title( $post ), + 'mime_type' => $post->post_mime_type, + 'url' => wp_get_attachment_url( $post->ID ), + 'date' => get_post_time( DATE_ATOM, false, $post ), + 'alt_text' => get_post_meta( $post->ID, '_wp_attachment_image_alt', true ), + 'parent_id' => (int) $post->post_parent, + ); + }, + $query->posts + ), + ); +} + +function codex_blog_list_plugins() { + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + $plugins = get_plugins(); + $result = array(); + + foreach ( $plugins as $plugin_file => $data ) { + $result[] = array( + 'plugin_file' => $plugin_file, + 'name' => $data['Name'] ?? $plugin_file, + 'version' => $data['Version'] ?? '', + 'description' => wp_strip_all_tags( $data['Description'] ?? '' ), + 'active' => is_plugin_active( $plugin_file ), + ); + } + + return array( 'plugins' => $result ); +} + +function codex_blog_activate_plugin( $input ) { + $args = codex_blog_input( $input ); + $plugin_file = plugin_basename( sanitize_text_field( $args['plugin_file'] ) ); + + if ( ! function_exists( 'get_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + if ( ! array_key_exists( $plugin_file, get_plugins() ) ) { + return new WP_Error( 'codex_blog_plugin_not_found', 'Plugin not found.' ); + } + + $result = activate_plugin( $plugin_file ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'plugin_file' => $plugin_file, + 'active' => is_plugin_active( $plugin_file ), + ); +} + +function codex_blog_deactivate_plugin( $input ) { + $args = codex_blog_input( $input ); + $plugin_file = plugin_basename( sanitize_text_field( $args['plugin_file'] ) ); + $critical = array( + CODEX_BLOG_ABILITIES_PLUGIN_FILE, + 'mcp-adapter/mcp-adapter.php', + ); + + if ( ! function_exists( 'deactivate_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + if ( in_array( $plugin_file, $critical, true ) && empty( $args['allow_critical'] ) ) { + return new WP_Error( 'codex_blog_critical_plugin', 'Refusing to deactivate an MCP-critical plugin unless allow_critical is true.' ); + } + + deactivate_plugins( $plugin_file ); + + return array( + 'success' => true, + 'plugin_file' => $plugin_file, + 'active' => is_plugin_active( $plugin_file ), + ); +} + +function codex_blog_option_allowlist() { + return array( + 'blogname', + 'blogdescription', + 'admin_email', + 'start_of_week', + 'timezone_string', + 'date_format', + 'time_format', + 'posts_per_page', + 'default_category', + 'default_post_format', + 'default_comment_status', + 'default_ping_status', + 'comment_moderation', + 'comment_previously_approved', + 'permalink_structure', + ); +} + +function codex_blog_get_options( $input ) { + $args = codex_blog_input( $input ); + $allowlist = codex_blog_option_allowlist(); + $names = ! empty( $args['names'] ) && is_array( $args['names'] ) ? $args['names'] : $allowlist; + $result = array(); + + foreach ( $names as $name ) { + $name = sanitize_key( $name ); + if ( ! in_array( $name, $allowlist, true ) ) { + continue; + } + $result[ $name ] = get_option( $name ); + } + + return array( + 'options' => $result, + 'allowlist' => $allowlist, + ); +} + +function codex_blog_update_options( $input ) { + $args = codex_blog_input( $input ); + $updates = isset( $args['updates'] ) && is_array( $args['updates'] ) ? $args['updates'] : array(); + $allowlist = codex_blog_option_allowlist(); + $result = array(); + + foreach ( $updates as $name => $value ) { + $name = sanitize_key( $name ); + if ( ! in_array( $name, $allowlist, true ) ) { + $result[ $name ] = array( + 'updated' => false, + 'error' => 'Option is not in the allowlist.', + ); + continue; + } + + $old_value = get_option( $name ); + $new_value = is_string( $value ) ? sanitize_text_field( $value ) : $value; + $updated = update_option( $name, $new_value ); + + if ( 'permalink_structure' === $name ) { + flush_rewrite_rules(); + } + + $result[ $name ] = array( + 'updated' => (bool) $updated, + 'previous_value' => $old_value, + 'new_value' => get_option( $name ), + ); + } + + return array( 'options' => $result ); +}