Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions config/filament-lms.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
];
89 changes: 89 additions & 0 deletions src/Console/Commands/LmsMcpTokenCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Laravel\Sanctum\PersonalAccessToken;

class LmsMcpTokenCommand extends Command
{
protected $signature = 'lms:mcp-token
{email : LMS admin email to mint the token for}
{--rotate : Delete existing lms-mcp tokens for this user before creating a new one}
{--name=lms-mcp : Sanctum token name}
{--server-key=filament-lms : Claude Desktop mcpServers key}';

protected $description = 'Mint a Sanctum token for LMS MCP and print Claude Desktop JSON';

public function handle(): int
{
$email = (string) $this->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;
}
}
31 changes: 31 additions & 0 deletions src/FilamentLmsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/Http/Middleware/EnsureLmsMcpAdmin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureLmsMcpAdmin
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();

if ($user === null || ! method_exists($user, 'isLmsAdmin') || ! $user->isLmsAdmin()) {
abort(403, 'LMS MCP access is limited to LMS admins.');
}

return $next($request);
}
}
Loading