PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.9
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.9
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 4.2.1 All 138 releases
learnpress / inc / MCP / Abilities.php

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

410 lines 12.1 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\Concerns\AbilityExecutors;
7 use LearnPress\MCP\Concerns\AbilityHelpers;
8 use LearnPress\MCP\Concerns\AbilitySchemas;
9 use WP_REST_Server;
10 use WP_REST_Request;
11 use WP_REST_Response;
12 use WP_Error;
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Registers LearnPress abilities for the WordPress Abilities API.
17 *
18 * This class is intentionally small and orchestration-focused:
19 * - bootstrap lifecycle hooks
20 * - register category
21 * - define ability manifests
22 *
23 * Execution logic, schemas, and mapping helpers are split into traits.
24 */
25 class Abilities {
26
27 use AbilitySchemas;
28 use AbilityHelpers;
29 use AbilityExecutors;
30
31 /**
32 * Abilities API category slug for LearnPress abilities.
33 */
34 const CATEGORY = 'learnpress';
35
36 /**
37 * Core MCP adapter route provided by WordPress Abilities API.
38 */
39 const MCP_ADAPTER_ROUTE = '/mcp/mcp-adapter-default-server';
40
41 /**
42 * LearnPress MCP alias route for clients.
43 */
44 const MCP_ALIAS_NAMESPACE = 'lp/v1';
45 const MCP_ALIAS_ROUTE = '/mcp';
46 /**
47 * Guard flag to avoid registering hooks more than once.
48 *
49 * @var bool
50 */
51 protected static $initialized = false;
52
53 /**
54 * Initialize ability registration hooks when the WordPress Abilities API runtime is available.
55 *
56 * @return void
57 */
58 public static function init(): void {
59 if ( self::$initialized
60 || ! function_exists( 'wp_register_ability' )
61 || ! function_exists( 'wp_register_ability_category' ) ) {
62 return;
63 }
64
65 add_action( 'wp_abilities_api_categories_init', array( __CLASS__, 'register_category' ) );
66 add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
67 add_action( 'rest_api_init', array( __CLASS__, 'register_mcp_alias_route' ), 20 );
68 self::$initialized = true;
69 }
70
71 /**
72 * Register LearnPress MCP alias endpoint.
73 *
74 * Proxy requests to the default MCP adapter server so clients can use:
75 * /wp-json/lp/v1/mcp
76 *
77 * @return void
78 */
79 public static function register_mcp_alias_route(): void {
80
81 register_rest_route(
82 self::MCP_ALIAS_NAMESPACE,
83 self::MCP_ALIAS_ROUTE,
84 array(
85 'methods' => WP_REST_Server::ALLMETHODS,
86 'callback' => array( __CLASS__, 'proxy_mcp_adapter_request' ),
87 'permission_callback' => '__return_true',
88 )
89 );
90 }
91
92 /**
93 * Proxy LearnPress MCP alias request to the core MCP adapter route.
94 *
95 * @param WP_REST_Request $request
96 *
97 * @return WP_REST_Response|WP_Error
98 */
99 public static function proxy_mcp_adapter_request( WP_REST_Request $request ) {
100
101 $proxy_request = new WP_REST_Request( $request->get_method(), self::MCP_ADAPTER_ROUTE );
102 $proxy_request->set_headers( $request->get_headers() );
103 $proxy_request->set_query_params( $request->get_query_params() );
104 $proxy_request->set_body_params( $request->get_body_params() );
105 $proxy_request->set_file_params( $request->get_file_params() );
106 $proxy_request->set_body( $request->get_body() );
107
108 return rest_do_request( $proxy_request );
109 }
110 /**
111 * Register the LearnPress ability category.
112 *
113 * @return void
114 */
115 public static function register_category(): void {
116 wp_register_ability_category(
117 self::CATEGORY,
118 array(
119 'label' => __( 'LearnPress LMS', 'learnpress' ),
120 'description' => __( 'Read-only abilities for LearnPress LMS data.', 'learnpress' ),
121 )
122 );
123 }
124
125 /**
126 * Register all Phase 1 (read-only) LearnPress abilities.
127 *
128 * @return void
129 */
130 public static function register_abilities(): void {
131 self::reg(
132 'learnpress/get-courses',
133 __( 'Get Courses', 'learnpress' ),
134 __( 'List courses with optional filters and pagination.', 'learnpress' ),
135 self::schema_get_courses_input(),
136 self::schema_list_output( self::schema_course_summary() ),
137 array( __CLASS__, 'execute_get_courses' )
138 );
139
140 self::reg(
141 'learnpress/get-course-details',
142 __( 'Get Course Details', 'learnpress' ),
143 __( 'Get details and curriculum summary for a course.', 'learnpress' ),
144 self::schema_required_id( 'course_id' ),
145 self::schema_course_detail_output(),
146 array( __CLASS__, 'execute_get_course_details' )
147 );
148
149 self::reg(
150 'learnpress/list-lessons',
151 __( 'List Lessons', 'learnpress' ),
152 __( 'List lessons in a course with optional filters.', 'learnpress' ),
153 self::schema_list_lessons_input(),
154 self::schema_list_output( self::schema_lesson_summary() ),
155 array( __CLASS__, 'execute_list_lessons' )
156 );
157
158 self::reg(
159 'learnpress/get-lesson-details',
160 __( 'Get Lesson Details', 'learnpress' ),
161 __( 'Get lesson details including content, video intro, and materials.', 'learnpress' ),
162 self::schema_required_id( 'lesson_id' ),
163 self::schema_lesson_detail_output(),
164 array( __CLASS__, 'execute_get_lesson_details' )
165 );
166
167 self::reg(
168 'learnpress/list-quizzes',
169 __( 'List Quizzes', 'learnpress' ),
170 __( 'List quizzes in a course with pagination.', 'learnpress' ),
171 self::schema_list_quizzes_input(),
172 self::schema_list_output( self::schema_quiz_summary() ),
173 array( __CLASS__, 'execute_list_quizzes' )
174 );
175
176 self::reg(
177 'learnpress/get-quiz-details',
178 __( 'Get Quiz Details', 'learnpress' ),
179 __( 'Get quiz details including duration, passing grade, and question count.', 'learnpress' ),
180 self::schema_required_id( 'quiz_id' ),
181 self::schema_quiz_detail_output(),
182 array( __CLASS__, 'execute_get_quiz_details' )
183 );
184
185 self::reg(
186 'learnpress/get-student-progress',
187 __( 'Get Student Progress', 'learnpress' ),
188 __( 'Get user progress and results for a course enrollment.', 'learnpress' ),
189 self::schema_progress_input(),
190 self::schema_object_output( 'progress' ),
191 array( __CLASS__, 'execute_get_student_progress' )
192 );
193
194 self::reg(
195 'learnpress/get-enrollments',
196 __( 'Get Enrollments', 'learnpress' ),
197 __( 'List course enrollments with optional filters and pagination.', 'learnpress' ),
198 self::schema_get_enrollments_input(),
199 self::schema_list_output( array( 'type' => 'object' ) ),
200 array( __CLASS__, 'execute_get_enrollments' )
201 );
202 }
203
204 /**
205 * Shared permission callback for LearnPress MCP abilities.
206 *
207 * @param string $ability_name Ability ID.
208 * @param mixed $input Ability input.
209 *
210 * @return bool|WP_Error
211 */
212 public static function permission_callback( string $ability_name, $input = null ) {
213
214 if ( ! AuthContext::is_api_key_auth() ) {
215 return self::error_missing_auth();
216 }
217
218 $current_user_id = get_current_user_id();
219 $base_capability = self::get_base_capability( $ability_name, $input );
220
221 if ( $current_user_id <= 0 ) {
222 return self::error_missing_auth();
223 }
224
225 if ( ! current_user_can( $base_capability ) ) {
226 return self::error_missing_base_capability( $base_capability );
227 }
228
229 $required_scope = self::get_required_scope( $ability_name, $input );
230 $granted_scope = AuthContext::get_permissions();
231
232 if ( ! self::scope_allows( $granted_scope, $required_scope ) ) {
233 return self::error_insufficient_scope( $required_scope, $granted_scope );
234 }
235
236 return true;
237 }
238 /**
239 * Register a single ability with common metadata annotations.
240 *
241 * @param string $name Ability name.
242 * @param string $label Human-readable label.
243 * @param string $description Description for clients.
244 * @param array $input_schema Input JSON schema.
245 * @param array $output_schema Output JSON schema.
246 * @param callable $execute_callback Callback that executes the ability.
247 *
248 * @return void
249 */
250 protected static function reg(
251 string $name,
252 string $label,
253 string $description,
254 array $input_schema,
255 array $output_schema,
256 $execute_callback
257 ): void {
258 $permission_callback = static function ( $input = null ) use ( $name ) {
259 return self::permission_callback( $name, $input );
260 };
261
262 wp_register_ability(
263 $name,
264 array(
265 'label' => $label,
266 'description' => $description,
267 'category' => self::CATEGORY,
268 'execute_callback' => $execute_callback,
269 'permission_callback' => $permission_callback,
270 'input_schema' => $input_schema,
271 'output_schema' => $output_schema,
272 'meta' => array(
273 'annotations' => array(
274 'readonly' => true,
275 'destructive' => false,
276 'idempotent' => true,
277 ),
278 'mcp' => array(
279 'public' => true,
280 'type' => 'tool',
281 'required_scope' => self::get_required_scope( $name ),
282 ),
283 'show_in_rest' => true,
284 ),
285 )
286 );
287 }
288
289 /**
290 * Resolve base capability required for ability execution.
291 *
292 * @param string $ability_name Ability ID.
293 * @param mixed $input Ability input payload.
294 *
295 * @return string
296 */
297 protected static function get_base_capability( string $ability_name, $input = null ): string {
298
299 $capability = apply_filters( 'learn-press/mcp/api-keys/base-capability', 'manage_options', $ability_name, $input );
300
301 return is_string( $capability ) && '' !== $capability ? $capability : 'manage_options';
302 }
303
304 /**
305 * Resolve required key scope for an ability.
306 *
307 * @param string $ability_name Ability ID.
308 * @param mixed $input Ability input payload.
309 *
310 * @return string
311 */
312 protected static function get_required_scope( string $ability_name, $input = null ): string {
313
314 $default_scopes = array(
315 'learnpress/get-courses' => 'read',
316 'learnpress/get-course-details' => 'read',
317 'learnpress/list-lessons' => 'read',
318 'learnpress/get-lesson-details' => 'read',
319 'learnpress/list-quizzes' => 'read',
320 'learnpress/get-quiz-details' => 'read',
321 'learnpress/get-student-progress' => 'read',
322 'learnpress/get-enrollments' => 'read',
323 );
324
325 $scope = $default_scopes[ $ability_name ] ?? 'read';
326 $scope = apply_filters( 'learn-press/mcp/ability-required-scope', $scope, $ability_name, $input );
327
328 return in_array( $scope, array( 'read', 'write', 'read_write' ), true ) ? $scope : 'read';
329 }
330
331 /**
332 * Check if granted key scope satisfies required scope.
333 *
334 * @param string $granted_scope Scope attached to current API key.
335 * @param string $required_scope Scope required by the ability.
336 *
337 * @return bool
338 */
339 protected static function scope_allows( string $granted_scope, string $required_scope ): bool {
340
341 if ( 'read_write' === $granted_scope ) {
342 return true;
343 }
344
345 return $granted_scope === $required_scope;
346 }
347
348 /**
349 * Error for missing/invalid authentication.
350 *
351 * @param string $message Optional custom error message.
352 *
353 * @return WP_Error
354 */
355 protected static function error_missing_auth( string $message = '' ): WP_Error {
356
357 if ( '' === $message ) {
358 $message = __( 'Missing or invalid MCP authentication.', 'learnpress' );
359 }
360
361 return new WP_Error(
362 'learnpress_mcp_missing_auth',
363 $message,
364 array( 'status' => 401 )
365 );
366 }
367
368 /**
369 * Error for base capability failure.
370 *
371 * @param string $capability Required capability name.
372 *
373 * @return WP_Error
374 */
375 protected static function error_missing_base_capability( string $capability ): WP_Error {
376
377 return new WP_Error(
378 'learnpress_mcp_missing_base_capability',
379 sprintf(
380 /* translators: %s: capability. */
381 __( 'Current user does not have required base capability: %s.', 'learnpress' ),
382 $capability
383 ),
384 array( 'status' => 403 )
385 );
386 }
387
388 /**
389 * Error for scope mismatch.
390 *
391 * @param string $required_scope Required scope for the ability.
392 * @param string $granted_scope Scope granted by authenticated API key.
393 *
394 * @return WP_Error
395 */
396 protected static function error_insufficient_scope( string $required_scope, string $granted_scope ): WP_Error {
397
398 return new WP_Error(
399 'learnpress_mcp_insufficient_scope',
400 sprintf(
401 /* translators: 1: required scope, 2: granted scope. */
402 __( 'API key scope is insufficient. Required: %1$s. Granted: %2$s.', 'learnpress' ),
403 $required_scope,
404 $granted_scope
405 ),
406 array( 'status' => 403 )
407 );
408 }
409 }
410