PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.8
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / inc / MCP / Abilities.php

Abilities.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.8, at inc/MCP/Abilities.php

587 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\MCP;
4
5 use LearnPress\MCP\Auth\AuthContext;
6 use LearnPress\MCP\Domain\CourseTools;
7 use LearnPress\MCP\Domain\SectionTools;
8 use LearnPress\MCP\Domain\LessonTools;
9 use LearnPress\MCP\Domain\QuizTools;
10 use LearnPress\MCP\Domain\QuestionTools;
11 use LearnPress\MCP\Domain\EnrollmentTools;
12 use LearnPress\MCP\Schemas\CourseSchemas;
13 use LearnPress\MCP\Schemas\SectionSchemas;
14 use LearnPress\MCP\Schemas\LessonSchemas;
15 use LearnPress\MCP\Schemas\QuizSchemas;
16 use LearnPress\MCP\Schemas\QuestionSchemas;
17 use LearnPress\MCP\Schemas\EnrollmentSchemas;
18 use LearnPress\MCP\Support\Errors;
19 use LearnPress\MCP\Support\Pagination;
20 use LearnPress\MCP\Support\Schemas;
21 use WP_REST_Server;
22 use WP_REST_Request;
23 use WP_REST_Response;
24 use WP_Error;
25 defined( 'ABSPATH' ) || exit;
26
27 /**
28 * Registers LearnPress abilities for the WordPress Abilities API.
29 *
30 * This class is intentionally small and orchestration-focused:
31 * - bootstrap lifecycle hooks
32 * - register category
33 * - define ability manifests
34 *
35 * Execution logic, schemas, and mapping helpers are split into traits.
36 */
37 class Abilities {
38
39 /**
40 * Abilities API category slug for LearnPress abilities.
41 */
42 const CATEGORY = 'learnpress';
43
44 /**
45 * Core MCP adapter route provided by WordPress Abilities API.
46 */
47 const MCP_ADAPTER_ROUTE = '/mcp/mcp-adapter-default-server';
48
49 /**
50 * LearnPress MCP alias route for clients.
51 */
52 const MCP_ALIAS_NAMESPACE = 'lp/v1';
53 const MCP_ALIAS_ROUTE = '/mcp';
54 /**
55 * Guard flag to avoid registering hooks more than once.
56 *
57 * @var bool
58 */
59 protected static $initialized = false;
60
61 /**
62 * Initialize ability registration hooks when the WordPress Abilities API runtime is available.
63 *
64 * @return void
65 */
66 public static function init(): void {
67 if ( self::$initialized
68 || ! function_exists( 'wp_register_ability' )
69 || ! function_exists( 'wp_register_ability_category' ) ) {
70 return;
71 }
72
73 add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'register_category' ) );
74 add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
75 add_action( 'rest_api_init', array( __CLASS__, 'register_mcp_alias_route' ), 20 );
76 self::$initialized = true;
77 }
78
79 /**
80 * Register LearnPress MCP alias endpoint.
81 *
82 * Proxy requests to the default MCP adapter server so clients can use:
83 * /wp-json/lp/v1/mcp
84 *
85 * @return void
86 */
87 public static function register_mcp_alias_route(): void {
88
89 register_rest_route(
90 self::MCP_ALIAS_NAMESPACE,
91 self::MCP_ALIAS_ROUTE,
92 array(
93 'methods' => WP_REST_Server::ALLMETHODS,
94 'callback' => array( __CLASS__, 'proxy_mcp_adapter_request' ),
95 'permission_callback' => '__return_true',
96 )
97 );
98 }
99
100 /**
101 * Proxy LearnPress MCP alias request to the core MCP adapter route.
102 *
103 * @param WP_REST_Request $request
104 *
105 * @return WP_REST_Response|WP_Error
106 */
107 public static function proxy_mcp_adapter_request( WP_REST_Request $request ) {
108
109 $proxy_request = new WP_REST_Request( $request->get_method(), self::MCP_ADAPTER_ROUTE );
110 $proxy_request->set_headers( $request->get_headers() );
111 $proxy_request->set_query_params( $request->get_query_params() );
112 $proxy_request->set_body_params( $request->get_body_params() );
113 $proxy_request->set_file_params( $request->get_file_params() );
114 $proxy_request->set_body( $request->get_body() );
115
116 return rest_do_request( $proxy_request );
117 }
118 /**
119 * Register the LearnPress ability category.
120 *
121 * @return void
122 */
123 public static function register_category(): void {
124 wp_register_ability_category(
125 self::CATEGORY,
126 array(
127 'label' => __( 'LearnPress LMS', 'learnpress' ),
128 'description' => __( 'Read-only abilities for LearnPress LMS data.', 'learnpress' ),
129 )
130 );
131 }
132
133 /**
134 * Register all Phase 1 (read-only) LearnPress abilities.
135 *
136 * @return void
137 */
138 public static function register_abilities(): void {
139 self::reg(
140 'learnpress/get-courses',
141 __( 'Get Courses', 'learnpress' ),
142 __( 'List courses with optional filters and pagination.', 'learnpress' ),
143 CourseSchemas::get_courses_input(),
144 Pagination::list_output( CourseSchemas::course_summary() ),
145 array( CourseTools::class, 'get_courses' )
146 );
147
148 self::reg(
149 'learnpress/get-course-details',
150 __( 'Get Course Details', 'learnpress' ),
151 __( 'Get details and curriculum summary for a course.', 'learnpress' ),
152 Schemas::required_id( 'course_id' ),
153 Schemas::object_output( 'course' ),
154 array( CourseTools::class, 'get_course_details' )
155 );
156
157 self::reg(
158 'learnpress/list-lessons',
159 __( 'List Lessons', 'learnpress' ),
160 __( 'List lessons in a course with optional filters.', 'learnpress' ),
161 LessonSchemas::list_lessons_input(),
162 Pagination::list_output( LessonSchemas::lesson_summary() ),
163 array( LessonTools::class, 'list_lessons' )
164 );
165
166 self::reg(
167 'learnpress/get-lesson-details',
168 __( 'Get Lesson Details', 'learnpress' ),
169 __( 'Get lesson details including content, video intro, and materials.', 'learnpress' ),
170 Schemas::required_id( 'lesson_id' ),
171 Schemas::object_output( 'lesson' ),
172 array( LessonTools::class, 'get_lesson_details' )
173 );
174
175 self::reg(
176 'learnpress/list-quizzes',
177 __( 'List Quizzes', 'learnpress' ),
178 __( 'List quizzes in a course with pagination.', 'learnpress' ),
179 QuizSchemas::list_quizzes_input(),
180 Pagination::list_output( QuizSchemas::quiz_summary() ),
181 array( QuizTools::class, 'list_quizzes' )
182 );
183
184 self::reg(
185 'learnpress/get-quiz-details',
186 __( 'Get Quiz Details', 'learnpress' ),
187 __( 'Get quiz details including duration, passing grade, and question count.', 'learnpress' ),
188 Schemas::required_id( 'quiz_id' ),
189 Schemas::object_output( 'quiz' ),
190 array( QuizTools::class, 'get_quiz_details' )
191 );
192
193 self::reg(
194 'learnpress/get-student-progress',
195 __( 'Get Student Progress', 'learnpress' ),
196 __( 'Get user progress and results for a course enrollment.', 'learnpress' ),
197 EnrollmentSchemas::progress_input(),
198 Schemas::object_output( 'progress' ),
199 array( EnrollmentTools::class, 'get_student_progress' )
200 );
201
202 self::reg(
203 'learnpress/get-enrollments',
204 __( 'Get Enrollments', 'learnpress' ),
205 __( 'List course enrollments with optional filters and pagination.', 'learnpress' ),
206 EnrollmentSchemas::get_enrollments_input(),
207 Pagination::list_output( array( 'type' => 'object' ) ),
208 array( EnrollmentTools::class, 'get_enrollments' )
209 );
210
211 self::register_write_abilities();
212 }
213
214 /**
215 * Register all Phase 2 write abilities (course, section, lesson, quiz,
216 * quiz question, and enrollment management).
217 *
218 * Domain logic lives in focused `LearnPress\MCP\Domain` executors and
219 * `LearnPress\MCP\Schemas` providers, not in this orchestration class.
220 *
221 * @return void
222 */
223 protected static function register_write_abilities(): void {
224 // Course tools.
225 self::reg(
226 'learnpress/create-course',
227 __( 'Create Course', 'learnpress' ),
228 __( 'Create a new LearnPress course.', 'learnpress' ),
229 CourseSchemas::create_input(),
230 CourseSchemas::write_output(),
231 array( CourseTools::class, 'create_course' ),
232 self::write_annotations()
233 );
234 self::reg(
235 'learnpress/update-course',
236 __( 'Update Course', 'learnpress' ),
237 __( 'Update an existing LearnPress course.', 'learnpress' ),
238 CourseSchemas::update_input(),
239 CourseSchemas::write_output(),
240 array( CourseTools::class, 'update_course' ),
241 self::write_annotations()
242 );
243 self::reg(
244 'learnpress/delete-course',
245 __( 'Delete Course', 'learnpress' ),
246 __( 'Move a LearnPress course to trash (reversible).', 'learnpress' ),
247 CourseSchemas::delete_input(),
248 CourseSchemas::delete_output(),
249 array( CourseTools::class, 'delete_course' ),
250 self::destructive_annotations()
251 );
252
253 // Section tools.
254 self::reg(
255 'learnpress/create-section',
256 __( 'Create Section', 'learnpress' ),
257 __( 'Create a curriculum section in a course.', 'learnpress' ),
258 SectionSchemas::create_input(),
259 SectionSchemas::write_output(),
260 array( SectionTools::class, 'create_section' ),
261 self::write_annotations()
262 );
263 self::reg(
264 'learnpress/update-section',
265 __( 'Update Section', 'learnpress' ),
266 __( 'Update a curriculum section in a course.', 'learnpress' ),
267 SectionSchemas::update_input(),
268 SectionSchemas::write_output(),
269 array( SectionTools::class, 'update_section' ),
270 self::write_annotations()
271 );
272 self::reg(
273 'learnpress/delete-section',
274 __( 'Delete Section', 'learnpress' ),
275 __( 'Remove a section relationship while preserving its lessons/quizzes (reversible).', 'learnpress' ),
276 SectionSchemas::delete_input(),
277 SectionSchemas::delete_output(),
278 array( SectionTools::class, 'delete_section' ),
279 self::destructive_annotations()
280 );
281
282 // Lesson tools.
283 self::reg(
284 'learnpress/create-lesson',
285 __( 'Create Lesson', 'learnpress' ),
286 __( 'Create a lesson and assign it to a course section.', 'learnpress' ),
287 LessonSchemas::create_input(),
288 LessonSchemas::write_output(),
289 array( LessonTools::class, 'create_lesson' ),
290 self::write_annotations()
291 );
292 self::reg(
293 'learnpress/update-lesson',
294 __( 'Update Lesson', 'learnpress' ),
295 __( 'Update an existing lesson.', 'learnpress' ),
296 LessonSchemas::update_input(),
297 LessonSchemas::write_output(),
298 array( LessonTools::class, 'update_lesson' ),
299 self::write_annotations()
300 );
301 self::reg(
302 'learnpress/delete-lesson',
303 __( 'Delete Lesson', 'learnpress' ),
304 __( 'Move a lesson to trash and remove it from the curriculum (reversible).', 'learnpress' ),
305 LessonSchemas::delete_input(),
306 LessonSchemas::delete_output(),
307 array( LessonTools::class, 'delete_lesson' ),
308 self::destructive_annotations()
309 );
310
311 // Quiz tools.
312 self::reg(
313 'learnpress/create-quiz',
314 __( 'Create Quiz', 'learnpress' ),
315 __( 'Create a quiz and assign it to a course section.', 'learnpress' ),
316 QuizSchemas::create_input(),
317 QuizSchemas::write_output(),
318 array( QuizTools::class, 'create_quiz' ),
319 self::write_annotations()
320 );
321 self::reg(
322 'learnpress/update-quiz',
323 __( 'Update Quiz', 'learnpress' ),
324 __( 'Update an existing quiz and its settings.', 'learnpress' ),
325 QuizSchemas::update_input(),
326 QuizSchemas::write_output(),
327 array( QuizTools::class, 'update_quiz' ),
328 self::write_annotations()
329 );
330 self::reg(
331 'learnpress/delete-quiz',
332 __( 'Delete Quiz', 'learnpress' ),
333 __( 'Move a quiz to trash and remove it from the curriculum (reversible).', 'learnpress' ),
334 QuizSchemas::delete_input(),
335 QuizSchemas::delete_output(),
336 array( QuizTools::class, 'delete_quiz' ),
337 self::destructive_annotations()
338 );
339
340 // Quiz question tools.
341 self::reg(
342 'learnpress/add-quiz-question',
343 __( 'Add Quiz Question', 'learnpress' ),
344 __( 'Create a question and add it to a quiz.', 'learnpress' ),
345 QuestionSchemas::add_input(),
346 QuestionSchemas::add_output(),
347 array( QuestionTools::class, 'add_quiz_question' ),
348 self::write_annotations()
349 );
350 self::reg(
351 'learnpress/update-quiz-question',
352 __( 'Update Quiz Question', 'learnpress' ),
353 __( 'Update a quiz question and its answers.', 'learnpress' ),
354 QuestionSchemas::update_input(),
355 QuestionSchemas::write_output(),
356 array( QuestionTools::class, 'update_quiz_question' ),
357 self::write_annotations()
358 );
359 self::reg(
360 'learnpress/delete-quiz-question',
361 __( 'Delete Quiz Question', 'learnpress' ),
362 __( 'Remove a question from a quiz while preserving the question post (reversible).', 'learnpress' ),
363 QuestionSchemas::delete_input(),
364 QuestionSchemas::delete_output(),
365 array( QuestionTools::class, 'delete_quiz_question' ),
366 self::destructive_annotations()
367 );
368
369 // Enrollment tools.
370 self::reg(
371 'learnpress/enroll-student',
372 __( 'Enroll Student', 'learnpress' ),
373 __( 'Manually enroll a student in a course.', 'learnpress' ),
374 EnrollmentSchemas::enroll_input(),
375 EnrollmentSchemas::enroll_output(),
376 array( EnrollmentTools::class, 'enroll_student' ),
377 self::write_annotations()
378 );
379 self::reg(
380 'learnpress/update-enrollment',
381 __( 'Update Enrollment', 'learnpress' ),
382 __( 'Update enrollment status and learning result metadata.', 'learnpress' ),
383 EnrollmentSchemas::update_input(),
384 EnrollmentSchemas::write_output(),
385 array( EnrollmentTools::class, 'update_enrollment' ),
386 self::write_annotations()
387 );
388 }
389
390 /**
391 * Shared permission callback for LearnPress MCP abilities.
392 *
393 * @param string $ability_name Ability ID.
394 * @param mixed $input Ability input.
395 *
396 * @return bool|WP_Error
397 */
398 public static function permission_callback( string $ability_name, $input = null ) {
399
400 if ( ! AuthContext::is_api_key_auth() ) {
401 return Errors::missing_auth();
402 }
403
404 $current_user_id = get_current_user_id();
405 $base_capability = self::get_base_capability( $ability_name, $input );
406
407 if ( $current_user_id <= 0 ) {
408 return Errors::missing_auth();
409 }
410
411 if ( ! current_user_can( $base_capability ) ) {
412 return Errors::missing_capability( $base_capability );
413 }
414
415 $required_scope = self::get_required_scope( $ability_name, $input );
416 $granted_scope = AuthContext::get_permissions();
417
418 if ( ! self::scope_allows( $granted_scope, $required_scope ) ) {
419 return Errors::insufficient_scope( $required_scope, $granted_scope );
420 }
421
422 return true;
423 }
424 /**
425 * Register a single ability with common metadata annotations.
426 *
427 * @param string $name Ability name.
428 * @param string $label Human-readable label.
429 * @param string $description Description for clients.
430 * @param array $input_schema Input JSON schema.
431 * @param array $output_schema Output JSON schema.
432 * @param callable $execute_callback Callback that executes the ability.
433 * @param array $annotations Optional MCP annotation overrides
434 * (readonly, destructive, idempotent).
435 * Read tools keep the read-only defaults.
436 *
437 * @return void
438 */
439 protected static function reg(
440 string $name,
441 string $label,
442 string $description,
443 array $input_schema,
444 array $output_schema,
445 $execute_callback,
446 array $annotations = array()
447 ): void {
448 $permission_callback = static function ( $input = null ) use ( $name ) {
449 return self::permission_callback( $name, $input );
450 };
451
452 $annotations = array_merge(
453 array(
454 'readonly' => true,
455 'destructive' => false,
456 'idempotent' => true,
457 ),
458 $annotations
459 );
460
461 wp_register_ability(
462 $name,
463 array(
464 'label' => $label,
465 'description' => $description,
466 'category' => self::CATEGORY,
467 'execute_callback' => $execute_callback,
468 'permission_callback' => $permission_callback,
469 'input_schema' => $input_schema,
470 'output_schema' => $output_schema,
471 'meta' => array(
472 'annotations' => $annotations,
473 'mcp' => array(
474 'public' => true,
475 'type' => 'tool',
476 'required_scope' => self::get_required_scope( $name ),
477 ),
478 'show_in_rest' => true,
479 ),
480 )
481 );
482 }
483
484 /**
485 * Annotation set for create/update write tools.
486 *
487 * @return array
488 */
489 protected static function write_annotations(): array {
490 return array(
491 'readonly' => false,
492 'destructive' => false,
493 'idempotent' => false,
494 );
495 }
496
497 /**
498 * Annotation set for destructive (delete) write tools.
499 *
500 * @return array
501 */
502 protected static function destructive_annotations(): array {
503 return array(
504 'readonly' => false,
505 'destructive' => true,
506 'idempotent' => false,
507 );
508 }
509
510 /**
511 * Resolve base capability required for ability execution.
512 *
513 * @param string $ability_name Ability ID.
514 * @param mixed $input Ability input payload.
515 *
516 * @return string
517 */
518 protected static function get_base_capability( string $ability_name, $input = null ): string {
519
520 $capability = apply_filters( 'learn-press/mcp/api-keys/base-capability', 'manage_options', $ability_name, $input );
521
522 return is_string( $capability ) && '' !== $capability ? $capability : 'manage_options';
523 }
524
525 /**
526 * Resolve required key scope for an ability.
527 *
528 * @param string $ability_name Ability ID.
529 * @param mixed $input Ability input payload.
530 *
531 * @return string
532 */
533 protected static function get_required_scope( string $ability_name, $input = null ): string {
534
535 $default_scopes = array(
536 'learnpress/get-courses' => 'read',
537 'learnpress/get-course-details' => 'read',
538 'learnpress/list-lessons' => 'read',
539 'learnpress/get-lesson-details' => 'read',
540 'learnpress/list-quizzes' => 'read',
541 'learnpress/get-quiz-details' => 'read',
542 'learnpress/get-student-progress' => 'read',
543 'learnpress/get-enrollments' => 'read',
544 // Phase 2 write tools require write (or read_write) scope.
545 'learnpress/create-course' => 'write',
546 'learnpress/update-course' => 'write',
547 'learnpress/delete-course' => 'write',
548 'learnpress/create-section' => 'write',
549 'learnpress/update-section' => 'write',
550 'learnpress/delete-section' => 'write',
551 'learnpress/create-lesson' => 'write',
552 'learnpress/update-lesson' => 'write',
553 'learnpress/delete-lesson' => 'write',
554 'learnpress/create-quiz' => 'write',
555 'learnpress/update-quiz' => 'write',
556 'learnpress/delete-quiz' => 'write',
557 'learnpress/add-quiz-question' => 'write',
558 'learnpress/update-quiz-question' => 'write',
559 'learnpress/delete-quiz-question' => 'write',
560 'learnpress/enroll-student' => 'write',
561 'learnpress/update-enrollment' => 'write',
562 );
563
564 $scope = $default_scopes[ $ability_name ] ?? 'read';
565 $scope = apply_filters( 'learn-press/mcp/ability-required-scope', $scope, $ability_name, $input );
566
567 return in_array( $scope, array( 'read', 'write', 'read_write' ), true ) ? $scope : 'read';
568 }
569
570 /**
571 * Check if granted key scope satisfies required scope.
572 *
573 * @param string $granted_scope Scope attached to current API key.
574 * @param string $required_scope Scope required by the ability.
575 *
576 * @return bool
577 */
578 protected static function scope_allows( string $granted_scope, string $required_scope ): bool {
579
580 if ( 'read_write' === $granted_scope ) {
581 return true;
582 }
583
584 return $granted_scope === $required_scope;
585 }
586 }
587