Skip to content

Commit 8c4cc01

Browse files
authored
Add Lumen support and integration tests (#82)
* Add Lumen support with tests * Fix styling --------- Co-authored-by: kargnas <[email protected]>
1 parent 48014e8 commit 8c4cc01

File tree

7 files changed

+265
-16
lines changed

7 files changed

+265
-16
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ The MCP protocol also defines a "Streamable HTTP SSE" mode, but this package doe
270270
## Requirements
271271

272272
- PHP >=8.2
273-
- Laravel >=10.x
273+
- Laravel >=10.x or Lumen >=11.x
274274

275275
## Installation
276276

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

288+
### Lumen Setup
289+
290+
The package also supports Lumen 11.x applications. After installing the dependency via Composer:
291+
292+
1. Enable the optional helpers you need inside `bootstrap/app.php`:
293+
```php
294+
$app->withFacades();
295+
$app->withEloquent();
296+
```
297+
298+
2. Register the service provider:
299+
```php
300+
$app->register(OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider::class);
301+
```
302+
303+
3. Copy the configuration file (Lumen does not ship with `vendor:publish` by default):
304+
```bash
305+
cp vendor/opgginc/laravel-mcp-server/config/mcp-server.php config/mcp-server.php
306+
```
307+
308+
4. Tell Lumen to load the configuration:
309+
```php
310+
$app->configure('mcp-server');
311+
```
312+
313+
(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.)
314+
315+
288316
## Basic Usage
289317

290318
### 🔐 Authentication (CRITICAL FOR PRODUCTION)

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
},
1616
"require-dev": {
1717
"laravel/pint": "^1.14",
18+
"laravel/lumen-framework": "^11.0",
1819
"nunomaduro/collision": "^8.1.1||^7.10.0",
1920
"larastan/larastan": "^2.9||^3.0",
20-
"orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0",
21+
"orchestra/testbench": "^9.0.0||^8.22.0",
2122
"pestphp/pest": "^3.0",
2223
"pestphp/pest-plugin-arch": "^3.0",
2324
"pestphp/pest-plugin-laravel": "^3.0",

src/LaravelMcpServerServiceProvider.php

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace OPGG\LaravelMcpServer;
44

5-
use Illuminate\Support\Facades\Config;
65
use Illuminate\Support\Facades\Route;
76
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpNotificationCommand;
87
use OPGG\LaravelMcpServer\Console\Commands\MakeMcpPromptCommand;
@@ -49,7 +48,9 @@ public function register(): void
4948
{
5049
parent::register();
5150

52-
$provider = match (Config::get('mcp-server.server_provider')) {
51+
$this->registerConfiguration();
52+
53+
$provider = match ($this->getConfig('mcp-server.server_provider')) {
5354
'streamable_http' => StreamableHttpServiceProvider::class,
5455
default => SseServiceProvider::class,
5556
};
@@ -70,7 +71,7 @@ public function boot(): void
7071
protected function registerRoutes(): void
7172
{
7273
// Skip route registration if the server is disabled
73-
if (! Config::get('mcp-server.enabled', true)) {
74+
if (! $this->getConfig('mcp-server.enabled', true)) {
7475
return;
7576
}
7677

@@ -79,10 +80,10 @@ protected function registerRoutes(): void
7980
return;
8081
}
8182

82-
$path = Config::get('mcp-server.default_path');
83-
$middlewares = Config::get('mcp-server.middlewares', []);
84-
$domain = Config::get('mcp-server.domain');
85-
$provider = Config::get('mcp-server.server_provider');
83+
$path = $this->getConfig('mcp-server.default_path');
84+
$middlewares = $this->getConfig('mcp-server.middlewares', []);
85+
$domain = $this->getConfig('mcp-server.domain');
86+
$provider = $this->getConfig('mcp-server.server_provider');
8687

8788
// Handle multiple domains support
8889
$domains = $this->normalizeDomains($domain);
@@ -121,25 +122,89 @@ protected function normalizeDomains($domain): array
121122
*/
122123
protected function registerRoutesForDomain(?string $domain, string $path, array $middlewares, string $provider): void
123124
{
125+
$router = $this->app->make('router');
126+
127+
if ($this->isLumenRouter($router)) {
128+
$this->registerLumenRoutes($router, $domain, $path, $middlewares, $provider);
129+
130+
return;
131+
}
132+
124133
// Build route configuration
125-
$router = Route::middleware($middlewares);
134+
$routeRegistrar = Route::middleware($middlewares);
126135

127136
// Apply domain restriction if specified
128137
if ($domain !== null) {
129-
$router = $router->domain($domain);
138+
$routeRegistrar = $routeRegistrar->domain($domain);
130139
}
131140

132141
// Register provider-specific routes
133142
switch ($provider) {
134143
case 'sse':
135-
$router->get("{$path}/sse", [SseController::class, 'handle']);
136-
$router->post("{$path}/message", [MessageController::class, 'handle']);
144+
$routeRegistrar->get("{$path}/sse", [SseController::class, 'handle']);
145+
$routeRegistrar->post("{$path}/message", [MessageController::class, 'handle']);
137146
break;
138147

139148
case 'streamable_http':
140-
$router->get($path, [StreamableHttpController::class, 'getHandle']);
141-
$router->post($path, [StreamableHttpController::class, 'postHandle']);
149+
$routeRegistrar->get($path, [StreamableHttpController::class, 'getHandle']);
150+
$routeRegistrar->post($path, [StreamableHttpController::class, 'postHandle']);
142151
break;
143152
}
144153
}
154+
155+
protected function registerConfiguration(): void
156+
{
157+
if ($this->isLumenApplication() && ! $this->app['config']->has('mcp-server')) {
158+
$this->app->configure('mcp-server');
159+
}
160+
161+
$this->mergeConfigFrom(__DIR__.'/../config/mcp-server.php', 'mcp-server');
162+
}
163+
164+
protected function getConfig(string $key, $default = null)
165+
{
166+
if ($this->app->bound('config')) {
167+
return $this->app['config']->get($key, $default);
168+
}
169+
170+
return $default;
171+
}
172+
173+
protected function isLumenApplication(): bool
174+
{
175+
return class_exists(\Laravel\Lumen\Application::class) && $this->app instanceof \Laravel\Lumen\Application;
176+
}
177+
178+
protected function isLumenRouter($router): bool
179+
{
180+
return class_exists(\Laravel\Lumen\Routing\Router::class) && $router instanceof \Laravel\Lumen\Routing\Router;
181+
}
182+
183+
protected function registerLumenRoutes($router, ?string $domain, string $path, array $middlewares, string $provider): void
184+
{
185+
$groupAttributes = [];
186+
187+
if (! empty($middlewares)) {
188+
$groupAttributes['middleware'] = $middlewares;
189+
}
190+
191+
if ($domain !== null) {
192+
$groupAttributes['domain'] = $domain;
193+
}
194+
195+
$router->group($groupAttributes, function ($router) use ($path, $provider) {
196+
switch ($provider) {
197+
case 'sse':
198+
$router->get("{$path}/sse", [SseController::class, 'handle']);
199+
$router->post("{$path}/message", [MessageController::class, 'handle']);
200+
break;
201+
202+
case 'streamable_http':
203+
default:
204+
$router->get($path, [StreamableHttpController::class, 'getHandle']);
205+
$router->post($path, [StreamableHttpController::class, 'postHandle']);
206+
break;
207+
}
208+
});
209+
}
145210
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
use Illuminate\Support\Arr;
4+
use OPGG\LaravelMcpServer\LaravelMcpServerServiceProvider;
5+
use OPGG\LaravelMcpServer\Server\MCPServer;
6+
7+
beforeEach(function () {
8+
$this->app->singleton(MCPServer::class, fn () => \Mockery::mock(MCPServer::class));
9+
10+
$this->app['config']->set('mcp-server.enabled', true);
11+
$this->app['config']->set('mcp-server.default_path', '/mcp');
12+
$this->app['config']->set('mcp-server.middlewares', ['auth']);
13+
$this->app['config']->set('mcp-server.domain', null);
14+
});
15+
16+
function lumenRegisteredRoutes($app, ?string $domain = null): array
17+
{
18+
$routes = [];
19+
20+
foreach ($app->router->getRoutes() as $route) {
21+
$action = $route['action'] ?? [];
22+
$routeDomain = $action['domain'] ?? null;
23+
24+
if ($domain !== null && $routeDomain !== null && $routeDomain !== $domain) {
25+
continue;
26+
}
27+
28+
if ($domain === null && $routeDomain !== null) {
29+
continue;
30+
}
31+
32+
$routes[] = [
33+
'method' => $route['method'],
34+
'uri' => $route['uri'],
35+
'middleware' => Arr::wrap($action['middleware'] ?? []),
36+
'domain' => $routeDomain,
37+
];
38+
}
39+
40+
usort($routes, fn ($a, $b) => [$a['uri'], $a['method']] <=> [$b['uri'], $b['method']]);
41+
42+
return $routes;
43+
}
44+
45+
it('registers streamable http routes in lumen', function () {
46+
$this->app['config']->set('mcp-server.server_provider', 'streamable_http');
47+
48+
$provider = new LaravelMcpServerServiceProvider($this->app);
49+
$provider->register();
50+
$provider->boot();
51+
52+
$routes = lumenRegisteredRoutes($this->app);
53+
54+
expect($routes)->sequence(
55+
fn ($route) => $route
56+
->uri->toBe('/mcp')
57+
->method->toBe('GET')
58+
->middleware->toContain('auth'),
59+
fn ($route) => $route
60+
->uri->toBe('/mcp')
61+
->method->toBe('POST')
62+
->middleware->toContain('auth'),
63+
);
64+
});
65+
66+
it('registers sse routes with domain restriction in lumen', function () {
67+
$this->app['config']->set('mcp-server.server_provider', 'sse');
68+
$this->app['config']->set('mcp-server.domain', 'lumen.example.com');
69+
70+
$provider = new LaravelMcpServerServiceProvider($this->app);
71+
$provider->register();
72+
$provider->boot();
73+
74+
$routes = lumenRegisteredRoutes($this->app, 'lumen.example.com');
75+
76+
expect($routes)->sequence(
77+
fn ($route) => $route
78+
->uri->toBe('/mcp/message')
79+
->method->toBe('POST'),
80+
fn ($route) => $route
81+
->uri->toBe('/mcp/sse')
82+
->method->toBe('GET'),
83+
);
84+
});
85+
86+
it('skips registration when lumen server disabled', function () {
87+
$this->app['config']->set('mcp-server.enabled', false);
88+
$this->app['config']->set('mcp-server.server_provider', 'streamable_http');
89+
90+
$provider = new LaravelMcpServerServiceProvider($this->app);
91+
$provider->register();
92+
$provider->boot();
93+
94+
$routes = lumenRegisteredRoutes($this->app);
95+
96+
expect($routes)->toBeEmpty();
97+
});

tests/Lumen/TestCase.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Tests\Lumen;
4+
5+
use Illuminate\Config\Repository as ConfigRepository;
6+
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
7+
use PHPUnit\Framework\TestCase as BaseTestCase;
8+
9+
abstract class TestCase extends BaseTestCase
10+
{
11+
use MockeryPHPUnitIntegration;
12+
13+
protected TestingApplication $app;
14+
15+
protected function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
$this->app = new TestingApplication($this->basePath());
20+
$this->app->instance('path.config', $this->basePath('config'));
21+
$this->app->instance('config', new ConfigRepository);
22+
$this->app->alias('config', \Illuminate\Contracts\Config\Repository::class);
23+
24+
$this->app->withFacades();
25+
$this->app->withEloquent();
26+
}
27+
28+
protected function basePath(string $path = ''): string
29+
{
30+
$basePath = realpath(__DIR__.'/../..');
31+
32+
return $path === '' ? $basePath : $basePath.DIRECTORY_SEPARATOR.ltrim($path, DIRECTORY_SEPARATOR);
33+
}
34+
}

tests/Lumen/TestingApplication.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace OPGG\LaravelMcpServer\Tests\Lumen;
4+
5+
use Laravel\Lumen\Application;
6+
7+
class TestingApplication extends Application
8+
{
9+
protected function registerErrorHandling()
10+
{
11+
// Disable Lumen's global error handlers during tests to avoid
12+
// polluting PHPUnit's error handling expectations.
13+
}
14+
}

tests/Pest.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
<?php
22

3+
use OPGG\LaravelMcpServer\Tests\Lumen\TestCase as LumenTestCase;
34
use OPGG\LaravelMcpServer\Tests\TestCase;
45

5-
uses(TestCase::class)->in(__DIR__);
6+
uses(TestCase::class)->in(
7+
__DIR__.'/Console',
8+
__DIR__.'/Http',
9+
__DIR__.'/Services',
10+
__DIR__.'/Unit',
11+
__DIR__.'/Utils',
12+
__DIR__.'/LaravelMcpServerServiceProviderTest.php',
13+
);
14+
15+
uses(LumenTestCase::class)->in(__DIR__.'/Lumen');

0 commit comments

Comments
 (0)