[
'name' => 'wp_get_rendered',
'description' => 'See what a page really shows, to check your work: for a published post or any URL of this site, the HTTP status,
, meta description, robots, canonical, social tags, headings, links, images missing alt text, and the start of the visible text, as the theme and plugins render them. A draft is rendered without the theme (its content only).',
'inputSchema' => [ 'type' => 'object', 'properties' => [
'ID' => [ 'type' => 'integer', 'description' => 'A post or page ID.' ],
'url' => [ 'type' => 'string', 'description' => 'Or a URL on this site (the home page, an archive...).' ],
] ],
'accessLevel' => 'read',
],
'wp_get_diagnostics' => [
'name' => 'wp_get_diagnostics',
'description' => 'Check the site\'s health when something looks broken or slow: versions and limits (PHP, WordPress, memory, debug settings), the latest PHP errors from the debug log, scheduled tasks (cron) and the overdue ones, and the Site Health issues WordPress found. Pass only to get one section: environment, errors, cron or health.',
'inputSchema' => [ 'type' => 'object', 'properties' => [
'only' => [ 'type' => 'string', 'enum' => [ 'environment', 'errors', 'cron', 'health' ] ],
] ],
'accessLevel' => 'admin',
],
'wp_site_maintenance' => [
'name' => 'wp_site_maintenance',
'description' => 'Common fixes, one "task" at a time: "flush_cache" (the object cache, and the page cache of LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache or SiteGround when present), "flush_permalinks" (fixes 404s after a slug or structure change), "run_cron" (runs the scheduled tasks that are due), "delete_expired_transients".',
'inputSchema' => [ 'type' => 'object', 'properties' => [
'task' => [ 'type' => 'string', 'enum' => [ 'flush_cache', 'flush_permalinks', 'run_cron', 'delete_expired_transients' ] ],
], 'required' => [ 'task' ] ],
// 'write', not 'admin': a change has a write level or a write verb in its name (labs/mcp.php
// is_write_call()), and "maintenance" has none, so Drafts Only and History would miss it.
// The handler still requires manage_options. Its argument is "task", not "action": an
// "action" argument makes is_write_call() look for a verb in its value instead.
'accessLevel' => 'write',
],
'wp_get_revisions' => [
'name' => 'wp_get_revisions',
'description' => 'List the saved versions (revisions) of a post or page: who, when, and how big the change was. Pass revision_id to see exactly what changed in that version (a line diff of title, content and excerpt against the one before it, or against the current post with compare_to_current). Covers every edit, made by a person or an assistant.',
'inputSchema' => [ 'type' => 'object', 'properties' => [
'ID' => [ 'type' => 'integer', 'description' => 'The post or page ID.' ],
'revision_id' => [ 'type' => 'integer', 'description' => 'Optional: one revision, to see its changes.' ],
'compare_to_current' => [ 'type' => 'boolean', 'description' => 'With revision_id: compare it to the current post instead.' ],
], 'required' => [ 'ID' ] ],
'accessLevel' => 'read',
],
'wp_restore_revision' => [
'name' => 'wp_restore_revision',
'description' => 'Bring a post back to an earlier revision (title, content and excerpt), as the Restore button of WordPress does. The current version stays in the revisions, and this can be undone with mcp_undo_change.',
'inputSchema' => [ 'type' => 'object', 'properties' => [
'ID' => [ 'type' => 'integer', 'description' => 'The post or page ID.' ],
'revision_id' => [ 'type' => 'integer', 'description' => 'The revision to restore, from wp_get_revisions.' ],
], 'required' => [ 'ID', 'revision_id' ] ],
'accessLevel' => 'write',
],
];
}
public function register_tools( array $prev ): array {
foreach ( $this->tools() as $name => $tool ) {
$tool['annotations'] = [ 'readOnlyHint' => strpos( $name, '_get_' ) !== false, 'destructiveHint' => false, 'openWorldHint' => false ];
$prev[] = $tool;
}
return $prev;
}
public function handle_call( $prev, string $tool, array $args, $id ) {
if ( !empty( $prev ) || !isset( $this->tools()[ $tool ] ) ) {
return $prev;
}
switch ( $tool ) {
case 'wp_get_rendered':
return $this->rendered( $args );
case 'wp_get_diagnostics':
self::require_cap( 'manage_options' );
return $this->diagnostics( (string) ( $args['only'] ?? '' ) );
case 'wp_site_maintenance':
self::require_cap( 'manage_options' );
return $this->maintenance( (string) ( $args['task'] ?? '' ) );
case 'wp_get_revisions':
return $this->revisions( $args );
case 'wp_restore_revision':
return $this->restore( $args );
}
return $prev;
}
private static function require_cap( string $cap, $object_id = null ) {
if ( $object_id ? !current_user_can( $cap, $object_id ) : !current_user_can( $cap ) ) {
throw new Exception( 'You are not allowed to do this.', Meow_MWAI_Labs_MCP::DENIED_CODE );
}
}
private static function post( array $a ): WP_Post {
$post = get_post( (int) ( $a['ID'] ?? $a['post_id'] ?? $a['id'] ?? 0 ) );
if ( !$post || $post->post_type === 'revision' ) {
throw new Exception( 'Pass the ID of a post or page.' );
}
return $post;
}
#region Rendered
private function rendered( array $a ): array {
$url = trim( (string) ( $a['url'] ?? '' ) );
$post = null;
if ( $url === '' ) {
$post = self::post( $a );
self::require_cap( 'edit_post', $post->ID );
// Not public yet: no theme to fetch, so the content alone, rendered as WordPress would.
if ( !in_array( $post->post_status, [ 'publish' ], true ) || post_password_required( $post ) ) {
setup_postdata( $post );
$html = apply_filters( 'the_content', $post->post_content );
wp_reset_postdata();
return [ 'ID' => $post->ID, 'status' => $post->post_status, 'note' => 'Not public, so this is the content only, without the theme.' ] + self::summarize( $html, false );
}
$url = get_permalink( $post );
}
// Only this site: an assistant must not make the server fetch other hosts.
if ( wp_parse_url( $url, PHP_URL_HOST ) !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
throw new Exception( 'Only URLs of this site can be checked.' );
}
$response = wp_remote_get( add_query_arg( 'mwai_nocache', wp_rand(), $url ), [
'timeout' => 20, 'redirection' => 3,
'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
'headers' => [ 'Cache-Control' => 'no-cache' ],
] );
if ( is_wp_error( $response ) ) {
throw new Exception( 'The page could not be loaded: ' . $response->get_error_message() );
}
$out = [ 'url' => $url, 'http_status' => wp_remote_retrieve_response_code( $response ) ];
if ( $post ) {
$out = [ 'ID' => $post->ID ] + $out;
}
return $out + self::summarize( wp_remote_retrieve_body( $response ), true );
}
private static function summarize( string $html, bool $page ): array {
$out = [];
$text = function ( $s ) { return trim( html_entity_decode( wp_strip_all_tags( (string) $s ), ENT_QUOTES, 'UTF-8' ) ); };
if ( $page ) {
if ( preg_match( '/]*>(.*?)<\/title>/is', $html, $m ) ) {
$out['title'] = $text( $m[1] );
}
$metas = [];
if ( preg_match_all( '/]*>/i', $html, $tags ) ) {
foreach ( $tags[0] as $tag ) {
if ( preg_match( '/(?:name|property)=["\']([^"\']+)["\']/i', $tag, $n ) && preg_match( '/content=["\']([^"\']*)["\']/i', $tag, $c ) ) {
$key = strtolower( $n[1] );
if ( in_array( $key, [ 'description', 'robots', 'og:title', 'og:description', 'og:image', 'twitter:card' ], true ) ) {
$metas[ $key ] = $text( $c[1] );
}
}
}
}
$out['meta'] = (object) $metas;
if ( preg_match( '/]+rel=["\']canonical["\'][^>]*href=["\']([^"\']+)["\']/i', $html, $m ) ) {
$out['canonical'] = $m[1];
}
// Header and footer apart, so a change there can be checked too.
$strip = function ( $part ) use ( $text ) {
$part = preg_replace( '/\s+/u', ' ', $text( preg_replace( '/<(script|style)\b.*?<\/\1>/is', '', $part ) ) );
return mb_strlen( $part ) > 300 ? mb_substr( $part, 0, 300 ) . '...' : $part;
};
if ( preg_match( '//is', $html, $m ) ) {
$out['header_text'] = $strip( $m[0] );
}
if ( preg_match_all( '/