mutator = $mutator;
}
protected function get_ability_id(): string {
return 'elementor/build-composition';
}
public function is_exposed_via_proxy(): bool {
return false;
}
protected function get_definition(): Ability_Definition {
return new Ability_Definition(
__( 'Build Composition', 'elementor' ),
$this->get_ability_description(),
'elementor',
$this->get_output_schema(),
[
'annotations' => [
'readonly' => false,
'idempotent' => false,
'destructive' => true,
],
],
fn() => current_user_can( 'edit_posts' ),
$this->get_input_schema()
);
}
public function execute( $input = [] ) {
$started_at = hrtime( true );
$input = is_array( $input ) ? $input : [];
$post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
$parent_id = $input['parent_id'] ?? self::DEFAULT_PARENT_ID;
$dry_run = ! empty( $input['dry_run'] );
$mode = is_string( $input['mode'] ?? null ) ? $input['mode'] : self::MODE_APPEND;
$validation_error = $this->validate_input( $input );
if ( $validation_error ) {
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $validation_error );
return $validation_error;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
$error = new \WP_Error(
'elementor_forbidden',
__( 'You do not have permission to edit this post.', 'elementor' ),
[ 'status' => \WP_Http::FORBIDDEN ]
);
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $error );
return $error;
}
$document = $this->resolve_document( $post_id );
if ( is_wp_error( $document ) ) {
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $document );
return $document;
}
$elements_data = $document->get_elements_data();
$compiled = Composition_Compiler::make()->compile(
$input,
$document,
is_array( $elements_data ) ? $elements_data : [],
$parent_id
);
if ( is_wp_error( $compiled ) ) {
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $compiled, null, [], $document );
return $compiled;
}
$subtrees = $compiled['elements'];
$warnings = $compiled['warnings'];
$warning_codes = $compiled['warning_codes'] ?? [];
$dom = $compiled['dom'];
$xml_parser = $compiled['xml_parser'];
if ( $dry_run ) {
$response = $this->build_response( $post_id, $document, $xml_parser, $dom, [], $warnings, $mode, [] );
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, null, $subtrees, $warning_codes, $document, $response, [] );
return $response;
}
$persister = new Composition_Persister( $this->get_mutator(), $xml_parser );
$persisted = $persister->insert_and_save( $document, $subtrees, $parent_id, $mode );
if ( is_wp_error( $persisted ) ) {
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, $persisted, $subtrees, $warning_codes, $document );
return $persisted;
}
$persister->embed_ids_into_dom( $dom, $persisted['tree'], $parent_id, $persisted['root_ids'] );
$response = $this->build_response( $post_id, $document, $xml_parser, $dom, $persisted['root_ids'], $warnings, $mode, $persisted['removed_ids'] );
$this->emit_mcp_build_composition_executed( $started_at, $post_id, $mode, $dry_run, null, $subtrees, $warning_codes, $document, $response, $persisted['removed_ids'] );
return $response;
}
private function emit_mcp_build_composition_executed(
int $started_at,
int $post_id,
string $mode,
bool $dry_run,
?\WP_Error $error = null,
?array $subtrees = null,
array $warning_codes = [],
?Document $document = null,
array $response = [],
array $removed_ids = []
): void {
$duration_ms = Tool_Performance_Metrics::duration_ms_since( $started_at );
$status = null === $error ? 'success' : 'error';
$error_code = null !== $error ? $error->get_error_code() : null;
$operations_count = 0;
$operations_by_type = [];
$class_attachments = 0;
$interactions_count = 0;
$removed_count = count( $removed_ids );
if ( null !== $subtrees ) {
$this->collect_composition_counts( $subtrees, $operations_count, $operations_by_type, $class_attachments, $interactions_count );
}
$style_input = [];
$vars_referenced = 0;
$document_type = null !== $document ? $this->resolve_document_type( $document ) : null;
$payload = [
'tool_name' => $this->get_ability_id(),
'status' => $status,
'duration_ms' => $duration_ms,
'post_id' => $post_id,
'mode' => $mode,
'dry_run' => $dry_run,
'operations_count' => $operations_count,
'operations_by_type' => $operations_by_type,
'class_attachments_count' => $class_attachments,
'interactions_applied_count' => $interactions_count,
'removed_count' => $removed_count,
'warning_count' => count( $warning_codes ),
'warning_types' => array_values( array_unique( $warning_codes ) ),
];
if ( null !== $document_type ) {
$payload['document_type'] = $document_type;
}
if ( null !== $error_code ) {
$payload['error_code'] = $error_code;
}
Mcp_Event_Dispatcher::emit( 'mcp_build_composition_executed', $payload );
}
private function collect_composition_counts( array $subtrees, int &$count, array &$by_type, int &$class_attachments, int &$interactions ): void {
$stack = $subtrees;
while ( ! empty( $stack ) ) {
$node = array_pop( $stack );
$type = $node['widgetType'] ?? $node['elType'] ?? '';
if ( '' !== $type ) {
++$count;
$by_type[ $type ] = ( $by_type[ $type ] ?? 0 ) + 1;
}
$classes = $node['settings']['classes']['value'] ?? [];
$class_attachments += count( array_filter( (array) $classes, fn( $c ) => is_string( $c ) && str_starts_with( $c, 'g-' ) ) );
if ( ! empty( $node['interactions']['items'] ) && is_array( $node['interactions']['items'] ) ) {
$interactions += count( $node['interactions']['items'] );
}
foreach ( $node['elements'] ?? [] as $child ) {
$stack[] = $child;
}
}
}
private function resolve_document_type( Document $document ): string {
return $document->get_name();
}
private function get_ability_description(): string {
return Prompt_Loader::load( 'build-composition' );
}
private function get_output_schema(): array {
return [
'type' => 'object',
'required' => [ 'success', 'post_id', 'root_element_ids', 'edit_url', 'version' ],
'properties' => [
'success' => [ 'type' => 'boolean' ],
'post_id' => [ 'type' => 'integer' ],
'root_element_ids' => [
'type' => 'array',
'items' => [ 'type' => 'string' ],
'description' => 'IDs of the created root-level elements.',
],
'edit_url' => [
'type' => 'string',
'format' => 'uri',
'description' => 'Elementor editor URL for the document. Share with the user when they need a link (they must be logged into WordPress as an editor).',
],
'version' => [ 'type' => 'string' ],
'resolved_xml' => [
'type' => 'string',
'description' => 'The XML with element IDs embedded.',
],
'warnings' => [
'type' => 'array',
'items' => [ 'type' => 'string' ],
'description' => 'Non-fatal notices, e.g. props skipped because the target widget does not support them, or CSS that fell back to custom_css. The composition was still built.',
],
'removed_element_ids' => [
'type' => 'array',
'items' => [ 'type' => 'string' ],
'description' => 'Element IDs removed when mode is replace_children (empty when none existed).',
],
],
];
}
private function get_input_schema(): array {
return [
'type' => 'object',
'required' => [ 'post_id', 'xml_structure' ],
'properties' => [
'post_id' => [
'type' => 'integer',
'description' => 'WordPress post ID of the document to mutate.',
],
'xml_structure' => [
'type' => 'string',
'description' => 'Valid XML structure with custom Elementor widget tags. Every element MUST have a unique configuration-id attribute (e.g. ). No attributes, classes, IDs, or text nodes in XML.',
],
'element_config' => [
'type' => 'object',
'default' => (object) [],
'description' => 'Record mapping configuration-id → plain widget settings matching elementor://widgets/schema/{type}. Keys MUST match configuration-id attributes in xml_structure. For configuration-ids, the value is { component_id: int, overrides?: {: } } — see elementor/list-components.',
],
'style' => [
'type' => 'object',
'default' => (object) [],
'description' => 'Record mapping configuration-id → plain CSS string. Supports &:hover/&:focus/&:active nesting and @media(--breakpoint) blocks. Keys MUST match configuration-id attributes in xml_structure.',
'additionalProperties' => [ 'type' => 'string' ],
],
'classes' => [
'type' => 'object',
'default' => (object) [],
'description' => 'Record mapping configuration-id → list of existing global class labels to attach to that element. Create classes first via elementor/manage-classes.',
'additionalProperties' => [
'type' => 'array',
'items' => [ 'type' => 'string' ],
],
],
'interactions' => [
'type' => 'object',
'default' => (object) [],
'description' => 'Record mapping configuration-id → array of interaction items in the native shape. Read elementor://interactions/schema for the full shape and allowed enum values. Send [] for a configuration-id to clear its interactions.',
'additionalProperties' => [
'type' => 'array',
'items' => [ 'type' => 'object' ],
],
],
'parent_id' => [
'type' => 'string',
'default' => self::DEFAULT_PARENT_ID,
'description' => 'ID of the parent container. Omit to insert at document root.',
],
'dry_run' => [
'type' => 'boolean',
'default' => false,
'description' => 'If true, validate and return resolved tree without persisting.',
],
'mode' => [
'type' => 'string',
'enum' => [ self::MODE_APPEND, self::MODE_REPLACE_CHILDREN ],
'default' => self::MODE_APPEND,
'description' => 'append (default) inserts under parent_id; replace_children removes existing direct children of parent_id first, then inserts.',
],
],
];
}
private function validate_input( array $input ): ?\WP_Error {
if ( empty( $input['post_id'] ) ) {
return new \WP_Error(
'invalid_input',
__( 'post_id is required.', 'elementor' ),
[ 'status' => \WP_Http::BAD_REQUEST ]
);
}
if ( empty( $input['xml_structure'] ) || ! is_string( $input['xml_structure'] ) ) {
return new \WP_Error(
'invalid_input',
__( 'xml_structure is required and must be a string.', 'elementor' ),
[ 'status' => \WP_Http::BAD_REQUEST ]
);
}
$mode = $input['mode'] ?? self::MODE_APPEND;
$valid_modes = [ self::MODE_APPEND, self::MODE_REPLACE_CHILDREN ];
if ( ! in_array( $mode, $valid_modes, true ) ) {
return new \WP_Error(
'invalid_input',
sprintf(
/* translators: 1: Provided mode value, 2: List of valid modes */
__( 'Invalid mode "%1$s". Must be one of: %2$s', 'elementor' ),
$mode,
implode( ', ', $valid_modes )
),
[ 'status' => \WP_Http::BAD_REQUEST ]
);
}
return null;
}
private function resolve_document( int $post_id ) {
$document = Plugin::$instance->documents->get_doc_or_auto_save( $post_id, get_current_user_id() )
?? Plugin::$instance->documents->get( $post_id );
if ( ! $document ) {
return new \WP_Error(
'elementor_not_found',
__( 'Post not found.', 'elementor' ),
[ 'status' => \WP_Http::NOT_FOUND ]
);
}
return $document;
}
private function build_response(
int $post_id,
Document $document,
Xml_Parser $xml_parser,
\DOMDocument $dom,
array $root_ids,
array $warnings,
string $mode,
array $removed_ids
): array {
$post = get_post( $post_id );
$response = [
'success' => true,
'post_id' => $post_id,
'root_element_ids' => $root_ids,
'edit_url' => $document->get_edit_url(),
'version' => $post ? $post->post_modified_gmt : current_time( 'mysql', true ),
'resolved_xml' => $xml_parser->serialize_children( $dom ),
];
if ( ! empty( $warnings ) ) {
$response['warnings'] = $warnings;
}
if ( self::MODE_REPLACE_CHILDREN === $mode ) {
$response['removed_element_ids'] = $removed_ids;
}
return $response;
}
private function get_mutator(): Document_Mutator {
return $this->mutator ?? Document_Mutator::instance();
}
}