| @@ -2,11 +2,23 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace LearnPress\MCP; |
| 4 | 4 | |
| 5 | 5 | use LearnPress\MCP\Auth\AuthContext; |
| 6 | -use LearnPress\MCP\Concerns\AbilityExecutors; | |
| 7 | -use LearnPress\MCP\Concerns\AbilityHelpers; | |
| 8 | -use LearnPress\MCP\Concerns\AbilitySchemas; | |
| 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; | |
| 9 | 21 | use WP_REST_Server; |
| 10 | 22 | use WP_REST_Request; |
| 11 | 23 | use WP_REST_Response; |
| 12 | 24 | use WP_Error; |
| @@ -23,12 +35,8 @@ | ||
| 23 | 35 | * Execution logic, schemas, and mapping helpers are split into traits. |
| 24 | 36 | */ |
| 25 | 37 | class Abilities { |
| 26 | 38 | |
| 27 | - use AbilitySchemas; | |
| 28 | - use AbilityHelpers; | |
| 29 | - use AbilityExecutors; | |
| 30 | - | |
| 31 | 39 | /** |
| 32 | 40 | * Abilities API category slug for LearnPress abilities. |
| 33 | 41 | */ |
| 34 | 42 | const CATEGORY = 'learnpress'; |
| @@ -131,11 +139,11 @@ | ||
| 131 | 139 | self::reg( |
| 132 | 140 | 'learnpress/get-courses', |
| 133 | 141 | __( 'Get Courses', 'learnpress' ), |
| 134 | 142 | __( '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' ) | |
| 143 | + CourseSchemas::get_courses_input(), | |
| 144 | + Pagination::list_output( CourseSchemas::course_summary() ), | |
| 145 | + array( CourseTools::class, 'get_courses' ) | |
| 138 | 146 | ); |
| 139 | 147 | |
| 140 | 148 | self::reg( |
| 141 | 149 | 'learnpress/get-course-details', |
| @@ -140,11 +148,11 @@ | ||
| 140 | 148 | self::reg( |
| 141 | 149 | 'learnpress/get-course-details', |
| 142 | 150 | __( 'Get Course Details', 'learnpress' ), |
| 143 | 151 | __( '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' ) | |
| 152 | + Schemas::required_id( 'course_id' ), | |
| 153 | + Schemas::object_output( 'course' ), | |
| 154 | + array( CourseTools::class, 'get_course_details' ) | |
| 147 | 155 | ); |
| 148 | 156 | |
| 149 | 157 | self::reg( |
| 150 | 158 | 'learnpress/list-lessons', |
| @@ -149,11 +157,11 @@ | ||
| 149 | 157 | self::reg( |
| 150 | 158 | 'learnpress/list-lessons', |
| 151 | 159 | __( 'List Lessons', 'learnpress' ), |
| 152 | 160 | __( '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' ) | |
| 161 | + LessonSchemas::list_lessons_input(), | |
| 162 | + Pagination::list_output( LessonSchemas::lesson_summary() ), | |
| 163 | + array( LessonTools::class, 'list_lessons' ) | |
| 156 | 164 | ); |
| 157 | 165 | |
| 158 | 166 | self::reg( |
| 159 | 167 | 'learnpress/get-lesson-details', |
| @@ -158,11 +166,11 @@ | ||
| 158 | 166 | self::reg( |
| 159 | 167 | 'learnpress/get-lesson-details', |
| 160 | 168 | __( 'Get Lesson Details', 'learnpress' ), |
| 161 | 169 | __( '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' ) | |
| 170 | + Schemas::required_id( 'lesson_id' ), | |
| 171 | + Schemas::object_output( 'lesson' ), | |
| 172 | + array( LessonTools::class, 'get_lesson_details' ) | |
| 165 | 173 | ); |
| 166 | 174 | |
| 167 | 175 | self::reg( |
| 168 | 176 | 'learnpress/list-quizzes', |
| @@ -167,11 +175,11 @@ | ||
| 167 | 175 | self::reg( |
| 168 | 176 | 'learnpress/list-quizzes', |
| 169 | 177 | __( 'List Quizzes', 'learnpress' ), |
| 170 | 178 | __( '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' ) | |
| 179 | + QuizSchemas::list_quizzes_input(), | |
| 180 | + Pagination::list_output( QuizSchemas::quiz_summary() ), | |
| 181 | + array( QuizTools::class, 'list_quizzes' ) | |
| 174 | 182 | ); |
| 175 | 183 | |
| 176 | 184 | self::reg( |
| 177 | 185 | 'learnpress/get-quiz-details', |
| @@ -176,11 +184,11 @@ | ||
| 176 | 184 | self::reg( |
| 177 | 185 | 'learnpress/get-quiz-details', |
| 178 | 186 | __( 'Get Quiz Details', 'learnpress' ), |
| 179 | 187 | __( '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' ) | |
| 188 | + Schemas::required_id( 'quiz_id' ), | |
| 189 | + Schemas::object_output( 'quiz' ), | |
| 190 | + array( QuizTools::class, 'get_quiz_details' ) | |
| 183 | 191 | ); |
| 184 | 192 | |
| 185 | 193 | self::reg( |
| 186 | 194 | 'learnpress/get-student-progress', |
| @@ -185,11 +193,11 @@ | ||
| 185 | 193 | self::reg( |
| 186 | 194 | 'learnpress/get-student-progress', |
| 187 | 195 | __( 'Get Student Progress', 'learnpress' ), |
| 188 | 196 | __( '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' ) | |
| 197 | + EnrollmentSchemas::progress_input(), | |
| 198 | + Schemas::object_output( 'progress' ), | |
| 199 | + array( EnrollmentTools::class, 'get_student_progress' ) | |
| 192 | 200 | ); |
| 193 | 201 | |
| 194 | 202 | self::reg( |
| 195 | 203 | 'learnpress/get-enrollments', |
| @@ -194,15 +202,193 @@ | ||
| 194 | 202 | self::reg( |
| 195 | 203 | 'learnpress/get-enrollments', |
| 196 | 204 | __( 'Get Enrollments', 'learnpress' ), |
| 197 | 205 | __( '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' ) | |
| 206 | + EnrollmentSchemas::get_enrollments_input(), | |
| 207 | + Pagination::list_output( array( 'type' => 'object' ) ), | |
| 208 | + array( EnrollmentTools::class, 'get_enrollments' ) | |
| 201 | 209 | ); |
| 210 | + | |
| 211 | + self::register_write_abilities(); | |
| 202 | 212 | } |
| 203 | 213 | |
| 204 | 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 | + /** | |
| 205 | 391 | * Shared permission callback for LearnPress MCP abilities. |
| 206 | 392 | * |
| 207 | 393 | * @param string $ability_name Ability ID. |
| 208 | 394 | * @param mixed $input Ability input. |
| @@ -211,9 +397,9 @@ | ||
| 211 | 397 | */ |
| 212 | 398 | public static function permission_callback( string $ability_name, $input = null ) { |
| 213 | 399 | |
| 214 | 400 | if ( ! AuthContext::is_api_key_auth() ) { |
| 215 | - return self::error_missing_auth(); | |
| 401 | + return Errors::missing_auth(); | |
| 216 | 402 | } |
| 217 | 403 | |
| 218 | 404 | $current_user_id = get_current_user_id(); |
| 219 | 405 | $base_capability = self::get_base_capability( $ability_name, $input ); |
| @@ -218,13 +404,13 @@ | ||
| 218 | 404 | $current_user_id = get_current_user_id(); |
| 219 | 405 | $base_capability = self::get_base_capability( $ability_name, $input ); |
| 220 | 406 | |
| 221 | 407 | if ( $current_user_id <= 0 ) { |
| 222 | - return self::error_missing_auth(); | |
| 408 | + return Errors::missing_auth(); | |
| 223 | 409 | } |
| 224 | 410 | |
| 225 | 411 | if ( ! current_user_can( $base_capability ) ) { |
| 226 | - return self::error_missing_base_capability( $base_capability ); | |
| 412 | + return Errors::missing_capability( $base_capability ); | |
| 227 | 413 | } |
| 228 | 414 | |
| 229 | 415 | $required_scope = self::get_required_scope( $ability_name, $input ); |
| 230 | 416 | $granted_scope = AuthContext::get_permissions(); |
| @@ -229,9 +415,9 @@ | ||
| 229 | 415 | $required_scope = self::get_required_scope( $ability_name, $input ); |
| 230 | 416 | $granted_scope = AuthContext::get_permissions(); |
| 231 | 417 | |
| 232 | 418 | if ( ! self::scope_allows( $granted_scope, $required_scope ) ) { |
| 233 | - return self::error_insufficient_scope( $required_scope, $granted_scope ); | |
| 419 | + return Errors::insufficient_scope( $required_scope, $granted_scope ); | |
| 234 | 420 | } |
| 235 | 421 | |
| 236 | 422 | return true; |
| 237 | 423 | } |
| @@ -243,8 +429,11 @@ | ||
| 243 | 429 | * @param string $description Description for clients. |
| 244 | 430 | * @param array $input_schema Input JSON schema. |
| 245 | 431 | * @param array $output_schema Output JSON schema. |
| 246 | 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. | |
| 247 | 436 | * |
| 248 | 437 | * @return void |
| 249 | 438 | */ |
| 250 | 439 | protected static function reg( |
| @@ -252,14 +441,24 @@ | ||
| 252 | 441 | string $label, |
| 253 | 442 | string $description, |
| 254 | 443 | array $input_schema, |
| 255 | 444 | array $output_schema, |
| 256 | - $execute_callback | |
| 445 | + $execute_callback, | |
| 446 | + array $annotations = array() | |
| 257 | 447 | ): void { |
| 258 | 448 | $permission_callback = static function ( $input = null ) use ( $name ) { |
| 259 | 449 | return self::permission_callback( $name, $input ); |
| 260 | 450 | }; |
| 261 | 451 | |
| 452 | + $annotations = array_merge( | |
| 453 | + array( | |
| 454 | + 'readonly' => true, | |
| 455 | + 'destructive' => false, | |
| 456 | + 'idempotent' => true, | |
| 457 | + ), | |
| 458 | + $annotations | |
| 459 | + ); | |
| 460 | + | |
| 262 | 461 | wp_register_ability( |
| 263 | 462 | $name, |
| 264 | 463 | array( |
| 265 | 464 | 'label' => $label, |
| @@ -269,13 +468,9 @@ | ||
| 269 | 468 | 'permission_callback' => $permission_callback, |
| 270 | 469 | 'input_schema' => $input_schema, |
| 271 | 470 | 'output_schema' => $output_schema, |
| 272 | 471 | 'meta' => array( |
| 273 | - 'annotations' => array( | |
| 274 | - 'readonly' => true, | |
| 275 | - 'destructive' => false, | |
| 276 | - 'idempotent' => true, | |
| 277 | - ), | |
| 472 | + 'annotations' => $annotations, | |
| 278 | 473 | 'mcp' => array( |
| 279 | 474 | 'public' => true, |
| 280 | 475 | 'type' => 'tool', |
| 281 | 476 | 'required_scope' => self::get_required_scope( $name ), |
| @@ -286,8 +481,34 @@ | ||
| 286 | 481 | ); |
| 287 | 482 | } |
| 288 | 483 | |
| 289 | 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 | + /** | |
| 290 | 511 | * Resolve base capability required for ability execution. |
| 291 | 512 | * |
| 292 | 513 | * @param string $ability_name Ability ID. |
| 293 | 514 | * @param mixed $input Ability input payload. |
| @@ -319,8 +540,26 @@ | ||
| 319 | 540 | 'learnpress/list-quizzes' => 'read', |
| 320 | 541 | 'learnpress/get-quiz-details' => 'read', |
| 321 | 542 | 'learnpress/get-student-progress' => 'read', |
| 322 | 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', | |
| 323 | 562 | ); |
| 324 | 563 | |
| 325 | 564 | $scope = $default_scopes[ $ability_name ] ?? 'read'; |
| 326 | 565 | $scope = apply_filters( 'learn-press/mcp/ability-required-scope', $scope, $ability_name, $input ); |
| @@ -342,68 +581,6 @@ | ||
| 342 | 581 | return true; |
| 343 | 582 | } |
| 344 | 583 | |
| 345 | 584 | 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 | 585 | } |
| 409 | 586 | } |