PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.8
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.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 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.8, at inc/MCP/Abilities.php

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