From 7068bf3b45ba86497dee69b8951b2f07221c2cb0 Mon Sep 17 00:00:00 2001 From: Sangrak Choi Date: Sun, 12 Oct 2025 05:30:29 +0900 Subject: [PATCH 1/2] Add Lumen support with tests --- README.md | 30 ++++++- composer.json | 3 +- src/LaravelMcpServerServiceProvider.php | 91 +++++++++++++++++--- tests/Lumen/LumenRouteRegistrationTest.php | 97 ++++++++++++++++++++++ tests/Lumen/TestCase.php | 34 ++++++++ tests/Lumen/TestingApplication.php | 14 ++++ tests/Pest.php | 12 ++- 7 files changed, 265 insertions(+), 16 deletions(-) create mode 100644 tests/Lumen/LumenRouteRegistrationTest.php create mode 100644 tests/Lumen/TestCase.php create mode 100644 tests/Lumen/TestingApplication.php diff --git a/README.md b/README.md index c5ae8a5..b49e6d7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/composer.json b/composer.json index ee83d50..0710302 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/LaravelMcpServerServiceProvider.php b/src/LaravelMcpServerServiceProvider.php index ebf63bf..6cbde19 100644 --- a/src/LaravelMcpServerServiceProvider.php +++ b/src/LaravelMcpServerServiceProvider.php @@ -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; @@ -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, }; @@ -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; } @@ -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); @@ -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; + } + }); + } } diff --git a/tests/Lumen/LumenRouteRegistrationTest.php b/tests/Lumen/LumenRouteRegistrationTest.php new file mode 100644 index 0000000..b2cd7c2 --- /dev/null +++ b/tests/Lumen/LumenRouteRegistrationTest.php @@ -0,0 +1,97 @@ +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(); +}); diff --git a/tests/Lumen/TestCase.php b/tests/Lumen/TestCase.php new file mode 100644 index 0000000..0a20f7b --- /dev/null +++ b/tests/Lumen/TestCase.php @@ -0,0 +1,34 @@ +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); + } +} diff --git a/tests/Lumen/TestingApplication.php b/tests/Lumen/TestingApplication.php new file mode 100644 index 0000000..6656157 --- /dev/null +++ b/tests/Lumen/TestingApplication.php @@ -0,0 +1,14 @@ +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'); From 63f9a18492112b6c888c9cfd58746aadebc7bd2a Mon Sep 17 00:00:00 2001 From: kargnas <1438533+kargnas@users.noreply.github.com> Date: Sat, 11 Oct 2025 20:30:50 +0000 Subject: [PATCH 2/2] Fix styling --- tests/Lumen/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Lumen/TestCase.php b/tests/Lumen/TestCase.php index 0a20f7b..bfdeea4 100644 --- a/tests/Lumen/TestCase.php +++ b/tests/Lumen/TestCase.php @@ -18,7 +18,7 @@ protected function setUp(): void $this->app = new TestingApplication($this->basePath()); $this->app->instance('path.config', $this->basePath('config')); - $this->app->instance('config', new ConfigRepository()); + $this->app->instance('config', new ConfigRepository); $this->app->alias('config', \Illuminate\Contracts\Config\Repository::class); $this->app->withFacades();