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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,39 @@ Turn off auto-registration with:
],
```

### Register the tools on your own server

An app that already runs its own MCP server can serve the LMS tools from it. Spread `LmsServer::TOOLS` into your server and add your own tools. Use `HasSanctumAbilities` to get the Sanctum ability list for every tool on your server:

```php
use Laravel\Mcp\Server;
use Tapp\FilamentLms\Mcp\Concerns\HasSanctumAbilities;
use Tapp\FilamentLms\Mcp\LmsServer;

class AppMcpServer extends Server
{
use HasSanctumAbilities;

protected array $tools = [
...LmsServer::TOOLS,
\App\Mcp\Tools\CreateWidget::class,
];
}
```

Register it in `routes/ai.php`. Keep the `filament-lms` handle and the `/mcp/lms` path if existing clients and tokens must keep working:

```php
use Laravel\Mcp\Facades\Mcp;
use Tapp\FilamentLms\Http\Middleware\EnsureLmsMcpAdmin;

Mcp::local('filament-lms', AppMcpServer::class);
Mcp::web('/mcp/lms', AppMcpServer::class)
->middleware(['auth:sanctum', 'throttle:mcp', EnsureLmsMcpAdmin::class]);
```

The package registers its own servers after every provider boots. It skips the `filament-lms` handle and the `/mcp/lms` path when your app already registered them, so your server wins without a config change. The package always defines the `mcp` rate limiter unless your app defines it first. To add your own tools to the Filament Sanctum ability checkboxes, merge `AppMcpServer::sanctumAbilities()` into `filament-sanctum.abilities.list`.

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
Expand Down
2 changes: 2 additions & 0 deletions config/filament-lms.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,8 @@
| 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.
| Both stay out of the way when the host app registers its own server on the
| same handle or path, for example in routes/ai.php.
| contribute_abilities merges MCP tool names into filament-sanctum.abilities.list.
|
*/
Expand Down
27 changes: 17 additions & 10 deletions src/FilamentLmsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@

class FilamentLmsServiceProvider extends PackageServiceProvider
{
private const MCP_LOCAL_HANDLE = 'filament-lms';

private const MCP_WEB_PATH = 'mcp/lms';

public function configurePackage(Package $package): void
{
$package
Expand Down Expand Up @@ -166,20 +170,23 @@ protected function registerMcpServer(): void
return;
}

if (config('filament-lms.mcp.enabled', true)) {
Mcp::local('filament-lms', LmsServer::class);
if (RateLimiter::limiter('mcp') === null) {
RateLimiter::for('mcp', function (Request $request) {
return Limit::perMinute(60)->by((string) ($request->user()?->getAuthIdentifier() ?: $request->ip()));
});
}

if (! config('filament-lms.mcp.web', true)) {
return;
}
// Wait until every provider has booted so routes/ai.php has run and a host server can claim the handle or path first.
$this->app->booted(function (): void {
if (config('filament-lms.mcp.enabled', true) && Mcp::getLocalServer(self::MCP_LOCAL_HANDLE) === null) {
Mcp::local(self::MCP_LOCAL_HANDLE, LmsServer::class);
}

RateLimiter::for('mcp', function (Request $request) {
return Limit::perMinute(60)->by((string) ($request->user()?->getAuthIdentifier() ?: $request->ip()));
if (config('filament-lms.mcp.web', true) && Mcp::getWebServer(self::MCP_WEB_PATH) === null) {
Mcp::web(self::MCP_WEB_PATH, LmsServer::class)
->middleware(['auth:sanctum', 'throttle:mcp', EnsureLmsMcpAdmin::class]);
}
});

Mcp::web('/mcp/lms', LmsServer::class)
->middleware(['auth:sanctum', 'throttle:mcp', EnsureLmsMcpAdmin::class]);
}

protected function contributeSanctumAbilities(): void
Expand Down
31 changes: 31 additions & 0 deletions src/Mcp/Concerns/HasSanctumAbilities.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Mcp\Concerns;

use Laravel\Mcp\Server\Tool;
use ReflectionClass;

trait HasSanctumAbilities
{
/**
* Sanctum ability keys mapped to Filament checkbox labels.
*
* @return array<string, string>
*/
public static function sanctumAbilities(): array
{
/** @var list<class-string<Tool>> $toolClasses */
$toolClasses = (new ReflectionClass(static::class))->getDefaultProperties()['tools'] ?? [];

$abilities = [];

foreach ($toolClasses as $toolClass) {
$tool = new $toolClass;
$abilities[$tool->name()] = $tool->title();
}

return $abilities;
}
}
29 changes: 9 additions & 20 deletions src/Mcp/LmsServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Tool;
use ReflectionClass;
use Tapp\FilamentLms\Mcp\Concerns\HasSanctumAbilities;
use Tapp\FilamentLms\Mcp\Tools\CreateLesson;
use Tapp\FilamentLms\Mcp\Tools\CreateVideoCourse;
use Tapp\FilamentLms\Mcp\Tools\CreateVideoStep;
Expand All @@ -21,6 +21,8 @@

class LmsServer extends Server
{
use HasSanctumAbilities;

protected string $name = 'Filament LMS';

protected string $version = '1.0.0';
Expand All @@ -38,9 +40,11 @@ class LmsServer extends Server
MARKDOWN;

/**
* @var array<int, class-string<Tool>>
* Host apps spread this into their own server's $tools.
*
* @var list<class-string<Tool>>
*/
protected array $tools = [
public const TOOLS = [
CreateVideoCourse::class,
ListCourses::class,
GetCourse::class,
Expand All @@ -55,22 +59,7 @@ class LmsServer extends Server
];

/**
* Sanctum ability keys mapped to Filament checkbox labels.
*
* @return array<string, string>
* @var array<int, class-string<Tool>>
*/
public static function sanctumAbilities(): array
{
/** @var list<class-string<Tool>> $toolClasses */
$toolClasses = (new ReflectionClass(static::class))->getDefaultProperties()['tools'] ?? [];

$abilities = [];

foreach ($toolClasses as $toolClass) {
$tool = new $toolClass;
$abilities[$tool->name()] = $tool->title();
}

return $abilities;
}
protected array $tools = self::TOOLS;
}
115 changes: 115 additions & 0 deletions tests/Feature/LmsMcpHostServerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

declare(strict_types=1);

use Illuminate\Routing\RouteCollection;
use Illuminate\Support\Facades\Route;
use Laravel\Mcp\Facades\Mcp;
use Laravel\Mcp\Server\Registrar;
use Laravel\Sanctum\Sanctum;
use Tapp\FilamentLms\FilamentLmsServiceProvider;
use Tapp\FilamentLms\Http\Middleware\EnsureLmsMcpAdmin;
use Tapp\FilamentLms\Mcp\LmsServer;
use Tapp\FilamentLms\Tests\Support\HostMcpServer;
use Tapp\FilamentLms\Tests\TestUser;

beforeEach(function () {
if (! class_exists(LmsServer::class)) {
$this->markTestSkipped('laravel/mcp is required for host MCP server tests.');
}
});

function resetMcpRegistrarAndRoutes(): void
{
app()->instance(Registrar::class, new Registrar);
Mcp::clearResolvedInstances();
Route::setRoutes(new RouteCollection);
}

function runPackageMcpRegistration(): void
{
$provider = app()->getProvider(FilamentLmsServiceProvider::class);

(new ReflectionMethod($provider, 'registerMcpServer'))->invoke($provider);
}

/**
* @return list<string>
*/
function listMcpToolNamesOverHttp(): array
{
$admin = new class extends TestUser
{
public function isLmsAdmin(): bool
{
return true;
}
};
$admin->forceFill([
'name' => 'Admin',
'email' => 'host-admin@example.com',
'password' => bcrypt('password'),
])->save();

Sanctum::actingAs($admin);

$response = test()->postJson('/mcp/lms', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/list',
], ['Accept' => 'application/json, text/event-stream']);

$response->assertOk();

return array_column($response->json('result.tools'), 'name');
}

test('LmsServer::TOOLS is the tool list the package server serves', function () {
$names = listMcpToolNamesOverHttp();

expect($names)->toHaveCount(count(LmsServer::TOOLS))
->toContain('list_courses', 'create_video_course', 'delete_step')
->not->toContain('host_ping');
});

test('the package registers its own servers when the host has none', function () {
resetMcpRegistrarAndRoutes();

runPackageMcpRegistration();

expect(Mcp::getLocalServer('filament-lms'))->not->toBeNull()
->and(Mcp::getWebServer('mcp/lms'))->not->toBeNull()
->and(listMcpToolNamesOverHttp())->not->toContain('host_ping');
});

test('the package steps aside for a host server on the same handle and path', function () {
resetMcpRegistrarAndRoutes();

Mcp::local('filament-lms', HostMcpServer::class);
$hostLocal = Mcp::getLocalServer('filament-lms');
$hostRoute = Mcp::web('/mcp/lms', HostMcpServer::class)
->middleware(['auth:sanctum', 'throttle:mcp', EnsureLmsMcpAdmin::class]);

runPackageMcpRegistration();

expect(Mcp::getLocalServer('filament-lms'))->toBe($hostLocal)
->and(Mcp::getWebServer('mcp/lms'))->toBe($hostRoute)
->and(listMcpToolNamesOverHttp())->toContain('host_ping', 'list_courses', 'delete_step');
});

test('mcp.enabled and mcp.web set to false skip package registration', function () {
resetMcpRegistrarAndRoutes();
config(['filament-lms.mcp.enabled' => false, 'filament-lms.mcp.web' => false]);

runPackageMcpRegistration();

expect(Mcp::getLocalServer('filament-lms'))->toBeNull()
->and(Mcp::getWebServer('mcp/lms'))->toBeNull();
});

test('a host server that uses HasSanctumAbilities lists package and host abilities', function () {
$abilities = HostMcpServer::sanctumAbilities();

expect($abilities)->toHaveKey('host_ping')
->and(array_diff_key(LmsServer::sanctumAbilities(), $abilities))->toBe([]);
});
21 changes: 21 additions & 0 deletions tests/Support/HostMcpServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Tests\Support;

use Laravel\Mcp\Server;
use Tapp\FilamentLms\Mcp\Concerns\HasSanctumAbilities;
use Tapp\FilamentLms\Mcp\LmsServer;

class HostMcpServer extends Server
{
use HasSanctumAbilities;

protected string $name = 'Host App';

protected array $tools = [
...LmsServer::TOOLS,
HostPingTool::class,
];
}
21 changes: 21 additions & 0 deletions tests/Support/HostPingTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Tapp\FilamentLms\Tests\Support;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;

class HostPingTool extends Tool
{
protected string $name = 'host_ping';

protected string $description = 'Host-only tool used to tell the host server apart from the package server.';

public function handle(Request $request): Response
{
return Response::text('pong');
}
}
Loading