> $registry Registry. * @return array> */ function aafm_register_themes_definitions( array $registry ): array { $registry['aafm/get-active-theme'] = array( 'label' => __( 'Get active theme', 'agent-abilities-for-mcp' ), 'description' => __( 'Reads the active theme: name, version, stylesheet, parent, and whether it is a block theme. Requires the edit-theme-options capability.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_get_active_theme', ); $registry['aafm/list-themes'] = array( 'label' => __( 'List themes', 'agent-abilities-for-mcp' ), 'description' => __( 'Lists installed themes by name, version, stylesheet, and active state. Requires the edit-theme-options capability.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_list_themes', ); $registry['aafm/list-templates'] = array( 'label' => __( 'List templates', 'agent-abilities-for-mcp' ), 'description' => __( 'Lists block templates (or template parts) by id, slug, title, type, and source. A classic theme without block-template support usually has none, so the list can be empty there. Requires the edit-theme-options capability.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_list_templates', ); $registry['aafm/get-template'] = array( 'label' => __( 'Get template', 'agent-abilities-for-mcp' ), 'description' => __( 'Reads one block template by id, including its markup. Requires the edit-theme-options capability.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_get_template', ); $registry['aafm/get-global-styles'] = array( 'label' => __( 'Get global styles', 'agent-abilities-for-mcp' ), 'description' => __( "Reads the site's resolved theme.json settings and styles, on block and classic themes alike: the colour palette slugs to use as backgroundColor and textColor, font sizes, spacing, layout widths and whether fluid typography is on. On a classic theme most values are WordPress core defaults, and some keys (such as layout or typography.fluid) may be absent. Requires the edit-theme-options capability.", 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'site', 'args_builder' => 'aafm_args_get_global_styles', ); $registry['aafm/update-template'] = array( 'label' => __( 'Update template', 'agent-abilities-for-mcp' ), 'description' => __( 'Updates a database block template by id. Its markup is sanitized, and theme-file templates cannot be edited. Requires the edit-theme-options capability.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'write', 'subject' => 'site', 'args_builder' => 'aafm_args_update_template', ); return $registry; } /** * Args for aafm/get-active-theme. * * @return array */ function aafm_args_get_active_theme(): array { return array( 'label' => aafm_ability_label( 'aafm/get-active-theme' ), 'description' => aafm_ability_description( 'aafm/get-active-theme' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string' ), 'version' => array( 'type' => 'string' ), 'stylesheet' => array( 'type' => 'string' ), 'template' => array( 'type' => 'string' ), 'is_block_theme' => array( 'type' => 'boolean' ), ), ), 'execute_callback' => 'aafm_exec_get_active_theme', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/get-active-theme. * * Returns the active theme's header fields and slugs only - never a filesystem directory. * stylesheet is the active (child) slug; template is the parent slug (same as stylesheet for a * non-child theme). * * @return array */ function aafm_exec_get_active_theme(): array { $theme = wp_get_theme(); return array( 'name' => (string) $theme->get( 'Name' ), 'version' => (string) $theme->get( 'Version' ), 'stylesheet' => $theme->get_stylesheet(), 'template' => $theme->get_template(), 'is_block_theme' => wp_is_block_theme(), ); } /** * Args for aafm/list-themes. * * @return array */ function aafm_args_list_themes(): array { return array( 'label' => aafm_ability_label( 'aafm/list-themes' ), 'description' => aafm_ability_description( 'aafm/list-themes' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'themes' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string' ), 'version' => array( 'type' => 'string' ), 'stylesheet' => array( 'type' => 'string' ), 'status' => array( 'type' => 'string' ), ), ), ), ), ), 'execute_callback' => 'aafm_exec_list_themes', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/list-themes. * * Lists every installed theme with its header fields and stylesheet slug, marking the active one. * No filesystem path is returned (the slug is the stylesheet directory NAME, not its full path). * * @return array */ function aafm_exec_list_themes(): array { $active = wp_get_theme()->get_stylesheet(); $themes = array(); foreach ( wp_get_themes() as $theme ) { $stylesheet = $theme->get_stylesheet(); $themes[] = array( 'name' => (string) $theme->get( 'Name' ), 'version' => (string) $theme->get( 'Version' ), 'stylesheet' => $stylesheet, 'status' => $stylesheet === $active ? 'active' : 'inactive', ); } return array( 'themes' => $themes ); } /** * Resolve the template type from input, constrained to the two valid block-template types. * * The schema enum already rejects anything else, but coercing here keeps the core API calls * fed a known value (wp_template by default) rather than trusting raw input. The narrowed * phpstan-return lets the core get_block_template(s) calls see the exact literal union they expect. * * @param array $input Validated input. * @return string Either 'wp_template' or 'wp_template_part'. * @phpstan-return 'wp_template'|'wp_template_part' */ function aafm_template_type( array $input ): string { $type = isset( $input['type'] ) ? sanitize_key( (string) $input['type'] ) : 'wp_template'; return 'wp_template_part' === $type ? 'wp_template_part' : 'wp_template'; } /** * Safe shape for one block template - id, slug, title, type, and source only. * * No markup here (the list stays lean); get-template adds content. source is theme|custom: a * theme-FILE template ('theme') has no backing post, a database-overridden one is 'custom'. * * @param WP_Block_Template $template Block template object. * @return array */ function aafm_redact_template( WP_Block_Template $template ): array { return array( 'id' => (string) $template->id, 'slug' => (string) $template->slug, 'title' => (string) $template->title, 'type' => (string) $template->type, 'source' => (string) $template->source, ); } /** * Args for aafm/list-templates. * * @return array */ function aafm_args_list_templates(): array { return array( 'label' => aafm_ability_label( 'aafm/list-templates' ), 'description' => aafm_ability_description( 'aafm/list-templates' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'type' => array( 'type' => 'string', 'enum' => array( 'wp_template', 'wp_template_part' ), 'description' => __( 'Which kind of block template to list: wp_template for full page templates, or wp_template_part for reusable regions such as a header or footer. Defaults to wp_template when omitted.', 'agent-abilities-for-mcp' ), ), ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'templates' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => aafm_template_output_properties(), ), ), ), ), 'execute_callback' => 'aafm_exec_list_templates', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/list-templates. * * Returns every block template (or template part) for the active theme, redacted to the lean * shape (no markup). The type defaults to wp_template. * * @param array $input Validated input. * @return array */ function aafm_exec_list_templates( array $input ): array { $type = aafm_template_type( $input ); $templates = array(); foreach ( get_block_templates( array(), $type ) as $template ) { if ( $template instanceof WP_Block_Template ) { $templates[] = aafm_redact_template( $template ); } } return array( 'templates' => $templates ); } /** * Args for aafm/get-template. * * @return array */ function aafm_args_get_template(): array { return array( 'label' => aafm_ability_label( 'aafm/get-template' ), 'description' => aafm_ability_description( 'aafm/get-template' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'template_id' => array( 'type' => 'string', 'description' => __( 'The template\'s theme//slug identifier, as returned by list-templates, for example twentytwentyfour//single. An unknown ID returns a generic error rather than confirming which IDs exist.', 'agent-abilities-for-mcp' ), ), 'type' => array( 'type' => 'string', 'enum' => array( 'wp_template', 'wp_template_part' ), 'description' => __( 'Which kind of template template_id refers to: wp_template or wp_template_part. Defaults to wp_template, and must match the type of the template actually named.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'template_id' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => aafm_rich_template_output_properties(), ), 'execute_callback' => 'aafm_exec_get_template', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/get-template. * * Resolves the template by its theme//slug id; an unknown id returns a generic error rather than * leaking which ids exist. The rich shape adds the template markup to the lean fields. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_get_template( array $input ) { $id = sanitize_text_field( (string) ( $input['template_id'] ?? '' ) ); $type = aafm_template_type( $input ); $template = get_block_template( $id, $type ); if ( ! $template instanceof WP_Block_Template ) { return aafm_generic_error(); } $out = aafm_redact_template( $template ); $out['content'] = (string) $template->content; return $out; } /** * Args for aafm/update-template. * * Closed schema: the template id plus the new markup (both required), with an optional type. No * other field is writable, so nothing extra can be smuggled into the backing post. * * @return array */ function aafm_args_update_template(): array { return array( 'label' => aafm_ability_label( 'aafm/update-template' ), 'description' => aafm_ability_description( 'aafm/update-template' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'template_id' => array( 'type' => 'string', 'description' => __( 'The theme//slug identifier of the database template to update, as returned by list-templates or get-template. A theme-file template with no database override has no backing post and is refused.', 'agent-abilities-for-mcp' ), ), 'content' => array( 'type' => 'string', 'description' => aafm_write_content_description(), ), 'type' => array( 'type' => 'string', 'enum' => array( 'wp_template', 'wp_template_part' ), 'description' => __( 'Which kind of template template_id refers to: wp_template or wp_template_part. Defaults to wp_template, and must match the type of the template actually named.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'template_id', 'content' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => aafm_rich_template_output_properties(), ), 'execute_callback' => 'aafm_exec_update_template', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, ), ), ); } /** * Execute aafm/update-template. * * Resolves the template by id (unknown id → generic error), then refuses any template that lives * only as a THEME FILE - those have no backing wp_template post, so editing them is not allowed * (we never write theme files). * * B-1 (load-bearing): a theme-file template's wp_id is UNSET (effectively null), not 0, and * 0 === null is false in PHP - so a naive `0 === $wp_id` guard would let a file template fall * through to wp_update_post(['ID'=>null,...]), which inserts a stray post. The cast-then-compare * below ((int)($wp_id ?? 0) <= 0) catches both the unset/null and the literal 0 case. * * For a real database template the markup is kses-hardened (block delimiters survive, scripts are * stripped), the whole array is wp_slash()'d once for the insert layer, and the refreshed * get-template shape is returned. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_update_template( array $input ) { $id = sanitize_text_field( (string) ( $input['template_id'] ?? '' ) ); $type = aafm_template_type( $input ); $template = get_block_template( $id, $type ); if ( ! $template instanceof WP_Block_Template ) { return aafm_generic_error(); } $wp_id = (int) ( $template->wp_id ?? 0 ); if ( $wp_id <= 0 ) { // The template exists (the caller already passed the theme-options capability), so say why it // is refused instead of the generic error that hides existence. return new WP_Error( 'aafm_template_not_customized', __( 'This template comes from the theme files and has not been customized, so there is no saved copy to update. Open it once in the Site Editor and save a change to create one, then try again.', 'agent-abilities-for-mcp' ) ); } $sanitized = aafm_kses_block_markup( (string) ( $input['content'] ?? '' ) ); $content = $sanitized['content']; // Read before the write: the raw post_content confirmation below reads back, matching the // context aafm_post_field_write_confirmed()'s own read-back uses. $stored = aafm_exact_object_chain( 'post', $wp_id ); if ( ! $stored instanceof WP_Post ) { return aafm_generic_error(); } $content_was = $stored->post_content; $content_was = is_scalar( $content_was ) ? (string) $content_was : ''; // Template content is pure Gutenberg block markup edited in the Site Editor, which runs the // same JS save() re-validation as the post editor. Guard it: strict mode blocks the write, // warn mode rides the warnings back on the response. $guard = aafm_block_guard_evaluate( $content ); if ( $guard['error'] instanceof WP_Error ) { return $guard['error']; } $guard['warnings'] = array_merge( $guard['warnings'], $sanitized['warnings'] ); $result = wp_update_post( wp_slash( array( 'ID' => $wp_id, 'post_content' => $content, ) ), true ); if ( is_wp_error( $result ) || 0 === (int) $result ) { return aafm_generic_error(); } $refreshed = get_block_template( $id, $type ); if ( ! $refreshed instanceof WP_Block_Template ) { return aafm_generic_error(); } // The reread above only proved the template still resolves, never that // its content matches what this update actually asked for - a wp_insert_post_data filter // silently vetoing or normalizing the write would still report success on the caller's stale // intent. $wp_id is the underlying post; the default $sanitize_context_id (the same $wp_id) // is correct here since this is an update against a row that already existed. if ( ! aafm_post_field_confirm_logged( $wp_id, 'post_content', $content, $content_was ) ) { return aafm_generic_error(); } $out = aafm_redact_template( $refreshed ); $out['content'] = (string) $refreshed->content; return aafm_block_with_warnings( $out, $guard['warnings'] ); } /** * Args for aafm/get-global-styles. * * @return array */ function aafm_args_get_global_styles(): array { return array( 'label' => aafm_ability_label( 'aafm/get-global-styles' ), 'description' => aafm_ability_description( 'aafm/get-global-styles' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array(), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object' ), 'styles' => array( 'type' => 'object' ), ), ), 'execute_callback' => 'aafm_exec_get_global_styles', 'permission_callback' => 'aafm_perm_edit_theme_options', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/get-global-styles. * * Returns the active theme's resolved theme.json settings and styles arrays. Both are theme.json * data structures (no filesystem path), read-only. * * Both values are guarded to the declared object schema. The 13-site `array() === $x` convention * is not enough here because core can hand back NULL, not only an empty array: with every * wp_theme_json_data_* layer emptied, wp_get_global_styles() reads `['styles']` with no fallback * and _wp_array_get() returns its null default (global-styles-and-settings.php:132-133). An empty * settings array was proven unreachable on WP 6.9.4 (core's own theme.json always populates it), * so the null branch of the guard is the one that is live; the guard covers both anyway. * * @return array */ function aafm_exec_get_global_styles(): array { $settings = wp_get_global_settings(); $styles = wp_get_global_styles(); return array( 'settings' => is_array( $settings ) && array() !== $settings ? $settings : (object) array(), 'styles' => is_array( $styles ) && array() !== $styles ? $styles : (object) array(), ); }