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
← All changes | inc/MCP/Abilities.php +586 -407 4.3.84.4.8 View file →
@@ -1,407 +1,586 @@
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 -}
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 +}