PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.13
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.13
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / wp-mcp-server / tools / class-mcp-tool-posts.php

class-mcp-tool-posts.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.13, at wp-mcp-server/tools/class-mcp-tool-posts.php

487 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tool: Post Operations
4 *
5 * Provides tools for managing WordPress posts and pages.
6 *
7 * @package MetaSync
8 * @subpackage MCP_Server/Tools
9 */
10
11 if (!defined('ABSPATH')) {
12 exit;
13 }
14
15 /**
16 * Get Post Tool
17 */
18 class MCP_Tool_Get_Post extends MCP_Tool_Base {
19
20 public function get_name() {
21 return 'wordpress_get_post';
22 }
23
24 public function get_description() {
25 return 'Get a single WordPress post or page by ID with complete information';
26 }
27
28 public function get_input_schema() {
29 return [
30 'type' => 'object',
31 'properties' => [
32 'post_id' => [
33 'type' => 'integer',
34 'description' => 'WordPress post or page ID',
35 'minimum' => 1
36 ]
37 ],
38 'required' => ['post_id']
39 ];
40 }
41
42 public function execute($params) {
43 $this->validate_params($params);
44 $this->require_capability('read');
45
46 $post_id = $this->sanitize_integer($params['post_id']);
47
48 // Get post
49 $post = $this->verify_post_exists($post_id);
50
51 // SECURITY: Check user has permission to read this specific post
52 $this->check_post_permission($post_id);
53
54 // Build response
55 $result = [
56 'id' => $post->ID,
57 'title' => $post->post_title,
58 'content' => $post->post_content,
59 'excerpt' => $post->post_excerpt,
60 'type' => $post->post_type,
61 'status' => $post->post_status,
62 'author_id' => $post->post_author,
63 'date' => $post->post_date,
64 'modified' => $post->post_modified,
65 'url' => get_permalink($post_id),
66 'edit_url' => get_edit_post_link($post_id, 'raw')
67 ];
68
69 return $this->success($result);
70 }
71 }
72
73 /**
74 * List Posts Tool
75 */
76 class MCP_Tool_List_Posts extends MCP_Tool_Base {
77
78 public function get_name() {
79 return 'wordpress_list_posts';
80 }
81
82 public function get_description() {
83 return 'List WordPress posts and pages with filters (type, status, limit)';
84 }
85
86 public function get_input_schema() {
87 return [
88 'type' => 'object',
89 'properties' => [
90 'post_type' => [
91 'type' => 'string',
92 'description' => 'Post type to list',
93 'enum' => ['post', 'page', 'any'],
94 'default' => 'any'
95 ],
96 'post_status' => [
97 'type' => 'string',
98 'description' => 'Post status filter',
99 'enum' => ['publish', 'draft', 'pending', 'private', 'any'],
100 'default' => 'publish'
101 ],
102 'limit' => [
103 'type' => 'integer',
104 'description' => 'Maximum number of posts to return',
105 'default' => 10,
106 'minimum' => 1,
107 'maximum' => 100
108 ],
109 'offset' => [
110 'type' => 'integer',
111 'description' => 'Number of posts to skip',
112 'default' => 0,
113 'minimum' => 0
114 ]
115 ],
116 'required' => []
117 ];
118 }
119
120 public function execute($params) {
121 $this->validate_params($params);
122 $this->require_capability('read');
123
124 $post_status = isset($params['post_status']) ? $params['post_status'] : 'publish';
125
126 // SECURITY: Sensitive statuses require edit_posts capability
127 $sensitive_statuses = ['private', 'draft', 'pending', 'any'];
128 if (in_array($post_status, $sensitive_statuses, true)) {
129 $this->require_capability('edit_posts');
130 }
131
132 // Build query args
133 $args = [
134 'post_type' => isset($params['post_type']) ? $params['post_type'] : 'any',
135 'post_status' => $post_status,
136 'posts_per_page' => isset($params['limit']) ? $this->sanitize_integer($params['limit']) : 10,
137 'offset' => isset($params['offset']) ? $this->sanitize_integer($params['offset']) : 0,
138 'orderby' => 'date',
139 'order' => 'DESC'
140 ];
141
142 // Get posts
143 $query = new WP_Query($args);
144 $posts = [];
145
146 if ($query->have_posts()) {
147 while ($query->have_posts()) {
148 $query->the_post();
149 $post_id = get_the_ID();
150
151 $posts[] = [
152 'id' => $post_id,
153 'title' => get_the_title(),
154 'type' => get_post_type(),
155 'status' => get_post_status(),
156 'date' => get_the_date('c'),
157 'modified' => get_the_modified_date('c'),
158 'url' => get_permalink(),
159 'author_id' => get_post_field('post_author', $post_id),
160 'excerpt' => get_the_excerpt()
161 ];
162 }
163 wp_reset_postdata();
164 }
165
166 return $this->success([
167 'posts' => $posts,
168 'total_found' => $query->found_posts,
169 'query' => [
170 'post_type' => $args['post_type'],
171 'post_status' => $args['post_status'],
172 'limit' => $args['posts_per_page'],
173 'offset' => $args['offset']
174 ]
175 ]);
176 }
177 }
178
179 /**
180 * Update Post Tool
181 */
182 class MCP_Tool_Update_Post extends MCP_Tool_Base {
183
184 public function get_name() {
185 return 'wordpress_update_post';
186 }
187
188 public function get_description() {
189 return 'Update a WordPress post title, content, excerpt, or status';
190 }
191
192 public function get_input_schema() {
193 return [
194 'type' => 'object',
195 'properties' => [
196 'post_id' => [
197 'type' => 'integer',
198 'description' => 'WordPress post or page ID',
199 'minimum' => 1
200 ],
201 'title' => [
202 'type' => 'string',
203 'description' => 'New post title (optional)'
204 ],
205 'content' => [
206 'type' => 'string',
207 'description' => 'New post content (optional)'
208 ],
209 'excerpt' => [
210 'type' => 'string',
211 'description' => 'New post excerpt (optional)'
212 ],
213 'status' => [
214 'type' => 'string',
215 'description' => 'New post status (optional)',
216 'enum' => ['publish', 'draft', 'pending', 'private']
217 ]
218 ],
219 'required' => ['post_id']
220 ];
221 }
222
223 public function execute($params) {
224 $this->validate_params($params);
225 $this->require_capability('edit_posts');
226
227 $post_id = $this->sanitize_integer($params['post_id']);
228
229 // Verify post exists
230 $post = $this->verify_post_exists($post_id);
231
232 // SECURITY: Check user has permission to edit this specific post
233 $this->check_post_permission($post_id);
234
235 // Build update args
236 $update_args = ['ID' => $post_id];
237
238 if (isset($params['title'])) {
239 $update_args['post_title'] = $this->sanitize_string($params['title']);
240 }
241
242 if (isset($params['content'])) {
243 $update_args['post_content'] = wp_kses_post($params['content']);
244 }
245
246 if (isset($params['excerpt'])) {
247 $update_args['post_excerpt'] = $this->sanitize_textarea($params['excerpt']);
248 }
249
250 if (isset($params['status'])) {
251 $status = $this->sanitize_string($params['status']);
252 if (!in_array($status, ['publish', 'draft', 'pending', 'private'], true)) {
253 throw new InvalidArgumentException('Invalid status value');
254 }
255 $update_args['post_status'] = $status;
256 }
257
258 // Only update if we have fields to update
259 if (count($update_args) === 1) {
260 throw new InvalidArgumentException('At least one field (title, content, excerpt, or status) must be provided');
261 }
262
263 // Update post
264 $updated_id = wp_update_post($update_args, true);
265
266 if (is_wp_error($updated_id)) {
267 throw new Exception("Failed to update post: " . $updated_id->get_error_message());
268 }
269
270 // Get updated post
271 $updated_post = get_post($post_id);
272
273 return $this->success([
274 'post_id' => $post_id,
275 'title' => $updated_post->post_title,
276 'type' => $updated_post->post_type,
277 'status' => $updated_post->post_status,
278 'updated_fields' => array_keys(array_diff_key($update_args, ['ID' => null]))
279 ], 'Post updated successfully');
280 }
281 }
282
283 /**
284 * Get Post Types Tool
285 */
286 class MCP_Tool_Get_Post_Types extends MCP_Tool_Base {
287
288 public function get_name() {
289 return 'wordpress_get_post_types';
290 }
291
292 public function get_description() {
293 return 'Get list of available WordPress post types';
294 }
295
296 public function get_input_schema() {
297 return [
298 'type' => 'object',
299 'properties' => (object)[],
300 'required' => []
301 ];
302 }
303
304 public function execute($params) {
305 $this->validate_params($params);
306 $this->require_capability('read');
307
308 // Get all post types
309 $post_types = get_post_types(['public' => true], 'objects');
310 $result = [];
311
312 foreach ($post_types as $post_type) {
313 $result[] = [
314 'name' => $post_type->name,
315 'label' => $post_type->label,
316 'singular_label' => $post_type->labels->singular_name,
317 'description' => $post_type->description,
318 'hierarchical' => $post_type->hierarchical,
319 'public' => $post_type->public
320 ];
321 }
322
323 return $this->success(['post_types' => $result]);
324 }
325 }
326
327 /**
328 * Get Post By URL Tool
329 */
330 class MCP_Tool_Get_Post_By_URL extends MCP_Tool_Base {
331
332 public function get_name() {
333 return 'wordpress_get_post_by_url';
334 }
335
336 public function get_description() {
337 return 'Resolve a WordPress URL to its post ID and basic metadata. Accepts full URLs, relative paths, or slugs.';
338 }
339
340 public function get_input_schema() {
341 return [
342 'type' => 'object',
343 'properties' => [
344 'url' => [
345 'type' => 'string',
346 'description' => 'Full URL (https://example.com/my-post/), relative path (/my-post/), or slug (my-post)'
347 ],
348 'post_type' => [
349 'type' => 'string',
350 'description' => 'Narrow search to a specific post type (post, page, any). Defaults to any.',
351 'default' => 'any'
352 ],
353 'include_seo' => [
354 'type' => 'boolean',
355 'description' => 'Whether to include full SEO metadata in the response. Defaults to true.',
356 'default' => true
357 ]
358 ],
359 'required' => ['url']
360 ];
361 }
362
363 public function execute( $params ) {
364 $this->validate_params( $params );
365 $this->require_capability( 'read' );
366
367 $raw = trim( $this->sanitize_string( $params['url'] ) );
368 $post_type = isset( $params['post_type'] ) ? $this->sanitize_string( $params['post_type'] ) : 'any';
369
370 if ( empty( $raw ) ) {
371 throw new InvalidArgumentException( 'URL cannot be empty' );
372 }
373
374 // ── Strategy 1: url_to_postid() — handles full URLs and relative paths ──
375 $post_id = $this->resolve_by_url( $raw );
376
377 // ── Strategy 2: slug lookup — if input looks like a bare slug ──
378 if ( ! $post_id ) {
379 $post_id = $this->resolve_by_slug( $raw, $post_type );
380 }
381
382 // ── Strategy 3: strip query string / fragment and retry ──
383 if ( ! $post_id ) {
384 $clean = strtok( $raw, '?' );
385 $clean = strtok( $clean, '#' );
386 if ( $clean !== $raw ) {
387 $post_id = $this->resolve_by_url( $clean );
388 }
389 }
390
391 if ( ! $post_id ) {
392 return $this->error( 'Post not found for the given URL. Tried url_to_postid() and slug lookup.' );
393 }
394
395 $post = get_post( $post_id );
396 if ( ! $post ) {
397 return $this->error( 'Post ID resolved but post no longer exists.' );
398 }
399
400 $result = [
401 'post_id' => $post->ID,
402 'title' => $post->post_title,
403 'post_type' => $post->post_type,
404 'post_status' => $post->post_status,
405 'slug' => $post->post_name,
406 'url' => get_permalink( $post->ID ),
407 'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
408 ];
409
410 $include_seo = isset( $params['include_seo'] ) ? (bool) $params['include_seo'] : true;
411 if ( $include_seo ) {
412 $schema_data = get_post_meta( $post->ID, 'metasync_schema_markup', true );
413 $result['seo'] = [
414 'meta_title' => get_post_meta( $post->ID, '_metasync_metatitle', true ),
415 'meta_description' => get_post_meta( $post->ID, '_metasync_metadesc', true ),
416 'focus_keyword' => get_post_meta( $post->ID, '_metasync_focus_keyword', true ),
417 'robots' => get_post_meta( $post->ID, '_metasync_robots_index', true ),
418 'canonical_url' => get_post_meta( $post->ID, '_metasync_canonical_url', true ),
419 'og_enabled' => get_post_meta( $post->ID, '_metasync_og_enabled', true ),
420 'og_title' => get_post_meta( $post->ID, '_metasync_og_title', true ),
421 'og_description' => get_post_meta( $post->ID, '_metasync_og_description', true ),
422 'og_image' => get_post_meta( $post->ID, '_metasync_og_image', true ),
423 'og_type' => get_post_meta( $post->ID, '_metasync_og_type', true ),
424 'twitter_title' => get_post_meta( $post->ID, '_metasync_twitter_title', true ),
425 'twitter_description' => get_post_meta( $post->ID, '_metasync_twitter_description', true ),
426 'schema_types' => ! empty( $schema_data ) ? array_keys( $schema_data ) : [],
427 'word_count' => str_word_count( wp_strip_all_tags( $post->post_content ) ),
428 'last_modified' => $post->post_modified,
429 ];
430 }
431
432 return $this->success( $result, 'Post resolved successfully' );
433 }
434
435 /**
436 * Resolve via WordPress core url_to_postid().
437 * Handles full URLs and relative paths by ensuring a full URL is passed.
438 *
439 * @param string $url
440 * @return int|false
441 */
442 private function resolve_by_url( $url ) {
443 // Make relative paths absolute so url_to_postid() can parse them
444 if ( strpos( $url, 'http' ) !== 0 ) {
445 $url = home_url( ltrim( $url, '/' ) );
446 }
447
448 $id = url_to_postid( $url );
449 return $id ? (int) $id : false;
450 }
451
452 /**
453 * Resolve by treating the input as a slug.
454 * Extracts the last non-empty path segment from URLs, or uses the raw value directly.
455 *
456 * @param string $raw
457 * @param string $post_type
458 * @return int|false
459 */
460 private function resolve_by_slug( $raw, $post_type ) {
461 // Extract last path segment (handles /blog/my-post/ → my-post)
462 $path = parse_url( $raw, PHP_URL_PATH );
463 $segments = array_filter( explode( '/', $path ?? $raw ) );
464 $slug = sanitize_title( end( $segments ) );
465
466 if ( empty( $slug ) ) {
467 return false;
468 }
469
470 $args = [
471 'name' => $slug,
472 'post_type' => $post_type === 'any' ? [ 'post', 'page' ] : $post_type,
473 'post_status' => 'any',
474 'posts_per_page' => 1,
475 'no_found_rows' => true,
476 ];
477
478 $query = new WP_Query( $args );
479
480 if ( $query->have_posts() ) {
481 return (int) $query->posts[0]->ID;
482 }
483
484 return false;
485 }
486 }
487