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
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ The MCP protocol also defines a "Streamable HTTP SSE" mode, but this package doe
## Requirements

- PHP >=8.2
- Laravel >=10.x
- Laravel >=10.x or Lumen >=11.x

## Installation

Expand All @@ -285,6 +285,34 @@ The MCP protocol also defines a "Streamable HTTP SSE" mode, but this package doe
php artisan vendor:publish --provider="OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider"
```

### Lumen Setup

The package also supports Lumen 11.x applications. After installing the dependency via Composer:

1. Enable the optional helpers you need inside `bootstrap/app.php`:
```php
$app->withFacades();
$app->withEloquent();
```

2. Register the service provider:
```php
$app->register(OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider::class);
```

3. Copy the configuration file (Lumen does not ship with `vendor:publish` by default):
```bash
cp vendor/opgginc/laravel-mcp-server/config/mcp-server.php config/mcp-server.php
```

4. Tell Lumen to load the configuration:
```php
$app->configure('mcp-server');
```

(If you skip steps 3-4 the package will still run with the default configuration. Creating the file simply allows you to override the defaults.)


## Basic Usage

### 🔐 Authentication (CRITICAL FOR PRODUCTION)
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
},
"require-dev": {
"laravel/pint": "^1.14",
"laravel/lumen-framework": "^11.0",
"nunomaduro/collision": "^8.1.1||^7.10.0",
"larastan/larastan": "^2.9||^3.0",
"orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0",
"orchestra/testbench": "^9.0.0||^8.22.0",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-arch": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0",
Expand Down
91 changes: 78 additions & 13 deletions src/LaravelMcpServerServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace OPGG\LaravelMcpServer;

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpNotificationCommand;
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpPromptCommand;
Expand Down Expand Up @@ -49,7 +48,9 @@ public function register(): void
{
parent::register();

$provider = match (Config::get('mcp-server.server_provider')) {
$this->registerConfiguration();

$provider = match ($this->getConfig('mcp-server.server_provider')) {
'streamable_http' => StreamableHttpServiceProvider::class,
default => SseServiceProvider::class,
};
Expand All @@ -70,7 +71,7 @@ public function boot(): void
protected function registerRoutes(): void
{
// Skip route registration if the server is disabled
if (! Config::get('mcp-server.enabled', true)) {
if (! $this->getConfig('mcp-server.enabled', true)) {
return;
}

Expand All @@ -79,10 +80,10 @@ protected function registerRoutes(): void
return;
}

$path = Config::get('mcp-server.default_path');
$middlewares = Config::get('mcp-server.middlewares', []);
$domain = Config::get('mcp-server.domain');
$provider = Config::get('mcp-server.server_provider');
$path = $this->getConfig('mcp-server.default_path');
$middlewares = $this->getConfig('mcp-server.middlewares', []);
$domain = $this->getConfig('mcp-server.domain');
$provider = $this->getConfig('mcp-server.server_provider');

// Handle multiple domains support
$domains = $this->normalizeDomains($domain);
Expand Down Expand Up @@ -121,25 +122,89 @@ protected function normalizeDomains($domain): array
*/
protected function registerRoutesForDomain(?string $domain, string $path, array $middlewares, string $provider): void
{
$router = $this->app->make('router');

if ($this->isLumenRouter($router)) {
$this->registerLumenRoutes($router, $domain, $path, $middlewares, $provider);

return;
}

// Build route configuration
$router = Route::middleware($middlewares);
$routeRegistrar = Route::middleware($middlewares);

// Apply domain restriction if specified
if ($domain !== null) {
$router = $router->domain($domain);
$routeRegistrar = $routeRegistrar->domain($domain);
}

// Register provider-specific routes
switch ($provider) {
case 'sse':
$router->get("{$path}/sse", [SseController::class, 'handle']);
$router->post("{$path}/message", [MessageController::class, 'handle']);
$routeRegistrar->get("{$path}/sse", [SseController::class, 'handle']);
$routeRegistrar->post("{$path}/message", [MessageController::class, 'handle']);
break;

case 'streamable_http':
$router->get($path, [StreamableHttpController::class, 'getHandle']);
$router->post($path, [StreamableHttpController::class, 'postHandle']);
$routeRegistrar->get($path, [StreamableHttpController::class, 'getHandle']);
$routeRegistrar->post($path, [StreamableHttpController::class, 'postHandle']);
break;
}
}

protected function registerConfiguration(): void
{
if ($this->isLumenApplication() && ! $this->app['config']->has('mcp-server')) {
$this->app->configure('mcp-server');
}

$this->mergeConfigFrom(__DIR__.'/../config/mcp-server.php', 'mcp-server');
}

protected function getConfig(string $key, $default = null)
{
if ($this->app->bound('config')) {
return $this->app['config']->get($key, $default);
}

return $default;
}

protected function isLumenApplication(): bool
{
return class_exists(\Laravel\Lumen\Application::class) && $this->app instanceof \Laravel\Lumen\Application;
}

protected function isLumenRouter($router): bool
{
return class_exists(\Laravel\Lumen\Routing\Router::class) && $router instanceof \Laravel\Lumen\Routing\Router;
}

protected function registerLumenRoutes($router, ?string $domain, string $path, array $middlewares, string $provider): void
{
$groupAttributes = [];

if (! empty($middlewares)) {
$groupAttributes['middleware'] = $middlewares;
}

if ($domain !== null) {
$groupAttributes['domain'] = $domain;
}

$router->group($groupAttributes, function ($router) use ($path, $provider) {
switch ($provider) {
case 'sse':
$router->get("{$path}/sse", [SseController::class, 'handle']);
$router->post("{$path}/message", [MessageController::class, 'handle']);
break;

case 'streamable_http':
default:
$router->get($path, [StreamableHttpController::class, 'getHandle']);
$router->post($path, [StreamableHttpController::class, 'postHandle']);
break;
}
});
}
}
97 changes: 97 additions & 0 deletions tests/Lumen/LumenRouteRegistrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

use Illuminate\Support\Arr;
use OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider;
use OPGG\LaravelMcpServer\Server\MCPServer;

beforeEach(function () {
$this->app->singleton(MCPServer::class, fn () => \Mockery::mock(MCPServer::class));

$this->app['config']->set('mcp-server.enabled', true);
$this->app['config']->set('mcp-server.default_path', '/mcp');
$this->app['config']->set('mcp-server.middlewares', ['auth']);
$this->app['config']->set('mcp-server.domain', null);
});

function lumenRegisteredRoutes($app, ?string $domain = null): array
{
$routes = [];

foreach ($app->router->getRoutes() as $route) {
$action = $route['action'] ?? [];
$routeDomain = $action['domain'] ?? null;

if ($domain !== null && $routeDomain !== null && $routeDomain !== $domain) {
continue;
}

if ($domain === null && $routeDomain !== null) {
continue;
}

$routes[] = [
'method' => $route['method'],
'uri' => $route['uri'],
'middleware' => Arr::wrap($action['middleware'] ?? []),
'domain' => $routeDomain,
];
}

usort($routes, fn ($a, $b) => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]);

return $routes;
}

it('registers streamable http routes in lumen', function () {
$this->app['config']->set('mcp-server.server_provider', 'streamable_http');

$provider = new LaravelMcpServerServiceProvider($this->app);
$provider->register();
$provider->boot();

$routes = lumenRegisteredRoutes($this->app);

expect($routes)->sequence(
fn ($route) => $route
->uri->toBe('/mcp')
->method->toBe('GET')
->middleware->toContain('auth'),
fn ($route) => $route
->uri->toBe('/mcp')
->method->toBe('POST')
->middleware->toContain('auth'),
);
});

it('registers sse routes with domain restriction in lumen', function () {
$this->app['config']->set('mcp-server.server_provider', 'sse');
$this->app['config']->set('mcp-server.domain', 'lumen.example.com');

$provider = new LaravelMcpServerServiceProvider($this->app);
$provider->register();
$provider->boot();

$routes = lumenRegisteredRoutes($this->app, 'lumen.example.com');

expect($routes)->sequence(
fn ($route) => $route
->uri->toBe('/mcp/message')
->method->toBe('POST'),
fn ($route) => $route
->uri->toBe('/mcp/sse')
->method->toBe('GET'),
);
});

it('skips registration when lumen server disabled', function () {
$this->app['config']->set('mcp-server.enabled', false);
$this->app['config']->set('mcp-server.server_provider', 'streamable_http');

$provider = new LaravelMcpServerServiceProvider($this->app);
$provider->register();
$provider->boot();

$routes = lumenRegisteredRoutes($this->app);

expect($routes)->toBeEmpty();
});
34 changes: 34 additions & 0 deletions tests/Lumen/TestCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace OPGG\LaravelMcpServer\Tests\Lumen;

use Illuminate\Config\Repository as ConfigRepository;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use PHPUnit\Framework\TestCase as BaseTestCase;

abstract class TestCase extends BaseTestCase
{
use MockeryPHPUnitIntegration;

protected TestingApplication $app;

protected function setUp(): void
{
parent::setUp();

$this->app = new TestingApplication($this->basePath());
$this->app->instance('path.config', $this->basePath('config'));
$this->app->instance('config', new ConfigRepository);
$this->app->alias('config', \Illuminate\Contracts\Config\Repository::class);

$this->app->withFacades();
$this->app->withEloquent();
}

protected function basePath(string $path = ''): string
{
$basePath = realpath(__DIR__.'/../..');

return $path === '' ? $basePath : $basePath.DIRECTORY_SEPARATOR.ltrim($path, DIRECTORY_SEPARATOR);
}
}
14 changes: 14 additions & 0 deletions tests/Lumen/TestingApplication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace OPGG\LaravelMcpServer\Tests\Lumen;

use Laravel\Lumen\Application;

class TestingApplication extends Application
{
protected function registerErrorHandling()
{
// Disable Lumen's global error handlers during tests to avoid
// polluting PHPUnit's error handling expectations.
}
}
12 changes: 11 additions & 1 deletion tests/Pest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
<?php

use OPGG\LaravelMcpServer\Tests\Lumen\TestCase as LumenTestCase;
use OPGG\LaravelMcpServer\Tests\TestCase;

uses(TestCase::class)->in(__DIR__);
uses(TestCase::class)->in(
__DIR__.'/Console',
__DIR__.'/Http',
__DIR__.'/Services',
__DIR__.'/Unit',
__DIR__.'/Utils',
__DIR__.'/LaravelMcpServerServiceProviderTest.php',
);

uses(LumenTestCase::class)->in(__DIR__.'/Lumen');