PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.5.23
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.5.23
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.5.23, at wp-mcp-server/tools/class-mcp-tool-posts.php

445 lines 13.6 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, or excerpt';
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 ],
214 'required' => ['post_id']
215 ];
216 }
217
218 public function execute($params) {
219 $this->validate_params($params);
220 $this->require_capability('edit_posts');
221
222 $post_id = $this->sanitize_integer($params['post_id']);
223
224 // Verify post exists
225 $post = $this->verify_post_exists($post_id);
226
227 // SECURITY: Check user has permission to edit this specific post
228 $this->check_post_permission($post_id);
229
230 // Build update args
231 $update_args = ['ID' => $post_id];
232
233 if (isset($params['title'])) {
234 $update_args['post_title'] = $this->sanitize_string($params['title']);
235 }
236
237 if (isset($params['content'])) {
238 $update_args['post_content'] = wp_kses_post($params['content']);
239 }
240
241 if (isset($params['excerpt'])) {
242 $update_args['post_excerpt'] = $this->sanitize_textarea($params['excerpt']);
243 }
244
245 // Only update if we have fields to update
246 if (count($update_args) === 1) {
247 throw new InvalidArgumentException('At least one field (title, content, or excerpt) must be provided');
248 }
249
250 // Update post
251 $updated_id = wp_update_post($update_args, true);
252
253 if (is_wp_error($updated_id)) {
254 throw new Exception("Failed to update post: " . $updated_id->get_error_message());
255 }
256
257 // Get updated post
258 $updated_post = get_post($post_id);
259
260 return $this->success([
261 'post_id' => $post_id,
262 'title' => $updated_post->post_title,
263 'type' => $updated_post->post_type,
264 'status' => $updated_post->post_status,
265 'updated_fields' => array_keys(array_diff_key($update_args, ['ID' => null]))
266 ], 'Post updated successfully');
267 }
268 }
269
270 /**
271 * Get Post Types Tool
272 */
273 class MCP_Tool_Get_Post_Types extends MCP_Tool_Base {
274
275 public function get_name() {
276 return 'wordpress_get_post_types';
277 }
278
279 public function get_description() {
280 return 'Get list of available WordPress post types';
281 }
282
283 public function get_input_schema() {
284 return [
285 'type' => 'object',
286 'properties' => (object)[],
287 'required' => []
288 ];
289 }
290
291 public function execute($params) {
292 $this->validate_params($params);
293 $this->require_capability('read');
294
295 // Get all post types
296 $post_types = get_post_types(['public' => true], 'objects');
297 $result = [];
298
299 foreach ($post_types as $post_type) {
300 $result[] = [
301 'name' => $post_type->name,
302 'label' => $post_type->label,
303 'singular_label' => $post_type->labels->singular_name,
304 'description' => $post_type->description,
305 'hierarchical' => $post_type->hierarchical,
306 'public' => $post_type->public
307 ];
308 }
309
310 return $this->success(['post_types' => $result]);
311 }
312 }
313
314 /**
315 * Get Post By URL Tool
316 */
317 class MCP_Tool_Get_Post_By_URL extends MCP_Tool_Base {
318
319 public function get_name() {
320 return 'wordpress_get_post_by_url';
321 }
322
323 public function get_description() {
324 return 'Resolve a WordPress URL to its post ID and basic metadata. Accepts full URLs, relative paths, or slugs.';
325 }
326
327 public function get_input_schema() {
328 return [
329 'type' => 'object',
330 'properties' => [
331 'url' => [
332 'type' => 'string',
333 'description' => 'Full URL (https://example.com/my-post/), relative path (/my-post/), or slug (my-post)'
334 ],
335 'post_type' => [
336 'type' => 'string',
337 'description' => 'Narrow search to a specific post type (post, page, any). Defaults to any.',
338 'default' => 'any'
339 ]
340 ],
341 'required' => ['url']
342 ];
343 }
344
345 public function execute( $params ) {
346 $this->validate_params( $params );
347 $this->require_capability( 'read' );
348
349 $raw = trim( $this->sanitize_string( $params['url'] ) );
350 $post_type = isset( $params['post_type'] ) ? $this->sanitize_string( $params['post_type'] ) : 'any';
351
352 if ( empty( $raw ) ) {
353 throw new InvalidArgumentException( 'URL cannot be empty' );
354 }
355
356 // ── Strategy 1: url_to_postid() — handles full URLs and relative paths ──
357 $post_id = $this->resolve_by_url( $raw );
358
359 // ── Strategy 2: slug lookup — if input looks like a bare slug ──
360 if ( ! $post_id ) {
361 $post_id = $this->resolve_by_slug( $raw, $post_type );
362 }
363
364 // ── Strategy 3: strip query string / fragment and retry ──
365 if ( ! $post_id ) {
366 $clean = strtok( $raw, '?' );
367 $clean = strtok( $clean, '#' );
368 if ( $clean !== $raw ) {
369 $post_id = $this->resolve_by_url( $clean );
370 }
371 }
372
373 if ( ! $post_id ) {
374 return $this->error( 'Post not found for the given URL. Tried url_to_postid() and slug lookup.' );
375 }
376
377 $post = get_post( $post_id );
378 if ( ! $post ) {
379 return $this->error( 'Post ID resolved but post no longer exists.' );
380 }
381
382 return $this->success( [
383 'post_id' => $post->ID,
384 'title' => $post->post_title,
385 'post_type' => $post->post_type,
386 'post_status'=> $post->post_status,
387 'slug' => $post->post_name,
388 'url' => get_permalink( $post->ID ),
389 'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
390 ], 'Post resolved successfully' );
391 }
392
393 /**
394 * Resolve via WordPress core url_to_postid().
395 * Handles full URLs and relative paths by ensuring a full URL is passed.
396 *
397 * @param string $url
398 * @return int|false
399 */
400 private function resolve_by_url( $url ) {
401 // Make relative paths absolute so url_to_postid() can parse them
402 if ( strpos( $url, 'http' ) !== 0 ) {
403 $url = home_url( ltrim( $url, '/' ) );
404 }
405
406 $id = url_to_postid( $url );
407 return $id ? (int) $id : false;
408 }
409
410 /**
411 * Resolve by treating the input as a slug.
412 * Extracts the last non-empty path segment from URLs, or uses the raw value directly.
413 *
414 * @param string $raw
415 * @param string $post_type
416 * @return int|false
417 */
418 private function resolve_by_slug( $raw, $post_type ) {
419 // Extract last path segment (handles /blog/my-post/ → my-post)
420 $path = parse_url( $raw, PHP_URL_PATH );
421 $segments = array_filter( explode( '/', $path ?? $raw ) );
422 $slug = sanitize_title( end( $segments ) );
423
424 if ( empty( $slug ) ) {
425 return false;
426 }
427
428 $args = [
429 'name' => $slug,
430 'post_type' => $post_type === 'any' ? [ 'post', 'page' ] : $post_type,
431 'post_status' => 'any',
432 'posts_per_page' => 1,
433 'no_found_rows' => true,
434 ];
435
436 $query = new WP_Query( $args );
437
438 if ( $query->have_posts() ) {
439 return (int) $query->posts[0]->ID;
440 }
441
442 return false;
443 }
444 }
445