From 548ee32dea7226a15e319eefe7e2ab424588f1a2 Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 24 Aug 2026 05:32:59 -0600 Subject: [PATCH 01/11] Add optional Laravel MCP write tools for video courses. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give AI clients a package-local stdio server to create and edit Course → Lesson → Step data from hosted YouTube/Vimeo URLs, with new courses private by default. Co-authored-by: Cursor --- CHANGELOG.md | 6 + README.md | 79 +++++++ composer.json | 2 + config/filament-lms.php | 15 ++ src/FilamentLmsServiceProvider.php | 15 ++ src/Mcp/LmsServer.php | 54 +++++ src/Mcp/LmsTool.php | 352 ++++++++++++++++++++++++++++ src/Mcp/Tools/CreateLesson.php | 57 +++++ src/Mcp/Tools/CreateVideoCourse.php | 82 +++++++ src/Mcp/Tools/CreateVideoStep.php | 54 +++++ src/Mcp/Tools/DeleteCourse.php | 58 +++++ src/Mcp/Tools/DeleteLesson.php | 53 +++++ src/Mcp/Tools/DeleteStep.php | 50 ++++ src/Mcp/Tools/GetCourse.php | 41 ++++ src/Mcp/Tools/ListCourses.php | 54 +++++ src/Mcp/Tools/UpdateCourse.php | 53 +++++ src/Mcp/Tools/UpdateLesson.php | 50 ++++ src/Mcp/Tools/UpdateStep.php | 83 +++++++ src/Models/Video.php | 7 + tests/Feature/LmsMcpTest.php | 239 +++++++++++++++++++ tests/TestCase.php | 4 + 21 files changed, 1408 insertions(+) create mode 100644 src/Mcp/LmsServer.php create mode 100644 src/Mcp/LmsTool.php create mode 100644 src/Mcp/Tools/CreateLesson.php create mode 100644 src/Mcp/Tools/CreateVideoCourse.php create mode 100644 src/Mcp/Tools/CreateVideoStep.php create mode 100644 src/Mcp/Tools/DeleteCourse.php create mode 100644 src/Mcp/Tools/DeleteLesson.php create mode 100644 src/Mcp/Tools/DeleteStep.php create mode 100644 src/Mcp/Tools/GetCourse.php create mode 100644 src/Mcp/Tools/ListCourses.php create mode 100644 src/Mcp/Tools/UpdateCourse.php create mode 100644 src/Mcp/Tools/UpdateLesson.php create mode 100644 src/Mcp/Tools/UpdateStep.php create mode 100644 tests/Feature/LmsMcpTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index def48080..a2959dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +* Optional Laravel MCP server for writing Course → Lesson → video Step data (`create_video_course` plus granular tools). New courses default to private. Hosts that want MCP should `composer require laravel/mcp`. + ## v4.7.6 - 2026-08-07 ### What's Changed diff --git a/README.md b/README.md index 8d1f5ba7..8cb5da7e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,85 @@ class AdminPanelProvider extends PanelProvider } ``` +## MCP Server + +The package can expose **write tools** for AI clients (Cursor, Claude Code, Claude Desktop) so a skill can create Course → Lesson → Step data. Melissa’s skill should draft titles, descriptions, and structure, then call these tools. Videos are hosted YouTube or Vimeo URLs — the package does not upload video files. Optional transcripts go on the step `text` field. New courses default to `is_private = true` (there is no draft flag). + +`laravel/mcp` is optional. Hosts that want MCP should install it: + +```bash +composer require laravel/mcp +``` + +The package registers a **local stdio** server when `laravel/mcp` is present and `filament-lms.mcp.enabled` is `true` (the default): + +```php +Mcp::local('filament-lms', \Tapp\FilamentLms\Mcp\LmsServer::class); +``` + +Start it from the host app: + +```bash +php artisan mcp:start filament-lms +``` + +Example Cursor MCP config: + +```json +{ + "mcpServers": { + "filament-lms": { + "command": "php", + "args": ["artisan", "mcp:start", "filament-lms"], + "cwd": "/path/to/your-app" + } + } +} +``` + +### Web (remote Claude) + Sanctum + +Do **not** auto-register `Mcp::web` from the package. Publish `routes/ai.php` in the host app and register the HTTP server there. Sanctum bearer tokens are the v1 path (no Passport): + +```php +use Laravel\Mcp\Facades\Mcp; + +Mcp::web('/mcp/lms', \Tapp\FilamentLms\Mcp\LmsServer::class) + ->middleware(['auth:sanctum', 'throttle:mcp']); +``` + +Issue a Sanctum token for an LMS admin user and send it as `Authorization: Bearer …`. Cursor and Claude Desktop work with that header. Claude.ai custom connectors often prefer OAuth — if a token URL is rejected, that is a follow-up (Passport on the host, or Switchboard). + +Turn off local auto-registration with: + +```php +// config/filament-lms.php +'mcp' => [ + 'enabled' => false, +], +``` + +Web requests that have `$request->user()` must be able to access the LMS Filament panel. Local stdio has no HTTP user — same trust model as tinker. + +### v1 tools + +Convenience: + +- `create_video_course` — `name`, `description`, optional `slug` / `external_id` / `award` / flags, and nested `lessons[]` each with `steps[]` (`name`, `video_url`, optional `text` / `is_optional`) + +Granular: + +- `list_courses` / `get_course` +- `update_course` / `delete_course` +- `create_lesson` / `update_lesson` / `delete_lesson` +- `create_video_step` / `update_step` / `delete_step` + +Course uniqueness matches the Filament form (`name`, `slug`, `external_id` unique; `external_id` regex `^[a-z][a-z0-9_]*$`). Award defaults to `default`. Completion mode defaults to `native`. Tools return created IDs and admin/learner URLs when those routes can be resolved. + +### Deferred + +Credits, evaluations, tests/forms, documents, SCORM, course images, user assignment, Switchboard, and a package REST API are out of scope for v1. + ### Tailwind CSS Setup This package uses Tailwind CSS classes in its Blade views. The configuration differs between Tailwind v3 and v4: diff --git a/composer.json b/composer.json index 0c4ef4d8..2209dd1a 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,13 @@ "tapp/filament-form-builder": "^4.3.5" }, "suggest": { + "laravel/mcp": "Optional. Enables the Filament LMS MCP server so AI clients can create and edit courses (hosts should composer require laravel/mcp).", "tapp/filament-library": "Required for Library file / Library link step materials (set filament-lms.integrations.filament_library.enabled for the admin material picker).", "spykapps/filament-uppy-upload": "Optional. Enables Uppy chunked (multipart) uploads for SCORM package import so large ZIPs stay under Cloudflare's ~100MB limit (set filament-lms.common_cartridge_import.multipart_upload.enabled)." }, "require-dev": { "filament/upgrade": "^4.0", + "laravel/mcp": "^0.5|^1.0", "larastan/larastan": "^3.0||^2.9", "laravel/pao": "^1.0", "laravel/pint": "^1.14", diff --git a/config/filament-lms.php b/config/filament-lms.php index 28c7371f..e249be76 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -246,4 +246,19 @@ 'evaluations' => [ 'enabled' => false, ], + + /* + |-------------------------------------------------------------------------- + | MCP server + |-------------------------------------------------------------------------- + | + | When laravel/mcp is installed, the package registers a local stdio server + | named `filament-lms`. Set enabled to false to skip that auto-registration. + | Web (HTTP) registration stays in the host app — do not rely on the package + | to call Mcp::web(). + | + */ + 'mcp' => [ + 'enabled' => true, + ], ]; diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index 8ba741d8..10d9b6e1 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -129,6 +129,21 @@ public function packageBooted() $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); $this->configureLivewireTemporaryUploadLimits(); + + $this->registerMcpServer(); + } + + protected function registerMcpServer(): void + { + if (! class_exists(\Laravel\Mcp\Facades\Mcp::class)) { + return; + } + + if (! config('filament-lms.mcp.enabled', true)) { + return; + } + + \Laravel\Mcp\Facades\Mcp::local('filament-lms', \Tapp\FilamentLms\Mcp\LmsServer::class); } protected function configureLivewireTemporaryUploadLimits(): void diff --git a/src/Mcp/LmsServer.php b/src/Mcp/LmsServer.php new file mode 100644 index 00000000..f9dfa7c0 --- /dev/null +++ b/src/Mcp/LmsServer.php @@ -0,0 +1,54 @@ +> + */ + protected array $tools = [ + CreateVideoCourse::class, + ListCourses::class, + GetCourse::class, + UpdateCourse::class, + DeleteCourse::class, + CreateLesson::class, + UpdateLesson::class, + DeleteLesson::class, + CreateVideoStep::class, + UpdateStep::class, + DeleteStep::class, + ]; +} diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php new file mode 100644 index 00000000..ff42d0ac --- /dev/null +++ b/src/Mcp/LmsTool.php @@ -0,0 +1,352 @@ +user(); + + if ($user === null) { + return null; + } + + if ($user instanceof FilamentUser) { + try { + $panel = Filament::getPanel('lms'); + + if ($user->canAccessPanel($panel)) { + return null; + } + } catch (Throwable) { + // Panel may be unregistered in some hosts; fall through to isLmsAdmin(). + } + } + + if (method_exists($user, 'isLmsAdmin') && $user->isLmsAdmin()) { + return null; + } + + return Response::error('You must be able to access the LMS Filament panel to use this tool.'); + } + + /** + * @return array + */ + protected function courseRules(?int $ignoreId = null, bool $creating = false): array + { + $awardKeys = array_keys(config('filament-lms.awards', ['default' => 'Default'])); + $unique = fn (string $column): \Illuminate\Validation\Rules\Unique => $ignoreId === null + ? Rule::unique('lms_courses', $column) + : Rule::unique('lms_courses', $column)->ignore($ignoreId); + + return [ + 'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255', $unique('name')], + 'description' => ['sometimes', 'nullable', 'string'], + 'slug' => [$creating ? 'required' : 'sometimes', 'string', 'max:255', $unique('slug')], + 'external_id' => [ + $creating ? 'required' : 'sometimes', + 'string', + 'max:100', + 'regex:/^[a-z][a-z0-9_]*$/', + $unique('external_id'), + ], + 'is_private' => ['sometimes', 'boolean'], + 'award' => ['sometimes', 'nullable', 'string', Rule::in($awardKeys)], + 'required_test_percentage' => ['sometimes', 'nullable', 'integer', 'min:0', 'max:100'], + 'embedded_player' => ['sometimes', 'boolean'], + 'completion_mode' => ['sometimes', 'nullable', 'string', Rule::enum(CompletionMode::class)], + ]; + } + + /** + * @return array + */ + protected function courseMessages(): array + { + return [ + 'external_id.regex' => 'External ID must contain only lowercase letters, numbers, and underscores, and must start with a letter.', + 'external_id.max' => 'External ID cannot exceed 100 characters.', + 'name.unique' => 'A course with this name already exists.', + 'slug.unique' => 'A course with this slug already exists.', + 'external_id.unique' => 'A course with this external ID already exists.', + ]; + } + + protected function applyGeneratedCourseFields(Request $request): void + { + $name = trim((string) $request->get('name', '')); + + if ($name === '') { + return; + } + + if (blank($request->get('slug'))) { + $request->merge(['slug' => Str::slug($name)]); + } + + if (blank($request->get('external_id'))) { + $request->merge(['external_id' => Str::slug($name, '_')]); + } + } + + /** + * @param array $input + * @return array + */ + protected function courseAttributesFromInput(array $input, bool $creating = false): array + { + $name = isset($input['name']) ? trim((string) $input['name']) : null; + $attributes = []; + + if ($name !== null) { + $attributes['name'] = $name; + } + + if (array_key_exists('description', $input)) { + $attributes['description'] = $input['description']; + } + + if ($creating) { + $attributes['slug'] = filled($input['slug'] ?? null) ? (string) $input['slug'] : Str::slug((string) $name); + $attributes['external_id'] = filled($input['external_id'] ?? null) + ? (string) $input['external_id'] + : Str::slug((string) $name, '_'); + $attributes['award'] = $input['award'] ?? 'default'; + $attributes['completion_mode'] = $input['completion_mode'] ?? CompletionMode::Native->value; + $attributes['is_private'] = array_key_exists('is_private', $input) + ? (bool) $input['is_private'] + : true; + $attributes['embedded_player'] = (bool) ($input['embedded_player'] ?? false); + $attributes['required_test_percentage'] = $input['required_test_percentage'] ?? 0; + } else { + foreach (['slug', 'external_id', 'award', 'completion_mode', 'required_test_percentage'] as $field) { + if (array_key_exists($field, $input)) { + $attributes[$field] = $input[$field]; + } + } + + if (array_key_exists('is_private', $input)) { + $attributes['is_private'] = (bool) $input['is_private']; + } + + if (array_key_exists('embedded_player', $input)) { + $attributes['embedded_player'] = (bool) $input['embedded_player']; + } + } + + return $attributes; + } + + /** + * @throws ValidationException + */ + protected function resolveVideoUrl(string $url): string + { + $result = VideoUrlService::validateAndConvertWithErrors($url); + + if ($result['errors'] !== []) { + throw ValidationException::withMessages([ + 'video_url' => $result['errors']['url'] ?? 'The video URL is invalid.', + ]); + } + + return $result['url']; + } + + /** + * @param array $input + */ + protected function createVideoStep(Lesson $lesson, array $input, ?int $order = null): Step + { + $name = trim((string) $input['name']); + $slug = filled($input['slug'] ?? null) + ? (string) $input['slug'] + : $lesson->slug.'-'.Str::slug($name); + $videoName = filled($input['video_name'] ?? null) ? (string) $input['video_name'] : $name; + $url = $this->resolveVideoUrl((string) $input['video_url']); + + $this->assertUniqueStepSlug($slug); + + $video = Video::create([ + 'name' => $videoName, + 'url' => $url, + ]); + + return Step::create([ + 'lesson_id' => $lesson->id, + 'order' => $order ?? ($lesson->steps()->count() + 1), + 'name' => $name, + 'slug' => $slug, + 'text' => $input['text'] ?? null, + 'is_optional' => (bool) ($input['is_optional'] ?? false), + 'material_id' => $video->id, + 'material_type' => 'video', + ]); + } + + /** + * @throws ValidationException + */ + protected function assertUniqueStepSlug(string $slug, ?int $ignoreId = null): void + { + $query = Step::query()->where('slug', $slug); + + if ($ignoreId !== null) { + $query->whereKeyNot($ignoreId); + } + + if ($query->exists()) { + throw ValidationException::withMessages([ + 'slug' => 'A step with this slug already exists.', + ]); + } + } + + protected function nextLessonOrder(Course $course): int + { + return (int) $course->lessons()->max('order') + 1; + } + + protected function deleteStepMaterial(Step $step): void + { + if ($step->material_type === 'video' && $step->material_id) { + Video::query()->whereKey($step->material_id)->delete(); + } + } + + /** + * @return array + */ + protected function serializeCourse(Course $course, bool $includeChildren = true): array + { + $course->loadMissing(['lessons.steps.material']); + + $payload = [ + 'id' => $course->id, + 'name' => $course->name, + 'slug' => $course->slug, + 'external_id' => $course->external_id, + 'description' => $course->description, + 'is_private' => (bool) $course->is_private, + 'award' => $course->award, + 'required_test_percentage' => $course->required_test_percentage, + 'embedded_player' => (bool) $course->embedded_player, + 'completion_mode' => $course->completionMode()->value, + 'urls' => $this->courseUrls($course), + ]; + + if ($includeChildren) { + $payload['lessons'] = $course->lessons->map(fn (Lesson $lesson): array => $this->serializeLesson($lesson))->all(); + } + + return $payload; + } + + /** + * @return array + */ + protected function serializeLesson(Lesson $lesson): array + { + $lesson->loadMissing(['steps.material']); + + return [ + 'id' => $lesson->id, + 'course_id' => $lesson->course_id, + 'name' => $lesson->name, + 'slug' => $lesson->slug, + 'order' => $lesson->order, + 'steps' => $lesson->steps->map(fn (Step $step): array => $this->serializeStep($step))->all(), + ]; + } + + /** + * @return array + */ + protected function serializeStep(Step $step): array + { + $step->loadMissing('material'); + + $video = $step->material instanceof Video ? $step->material : null; + + return [ + 'id' => $step->id, + 'lesson_id' => $step->lesson_id, + 'name' => $step->name, + 'slug' => $step->slug, + 'order' => $step->order, + 'is_optional' => (bool) $step->is_optional, + 'text' => $step->text, + 'material_type' => $step->material_type, + 'material_id' => $step->material_id, + 'video' => $video === null ? null : [ + 'id' => $video->id, + 'name' => $video->name, + 'url' => $video->url, + 'provider' => $video->provider, + ], + 'urls' => $this->stepUrls($step), + ]; + } + + /** + * @return array + */ + protected function courseUrls(Course $course): array + { + return [ + 'admin' => $this->safeUrl(fn (): string => CourseResource::getUrl('edit', ['record' => $course])), + 'learner' => $this->safeUrl(function () use ($course): string { + $firstStep = $course->firstStep(); + + return $firstStep instanceof Step + ? StepPage::getUrlForStep($firstStep) + : Dashboard::getUrl(); + }), + ]; + } + + /** + * @return array + */ + protected function stepUrls(Step $step): array + { + return [ + 'learner' => $this->safeUrl(fn (): string => StepPage::getUrlForStep($step)), + ]; + } + + /** + * @param callable(): string $callback + */ + protected function safeUrl(callable $callback): ?string + { + try { + return $callback(); + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Mcp/Tools/CreateLesson.php b/src/Mcp/Tools/CreateLesson.php new file mode 100644 index 00000000..22a003c5 --- /dev/null +++ b/src/Mcp/Tools/CreateLesson.php @@ -0,0 +1,57 @@ + $schema->integer()->description('Course ID.')->required(), + 'name' => $schema->string()->description('Lesson name.')->required(), + 'slug' => $schema->string()->description('URL slug. Defaults to a slugified name.'), + 'order' => $schema->integer()->description('Lesson order. Defaults to the next order in the course.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'course_id' => ['required', 'integer', 'exists:lms_courses,id'], + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'order' => ['sometimes', 'nullable', 'integer', 'min:1'], + ]); + + $course = Course::query()->findOrFail($validated['course_id']); + $name = trim((string) $validated['name']); + + $lesson = Lesson::create([ + 'course_id' => $course->id, + 'name' => $name, + 'slug' => filled($validated['slug'] ?? null) ? (string) $validated['slug'] : Str::slug($name), + 'order' => $validated['order'] ?? $this->nextLessonOrder($course), + ]); + + return Response::structured($this->serializeLesson($lesson)); + } +} diff --git a/src/Mcp/Tools/CreateVideoCourse.php b/src/Mcp/Tools/CreateVideoCourse.php new file mode 100644 index 00000000..b328a1fe --- /dev/null +++ b/src/Mcp/Tools/CreateVideoCourse.php @@ -0,0 +1,82 @@ + $schema->string()->description('Course name. Also used to auto-generate slug and external_id.')->required(), + 'description' => $schema->string()->description('Course description.'), + 'slug' => $schema->string()->description('URL slug. Defaults to a slugified name.'), + 'external_id' => $schema->string()->description('Integration ID. Lowercase letters, numbers, underscores; must start with a letter. Defaults to a slugified name with underscores.'), + 'award' => $schema->string()->description('Certificate award key. Defaults to default.'), + 'is_private' => $schema->boolean()->description('Private courses are only visible to assigned users and LMS admins. Defaults to true.'), + 'required_test_percentage' => $schema->integer()->description('Required average test score (0-100). Defaults to 0.'), + 'embedded_player' => $schema->boolean()->description('Embedded player mode. Defaults to false.'), + 'completion_mode' => $schema->string()->description('native, scorm12, or html5. Defaults to native.'), + 'lessons' => $schema->array()->description('Lessons, each with name and steps[{name, video_url, text?, is_optional?}].')->required(), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $this->applyGeneratedCourseFields($request); + + $validated = $request->validate(array_merge($this->courseRules(creating: true), [ + 'lessons' => ['required', 'array', 'min:1'], + 'lessons.*.name' => ['required', 'string', 'max:255'], + 'lessons.*.slug' => ['nullable', 'string', 'max:255'], + 'lessons.*.steps' => ['required', 'array', 'min:1'], + 'lessons.*.steps.*.name' => ['required', 'string', 'max:255'], + 'lessons.*.steps.*.video_url' => ['required', 'string'], + 'lessons.*.steps.*.slug' => ['nullable', 'string', 'max:255'], + 'lessons.*.steps.*.text' => ['nullable', 'string'], + 'lessons.*.steps.*.is_optional' => ['sometimes', 'boolean'], + 'lessons.*.steps.*.video_name' => ['nullable', 'string', 'max:255'], + ]), $this->courseMessages()); + + $course = DB::transaction(function () use ($validated): Course { + $course = Course::create($this->courseAttributesFromInput($validated, creating: true)); + + foreach (array_values($validated['lessons']) as $index => $lessonInput) { + $lessonName = trim((string) $lessonInput['name']); + $lesson = Lesson::create([ + 'course_id' => $course->id, + 'name' => $lessonName, + 'slug' => filled($lessonInput['slug'] ?? null) ? (string) $lessonInput['slug'] : Str::slug($lessonName), + 'order' => $index + 1, + ]); + + foreach (array_values($lessonInput['steps']) as $stepIndex => $stepInput) { + $this->createVideoStep($lesson, $stepInput, $stepIndex + 1); + } + } + + return $course->refresh(); + }); + + return Response::structured($this->serializeCourse($course)); + } +} diff --git a/src/Mcp/Tools/CreateVideoStep.php b/src/Mcp/Tools/CreateVideoStep.php new file mode 100644 index 00000000..60cc9b05 --- /dev/null +++ b/src/Mcp/Tools/CreateVideoStep.php @@ -0,0 +1,54 @@ + $schema->integer()->description('Lesson ID.')->required(), + 'name' => $schema->string()->description('Step name.')->required(), + 'video_url' => $schema->string()->description('YouTube or Vimeo URL. Converted to an embed URL.')->required(), + 'slug' => $schema->string()->description('URL slug. Defaults to {lesson-slug}-{step-slug}.'), + 'text' => $schema->string()->description('Optional transcript or supporting text.'), + 'is_optional' => $schema->boolean()->description('Whether the step can be skipped. Defaults to false.'), + 'video_name' => $schema->string()->description('Video record name. Defaults to the step name.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'lesson_id' => ['required', 'integer', 'exists:lms_lessons,id'], + 'name' => ['required', 'string', 'max:255'], + 'video_url' => ['required', 'string'], + 'slug' => ['nullable', 'string', 'max:255'], + 'text' => ['nullable', 'string'], + 'is_optional' => ['sometimes', 'boolean'], + 'video_name' => ['nullable', 'string', 'max:255'], + ]); + + $lesson = Lesson::query()->findOrFail($validated['lesson_id']); + $step = $this->createVideoStep($lesson, $validated); + + return Response::structured($this->serializeStep($step)); + } +} diff --git a/src/Mcp/Tools/DeleteCourse.php b/src/Mcp/Tools/DeleteCourse.php new file mode 100644 index 00000000..79eb2563 --- /dev/null +++ b/src/Mcp/Tools/DeleteCourse.php @@ -0,0 +1,58 @@ + $schema->integer()->description('Course ID.')->required(), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_courses,id'], + ]); + + $course = Course::query()->with('lessons.steps')->findOrFail($validated['id']); + + DB::transaction(function () use ($course): void { + foreach ($course->lessons as $lesson) { + foreach ($lesson->steps as $step) { + $this->deleteStepMaterial($step); + $step->delete(); + } + + $lesson->delete(); + } + + $course->delete(); + }); + + return Response::structured([ + 'deleted' => true, + 'id' => $validated['id'], + ]); + } +} diff --git a/src/Mcp/Tools/DeleteLesson.php b/src/Mcp/Tools/DeleteLesson.php new file mode 100644 index 00000000..12c68471 --- /dev/null +++ b/src/Mcp/Tools/DeleteLesson.php @@ -0,0 +1,53 @@ + $schema->integer()->description('Lesson ID.')->required(), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_lessons,id'], + ]); + + $lesson = Lesson::query()->with('steps')->findOrFail($validated['id']); + + DB::transaction(function () use ($lesson): void { + foreach ($lesson->steps as $step) { + $this->deleteStepMaterial($step); + } + + $lesson->delete(); + }); + + return Response::structured([ + 'deleted' => true, + 'id' => $validated['id'], + ]); + } +} diff --git a/src/Mcp/Tools/DeleteStep.php b/src/Mcp/Tools/DeleteStep.php new file mode 100644 index 00000000..26b0cb84 --- /dev/null +++ b/src/Mcp/Tools/DeleteStep.php @@ -0,0 +1,50 @@ + $schema->integer()->description('Step ID.')->required(), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_steps,id'], + ]); + + $step = Step::query()->findOrFail($validated['id']); + + DB::transaction(function () use ($step): void { + $this->deleteStepMaterial($step); + $step->delete(); + }); + + return Response::structured([ + 'deleted' => true, + 'id' => $validated['id'], + ]); + } +} diff --git a/src/Mcp/Tools/GetCourse.php b/src/Mcp/Tools/GetCourse.php new file mode 100644 index 00000000..15be3296 --- /dev/null +++ b/src/Mcp/Tools/GetCourse.php @@ -0,0 +1,41 @@ + $schema->integer()->description('Course ID.')->required(), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_courses,id'], + ]); + + $course = Course::query()->with(['lessons.steps.material'])->findOrFail($validated['id']); + + return Response::structured($this->serializeCourse($course)); + } +} diff --git a/src/Mcp/Tools/ListCourses.php b/src/Mcp/Tools/ListCourses.php new file mode 100644 index 00000000..6c8e39f4 --- /dev/null +++ b/src/Mcp/Tools/ListCourses.php @@ -0,0 +1,54 @@ + $schema->string()->description('Optional search against name, slug, or external_id.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'query' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + + $courses = Course::query() + ->with(['lessons.steps.material']) + ->when(filled($validated['query'] ?? null), function ($query) use ($validated): void { + $term = '%'.$validated['query'].'%'; + $query->where(function ($inner) use ($term): void { + $inner->where('name', 'like', $term) + ->orWhere('slug', 'like', $term) + ->orWhere('external_id', 'like', $term); + }); + }) + ->orderBy('name') + ->get(); + + return Response::structured([ + 'courses' => $courses->map(fn (Course $course): array => $this->serializeCourse($course))->all(), + ]); + } +} diff --git a/src/Mcp/Tools/UpdateCourse.php b/src/Mcp/Tools/UpdateCourse.php new file mode 100644 index 00000000..5ff7045d --- /dev/null +++ b/src/Mcp/Tools/UpdateCourse.php @@ -0,0 +1,53 @@ + $schema->integer()->description('Course ID.')->required(), + 'name' => $schema->string()->description('Course name.'), + 'description' => $schema->string()->description('Course description.'), + 'slug' => $schema->string()->description('URL slug.'), + 'external_id' => $schema->string()->description('Integration ID.'), + 'is_private' => $schema->boolean()->description('Private course flag.'), + 'award' => $schema->string()->description('Certificate award key.'), + 'required_test_percentage' => $schema->integer()->description('Required average test score (0-100).'), + 'embedded_player' => $schema->boolean()->description('Embedded player mode.'), + 'completion_mode' => $schema->string()->description('native, scorm12, or html5.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $id = is_numeric($request->get('id')) ? (int) $request->get('id') : null; + + $validated = $request->validate(array_merge([ + 'id' => ['required', 'integer', 'exists:lms_courses,id'], + ], $this->courseRules(ignoreId: $id)), $this->courseMessages()); + + $course = Course::query()->findOrFail($validated['id']); + $course->update($this->courseAttributesFromInput($validated)); + + return Response::structured($this->serializeCourse($course->refresh())); + } +} diff --git a/src/Mcp/Tools/UpdateLesson.php b/src/Mcp/Tools/UpdateLesson.php new file mode 100644 index 00000000..7710a7c1 --- /dev/null +++ b/src/Mcp/Tools/UpdateLesson.php @@ -0,0 +1,50 @@ + $schema->integer()->description('Lesson ID.')->required(), + 'name' => $schema->string()->description('Lesson name.'), + 'slug' => $schema->string()->description('URL slug.'), + 'course_id' => $schema->integer()->description('Course ID.'), + 'order' => $schema->integer()->description('Lesson order.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_lessons,id'], + 'name' => ['sometimes', 'string', 'max:255'], + 'slug' => ['sometimes', 'string', 'max:255'], + 'course_id' => ['sometimes', 'integer', 'exists:lms_courses,id'], + 'order' => ['sometimes', 'integer', 'min:1'], + ]); + + $lesson = Lesson::query()->findOrFail($validated['id']); + $lesson->update(collect($validated)->except('id')->all()); + + return Response::structured($this->serializeLesson($lesson->refresh())); + } +} diff --git a/src/Mcp/Tools/UpdateStep.php b/src/Mcp/Tools/UpdateStep.php new file mode 100644 index 00000000..2f600689 --- /dev/null +++ b/src/Mcp/Tools/UpdateStep.php @@ -0,0 +1,83 @@ + $schema->integer()->description('Step ID.')->required(), + 'name' => $schema->string()->description('Step name.'), + 'slug' => $schema->string()->description('URL slug.'), + 'lesson_id' => $schema->integer()->description('Lesson ID.'), + 'is_optional' => $schema->boolean()->description('Whether the step can be skipped.'), + 'text' => $schema->string()->description('Optional transcript or supporting text.'), + 'video_url' => $schema->string()->description('YouTube or Vimeo URL.'), + 'video_name' => $schema->string()->description('Video record name.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if ($denied = $this->authorizeWrite($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer', 'exists:lms_steps,id'], + 'name' => ['sometimes', 'string', 'max:255'], + 'slug' => ['sometimes', 'string', 'max:255'], + 'lesson_id' => ['sometimes', 'integer', 'exists:lms_lessons,id'], + 'is_optional' => ['sometimes', 'boolean'], + 'text' => ['sometimes', 'nullable', 'string'], + 'video_url' => ['sometimes', 'nullable', 'string'], + 'video_name' => ['sometimes', 'nullable', 'string', 'max:255'], + ]); + + $step = Step::query()->with('material')->findOrFail($validated['id']); + + if (isset($validated['slug'])) { + $this->assertUniqueStepSlug($validated['slug'], $step->id); + } + + $step->update(collect($validated)->only(['name', 'slug', 'lesson_id', 'is_optional', 'text'])->all()); + + if (isset($validated['video_url']) || isset($validated['video_name'])) { + $video = $step->material instanceof Video ? $step->material : null; + $url = isset($validated['video_url']) ? $this->resolveVideoUrl((string) $validated['video_url']) : $video?->url; + + if ($video instanceof Video) { + $video->update(array_filter([ + 'name' => $validated['video_name'] ?? null, + 'url' => $url, + ], fn (mixed $value): bool => $value !== null)); + } elseif (isset($validated['video_url'])) { + $video = Video::create([ + 'name' => $validated['video_name'] ?? $step->name, + 'url' => $url, + ]); + $step->update([ + 'material_id' => $video->id, + 'material_type' => 'video', + ]); + } + } + + return Response::structured($this->serializeStep($step->refresh()->load('material'))); + } +} diff --git a/src/Models/Video.php b/src/Models/Video.php index 6c305a5b..9fbe6fd8 100644 --- a/src/Models/Video.php +++ b/src/Models/Video.php @@ -8,6 +8,13 @@ use Tapp\FilamentLms\Database\Factories\VideoFactory; use Tapp\FilamentLms\Models\Traits\BelongsToTenant; +/** + * @property int $id + * @property string $name + * @property string $url + * @property string|null $description + * @property-read string $provider + */ class Video extends Model { use BelongsToTenant; diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php new file mode 100644 index 00000000..b6997b6c --- /dev/null +++ b/tests/Feature/LmsMcpTest.php @@ -0,0 +1,239 @@ +markTestSkipped('laravel/mcp is required to run LMS MCP tests.'); + } +}); + +function videoCoursePayload(array $overrides = []): array +{ + return array_merge([ + 'name' => 'DNS Cloudflare', + 'description' => 'How DNS works with Cloudflare.', + 'lessons' => [ + [ + 'name' => 'Getting started', + 'steps' => [ + [ + 'name' => 'Welcome video', + 'video_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'text' => 'Welcome to the course.', + ], + ], + ], + ], + ], $overrides); +} + +test('create_video_course defaults new courses to private', function () { + $response = LmsServer::tool(CreateVideoCourse::class, videoCoursePayload()); + + $response->assertOk(); + + $course = Course::query()->first(); + expect($course)->not->toBeNull() + ->and($course->is_private)->toBeTrue() + ->and($course->slug)->toBe('dns-cloudflare') + ->and($course->external_id)->toBe('dns_cloudflare') + ->and($course->award)->toBe('default') + ->and($course->completion_mode->value)->toBe('native'); +}); + +test('create_video_course creates nested lessons, videos, and steps', function () { + $response = LmsServer::tool(CreateVideoCourse::class, videoCoursePayload()); + + $response->assertOk() + ->assertSee('DNS Cloudflare') + ->assertSee('Getting started') + ->assertSee('Welcome video'); + + expect(Course::query()->count())->toBe(1) + ->and(Lesson::query()->count())->toBe(1) + ->and(Step::query()->count())->toBe(1) + ->and(Video::query()->count())->toBe(1); + + $step = Step::query()->first(); + expect($step->material_type)->toBe('video') + ->and($step->text)->toBe('Welcome to the course.') + ->and($step->is_optional)->toBeFalse(); +}); + +test('create_video_course converts youtube watch urls to embed urls', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + + $video = Video::query()->first(); + expect($video->url)->toBe('https://www.youtube.com/embed/dQw4w9WgXcQ') + ->and($video->provider)->toBe('youtube'); +}); + +test('create_video_course rejects invalid video urls', function () { + $response = LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'lessons' => [ + [ + 'name' => 'Getting started', + 'steps' => [ + [ + 'name' => 'Broken video', + 'video_url' => 'https://example.com/not-a-video', + ], + ], + ], + ], + ])); + + $response->assertHasErrors(['Automatic conversion from video link to embed link failed']); + expect(Course::query()->count())->toBe(0); +}); + +test('create_video_course rejects duplicate slug and external_id', function () { + Course::factory()->create([ + 'name' => 'Existing Course', + 'slug' => 'dns-cloudflare', + 'external_id' => 'dns_cloudflare', + ]); + + $response = LmsServer::tool(CreateVideoCourse::class, videoCoursePayload()); + + $response->assertHasErrors(); + expect(Course::query()->count())->toBe(1); +}); + +test('create_video_course rejects invalid external_id format', function () { + $response = LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'external_id' => '123-not-valid', + ])); + + $response->assertHasErrors(['External ID must contain only lowercase letters']); + expect(Course::query()->count())->toBe(0); +}); + +test('optional transcript is stored on step text', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'lessons' => [ + [ + 'name' => 'Lesson one', + 'steps' => [ + [ + 'name' => 'Talk track', + 'video_url' => 'https://youtu.be/dQw4w9WgXcQ', + 'text' => 'This is the transcript.', + 'is_optional' => true, + ], + ], + ], + ], + ]))->assertOk(); + + $step = Step::query()->first(); + expect($step->text)->toBe('This is the transcript.') + ->and($step->is_optional)->toBeTrue(); +}); + +test('list_courses and get_course include lessons and steps', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + $course = Course::query()->first(); + + $list = LmsServer::tool(ListCourses::class, []); + $list->assertOk()->assertSee('DNS Cloudflare')->assertSee('Welcome video'); + + $get = LmsServer::tool(GetCourse::class, ['id' => $course->id]); + $get->assertOk() + ->assertSee('dns-cloudflare') + ->assertSee('Getting started') + ->assertSee('https://www.youtube.com/embed/dQw4w9WgXcQ'); +}); + +test('update_course and delete_course work', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + $course = Course::query()->first(); + + LmsServer::tool(UpdateCourse::class, [ + 'id' => $course->id, + 'name' => 'DNS Updated', + 'is_private' => false, + 'description' => 'Updated description', + ])->assertOk()->assertSee('DNS Updated'); + + $course->refresh(); + expect($course->name)->toBe('DNS Updated') + ->and($course->is_private)->toBeFalse() + ->and($course->description)->toBe('Updated description'); + + LmsServer::tool(DeleteCourse::class, ['id' => $course->id])->assertOk(); + + expect(Course::query()->count())->toBe(0) + ->and(Lesson::query()->count())->toBe(0) + ->and(Step::query()->count())->toBe(0) + ->and(Video::query()->count())->toBe(0); +}); + +test('granular lesson and step tools create update and delete', function () { + $course = Course::factory()->create([ + 'name' => 'Manual Course', + 'slug' => 'manual-course', + 'external_id' => 'manual_course', + 'is_private' => true, + ]); + + LmsServer::tool(CreateLesson::class, [ + 'course_id' => $course->id, + 'name' => 'Lesson A', + ])->assertOk(); + + $lesson = Lesson::query()->first(); + expect($lesson->slug)->toBe('lesson-a')->and($lesson->order)->toBe(1); + + LmsServer::tool(UpdateLesson::class, [ + 'id' => $lesson->id, + 'name' => 'Lesson A updated', + ])->assertOk()->assertSee('Lesson A updated'); + + LmsServer::tool(CreateVideoStep::class, [ + 'lesson_id' => $lesson->id, + 'name' => 'Step one', + 'video_url' => 'https://vimeo.com/226053498', + 'text' => 'Vimeo transcript', + ])->assertOk(); + + $step = Step::query()->first(); + $video = Video::query()->first(); + expect($step->slug)->toBe('lesson-a-step-one') + ->and($step->text)->toBe('Vimeo transcript') + ->and($video->url)->toBe('https://player.vimeo.com/video/226053498'); + + LmsServer::tool(UpdateStep::class, [ + 'id' => $step->id, + 'name' => 'Step one updated', + 'video_name' => 'Renamed video', + ])->assertOk()->assertSee('Step one updated'); + + expect(Video::query()->first()->name)->toBe('Renamed video'); + + LmsServer::tool(DeleteStep::class, ['id' => $step->id])->assertOk(); + expect(Step::query()->count())->toBe(0)->and(Video::query()->count())->toBe(0); + + LmsServer::tool(DeleteLesson::class, ['id' => $lesson->id])->assertOk(); + expect(Lesson::query()->count())->toBe(0); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 43a77784..5c976d03 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -285,6 +285,10 @@ protected function getPackageProviders($app) $providers[] = FilamentFormBuilderServiceProvider::class; } + if (class_exists(\Laravel\Mcp\Server\McpServiceProvider::class)) { + $providers[] = \Laravel\Mcp\Server\McpServiceProvider::class; + } + return $providers; } } From 811dc6c9c73096a91ae2d0dcb427aadb9c3d824e Mon Sep 17 00:00:00 2001 From: scottgrayson <7796074+scottgrayson@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:33:28 +0000 Subject: [PATCH 02/11] Fix styling --- src/FilamentLmsServiceProvider.php | 6 ++++-- src/Mcp/LmsServer.php | 3 ++- src/Mcp/LmsTool.php | 3 ++- tests/Feature/LmsMcpTest.php | 3 ++- tests/TestCase.php | 5 +++-- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index 10d9b6e1..0fd9d5cf 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -6,6 +6,7 @@ use Filament\Support\Assets\Js; use Filament\Support\Facades\FilamentAsset; use Illuminate\Database\Eloquent\Relations\Relation; +use Laravel\Mcp\Facades\Mcp; use Livewire\Livewire; use Spatie\LaravelPackageTools\Commands\InstallCommand; use Spatie\LaravelPackageTools\Package; @@ -26,6 +27,7 @@ use Tapp\FilamentLms\Livewire\VideoStep; use Tapp\FilamentLms\Livewire\ViewGradedEntry; use Tapp\FilamentLms\Livewire\VimeoVideo; +use Tapp\FilamentLms\Mcp\LmsServer; use Tapp\FilamentLms\Pages\CreateTestEntry; class FilamentLmsServiceProvider extends PackageServiceProvider @@ -135,7 +137,7 @@ public function packageBooted() protected function registerMcpServer(): void { - if (! class_exists(\Laravel\Mcp\Facades\Mcp::class)) { + if (! class_exists(Mcp::class)) { return; } @@ -143,7 +145,7 @@ protected function registerMcpServer(): void return; } - \Laravel\Mcp\Facades\Mcp::local('filament-lms', \Tapp\FilamentLms\Mcp\LmsServer::class); + Mcp::local('filament-lms', LmsServer::class); } protected function configureLivewireTemporaryUploadLimits(): void diff --git a/src/Mcp/LmsServer.php b/src/Mcp/LmsServer.php index f9dfa7c0..33e4c7ca 100644 --- a/src/Mcp/LmsServer.php +++ b/src/Mcp/LmsServer.php @@ -5,6 +5,7 @@ namespace Tapp\FilamentLms\Mcp; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Tool; use Tapp\FilamentLms\Mcp\Tools\CreateLesson; use Tapp\FilamentLms\Mcp\Tools\CreateVideoCourse; use Tapp\FilamentLms\Mcp\Tools\CreateVideoStep; @@ -36,7 +37,7 @@ class LmsServer extends Server MARKDOWN; /** - * @var array> + * @var array> */ protected array $tools = [ CreateVideoCourse::class, diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php index ff42d0ac..fa4fb49f 100644 --- a/src/Mcp/LmsTool.php +++ b/src/Mcp/LmsTool.php @@ -8,6 +8,7 @@ use Filament\Models\Contracts\FilamentUser; use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -58,7 +59,7 @@ protected function authorizeWrite(Request $request): ?Response protected function courseRules(?int $ignoreId = null, bool $creating = false): array { $awardKeys = array_keys(config('filament-lms.awards', ['default' => 'Default'])); - $unique = fn (string $column): \Illuminate\Validation\Rules\Unique => $ignoreId === null + $unique = fn (string $column): Unique => $ignoreId === null ? Rule::unique('lms_courses', $column) : Rule::unique('lms_courses', $column)->ignore($ignoreId); diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index b6997b6c..9436fd3b 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -4,6 +4,7 @@ namespace Tapp\FilamentLms\Tests\Feature; +use Laravel\Mcp\Server; use Tapp\FilamentLms\Mcp\LmsServer; use Tapp\FilamentLms\Mcp\Tools\CreateLesson; use Tapp\FilamentLms\Mcp\Tools\CreateVideoCourse; @@ -22,7 +23,7 @@ use Tapp\FilamentLms\Models\Video; beforeEach(function () { - if (! class_exists(LmsServer::class) || ! class_exists(\Laravel\Mcp\Server::class)) { + if (! class_exists(LmsServer::class) || ! class_exists(Server::class)) { $this->markTestSkipped('laravel/mcp is required to run LMS MCP tests.'); } }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 5c976d03..7378b12d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,6 +10,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\MessageBag; use Illuminate\Support\ViewErrorBag; +use Laravel\Mcp\Server\McpServiceProvider; use Livewire\LivewireServiceProvider; use Maatwebsite\Excel\ExcelServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; @@ -285,8 +286,8 @@ protected function getPackageProviders($app) $providers[] = FilamentFormBuilderServiceProvider::class; } - if (class_exists(\Laravel\Mcp\Server\McpServiceProvider::class)) { - $providers[] = \Laravel\Mcp\Server\McpServiceProvider::class; + if (class_exists(McpServiceProvider::class)) { + $providers[] = McpServiceProvider::class; } return $providers; From 86d641b190c19e86180cc2fb27b6691293df6f6b Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 24 Aug 2026 14:02:21 -0600 Subject: [PATCH 03/11] Harden MCP auth and fix structured response test assertions. Require isLmsAdmin for authenticated MCP writes, guard tenant context when tenancy is enabled, prefix auto external_ids that start with a digit, and assert on JSON-safe video fields in LmsMcpTest. Co-authored-by: Cursor --- src/Mcp/LmsTool.php | 49 +++++++++++++++++++++++++----------- tests/Feature/LmsMcpTest.php | 11 +++++++- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php index fa4fb49f..3cd7000b 100644 --- a/src/Mcp/LmsTool.php +++ b/src/Mcp/LmsTool.php @@ -5,7 +5,6 @@ namespace Tapp\FilamentLms\Mcp; use Filament\Facades\Filament; -use Filament\Models\Contracts\FilamentUser; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Unique; @@ -28,29 +27,38 @@ abstract class LmsTool extends Tool { protected function authorizeWrite(Request $request): ?Response { + if ($denied = $this->ensureTenantContext()) { + return $denied; + } + $user = $request->user(); if ($user === null) { return null; } - if ($user instanceof FilamentUser) { - try { - $panel = Filament::getPanel('lms'); - - if ($user->canAccessPanel($panel)) { - return null; - } - } catch (Throwable) { - // Panel may be unregistered in some hosts; fall through to isLmsAdmin(). - } + if (method_exists($user, 'isLmsAdmin') && $user->isLmsAdmin()) { + return null; } - if (method_exists($user, 'isLmsAdmin') && $user->isLmsAdmin()) { + return Response::error('You must be an LMS admin to use this tool.'); + } + + protected function ensureTenantContext(): ?Response + { + if (! config('filament-lms.tenancy.enabled')) { return null; } - return Response::error('You must be able to access the LMS Filament panel to use this tool.'); + try { + if (Filament::getTenant() !== null) { + return null; + } + } catch (Throwable) { + return Response::error('Tenant context is required when LMS tenancy is enabled.'); + } + + return Response::error('Tenant context is required when LMS tenancy is enabled.'); } /** @@ -109,7 +117,7 @@ protected function applyGeneratedCourseFields(Request $request): void } if (blank($request->get('external_id'))) { - $request->merge(['external_id' => Str::slug($name, '_')]); + $request->merge(['external_id' => $this->generateExternalId($name)]); } } @@ -134,7 +142,7 @@ protected function courseAttributesFromInput(array $input, bool $creating = fals $attributes['slug'] = filled($input['slug'] ?? null) ? (string) $input['slug'] : Str::slug((string) $name); $attributes['external_id'] = filled($input['external_id'] ?? null) ? (string) $input['external_id'] - : Str::slug((string) $name, '_'); + : $this->generateExternalId((string) $name); $attributes['award'] = $input['award'] ?? 'default'; $attributes['completion_mode'] = $input['completion_mode'] ?? CompletionMode::Native->value; $attributes['is_private'] = array_key_exists('is_private', $input) @@ -350,4 +358,15 @@ protected function safeUrl(callable $callback): ?string return null; } } + + protected function generateExternalId(string $name): string + { + $externalId = Str::slug($name, '_'); + + if ($externalId === '' || preg_match('/^[0-9]/', $externalId) === 1) { + $externalId = 'course_'.$externalId; + } + + return $externalId; + } } diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index 9436fd3b..f1f8df2b 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -130,6 +130,14 @@ function videoCoursePayload(array $overrides = []): array expect(Course::query()->count())->toBe(0); }); +test('create_video_course prefixes auto external_id when name starts with a number', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'name' => '101 Intro', + ]))->assertOk(); + + expect(Course::query()->first()->external_id)->toBe('course_101_intro'); +}); + test('optional transcript is stored on step text', function () { LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ 'lessons' => [ @@ -163,7 +171,8 @@ function videoCoursePayload(array $overrides = []): array $get->assertOk() ->assertSee('dns-cloudflare') ->assertSee('Getting started') - ->assertSee('https://www.youtube.com/embed/dQw4w9WgXcQ'); + ->assertSee('dQw4w9WgXcQ') + ->assertSee('"provider": "youtube"'); }); test('update_course and delete_course work', function () { From 51e1dc8f68c987cb39310c7e486859242d16780f Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 24 Aug 2026 14:03:53 -0600 Subject: [PATCH 04/11] Fix MCP get_course assertion for varying JSON formatting. Assert on the video id and provider name instead of exact JSON key spacing. Co-authored-by: Cursor --- tests/Feature/LmsMcpTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index f1f8df2b..71a5119c 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -172,7 +172,7 @@ function videoCoursePayload(array $overrides = []): array ->assertSee('dns-cloudflare') ->assertSee('Getting started') ->assertSee('dQw4w9WgXcQ') - ->assertSee('"provider": "youtube"'); + ->assertSee('youtube'); }); test('update_course and delete_course work', function () { From ec64aaba44a0f2ad608f9495c3c1d02b6f7f4f61 Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 21 Sep 2026 17:29:20 -0600 Subject: [PATCH 05/11] Fix MCP course URLs and per-parent lesson/step order. HTTP MCP requests have no Filament panel, and Spatie sortable was using a global max, so new courses got null admin/learner links and order 34/83. Co-authored-by: Cursor --- CHANGELOG.md | 5 +++ src/Mcp/LmsTool.php | 69 ++++++++++++++++++++++++++++---- src/Mcp/Tools/CreateLesson.php | 10 ++++- src/Models/Lesson.php | 6 +++ src/Models/Step.php | 6 +++ tests/Feature/LessonTest.php | 14 +++++++ tests/Feature/LmsMcpTest.php | 43 +++++++++++++++++++- tests/Feature/StepTest.php | 15 +++++++ tests/TestAdminPanelProvider.php | 45 +++++++++++++++++++++ tests/TestCase.php | 1 + 10 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/TestAdminPanelProvider.php diff --git a/CHANGELOG.md b/CHANGELOG.md index daa38bdc..7af5fbac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Optional Laravel MCP server for writing Course → Lesson → video Step data (`create_video_course` plus granular tools). New courses default to private. Hosts that want MCP should `composer require laravel/mcp`. +### Fixed + +* MCP course/step payloads now generate admin and learner Filament URLs by setting the panel that owns the resource or page (HTTP MCP requests have no current panel). +* Lesson and step Spatie sort order is scoped to the parent course/lesson so a new course starts at order 1 instead of the global max. + ## v4.7.7 - 2026-08-24 ### What's Changed diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php index 3cd7000b..5a7af318 100644 --- a/src/Mcp/LmsTool.php +++ b/src/Mcp/LmsTool.php @@ -5,6 +5,7 @@ namespace Tapp\FilamentLms\Mcp; use Filament\Facades\Filament; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Unique; @@ -325,14 +326,25 @@ protected function serializeStep(Step $step): array */ protected function courseUrls(Course $course): array { + $adminPanelId = $this->panelIdForResource(CourseResource::class); + $learnerPanelId = $this->panelIdForPage(StepPage::class) ?? $this->panelIdForPage(Dashboard::class); + return [ - 'admin' => $this->safeUrl(fn (): string => CourseResource::getUrl('edit', ['record' => $course])), - 'learner' => $this->safeUrl(function () use ($course): string { + 'admin' => $this->urlOnPanel($adminPanelId, function () use ($course, $adminPanelId): string { + return CourseResource::getUrl( + 'edit', + ['record' => $course], + panel: $adminPanelId, + ); + }), + 'learner' => $this->urlOnPanel($learnerPanelId, function () use ($course, $learnerPanelId): string { $firstStep = $course->firstStep(); - return $firstStep instanceof Step - ? StepPage::getUrlForStep($firstStep) - : Dashboard::getUrl(); + if ($firstStep instanceof Step) { + return StepPage::getUrlForStep($firstStep); + } + + return Dashboard::getUrl(panel: $learnerPanelId); }), ]; } @@ -342,23 +354,64 @@ protected function courseUrls(Course $course): array */ protected function stepUrls(Step $step): array { + $learnerPanelId = $this->panelIdForPage(StepPage::class); + return [ - 'learner' => $this->safeUrl(fn (): string => StepPage::getUrlForStep($step)), + 'learner' => $this->urlOnPanel( + $learnerPanelId, + fn (): string => StepPage::getUrlForStep($step), + ), ]; } /** * @param callable(): string $callback */ - protected function safeUrl(callable $callback): ?string + protected function urlOnPanel(?string $panelId, callable $callback): ?string { + $previous = Filament::getCurrentPanel(); + try { + if ($panelId !== null) { + Filament::setCurrentPanel($panelId); + } + return $callback(); - } catch (Throwable) { + } catch (Throwable $exception) { + Log::debug('LMS MCP could not generate a Filament URL.', [ + 'panel' => $panelId, + 'exception' => $exception::class, + 'message' => $exception->getMessage(), + ]); + return null; + } finally { + Filament::setCurrentPanel($previous); } } + protected function panelIdForResource(string $resourceClass): ?string + { + foreach (Filament::getPanels() as $panel) { + if (in_array($resourceClass, $panel->getResources(), true)) { + return $panel->getId(); + } + } + + return null; + } + + protected function panelIdForPage(string $pageClass): ?string + { + foreach (Filament::getPanels() as $panel) { + if (in_array($pageClass, $panel->getPages(), true)) { + return $panel->getId(); + } + } + + return Filament::getPanel('lms', isStrict: false)?->getId(); + } + protected function generateExternalId(string $name): string { $externalId = Str::slug($name, '_'); diff --git a/src/Mcp/Tools/CreateLesson.php b/src/Mcp/Tools/CreateLesson.php index 22a003c5..e190fffb 100644 --- a/src/Mcp/Tools/CreateLesson.php +++ b/src/Mcp/Tools/CreateLesson.php @@ -45,13 +45,19 @@ public function handle(Request $request): Response|ResponseFactory $course = Course::query()->findOrFail($validated['course_id']); $name = trim((string) $validated['name']); - $lesson = Lesson::create([ + $lesson = new Lesson([ 'course_id' => $course->id, 'name' => $name, 'slug' => filled($validated['slug'] ?? null) ? (string) $validated['slug'] : Str::slug($name), 'order' => $validated['order'] ?? $this->nextLessonOrder($course), ]); - return Response::structured($this->serializeLesson($lesson)); + if (array_key_exists('order', $validated) && $validated['order'] !== null) { + $lesson->sortable['sort_when_creating'] = false; + } + + $lesson->save(); + + return Response::structured($this->serializeLesson($lesson->refresh())); } } diff --git a/src/Models/Lesson.php b/src/Models/Lesson.php index ebb84382..da95434d 100644 --- a/src/Models/Lesson.php +++ b/src/Models/Lesson.php @@ -3,6 +3,7 @@ namespace Tapp\FilamentLms\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -43,6 +44,11 @@ protected static function newFactory() return LessonFactory::new(); } + public function buildSortQuery(): Builder + { + return static::query()->where('course_id', $this->course_id); + } + public function course(): BelongsTo { return $this->belongsTo(Course::class); diff --git a/src/Models/Step.php b/src/Models/Step.php index 1154a874..5d0f6f65 100644 --- a/src/Models/Step.php +++ b/src/Models/Step.php @@ -6,6 +6,7 @@ use DOMDocument; use DOMElement; use DOMNode; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -105,6 +106,11 @@ protected static function newFactory() return StepFactory::new(); } + public function buildSortQuery(): Builder + { + return static::query()->where('lesson_id', $this->lesson_id); + } + public function lesson(): BelongsTo { return $this->belongsTo(Lesson::class); diff --git a/tests/Feature/LessonTest.php b/tests/Feature/LessonTest.php index 51ee99fd..98a84bda 100644 --- a/tests/Feature/LessonTest.php +++ b/tests/Feature/LessonTest.php @@ -37,6 +37,20 @@ expect($lesson->steps->first())->toBeInstanceOf(Step::class); }); +test('lesson order is scoped to the course', function () { + $courseA = Course::factory()->create(); + $courseB = Course::factory()->create(); + + $firstOnA = Lesson::factory()->create(['course_id' => $courseA->id]); + $firstOnB = Lesson::factory()->create(['course_id' => $courseB->id]); + $secondOnA = Lesson::factory()->create(['course_id' => $courseA->id]); + + expect($firstOnA->order)->toBe(1) + ->and($firstOnB->order)->toBe(1) + ->and($secondOnA->order)->toBe(2) + ->and($firstOnB->fresh()->order)->toBe(1); +}); + test('lesson can get ordered steps', function () { $course = Course::factory()->create(); $lesson = Lesson::factory()->create(['course_id' => $course->id]); diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index 71a5119c..2e13949d 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -172,7 +172,37 @@ function videoCoursePayload(array $overrides = []): array ->assertSee('dns-cloudflare') ->assertSee('Getting started') ->assertSee('dQw4w9WgXcQ') - ->assertSee('youtube'); + ->assertSee('youtube') + ->assertSee('admin\/lms\/courses') + ->assertSee('lms\/courses\/dns-cloudflare\/getting-started'); +}); + +test('create_video_course scopes lesson and step order to the new course', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'name' => 'Second Course', + 'slug' => 'second-course', + 'external_id' => 'second_course', + 'lessons' => [ + [ + 'name' => 'Lesson B', + 'steps' => [ + [ + 'name' => 'Second video', + 'video_url' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ], + ], + ], + ], + ]))->assertOk(); + + $first = Course::query()->where('slug', 'dns-cloudflare')->first(); + $second = Course::query()->where('slug', 'second-course')->first(); + + expect($first->lessons()->first()->order)->toBe(1) + ->and($first->lessons()->first()->steps()->first()->order)->toBe(1) + ->and($second->lessons()->first()->order)->toBe(1) + ->and($second->lessons()->first()->steps()->first()->order)->toBe(1); }); test('update_course and delete_course work', function () { @@ -215,6 +245,14 @@ function videoCoursePayload(array $overrides = []): array $lesson = Lesson::query()->first(); expect($lesson->slug)->toBe('lesson-a')->and($lesson->order)->toBe(1); + LmsServer::tool(CreateLesson::class, [ + 'course_id' => $course->id, + 'name' => 'Lesson Zero', + 'order' => 1, + ])->assertOk(); + + expect(Lesson::query()->where('name', 'Lesson Zero')->first()->order)->toBe(1); + LmsServer::tool(UpdateLesson::class, [ 'id' => $lesson->id, 'name' => 'Lesson A updated', @@ -245,5 +283,6 @@ function videoCoursePayload(array $overrides = []): array expect(Step::query()->count())->toBe(0)->and(Video::query()->count())->toBe(0); LmsServer::tool(DeleteLesson::class, ['id' => $lesson->id])->assertOk(); - expect(Lesson::query()->count())->toBe(0); + expect(Lesson::query()->whereKey($lesson->id)->exists())->toBeFalse() + ->and(Lesson::query()->where('name', 'Lesson Zero')->exists())->toBeTrue(); }); diff --git a/tests/Feature/StepTest.php b/tests/Feature/StepTest.php index cc27a536..44262ce7 100644 --- a/tests/Feature/StepTest.php +++ b/tests/Feature/StepTest.php @@ -44,6 +44,21 @@ expect($step->lesson->id)->toBe($lesson->id); }); +test('step order is scoped to the lesson', function () { + $course = Course::factory()->create(); + $lessonA = Lesson::factory()->create(['course_id' => $course->id]); + $lessonB = Lesson::factory()->create(['course_id' => $course->id]); + + $firstOnA = Step::factory()->create(['lesson_id' => $lessonA->id]); + $firstOnB = Step::factory()->create(['lesson_id' => $lessonB->id]); + $secondOnA = Step::factory()->create(['lesson_id' => $lessonA->id]); + + expect($firstOnA->order)->toBe(1) + ->and($firstOnB->order)->toBe(1) + ->and($secondOnA->order)->toBe(2) + ->and($firstOnB->fresh()->order)->toBe(1); +}); + test('step can be completed by user', function () { $course = Course::factory()->create(); $lesson = Lesson::factory()->create(['course_id' => $course->id]); diff --git a/tests/TestAdminPanelProvider.php b/tests/TestAdminPanelProvider.php new file mode 100644 index 00000000..5b3a7432 --- /dev/null +++ b/tests/TestAdminPanelProvider.php @@ -0,0 +1,45 @@ +id('admin') + ->path('admin') + ->login() + ->plugin(Lms::make()) + ->middleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + AuthenticateSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, + SubstituteBindings::class, + DisableBladeIconComponents::class, + DispatchServingFilamentEvent::class, + ]) + ->authMiddleware([ + Authenticate::class, + ]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 7378b12d..e9c75d75 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -279,6 +279,7 @@ protected function getPackageProviders($app) MediaLibraryServiceProvider::class, FilamentLmsServiceProvider::class, LmsPanelProvider::class, + TestAdminPanelProvider::class, ]; // Only add FilamentFormBuilderServiceProvider if it exists From d4a95f627d1ff11dcd54c18d35a783bcf95edd1d Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 28 Sep 2026 10:07:49 -0600 Subject: [PATCH 06/11] Accept both JSON slash encodings in LMS MCP URL assertions. prefer-stable laravel/mcp leaves slashes unescaped, so the old admin\/lms path check failed CI. Co-authored-by: Cursor --- tests/Feature/LmsMcpTest.php | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index 359138e7..17d6bf65 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -5,6 +5,8 @@ namespace Tapp\FilamentLms\Tests\Feature; use Laravel\Mcp\Server; +use Laravel\Mcp\Server\Testing\TestResponse; +use ReflectionMethod; use Tapp\FilamentLms\Mcp\LmsServer; use Tapp\FilamentLms\Mcp\Tools\CreateLesson; use Tapp\FilamentLms\Mcp\Tools\CreateVideoCourse; @@ -28,6 +30,17 @@ } }); +function assertMcpSeePath(TestResponse $response, string $path): void +{ + $method = new ReflectionMethod($response, 'content'); + $content = $method->invoke($response); + $haystack = implode("\n", $content); + $escaped = str_replace('/', '\\/', $path); + + expect(str_contains($haystack, $path) || str_contains($haystack, $escaped)) + ->toBeTrue("Expected MCP response to include [{$path}]"); +} + function videoCoursePayload(array $overrides = []): array { return array_merge([ @@ -171,9 +184,10 @@ function videoCoursePayload(array $overrides = []): array ->assertSee('dns-cloudflare') ->assertSee('Getting started') ->assertSee('dQw4w9WgXcQ') - ->assertSee('youtube') - ->assertSee('admin\/lms\/courses') - ->assertSee('lms\/courses\/dns-cloudflare\/getting-started'); + ->assertSee('youtube'); + + assertMcpSeePath($get, 'admin/lms/courses'); + assertMcpSeePath($get, 'lms/courses/dns-cloudflare/getting-started'); }); test('create_video_course scopes lesson and step order to the new course', function () { From 84c84121fac43d61ba1f013f5efda1d960a8dcdf Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 28 Sep 2026 11:09:24 -0600 Subject: [PATCH 07/11] Scope MCP course uniqueness to the tenant and shift lesson order on update. Co-authored-by: Cursor --- src/Mcp/LmsTool.php | 28 ++++++-- src/Mcp/Tools/UpdateLesson.php | 25 ++++++- tests/Feature/LmsMcpTest.php | 123 +++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php index 68a18217..af1753b9 100644 --- a/src/Mcp/LmsTool.php +++ b/src/Mcp/LmsTool.php @@ -14,6 +14,7 @@ use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; use Tapp\FilamentLms\Enums\CompletionMode; +use Tapp\FilamentLms\Helpers\TenantHelper; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Models\Lesson; use Tapp\FilamentLms\Models\Step; @@ -68,9 +69,18 @@ protected function ensureTenantContext(): ?Response */ protected function courseRules(?int $ignoreId = null, bool $creating = false): array { - $unique = fn (string $column): Unique => $ignoreId === null - ? Rule::unique('lms_courses', $column) - : Rule::unique('lms_courses', $column)->ignore($ignoreId); + $unique = function (string $column) use ($ignoreId): Unique { + $table = (new Course)->getTable(); + $rule = $ignoreId === null + ? Rule::unique($table, $column) + : Rule::unique($table, $column)->ignore($ignoreId); + + if (config('filament-lms.tenancy.enabled')) { + $rule->where(TenantHelper::getTenantColumnName(), Filament::getTenant()?->getKey()); + } + + return $rule; + }; return [ 'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255', $unique('name')], @@ -243,11 +253,15 @@ protected function nextLessonOrder(Course $course): int return (int) $course->lessons()->max('order') + 1; } - protected function makeRoomForLessonOrder(Course $course, int $order): void + protected function makeRoomForLessonOrder(Course $course, int $order, ?int $exceptLessonId = null): void { - $course->lessons() - ->where('order', '>=', $order) - ->increment('order'); + $query = $course->lessons()->where('order', '>=', $order); + + if ($exceptLessonId !== null) { + $query->whereKeyNot($exceptLessonId); + } + + $query->increment('order'); } protected function deleteStepMaterial(Step $step): void diff --git a/src/Mcp/Tools/UpdateLesson.php b/src/Mcp/Tools/UpdateLesson.php index 7710a7c1..ca479edf 100644 --- a/src/Mcp/Tools/UpdateLesson.php +++ b/src/Mcp/Tools/UpdateLesson.php @@ -5,10 +5,12 @@ namespace Tapp\FilamentLms\Mcp\Tools; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\DB; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; use Tapp\FilamentLms\Mcp\LmsTool; +use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Models\Lesson; class UpdateLesson extends LmsTool @@ -43,8 +45,27 @@ public function handle(Request $request): Response|ResponseFactory ]); $lesson = Lesson::query()->findOrFail($validated['id']); - $lesson->update(collect($validated)->except('id')->all()); - return Response::structured($this->serializeLesson($lesson->refresh())); + $lesson = DB::transaction(function () use ($lesson, $validated): Lesson { + $targetCourse = Course::query()->findOrFail( + (int) ($validated['course_id'] ?? $lesson->course_id), + ); + $movingCourse = (int) $lesson->course_id !== (int) $targetCourse->id; + $changingOrder = array_key_exists('order', $validated); + $targetOrder = $changingOrder + ? (int) $validated['order'] + : ($movingCourse ? $this->nextLessonOrder($targetCourse) : (int) $lesson->order); + + if ($movingCourse || $targetOrder !== (int) $lesson->order) { + $this->makeRoomForLessonOrder($targetCourse, $targetOrder, $lesson->id); + $validated['order'] = $targetOrder; + } + + $lesson->update(collect($validated)->except('id')->all()); + + return $lesson->refresh(); + }); + + return Response::structured($this->serializeLesson($lesson)); } } diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index 17d6bf65..4d681c3f 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -4,9 +4,13 @@ namespace Tapp\FilamentLms\Tests\Feature; +use Filament\Facades\Filament; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Testing\TestResponse; use ReflectionMethod; +use Tapp\FilamentLms\Helpers\TenantHelper; use Tapp\FilamentLms\Mcp\LmsServer; use Tapp\FilamentLms\Mcp\Tools\CreateLesson; use Tapp\FilamentLms\Mcp\Tools\CreateVideoCourse; @@ -21,8 +25,11 @@ use Tapp\FilamentLms\Mcp\Tools\UpdateStep; use Tapp\FilamentLms\Models\Course; use Tapp\FilamentLms\Models\Lesson; +use Tapp\FilamentLms\Models\Scopes\TenantScope; use Tapp\FilamentLms\Models\Step; use Tapp\FilamentLms\Models\Video; +use Tapp\FilamentLms\Tests\TestTeam; +use Tapp\FilamentLms\Tests\TestUser; beforeEach(function () { if (! class_exists(LmsServer::class) || ! class_exists(Server::class)) { @@ -323,3 +330,119 @@ function videoCoursePayload(array $overrides = []): array expect(Lesson::query()->whereKey($lesson->id)->exists())->toBeFalse() ->and(Lesson::query()->where('name', 'Lesson Zero')->exists())->toBeTrue(); }); + +test('update_lesson shifts sibling order instead of duplicating', function () { + $course = Course::factory()->create([ + 'name' => 'Order Course', + 'slug' => 'order-course', + 'external_id' => 'order_course', + ]); + + LmsServer::tool(CreateLesson::class, [ + 'course_id' => $course->id, + 'name' => 'First', + ])->assertOk(); + LmsServer::tool(CreateLesson::class, [ + 'course_id' => $course->id, + 'name' => 'Second', + ])->assertOk(); + + $first = Lesson::query()->where('name', 'First')->first(); + $second = Lesson::query()->where('name', 'Second')->first(); + + LmsServer::tool(UpdateLesson::class, [ + 'id' => $second->id, + 'order' => 1, + ])->assertOk(); + + expect($second->fresh()->order)->toBe(1) + ->and($first->fresh()->order)->toBe(2); +}); + +test('create_video_course allows the same slug on another tenant', function () { + enableMcpTenancy(); + + $admin = new class extends TestUser + { + public function isLmsAdmin(): bool + { + return true; + } + }; + $admin->forceFill([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + ])->save(); + $this->actingAs($admin); + + $teamA = TestTeam::query()->create(['name' => 'Team A']); + $teamB = TestTeam::query()->create(['name' => 'Team B']); + + Filament::setTenant($teamA); + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + + Filament::setTenant($teamB); + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + + expect(Course::query()->withoutGlobalScopes()->where('slug', 'dns-cloudflare')->count())->toBe(2); + + Filament::setTenant($teamA); + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertHasErrors(); +}); + +function enableMcpTenancy(): void +{ + $schema = Schema::connection((string) config('database.default')); + + if (! $schema->hasTable('teams')) { + $schema->create('teams', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + } + + foreach (['lms_courses', 'lms_lessons', 'lms_steps', 'lms_videos'] as $tableName) { + if (! $schema->hasColumn($tableName, 'team_id')) { + $schema->table($tableName, function (Blueprint $table): void { + $table->unsignedBigInteger('team_id')->nullable(); + }); + } + } + + config([ + 'filament-lms.tenancy.enabled' => true, + 'filament-lms.tenancy.model' => TestTeam::class, + 'filament-lms.tenancy.relationship_name' => 'team', + 'filament-lms.tenancy.column' => 'team_id', + ]); + + $migration = require dirname(__DIR__, 2).'/database/migrations/scope_lms_courses_unique_indexes_to_tenant.php.stub'; + $migration->up(); + + foreach ([Course::class, Lesson::class, Step::class, Video::class] as $model) { + $model::resolveRelationUsing( + TenantHelper::getTenantRelationshipName(), + fn ($instance) => $instance->belongsTo(TestTeam::class, TenantHelper::getTenantColumnName()), + ); + + if (! $model::hasGlobalScope('filament_lms_tenancy')) { + $model::addGlobalScope('filament_lms_tenancy', new TenantScope); + } + + $model::creating(function ($instance): void { + $column = TenantHelper::getTenantColumnName(); + + if (! empty($instance->{$column})) { + return; + } + + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + $instance->{$column} = $tenant->getKey(); + } + }); + } +} From eb05c1527f52800faa0666aca7491cf8a0f51d9c Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 28 Sep 2026 12:50:46 -0600 Subject: [PATCH 08/11] Reject unauthenticated HTTP MCP tool calls. Co-authored-by: Cursor --- src/Mcp/LmsTool.php | 26 ++++++++++++++++++ tests/Feature/LmsMcpTest.php | 52 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/Mcp/LmsTool.php b/src/Mcp/LmsTool.php index af1753b9..e3d70e04 100644 --- a/src/Mcp/LmsTool.php +++ b/src/Mcp/LmsTool.php @@ -37,6 +37,10 @@ protected function authorizeWrite(Request $request): ?Response $user = $request->user(); if ($user === null) { + if ($this->isHttpMcpRequest()) { + return Response::error('Authentication is required to use this tool over HTTP.'); + } + return null; } @@ -64,6 +68,28 @@ protected function ensureTenantContext(): ?Response return Response::error('Tenant context is required when LMS tenancy is enabled.'); } + /** + * Mcp::web() always attaches AddWwwAuthenticateHeader. Stdio and LmsServer::tool() do not. + */ + protected function isHttpMcpRequest(): bool + { + $route = request()->route(); + + if ($route === null) { + return false; + } + + foreach ($route->gatherMiddleware() as $middleware) { + $name = is_string($middleware) ? $middleware : $middleware::class; + + if (str_contains($name, 'AddWwwAuthenticateHeader')) { + return true; + } + } + + return false; + } + /** * @return array */ diff --git a/tests/Feature/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php index 4d681c3f..2bf899d6 100644 --- a/tests/Feature/LmsMcpTest.php +++ b/tests/Feature/LmsMcpTest.php @@ -7,6 +7,7 @@ use Filament\Facades\Filament; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; +use Laravel\Mcp\Facades\Mcp; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Testing\TestResponse; use ReflectionMethod; @@ -391,6 +392,57 @@ public function isLmsAdmin(): bool LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertHasErrors(); }); +test('web mcp denies tools when no user is authenticated', function () { + Mcp::web('/mcp/lms-unsecured', LmsServer::class); + + $response = $this->postJson('/mcp/lms-unsecured', mcpToolCallPayload('list_courses'), [ + 'Accept' => 'application/json, text/event-stream', + ]); + + expect($response->getContent())->toContain('Authentication is required to use this tool over HTTP.'); +}); + +test('web mcp allows tools for an authenticated lms admin', function () { + Mcp::web('/mcp/lms-secured', LmsServer::class); + + $admin = new class extends TestUser + { + public function isLmsAdmin(): bool + { + return true; + } + }; + $admin->forceFill([ + 'name' => 'Http Admin', + 'email' => 'http-admin@example.com', + 'password' => bcrypt('password'), + ])->save(); + $this->actingAs($admin); + + $response = $this->postJson('/mcp/lms-secured', mcpToolCallPayload('list_courses'), [ + 'Accept' => 'application/json, text/event-stream', + ]); + + expect($response->getContent())->not->toContain('Authentication is required to use this tool over HTTP.') + ->and($response->getContent())->not->toContain('You must be an LMS admin to use this tool.'); +}); + +/** + * @return array{jsonrpc: string, id: int, method: string, params: array{name: string, arguments: array}} + */ +function mcpToolCallPayload(string $tool): array +{ + return [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => [ + 'name' => $tool, + 'arguments' => [], + ], + ]; +} + function enableMcpTenancy(): void { $schema = Schema::connection((string) config('database.default')); From 803dc6657b8397f6aca283aed0008857be04007f Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 28 Sep 2026 13:16:54 -0600 Subject: [PATCH 09/11] Register HTTP MCP at /mcp/lms with Sanctum and lms:mcp-token. Hosts still add HasApiTokens and the tokens table; the package now mounts the route, admin gate, rate limit, and token command. Co-authored-by: Cursor --- CHANGELOG.md | 7 +- README.md | 26 +++--- composer.json | 4 +- config/filament-lms.php | 9 ++- src/Console/Commands/LmsMcpTokenCommand.php | 89 +++++++++++++++++++++ src/FilamentLmsServiceProvider.php | 19 ++++- src/Http/Middleware/EnsureLmsMcpAdmin.php | 23 ++++++ tests/Feature/LmsMcpHttpTest.php | 51 ++++++++++++ tests/Feature/LmsMcpTokenCommandTest.php | 52 ++++++++++++ tests/TestCase.php | 16 ++++ tests/TestUser.php | 2 + 11 files changed, 273 insertions(+), 25 deletions(-) create mode 100644 src/Console/Commands/LmsMcpTokenCommand.php create mode 100644 src/Http/Middleware/EnsureLmsMcpAdmin.php create mode 100644 tests/Feature/LmsMcpHttpTest.php create mode 100644 tests/Feature/LmsMcpTokenCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 12dfae28..2962734b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -* Optional Laravel MCP server for writing Course → Lesson → video Step data (`create_video_course` plus granular tools). New courses default to private. Hosts that want MCP should `composer require laravel/mcp`. +* Laravel MCP server for writing Course → Lesson → video Step data (`create_video_course` plus granular tools). New courses default to private. +* HTTP MCP at `POST /mcp/lms` is registered by the package (Sanctum, `throttle:mcp`, `isLmsAdmin()`). `php artisan lms:mcp-token` mints a bearer token. Hosts still add `HasApiTokens` and run the Sanctum `personal_access_tokens` migration. + +### Changed + +* `laravel/mcp` and `laravel/sanctum` are required. Set `filament-lms.mcp.web` to `false` to unpublish `/mcp/lms`. ### Fixed diff --git a/README.md b/README.md index 6471d09e..3b0b101d 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,7 @@ class AdminPanelProvider extends PanelProvider The package can expose **write tools** for AI clients (Cursor, Claude Code, Claude Desktop) so a skill can create Course → Lesson → Step data. Melissa’s skill should draft titles, descriptions, and structure, then call these tools. Videos are hosted YouTube or Vimeo URLs — the package does not upload video files. Optional transcripts go on the step `text` field. New courses default to `is_private = true` (there is no draft flag). -`laravel/mcp` is optional. Hosts that want MCP should install it: - -```bash -composer require laravel/mcp -``` - -The package registers a **local stdio** server when `laravel/mcp` is present and `filament-lms.mcp.enabled` is `true` (the default): +The package requires `laravel/mcp` and `laravel/sanctum`. It registers a **local stdio** server when `filament-lms.mcp.enabled` is `true` (the default): ```php Mcp::local('filament-lms', \Tapp\FilamentLms\Mcp\LmsServer::class); @@ -121,23 +115,23 @@ Example Cursor MCP config: ### Web (remote Claude) + Sanctum -Do **not** auto-register `Mcp::web` from the package. Publish `routes/ai.php` in the host app and register the HTTP server there. Sanctum bearer tokens are the v1 path (no Passport): +The package registers `POST /mcp/lms` with `auth:sanctum`, `throttle:mcp`, and `isLmsAdmin()`. Host leftover: `HasApiTokens` on the user model and the Sanctum `personal_access_tokens` table. -```php -use Laravel\Mcp\Facades\Mcp; - -Mcp::web('/mcp/lms', \Tapp\FilamentLms\Mcp\LmsServer::class) - ->middleware(['auth:sanctum', 'throttle:mcp']); +```bash +php artisan vendor:publish --tag=sanctum-migrations +php artisan migrate +php artisan lms:mcp-token admin@example.com --server-key=your-app ``` -Issue a Sanctum token for an LMS admin user and send it as `Authorization: Bearer …`. Cursor and Claude Desktop work with that header. Claude.ai custom connectors often prefer OAuth — if a token URL is rejected, that is a follow-up (Passport on the host, or Switchboard). +Issue that token only for an LMS admin (`isLmsAdmin()`). Send it as `Authorization: Bearer …`. Cursor and Claude Desktop work with that header. Claude.ai custom connectors often prefer OAuth — if a token URL is rejected, that is a follow-up (Passport on the host, or Switchboard). -Turn off local auto-registration with: +Turn off auto-registration with: ```php // config/filament-lms.php 'mcp' => [ - 'enabled' => false, + 'enabled' => false, // skip stdio + 'web' => false, // skip POST /mcp/lms ], ``` diff --git a/composer.json b/composer.json index 59787081..5adf5978 100644 --- a/composer.json +++ b/composer.json @@ -14,20 +14,20 @@ "filament/filament": "^5.0|^4.0", "filament/spatie-laravel-media-library-plugin": "^5.0|^4.0", "illuminate/contracts": "^13.0||^12.0", + "laravel/mcp": "^0.7.1|^0.8|^0.9|^1.0", + "laravel/sanctum": "^4.0", "maatwebsite/excel": "^4.0", "spatie/browsershot": "^5.0", "spatie/eloquent-sortable": "^5.0", "tapp/filament-form-builder": "^4.3.5" }, "suggest": { - "laravel/mcp": "Optional. Enables the Filament LMS MCP server so AI clients can create and edit courses (hosts should composer require laravel/mcp).", "tapp/filament-library": "Required for Library file / Library link step materials (set filament-lms.integrations.filament_library.enabled for the admin material picker).", "tapp/filament-certificate-builder": "Required for course certificates. Install before running filament-lms:upgrade-awards.", "spykapps/filament-uppy-upload": "Optional. Enables Uppy chunked (multipart) uploads for SCORM package import so large ZIPs stay under Cloudflare's ~100MB limit (set filament-lms.common_cartridge_import.multipart_upload.enabled)." }, "require-dev": { "filament/upgrade": "^4.0", - "laravel/mcp": "^0.5|^1.0", "larastan/larastan": "^3.0||^2.9", "laravel/pao": "^1.0", "laravel/pint": "^1.14", diff --git a/config/filament-lms.php b/config/filament-lms.php index 2454d339..c87301b0 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -285,13 +285,14 @@ | MCP server |-------------------------------------------------------------------------- | - | When laravel/mcp is installed, the package registers a local stdio server - | named `filament-lms`. Set enabled to false to skip that auto-registration. - | Web (HTTP) registration stays in the host app — do not rely on the package - | to call Mcp::web(). + | The package registers a local stdio server named `filament-lms` and an + | HTTP server at POST /mcp/lms. HTTP is on by default, behind Sanctum, + | throttle:mcp, and isLmsAdmin(). Set web to false to unpublish the route. + | Set enabled to false to skip stdio registration. | */ 'mcp' => [ 'enabled' => true, + 'web' => true, ], ]; diff --git a/src/Console/Commands/LmsMcpTokenCommand.php b/src/Console/Commands/LmsMcpTokenCommand.php new file mode 100644 index 00000000..58dcd23c --- /dev/null +++ b/src/Console/Commands/LmsMcpTokenCommand.php @@ -0,0 +1,89 @@ +argument('email'); + $userModel = config('filament-lms.user_model'); + + if (! is_string($userModel) || ! class_exists($userModel)) { + $this->error('filament-lms.user_model must be a valid user class.'); + + return self::FAILURE; + } + + /** @var Model|null $user */ + $user = $userModel::query()->where('email', $email)->first(); + + if ($user === null) { + $this->error("No user found for [{$email}]."); + + return self::FAILURE; + } + + if (! method_exists($user, 'isLmsAdmin') || ! $user->isLmsAdmin()) { + $this->error("[{$email}] is not an LMS admin."); + + return self::FAILURE; + } + + if (! method_exists($user, 'createToken')) { + $this->error('The user model must use Laravel\\Sanctum\\HasApiTokens.'); + + return self::FAILURE; + } + + $tokenName = (string) $this->option('name'); + + if ($this->option('rotate')) { + $deleted = PersonalAccessToken::query() + ->where('tokenable_type', $user->getMorphClass()) + ->where('tokenable_id', $user->getKey()) + ->where('name', $tokenName) + ->delete(); + + $this->info("Deleted {$deleted} existing [{$tokenName}] token(s)."); + } + + $plainTextToken = $user->createToken($tokenName)->plainTextToken; + $mcpUrl = rtrim((string) config('app.url'), '/').'/mcp/lms'; + $serverKey = (string) $this->option('server-key'); + + $config = [ + 'mcpServers' => [ + $serverKey => [ + 'url' => $mcpUrl, + 'headers' => [ + 'Authorization' => 'Bearer '.$plainTextToken, + ], + ], + ], + ]; + + $this->newLine(); + $this->info("Token minted for {$email} (id {$user->getKey()}). Copy into Claude Desktop MCP settings:"); + $this->newLine(); + $this->line(json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $this->newLine(); + $this->warn('The plaintext token is shown once. Use --rotate to replace it later.'); + + return self::SUCCESS; + } +} diff --git a/src/FilamentLmsServiceProvider.php b/src/FilamentLmsServiceProvider.php index afa6bc0e..4e9f31ed 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -5,7 +5,10 @@ use Filament\Support\Assets\Css; use Filament\Support\Assets\Js; use Filament\Support\Facades\FilamentAsset; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Laravel\Mcp\Facades\Mcp; use Livewire\Livewire; use Spatie\LaravelPackageTools\Commands\InstallCommand; @@ -15,9 +18,11 @@ use Tapp\FilamentLms\Console\Commands\BackfillCourseCompletedAt; use Tapp\FilamentLms\Console\Commands\BackfillEmbeddedPlayerCourses; use Tapp\FilamentLms\Console\Commands\ImportCartridgesCommand; +use Tapp\FilamentLms\Console\Commands\LmsMcpTokenCommand; use Tapp\FilamentLms\Console\Commands\MigrateAwardsToCertificateTemplatesCommand; use Tapp\FilamentLms\Console\Commands\ReconcileUserGroupMemberships; use Tapp\FilamentLms\Console\Commands\UpgradeAwardsCommand; +use Tapp\FilamentLms\Http\Middleware\EnsureLmsMcpAdmin; use Tapp\FilamentLms\Livewire\DocumentStep; use Tapp\FilamentLms\Livewire\FormStep; use Tapp\FilamentLms\Livewire\ImageStep; @@ -86,6 +91,7 @@ public function configurePackage(Package $package): void ->hasCommand(MigrateAwardsToCertificateTemplatesCommand::class) ->hasCommand(UpgradeAwardsCommand::class) ->hasCommand(ReconcileUserGroupMemberships::class) + ->hasCommand(LmsMcpTokenCommand::class) ->hasInstallCommand(function (InstallCommand $command) { $command ->publishMigrations() @@ -156,11 +162,20 @@ protected function registerMcpServer(): void return; } - if (! config('filament-lms.mcp.enabled', true)) { + if (config('filament-lms.mcp.enabled', true)) { + Mcp::local('filament-lms', LmsServer::class); + } + + if (! config('filament-lms.mcp.web', true)) { return; } - Mcp::local('filament-lms', LmsServer::class); + RateLimiter::for('mcp', function (Request $request) { + return Limit::perMinute(60)->by((string) ($request->user()?->getAuthIdentifier() ?: $request->ip())); + }); + + Mcp::web('/mcp/lms', LmsServer::class) + ->middleware(['auth:sanctum', 'throttle:mcp', EnsureLmsMcpAdmin::class]); } protected function registerUserGroupMembershipObserver(): void diff --git a/src/Http/Middleware/EnsureLmsMcpAdmin.php b/src/Http/Middleware/EnsureLmsMcpAdmin.php new file mode 100644 index 00000000..9f3dab8c --- /dev/null +++ b/src/Http/Middleware/EnsureLmsMcpAdmin.php @@ -0,0 +1,23 @@ +user(); + + if ($user === null || ! method_exists($user, 'isLmsAdmin') || ! $user->isLmsAdmin()) { + abort(403, 'LMS MCP access is limited to LMS admins.'); + } + + return $next($request); + } +} diff --git a/tests/Feature/LmsMcpHttpTest.php b/tests/Feature/LmsMcpHttpTest.php new file mode 100644 index 00000000..5a5c573b --- /dev/null +++ b/tests/Feature/LmsMcpHttpTest.php @@ -0,0 +1,51 @@ +markTestSkipped('laravel/mcp is required to run LMS MCP HTTP tests.'); + } +}); + +test('http mcp rejects unauthenticated requests', function () { + $this->postJson('/mcp/lms')->assertUnauthorized(); +}); + +test('http mcp rejects non-admin sanctum users', function () { + $user = TestUser::query()->create([ + 'name' => 'Member', + 'email' => 'member@example.com', + 'password' => bcrypt('password'), + ]); + + Sanctum::actingAs($user); + + $this->postJson('/mcp/lms')->assertForbidden(); +}); + +test('http mcp allows an authenticated lms admin past the gate', function () { + $admin = new class extends TestUser + { + public function isLmsAdmin(): bool + { + return true; + } + }; + $admin->forceFill([ + 'name' => 'Admin', + 'email' => 'http-admin@example.com', + 'password' => bcrypt('password'), + ])->save(); + + Sanctum::actingAs($admin); + + $response = $this->postJson('/mcp/lms'); + + expect($response->status())->not->toBe(401) + ->and($response->status())->not->toBe(403); +}); diff --git a/tests/Feature/LmsMcpTokenCommandTest.php b/tests/Feature/LmsMcpTokenCommandTest.php new file mode 100644 index 00000000..b8b303b3 --- /dev/null +++ b/tests/Feature/LmsMcpTokenCommandTest.php @@ -0,0 +1,52 @@ +artisan('lms:mcp-token', ['email' => 'missing@example.com']) + ->expectsOutputToContain('No user found') + ->assertFailed(); +}); + +test('lms mcp token command fails when the user is not an lms admin', function () { + TestUser::query()->create([ + 'name' => 'Member', + 'email' => 'member@example.com', + 'password' => bcrypt('password'), + ]); + + $this->artisan('lms:mcp-token', ['email' => 'member@example.com']) + ->expectsOutputToContain('is not an LMS admin') + ->assertFailed(); +}); + +test('lms mcp token command prints claude json for an lms admin', function () { + $admin = new class extends TestUser + { + public function isLmsAdmin(): bool + { + return true; + } + }; + $admin->setTable('users'); + $admin->forceFill([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + 'password' => bcrypt('password'), + ])->save(); + + config(['filament-lms.user_model' => $admin::class]); + + $this->artisan('lms:mcp-token', [ + 'email' => 'admin@example.com', + '--server-key' => 'check-lms-staging', + ]) + ->expectsOutputToContain('Token minted') + ->expectsOutputToContain('check-lms-staging') + ->assertSuccessful(); + + expect(PersonalAccessToken::query()->where('name', 'lms-mcp')->exists())->toBeTrue(); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 1be22dcf..b9640c44 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -11,6 +11,7 @@ use Illuminate\Support\MessageBag; use Illuminate\Support\ViewErrorBag; use Laravel\Mcp\Server\McpServiceProvider; +use Laravel\Sanctum\SanctumServiceProvider; use Livewire\LivewireServiceProvider; use Maatwebsite\Excel\ExcelServiceProvider; use Orchestra\Testbench\TestCase as Orchestra; @@ -95,6 +96,17 @@ protected function setUpDatabase($app) $table->timestamps(); }); + $app['db']->connection()->getSchemaBuilder()->create('personal_access_tokens', function (Blueprint $table) { + $table->id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + // Create lms_courses table $app['db']->connection()->getSchemaBuilder()->create('lms_courses', function (Blueprint $table) { $table->id(); @@ -334,6 +346,10 @@ protected function getPackageProviders($app) $providers[] = McpServiceProvider::class; } + if (class_exists(SanctumServiceProvider::class)) { + $providers[] = SanctumServiceProvider::class; + } + return $providers; } } diff --git a/tests/TestUser.php b/tests/TestUser.php index 22d71b66..2347aa31 100644 --- a/tests/TestUser.php +++ b/tests/TestUser.php @@ -4,11 +4,13 @@ use Illuminate\Foundation\Auth\User; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; use Tapp\FilamentLms\Traits\FilamentLmsUser; class TestUser extends User { use FilamentLmsUser; + use HasApiTokens; use Notifiable; protected $fillable = ['name', 'email', 'password']; From f7f8637e7624190ccfbf8273310bef145de279a8 Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Mon, 28 Sep 2026 13:39:10 -0600 Subject: [PATCH 10/11] Drop host-specific names from MCP docs and token tests. Package README and tests should describe any host, not one project. Co-authored-by: Cursor --- README.md | 16 ++++++++++++++-- tests/Feature/LmsMcpTokenCommandTest.php | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3b0b101d..587cf445 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ class AdminPanelProvider extends PanelProvider ## MCP Server -The package can expose **write tools** for AI clients (Cursor, Claude Code, Claude Desktop) so a skill can create Course → Lesson → Step data. Melissa’s skill should draft titles, descriptions, and structure, then call these tools. Videos are hosted YouTube or Vimeo URLs — the package does not upload video files. Optional transcripts go on the step `text` field. New courses default to `is_private = true` (there is no draft flag). +The package can expose **write tools** for AI clients (Cursor, Claude Code, Claude Desktop) so a skill can create Course → Lesson → Step data. Draft titles, descriptions, and structure, then call these tools. Videos are hosted YouTube or Vimeo URLs — the package does not upload video files. Optional transcripts go on the step `text` field. New courses default to `is_private = true` (there is no draft flag). The package requires `laravel/mcp` and `laravel/sanctum`. It registers a **local stdio** server when `filament-lms.mcp.enabled` is `true` (the default): @@ -123,7 +123,19 @@ php artisan migrate php artisan lms:mcp-token admin@example.com --server-key=your-app ``` -Issue that token only for an LMS admin (`isLmsAdmin()`). Send it as `Authorization: Bearer …`. Cursor and Claude Desktop work with that header. Claude.ai custom connectors often prefer OAuth — if a token URL is rejected, that is a follow-up (Passport on the host, or Switchboard). +Issue that token only for an LMS admin (`isLmsAdmin()`). The command prints Claude Desktop JSON once. Send the token as `Authorization: Bearer …`. + +Claude Desktop: Settings → Developer → Edit Config, merge the printed `mcpServers` entry, restart, then ask to list courses (`list_courses`). + +Claude Code: + +```bash +claude mcp add --transport http --scope user filament-lms \ + "{APP_URL}/mcp/lms" \ + --header "Authorization: Bearer {TOKEN}" +``` + +Cursor and Claude Desktop work with that header. Claude.ai custom connectors often prefer OAuth — if a token URL is rejected, that is a follow-up (Passport on the host, or Switchboard). Turn off auto-registration with: diff --git a/tests/Feature/LmsMcpTokenCommandTest.php b/tests/Feature/LmsMcpTokenCommandTest.php index b8b303b3..9e3974b0 100644 --- a/tests/Feature/LmsMcpTokenCommandTest.php +++ b/tests/Feature/LmsMcpTokenCommandTest.php @@ -42,10 +42,10 @@ public function isLmsAdmin(): bool $this->artisan('lms:mcp-token', [ 'email' => 'admin@example.com', - '--server-key' => 'check-lms-staging', + '--server-key' => 'filament-lms', ]) ->expectsOutputToContain('Token minted') - ->expectsOutputToContain('check-lms-staging') + ->expectsOutputToContain('filament-lms') ->assertSuccessful(); expect(PersonalAccessToken::query()->where('name', 'lms-mcp')->exists())->toBeTrue(); From 83e0b8cb9cbc6314575a259d6c5949a59cef956f Mon Sep 17 00:00:00 2001 From: Scott Grayson Date: Tue, 29 Sep 2026 14:24:01 -0600 Subject: [PATCH 11/11] Record v5.1.0 MCP HTTP server and write tools in the changelog. Co-authored-by: Cursor --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2962734b..a89c73f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## v5.1.0 - 2026-09-29 + +Register HTTP MCP at `POST /mcp/lms` and add write tools for Course → Lesson → video Step. + ### Added * Laravel MCP server for writing Course → Lesson → video Step data (`create_video_course` plus granular tools). New courses default to private. @@ -25,6 +29,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `create_lesson` with an explicit order shifts later lessons instead of leaving duplicate positions. * `update_step` validates the video URL before writing and will not convert a non-video step. +**Full Changelog**: https://github.com/TappNetwork/Filament-LMS/compare/v5.0.1...v5.1.0 + ## v5.0.1 - 2026-09-23 Fix landscape image steps overflowing on mobile, and add tap-to-zoom lightbox for readable full-size previews.