diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c37deb..a89c73f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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 + +## 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. +* 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 + +* 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. +* MCP course tools use `certificate_template_id` instead of the dropped `award` column. +* Auto-generated step slugs include the course slug so two courses can share lesson and step names. +* `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. @@ -59,12 +85,6 @@ Breaking change: course certificates require certificate-builder templates. `lms **Full Changelog**: https://github.com/TappNetwork/Filament-LMS/compare/v4.7.6...v4.7.7 -## Unreleased - -### Changed - -- Learner dashboard lists courses newest first (`created_at` descending). - ## v4.7.6 - 2026-08-07 ### What's Changed diff --git a/README.md b/README.md index 3191cbd..587cf44 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,91 @@ 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. 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): + +```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 + +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. + +```bash +php artisan vendor:publish --tag=sanctum-migrations +php artisan migrate +php artisan lms:mcp-token admin@example.com --server-key=your-app +``` + +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: + +```php +// config/filament-lms.php +'mcp' => [ + 'enabled' => false, // skip stdio + 'web' => false, // skip POST /mcp/lms +], +``` + +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 1744769..5adf597 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,8 @@ "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", diff --git a/config/filament-lms.php b/config/filament-lms.php index 64dd29b..c87301b 100644 --- a/config/filament-lms.php +++ b/config/filament-lms.php @@ -279,4 +279,20 @@ 'evaluations' => [ 'enabled' => false, ], + + /* + |-------------------------------------------------------------------------- + | MCP server + |-------------------------------------------------------------------------- + | + | 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 0000000..58dcd23 --- /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 2ccf8dc..4e9f31e 100644 --- a/src/FilamentLmsServiceProvider.php +++ b/src/FilamentLmsServiceProvider.php @@ -5,7 +5,11 @@ 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; use Spatie\LaravelPackageTools\Package; @@ -14,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; @@ -29,6 +35,7 @@ use Tapp\FilamentLms\Livewire\VideoStep; use Tapp\FilamentLms\Livewire\ViewGradedEntry; use Tapp\FilamentLms\Livewire\VimeoVideo; +use Tapp\FilamentLms\Mcp\LmsServer; use Tapp\FilamentLms\Observers\UserGroupMembershipUserObserver; use Tapp\FilamentLms\Pages\CreateTestEntry; use Tapp\FilamentLms\UserGroups\UserGroupCriteriaRegistry; @@ -84,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() @@ -145,6 +153,29 @@ public function packageBooted() $this->configureLivewireTemporaryUploadLimits(); $this->registerUserGroupMembershipObserver(); + $this->registerMcpServer(); + } + + protected function registerMcpServer(): void + { + if (! class_exists(Mcp::class)) { + return; + } + + if (config('filament-lms.mcp.enabled', true)) { + Mcp::local('filament-lms', LmsServer::class); + } + + if (! config('filament-lms.mcp.web', true)) { + return; + } + + 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 0000000..9f3dab8 --- /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/src/Mcp/LmsServer.php b/src/Mcp/LmsServer.php new file mode 100644 index 0000000..33e4c7c --- /dev/null +++ b/src/Mcp/LmsServer.php @@ -0,0 +1,55 @@ +> + */ + 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 0000000..e3d70e0 --- /dev/null +++ b/src/Mcp/LmsTool.php @@ -0,0 +1,479 @@ +ensureTenantContext()) { + return $denied; + } + + $user = $request->user(); + + if ($user === null) { + if ($this->isHttpMcpRequest()) { + return Response::error('Authentication is required to use this tool over HTTP.'); + } + + return null; + } + + if (method_exists($user, 'isLmsAdmin') && $user->isLmsAdmin()) { + return null; + } + + 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; + } + + 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.'); + } + + /** + * 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 + */ + protected function courseRules(?int $ignoreId = null, bool $creating = false): array + { + $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')], + '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'], + 'certificate_template_id' => ['sometimes', 'nullable', 'integer'], + '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' => $this->generateExternalId($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'] + : $this->generateExternalId((string) $name); + $attributes['certificate_template_id'] = array_key_exists('certificate_template_id', $input) + ? $input['certificate_template_id'] + : CertificateBuilder::defaultTemplateId(); + $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', 'certificate_template_id', '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 + { + $lesson->loadMissing('course'); + $name = trim((string) $input['name']); + $slug = filled($input['slug'] ?? null) + ? (string) $input['slug'] + : $lesson->course->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 makeRoomForLessonOrder(Course $course, int $order, ?int $exceptLessonId = null): void + { + $query = $course->lessons()->where('order', '>=', $order); + + if ($exceptLessonId !== null) { + $query->whereKeyNot($exceptLessonId); + } + + $query->increment('order'); + } + + 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, + 'certificate_template_id' => $course->certificate_template_id, + '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 + { + $adminPanelId = $this->panelIdForResource(CourseResource::class); + $learnerPanelId = $this->panelIdForPage(StepPage::class) ?? $this->panelIdForPage(Dashboard::class); + + return [ + '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(); + + if ($firstStep instanceof Step) { + return StepPage::getUrlForStep($firstStep); + } + + return Dashboard::getUrl(panel: $learnerPanelId); + }), + ]; + } + + /** + * @return array + */ + protected function stepUrls(Step $step): array + { + $learnerPanelId = $this->panelIdForPage(StepPage::class); + + return [ + 'learner' => $this->urlOnPanel( + $learnerPanelId, + fn (): string => StepPage::getUrlForStep($step), + ), + ]; + } + + /** + * @param callable(): string $callback + */ + protected function urlOnPanel(?string $panelId, callable $callback): ?string + { + $previous = Filament::getCurrentPanel(); + + try { + if ($panelId !== null) { + Filament::setCurrentPanel($panelId); + } + + return $callback(); + } 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(); + } + } + + try { + return Filament::getPanel('lms', isStrict: false)->getId(); + } catch (Throwable) { + 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/src/Mcp/Tools/CreateLesson.php b/src/Mcp/Tools/CreateLesson.php new file mode 100644 index 0000000..b0915af --- /dev/null +++ b/src/Mcp/Tools/CreateLesson.php @@ -0,0 +1,69 @@ + $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']); + $explicitOrder = array_key_exists('order', $validated) && $validated['order'] !== null; + $order = $explicitOrder ? (int) $validated['order'] : $this->nextLessonOrder($course); + + if ($explicitOrder) { + $this->makeRoomForLessonOrder($course, $order); + } + + $lesson = new Lesson([ + 'course_id' => $course->id, + 'name' => $name, + 'slug' => filled($validated['slug'] ?? null) ? (string) $validated['slug'] : Str::slug($name), + 'order' => $order, + ]); + + if ($explicitOrder) { + $lesson->sortable['sort_when_creating'] = false; + } + + $lesson->save(); + + return Response::structured($this->serializeLesson($lesson->refresh())); + } +} diff --git a/src/Mcp/Tools/CreateVideoCourse.php b/src/Mcp/Tools/CreateVideoCourse.php new file mode 100644 index 0000000..6f77cd9 --- /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.'), + 'certificate_template_id' => $schema->integer()->description('Certificate template ID. Defaults to the LMS default template when one exists.'), + '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 0000000..57601f2 --- /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 {course-slug}-{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 0000000..79eb256 --- /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 0000000..12c6847 --- /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 0000000..26b0cb8 --- /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 0000000..15be329 --- /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 0000000..6c8e39f --- /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 0000000..c56e473 --- /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.'), + 'certificate_template_id' => $schema->integer()->description('Certificate template ID.'), + '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 0000000..ca479ed --- /dev/null +++ b/src/Mcp/Tools/UpdateLesson.php @@ -0,0 +1,71 @@ + $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 = 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/src/Mcp/Tools/UpdateStep.php b/src/Mcp/Tools/UpdateStep.php new file mode 100644 index 0000000..4d027f2 --- /dev/null +++ b/src/Mcp/Tools/UpdateStep.php @@ -0,0 +1,98 @@ + $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); + } + + $url = isset($validated['video_url']) + ? $this->resolveVideoUrl((string) $validated['video_url']) + : null; + + if (isset($validated['video_url']) && $step->material_type !== null && $step->material_type !== 'video') { + throw ValidationException::withMessages([ + 'video_url' => 'This step is not a video. Create a new video step instead of changing the material type.', + ]); + } + + $step = DB::transaction(function () use ($step, $validated, $url): Step { + $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; + + 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 $step->refresh()->load('material'); + }); + + return Response::structured($this->serializeStep($step)); + } +} diff --git a/src/Models/Lesson.php b/src/Models/Lesson.php index ebb8438..da95434 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 1154a87..5d0f6f6 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/src/Models/Video.php b/src/Models/Video.php index 6c305a5..9fbe6fd 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/LessonTest.php b/tests/Feature/LessonTest.php index 51ee99f..98a84bd 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/LmsMcpHttpTest.php b/tests/Feature/LmsMcpHttpTest.php new file mode 100644 index 0000000..5a5c573 --- /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/LmsMcpTest.php b/tests/Feature/LmsMcpTest.php new file mode 100644 index 0000000..2bf899d --- /dev/null +++ b/tests/Feature/LmsMcpTest.php @@ -0,0 +1,500 @@ +markTestSkipped('laravel/mcp is required to run LMS MCP tests.'); + } +}); + +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([ + '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->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('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' => [ + [ + '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('dQw4w9WgXcQ') + ->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 () { + 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('create_video_course allows the same lesson and step names on a second course', function () { + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload())->assertOk(); + LmsServer::tool(CreateVideoCourse::class, videoCoursePayload([ + 'name' => 'Second Course', + 'slug' => 'second-course', + 'external_id' => 'second_course', + ]))->assertOk(); + + expect(Course::query()->count())->toBe(2) + ->and(Step::query()->pluck('slug')->all())->toBe([ + 'dns-cloudflare-getting-started-welcome-video', + 'second-course-getting-started-welcome-video', + ]); +}); + +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(CreateLesson::class, [ + 'course_id' => $course->id, + 'name' => 'Lesson Zero', + 'order' => 1, + ])->assertOk(); + + expect(Lesson::query()->where('name', 'Lesson Zero')->first()->order)->toBe(1) + ->and(Lesson::query()->where('name', 'Lesson A')->first()->order)->toBe(2); + + 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('manual-course-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' => 'Should not persist', + 'video_url' => 'https://example.com/not-a-video', + ])->assertHasErrors(); + + expect($step->fresh()->name)->toBe('Step one'); + + 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()->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(); +}); + +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')); + + 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(); + } + }); + } +} diff --git a/tests/Feature/LmsMcpTokenCommandTest.php b/tests/Feature/LmsMcpTokenCommandTest.php new file mode 100644 index 0000000..9e3974b --- /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' => 'filament-lms', + ]) + ->expectsOutputToContain('Token minted') + ->expectsOutputToContain('filament-lms') + ->assertSuccessful(); + + expect(PersonalAccessToken::query()->where('name', 'lms-mcp')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/StepTest.php b/tests/Feature/StepTest.php index cc27a53..44262ce 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 0000000..5b3a743 --- /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 a4e1433..b9640c4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,6 +10,8 @@ use Illuminate\Database\Schema\Blueprint; 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; @@ -94,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(); @@ -321,6 +334,7 @@ protected function getPackageProviders($app) MediaLibraryServiceProvider::class, FilamentLmsServiceProvider::class, LmsPanelProvider::class, + TestAdminPanelProvider::class, ]; // Only add FilamentFormBuilderServiceProvider if it exists @@ -328,6 +342,14 @@ protected function getPackageProviders($app) $providers[] = FilamentFormBuilderServiceProvider::class; } + if (class_exists(McpServiceProvider::class)) { + $providers[] = McpServiceProvider::class; + } + + if (class_exists(SanctumServiceProvider::class)) { + $providers[] = SanctumServiceProvider::class; + } + return $providers; } } diff --git a/tests/TestUser.php b/tests/TestUser.php index 22d71b6..2347aa3 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'];