From 53d7ca33ee9f4f8ec48a6be474c1f507fdd7f129 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 16:55:15 +0100 Subject: [PATCH 01/19] feat: add reorder JSON accept middleware We need this for MCP routes so the 'authenticate' middleware returns a JSON 401, even if the JSON isn't the first preference in the 'Accept' header. This happens in VS Code for example where its Accept header asks for text-event-stream first, then application/json, but it still expects a JSON 401 response or it breaks. --- src/Server/Middleware/ReorderJsonAccept.php | 34 +++++ .../Middleware/ReorderJsonAcceptTest.php | 135 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 src/Server/Middleware/ReorderJsonAccept.php create mode 100644 tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php diff --git a/src/Server/Middleware/ReorderJsonAccept.php b/src/Server/Middleware/ReorderJsonAccept.php new file mode 100644 index 0000000..7532f5b --- /dev/null +++ b/src/Server/Middleware/ReorderJsonAccept.php @@ -0,0 +1,34 @@ +header('Accept'); + if (is_string($accept) && str_contains($accept, ',')) { + $accept = array_map('trim', explode(',', $accept)); + } + + if (! is_array($accept)) { + return $next($request); + } + + usort($accept, fn ($a, $b) => str_contains($b, 'application/json') <=> str_contains($a, 'application/json')); + $request->headers->set('Accept', implode(', ', $accept)); + + return $next($request); + } +} diff --git a/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php b/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php new file mode 100644 index 0000000..3f1a679 --- /dev/null +++ b/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php @@ -0,0 +1,135 @@ +headers->set('Accept', 'application/json'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('application/json'); +}); + +it('leaves non-comma separated accept header unchanged', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('text/html'); +}); + +it('reorders multiple accept headers to prioritize json', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html, application/json, text/plain'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); +}); + +it('handles json already first in list', function () { + $request = new Request; + $request->headers->set('Accept', 'application/json, text/html, text/plain'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); +}); + +it('handles multiple json types correctly', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html, application/json, application/vnd.api+json, text/plain'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + $accept = $request->header('Accept'); + $parts = array_map('trim', explode(',', $accept)); + + expect($parts)->toMatchArray(['application/json', 'text/html', 'application/vnd.api+json', 'text/plain']) + ->and(count($parts))->toBe(4); +}); + +it('handles accept header with quality values', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html;q=0.9, application/json;q=0.8, text/plain;q=0.7'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + $accept = $request->header('Accept'); + $parts = array_map('trim', explode(',', $accept)); + + expect($parts[0])->toBe('application/json;q=0.8'); +}); + +it('handles whitespace in accept header', function () { + $request = new Request; + $request->headers->set('Accept', ' text/html , application/json , text/plain '); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); +}); + +it('handles no json in accept header', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html, text/plain, image/png'); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe('text/html, text/plain, image/png'); +}); + +it('handles empty accept header', function () { + $request = new Request; + $request->headers->set('Accept', ''); + + $middleware = new ReorderJsonAccept; + + $middleware->handle($request, fn ($req) => response('test')); + + expect($request->header('Accept'))->toBe(''); +}); + +it('handles missing accept header', function () { + $request = new Request; + + $middleware = new ReorderJsonAccept; + + $response = $middleware->handle($request, fn ($req) => response('test')); + + expect($response->getContent())->toBe('test'); +}); + +it('passes request through middleware correctly', function () { + $request = new Request; + $request->headers->set('Accept', 'text/html, application/json'); + + $middleware = new ReorderJsonAccept; + + $response = $middleware->handle($request, function ($req) { + expect($req->header('Accept'))->toBe('application/json, text/html'); + + return response('middleware worked'); + }); + + expect($response->getContent())->toBe('middleware worked'); +}); From fa776b1eca7b3090981fdacedff221360bc862cb Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 17:09:58 +0100 Subject: [PATCH 02/19] feat: add reorder json accept middleware to http servers --- src/Server/Registrar.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index e5d7351..c708e27 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -4,11 +4,11 @@ namespace Laravel\Mcp\Server; -use Illuminate\Container\Container; -use Illuminate\Http\Request; +use Exception; use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as Router; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Middleware\ReorderJsonAccept; use Laravel\Mcp\Server\Transport\HttpTransport; use Laravel\Mcp\Server\Transport\StdioTransport; @@ -18,11 +18,12 @@ class Registrar protected array $localServers = []; /** @var array */ - protected array $registeredWebServers = []; + protected array $httpServers = []; public function web(string $route, string $serverClass): Route { $this->registeredWebServers[$route] = $serverClass; + $this->httpServers[$route] = $serverClass; return Router::post($route, fn () => $this->bootServer( $serverClass, @@ -34,12 +35,11 @@ function () { (string) $request->header('Mcp-Session-Id') ); }, - ))->name('mcp-server.'.$route); + )) + ->name($this->routeName($route)) + ->middleware(ReorderJsonAccept::class); } - /** - * Register a local MCP server running over STDIO. - */ public function local(string $handle, string $serverClass): void { $this->localServers[$handle] = fn () => $this->bootServer( @@ -50,9 +50,11 @@ public function local(string $handle, string $serverClass): void ); } - /** - * Get the server class for a local MCP. - */ + public function routeName(string $path): string + { + return 'mcp-server.'.Str::kebab(Str::replace('/', '-', $path)); + } + public function getLocalServer(string $handle): ?callable { return $this->localServers[$handle] ?? null; @@ -60,7 +62,7 @@ public function getLocalServer(string $handle): ?callable public function getWebServer(string $handle): ?string { - return $this->registeredWebServers[$handle] ?? null; + return $this->httpServers[$handle] ?? null; } public function oauthRoutes(string $oauthPrefix = 'oauth'): void From 2dc6c0e56c63b56ea2bd6bb0f26fb91d9c4a2376 Mon Sep 17 00:00:00 2001 From: ashleyhindle <454975+ashleyhindle@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:10:30 +0000 Subject: [PATCH 03/19] Fix code styling --- src/Server/Registrar.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index c708e27..246b9d4 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -4,7 +4,6 @@ namespace Laravel\Mcp\Server; -use Exception; use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as Router; use Illuminate\Support\Str; From e8739a023f855840106207822933185eb68ddefe Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 17:15:54 +0100 Subject: [PATCH 04/19] feat: fix registrar static analysis issues --- src/Server/Registrar.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index 246b9d4..1faa6cb 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -4,6 +4,8 @@ namespace Laravel\Mcp\Server; +use Illuminate\Container\Container; +use Illuminate\Http\Request; use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as Router; use Illuminate\Support\Str; @@ -21,9 +23,11 @@ class Registrar public function web(string $route, string $serverClass): Route { - $this->registeredWebServers[$route] = $serverClass; $this->httpServers[$route] = $serverClass; + // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + Router::get($route, fn () => response(status: 405)); + return Router::post($route, fn () => $this->bootServer( $serverClass, function () { From b1edabe0a1e9f7038bde13828ad1f18b8d0c4b5f Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 17:17:47 +0100 Subject: [PATCH 05/19] feat: adds test for 405 response to GET /mcp --- tests/Feature/McpServerTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Feature/McpServerTest.php b/tests/Feature/McpServerTest.php index 5ae86dd..718ed72 100644 --- a/tests/Feature/McpServerTest.php +++ b/tests/Feature/McpServerTest.php @@ -168,6 +168,13 @@ expect($response->json())->toEqual(expectedListToolsResponse()); }); +it('returns 405 for GET requests to MCP web routes', function () { + $response = $this->get('test-mcp'); + + $response->assertStatus(405); + $response->assertSee(''); +}); + function initializeHttpConnection($that, $handle = 'test-mcp') { $response = $that->postJson($handle, initializeMessage()); From 501bc8101a245edb0aa6f529e2a257a0c2703469 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 17:18:21 +0100 Subject: [PATCH 06/19] feat: correctly return 202 status if response is empty MCP specification expects 202 status code to empty responses from pings/notifications --- src/Server/Transport/HttpTransport.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Server/Transport/HttpTransport.php b/src/Server/Transport/HttpTransport.php index c110beb..f402831 100644 --- a/src/Server/Transport/HttpTransport.php +++ b/src/Server/Transport/HttpTransport.php @@ -49,7 +49,10 @@ public function run(): Response|StreamedResponse return response()->stream($this->stream, 200, $this->getHeaders()); } - return response($this->reply, 200, $this->getHeaders()); + // Must be 202 - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server + $statusCode = empty($this->reply) ? 202 : 200; + + return response($this->reply, $statusCode, $this->getHeaders()); } public function sessionId(): ?string From 7c5f35388bc22c20e2e99341a72647523ce50a5a Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 18:47:16 +0100 Subject: [PATCH 07/19] feat: improve mcp inspector command --- src/Console/Commands/McpInspectorCommand.php | 39 +++++++++++++++++--- src/Server/Registrar.php | 27 ++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/Console/Commands/McpInspectorCommand.php b/src/Console/Commands/McpInspectorCommand.php index 92196ea..66953b2 100644 --- a/src/Console/Commands/McpInspectorCommand.php +++ b/src/Console/Commands/McpInspectorCommand.php @@ -7,6 +7,8 @@ use Exception; use Illuminate\Console\Command; use Illuminate\Container\Container; +use Illuminate\Routing\Route; +use Illuminate\Support\Arr; use Laravel\Mcp\Server\Registrar; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; @@ -34,14 +36,33 @@ public function handle(): int $this->info("Starting the MCP Inspector for server: {$handle}"); $localServer = $registrar->getLocalServer($handle); - $webServer = $registrar->getWebServer($handle); + $route = $registrar->getWebServer($handle); - if (is_null($localServer) && is_null($webServer)) { - $this->error('Please pass a valid MCP handle'); + $servers = $registrar->servers(); + if (empty($servers)) { + $this->error('No MCP servers found. Please run `php artisan make:mcp-server [name]`'); return static::FAILURE; } + // Only one server, we should just run it for them + if (count($servers) === 1) { + $server = array_shift($servers); + [$localServer, $route] = match (true) { + is_callable($server) => [$server, null], + get_class($server) === Route::class => [null, $server], + default => [null, null], + }; + } + + if (is_null($localServer) && is_null($route)) { + $this->error('Please pass a valid MCP handle or route: '.Arr::join(array_keys($servers), ', ')); + + return static::FAILURE; + } + + $env = []; + if ($localServer) { $currentDir = getcwd(); $command = [ @@ -58,11 +79,17 @@ public function handle(): int 'Arguments' => implode(' ', [base_path('/artisan'), 'mcp:start', $handle]), ]; } else { - $serverUrl = str_replace('https://', 'http://', route('mcp-server.'.$handle)); + $serverUrl = url($route->uri()); + if (parse_url($serverUrl, PHP_URL_SCHEME) === 'https') { + $env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; + } $command = [ 'npx', '@modelcontextprotocol/inspector', + '--transport', + 'http', + '--server-url', $serverUrl, ]; @@ -73,7 +100,7 @@ public function handle(): int ]; } - $process = new Process($command); + $process = new Process($command, null, $env); $process->setTimeout(null); try { @@ -99,7 +126,7 @@ public function handle(): int protected function getArguments(): array { return [ - ['handle', InputArgument::REQUIRED, 'The handle of the MCP server to inspect.'], + ['handle', InputArgument::REQUIRED, 'The handle or route of the MCP server to inspect.'], ]; } diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index 1faa6cb..11ff103 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -18,17 +18,15 @@ class Registrar /** @var array */ protected array $localServers = []; - /** @var array */ + /** @var array */ protected array $httpServers = []; public function web(string $route, string $serverClass): Route { - $this->httpServers[$route] = $serverClass; - // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server Router::get($route, fn () => response(status: 405)); - return Router::post($route, fn () => $this->bootServer( + $route = Router::post($route, fn () => $this->bootServer( $serverClass, function () { $request = request(); @@ -39,8 +37,12 @@ function () { ); }, )) - ->name($this->routeName($route)) + ->name($this->routeName(ltrim($route, '/'))) ->middleware(ReorderJsonAccept::class); + + $this->httpServers[$route->uri()] = $route; + + return $route; } public function local(string $handle, string $serverClass): void @@ -63,9 +65,20 @@ public function getLocalServer(string $handle): ?callable return $this->localServers[$handle] ?? null; } - public function getWebServer(string $handle): ?string + public function getWebServer(string $route): ?Route { - return $this->httpServers[$handle] ?? null; + return $this->httpServers[$route] ?? null; + } + + /** + * @return array + */ + public function servers(): array + { + return array_merge( + $this->localServers, + $this->httpServers, + ); } public function oauthRoutes(string $oauthPrefix = 'oauth'): void From c2dee35725c04f940349315d4c48aba7ec1590a1 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 20:02:26 +0100 Subject: [PATCH 08/19] feat: add www-authenticate header for OAuth and Sanctum --- .../Middleware/AddWwwAuthenticateHeader.php | 43 ++++++++++++++++++ src/Server/Registrar.php | 8 +++- tests/Feature/McpServerTest.php | 44 +++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/Server/Middleware/AddWwwAuthenticateHeader.php diff --git a/src/Server/Middleware/AddWwwAuthenticateHeader.php b/src/Server/Middleware/AddWwwAuthenticateHeader.php new file mode 100644 index 0000000..d07b79b --- /dev/null +++ b/src/Server/Middleware/AddWwwAuthenticateHeader.php @@ -0,0 +1,43 @@ +getStatusCode() !== 401) { + return $response; + } + + $isOauth = app('router')->has('mcp.oauth.protected-resource'); + if ($isOauth) { + $response->header( + 'WWW-Authenticate', + 'Bearer realm="mcp", resource_metadata="'.route('mcp.oauth.protected-resource').'"' + ); + + return $response; + } + + // Sanctum, can't share discover URL + $response->header( + 'WWW-Authenticate', + 'Bearer realm="mcp", error="invalid_token"' + ); + + return $response; + } +} diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index 11ff103..058cbae 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -9,6 +9,7 @@ use Illuminate\Routing\Route; use Illuminate\Support\Facades\Route as Router; use Illuminate\Support\Str; +use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader; use Laravel\Mcp\Server\Middleware\ReorderJsonAccept; use Laravel\Mcp\Server\Transport\HttpTransport; use Laravel\Mcp\Server\Transport\StdioTransport; @@ -38,7 +39,10 @@ function () { }, )) ->name($this->routeName(ltrim($route, '/'))) - ->middleware(ReorderJsonAccept::class); + ->middleware([ + ReorderJsonAccept::class, + AddWwwAuthenticateHeader::class, + ]); $this->httpServers[$route->uri()] = $route; @@ -88,7 +92,7 @@ public function oauthRoutes(string $oauthPrefix = 'oauth'): void 'resource' => config('app.url'), 'authorization_server' => url('/.well-known/oauth-authorization-server'), ]); - }); + })->name('mcp.oauth.protected-resource'); Router::get('/.well-known/oauth-authorization-server', function () use ($oauthPrefix) { return response()->json([ diff --git a/tests/Feature/McpServerTest.php b/tests/Feature/McpServerTest.php index 718ed72..1569641 100644 --- a/tests/Feature/McpServerTest.php +++ b/tests/Feature/McpServerTest.php @@ -1,5 +1,6 @@ assertSee(''); }); +it('returns OAuth WWW-Authenticate header when OAuth routes are enabled and response is 401', function () { + // Enable OAuth routes which registers the 'mcp.oauth.protected-resource' route + app('Laravel\Mcp\Server\Registrar')->oauthRoutes(); + + // Create a test route that returns 401 to trigger the middleware + Route::post('test-oauth-401', function () { + return response()->json(['error' => 'unauthorized'], 401); + })->middleware(['Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader']); + + $response = $this->postJson('test-oauth-401', []); + + $response->assertStatus(401); + $response->assertHeader('WWW-Authenticate'); + + $wwwAuth = $response->headers->get('WWW-Authenticate'); + expect($wwwAuth)->toContain('Bearer realm="mcp"'); + expect($wwwAuth)->toContain('resource_metadata="'.url('/.well-known/oauth-protected-resource').'"'); +}); + +it('returns Sanctum WWW-Authenticate header when OAuth routes are not enabled and response is 401', function () { + // Create a test route that returns 401 to trigger the middleware + Route::post('test-sanctum-401', function () { + return response()->json(['error' => 'unauthorized'], 401); + })->middleware(['Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader']); + + $response = $this->postJson('test-sanctum-401', []); + + $response->assertStatus(401); + $response->assertHeader('WWW-Authenticate'); + + $wwwAuth = $response->headers->get('WWW-Authenticate'); + expect($wwwAuth)->toBe('Bearer realm="mcp", error="invalid_token"'); +}); + +it('does not add WWW-Authenticate header when response is not 401', function () { + app('Laravel\Mcp\Server\Registrar')->oauthRoutes(); + + $response = $this->postJson('test-mcp', initializeMessage()); + + $response->assertStatus(200); + $response->assertHeaderMissing('WWW-Authenticate'); +}); + function initializeHttpConnection($that, $handle = 'test-mcp') { $response = $that->postJson($handle, initializeMessage()); From 91da1b48e1bb4c3cf50f0c51231a71a204f4c89a Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 20:13:12 +0100 Subject: [PATCH 09/19] docs: add basic sanctum docs to README --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5364879..cfea337 100644 --- a/README.md +++ b/README.md @@ -310,27 +310,65 @@ php artisan mcp:start demo ## Authentication -Web-based MCP servers can be protected using [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource. +## OAuth 2.1 -If you already have Passport set up for your app, all you need to do is add the `Mcp::oauthRoutes()` helper to your `routes/web.php` file. This registers the required OAuth2 discovery and client registration endpoints. The method accepts an optional route prefix, which defaults to `oauth`. +The recommended way to protect your web-based MCP servers is to +use [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource. + +If you already have Passport set up for your app, all you need to do is add the `Mcp::oauthRoutes()` helper to your +`routes/web.php` file. This registers the required OAuth2 discovery and client registration endpoints. + +To secure, apply Passport's `auth:api` middleware to your server registration in `routes/ai.php`: ```php +use App\Mcp\Servers\ExampleServer; use Laravel\Mcp\Facades\Mcp; -Mcp::oauthRoutes(); +Mcp::oauthRoutes('oauth'); + +Mcp::web('/mcp/demo', ExampleServer::class) + ->middleware('auth:api'); ``` -Then, apply the `auth:api` middleware to your server registration in `routes/ai.php`: +## Sanctum + +If you'd like to protect your MCP server using Sanctum, simply add the Sanctum middleware to your server in +`routes/ai.php`. Make sure MCP clients pass the usual `Authorization: Bearer token` header. ```php use App\Mcp\Servers\ExampleServer; use Laravel\Mcp\Facades\Mcp; Mcp::web('/mcp/demo', ExampleServer::class) - ->middleware('auth:api'); + ->middleware('auth:sanctum'); +``` + +# Authorization + +Type hint `User` or `Authenticatable` in your primitives to check authorization. + +```php +public function handle(Request $request, User $user) +{ + if ($user->tokenCan('server:update') === false) { + return ToolResult::error('Permission denied'); + } + + ... +} ``` -Your MCP server is now protected using OAuth. +### Conditionally register tools + +You can hide tools from certain users without modifying your server config by using `shouldRegister`. + +```php +/** UpdateServer tool **/ +public function shouldRegister(User $user): bool +{ + return $user->tokenCan('server:update'); +} +``` ## Testing Servers With the MCP Inspector Tool @@ -342,8 +380,6 @@ Run mcp:inspector to test your server: php artisan mcp:inspector demo ``` -This will run the MCP inspector and provide settings you can input to ensure it's setup correctly. - ## Contributing Thank you for considering contributing to Laravel MCP! You can read the contribution guide [here](.github/CONTRIBUTING.md). From 58335d4c395ae2c49b1eda74f5a05b465d39945b Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 8 Sep 2025 20:16:16 +0100 Subject: [PATCH 10/19] feat: slight oauth improvement --- src/Server/Registrar.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index 058cbae..2bf4b43 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -89,19 +89,20 @@ public function oauthRoutes(string $oauthPrefix = 'oauth'): void { Router::get('/.well-known/oauth-protected-resource', function () { return response()->json([ - 'resource' => config('app.url'), + 'resource' => url('/'), 'authorization_server' => url('/.well-known/oauth-authorization-server'), ]); })->name('mcp.oauth.protected-resource'); Router::get('/.well-known/oauth-authorization-server', function () use ($oauthPrefix) { return response()->json([ - 'issuer' => config('app.url'), + 'issuer' => url('/'), 'authorization_endpoint' => url($oauthPrefix.'/authorize'), 'token_endpoint' => url($oauthPrefix.'/token'), 'registration_endpoint' => url($oauthPrefix.'/register'), 'response_types_supported' => ['code'], 'code_challenge_methods_supported' => ['S256'], + 'supported_scopes' => ['mcp:use'], 'grant_types_supported' => ['authorization_code', 'refresh_token'], ]); }); @@ -124,6 +125,7 @@ public function oauthRoutes(string $oauthPrefix = 'oauth'): void return response()->json([ 'client_id' => $client->id, 'redirect_uris' => $client->redirect_uris, + 'scopes' => 'mcp:use', ]); }); } From 022d64d4794cfec50e12ccd7cdc498e4bf736308 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:02:59 +0100 Subject: [PATCH 11/19] docs: README: add basic oauth/sanctum/auth docs back --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6f56959..7e161e5 100644 --- a/README.md +++ b/README.md @@ -338,27 +338,68 @@ php artisan mcp:start demo ## Authentication -Web-based MCP servers can be protected using [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource. +### OAuth 2.1 -If you already have Passport set up for your app, all you need to do is add the `Mcp::oauthRoutes()` helper to your `routes/web.php` file. This registers the required OAuth2 discovery and client registration endpoints. The method accepts an optional route prefix, which defaults to `oauth`. +The recommended way to protect your web-based MCP servers is to +use [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource. + +If you already have Passport set up for your app, all you need to do is add the `Mcp::oauthRoutes()` helper to your +`routes/web.php` file. This registers the required OAuth2 discovery and client registration endpoints. + +To secure, apply Passport's `auth:api` middleware to your server registration in `routes/ai.php`: ```php +use App\Mcp\Servers\WeatherExample; use Laravel\Mcp\Facades\Mcp; Mcp::oauthRoutes(); + +Mcp::web('/mcp/weather', WeatherExample::class) + ->middleware('auth:api'); ``` -Then, apply the `auth:api` middleware to your server registration in `routes/ai.php`: +### Sanctum + +If you'd like to protect your MCP server using Sanctum, simply add the Sanctum middleware to your server in +`routes/ai.php`. Make sure MCP clients pass the usual `Authorization: Bearer token` header. ```php use App\Mcp\Servers\WeatherExample; use Laravel\Mcp\Facades\Mcp; -Mcp::web('/mcp/weather', WeatherExample::class) - ->middleware('auth:api'); +Mcp::web('/mcp/demo', WeatherExample::class) + ->middleware('auth:sanctum'); +``` + +## Authorization + +Type hint `Authenticatable` in your primitives, or use `$request->user()` to check authorization. + +```php +public function handle(Request $request, Authenticatable $user) view. +{ + if ($user->tokenCan('server:update') === false) { + return ToolResult::error('Permission denied'); + } + + if ($request->user()->can('check:weather') === false) { + return ToolResult::error('Permission denied'); + } + ... +} ``` -Your MCP server is now protected using OAuth. +### Conditionally registering tools + +You can hide tools from certain users without modifying your server config by using `shouldRegister`. + +```php +/** UpdateServer tool **/ +public function shouldRegister(Authenticatable $user): bool +{ + return $user->tokenCan('server:update'); +} +``` ## Testing Servers With the MCP Inspector Tool From 5ed10456093ce6066307b4998f87f54865174e84 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:04:51 +0100 Subject: [PATCH 12/19] Fix PHPStorm breaking formatting --- stubs/server.stub | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stubs/server.stub b/stubs/server.stub index 56ed848..a11072f 100644 --- a/stubs/server.stub +++ b/stubs/server.stub @@ -9,8 +9,7 @@ class {{ class }} extends Server /** * The MCP server's name. */ - protected - string $name = '{{ serverDisplayName }}'; + protected string $name = '{{ serverDisplayName }}'; /** * The MCP server's version. From 2eeffee755a42e315549fa13415e50d8f28f8397 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:05:07 +0100 Subject: [PATCH 13/19] Fix PHPStorm breaking formatting --- .github/workflows/static-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index d0eef19..994b58f 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -19,8 +19,8 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.4 ] - laravel: [ 12 ] + php: [8.4] + laravel: [12] name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} From 1fa436607954cc3e97a8c7dc017862f20fb0a335 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:05:35 +0100 Subject: [PATCH 14/19] Fix PHPStorm breaking formatting --- stubs/prompt.stub | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/stubs/prompt.stub b/stubs/prompt.stub index f4f7a73..990e475 100644 --- a/stubs/prompt.stub +++ b/stubs/prompt.stub @@ -13,22 +13,21 @@ class {{ class }} extends Prompt /** * The prompt's description. */ - protected - string $description = 'Asks the LLM to analyze code quality and suggest improvements'; + protected string $description = 'Asks the LLM to analyze code quality and suggest improvements'; /** * Handle the prompt. */ public function handle(Request $request): Response -{ - $request->validate([ - 'code' => 'required|string', - ]); - - $code = $request->string('code'); - - return Response::text("Please review this code: {$code}"); -} + { + $request->validate([ + 'code' => 'required|string', + ]); + + $code = $request->string('code'); + + return Response::text("Please review this code: {$code}"); + } /** * Get the prompt's arguments. From 4eb3affbbf340234b0ff84855d66c979a4c14c38 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:05:59 +0100 Subject: [PATCH 15/19] Fix PHPStorm breaking formatting --- stubs/tool.stub | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stubs/tool.stub b/stubs/tool.stub index 63af206..8d3cfa0 100644 --- a/stubs/tool.stub +++ b/stubs/tool.stub @@ -12,8 +12,7 @@ class {{ class }} extends Tool /** * The tool's description. */ - protected - string $description = 'Get current weather information for a location'; + protected string $description = 'Get current weather information for a location'; /** * Handle the tool call. From 319070b1e947351f6491be9b3fc586dd62276c46 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:08:11 +0100 Subject: [PATCH 16/19] Fix phpstorm breaking formatting --- DOCS.md | 188 +++++++++++++--------------------------------- stubs/prompt.stub | 4 +- 2 files changed, 53 insertions(+), 139 deletions(-) diff --git a/DOCS.md b/DOCS.md index f59ea30..26d8ad4 100644 --- a/DOCS.md +++ b/DOCS.md @@ -31,15 +31,11 @@ - [Conditional Resource Registration](#conditional-resource-registration) - ## Introduction -[Laravel MCP](https://github.com/laravel/mcp) provides a simple and elegant way for AI clients to interact with your -Laravel application through the Model Context Protocol. It offers an expressive, fluent interface for defining servers, -tools, resources, and prompts that enable AI-powered interactions with your application. +[Laravel MCP](https://github.com/laravel/mcp) provides a simple and elegant way for AI clients to interact with your Laravel application through the Model Context Protocol. It offers an expressive, fluent interface for defining servers, tools, resources, and prompts that enable AI-powered interactions with your application. - ## Installation To get started, install Laravel MCP into your project using the Composer package manager: @@ -48,29 +44,24 @@ To get started, install Laravel MCP into your project using the Composer package composer require laravel/mcp ``` -After installing Laravel MCP, you may execute the `vendor:publish` Artisan command, which will publish the -`routes/ai.php` file where you'll define your MCP servers: +After installing Laravel MCP, you may execute the `vendor:publish` Artisan command, which will publish the `routes/ai.php` file where you'll define your MCP servers: ```shell php artisan vendor:publish --tag=ai-routes ``` -This command will create the `routes/ai.php` file in your application's `routes` directory that you use to register your -MCP servers. +This command will create the `routes/ai.php` file in your application's `routes` directory that you use to register your MCP servers. - ## Creating Servers -You may create an MCP server by using the `make:mcp-server` Artisan command. Servers act as the central communication -point that exposes MCP methods like tools, resources, and prompts to AI clients: +You may create an MCP server by using the `make:mcp-server` Artisan command. Servers act as the central communication point that exposes MCP methods like tools, resources, and prompts to AI clients: ```shell php artisan make:mcp-server WeatherServer ``` -This command will create a new server class in the `app/Mcp/Servers` directory. The generated server class extends -Laravel MCP's base `Laravel\Mcp\Server` class and provides properties for registering tools, resources, and prompts: +This command will create a new server class in the `app/Mcp/Servers` directory. The generated server class extends Laravel MCP's base `Laravel\Mcp\Server` class and provides properties for registering tools, resources, and prompts: ```php - ### Server Registration -Once you've created a server, you must register it in your `routes/ai.php` file to make it accessible. Laravel MCP -provides two methods for registering servers: `web()` for HTTP-accessible servers and `local()` for command-line -servers. +Once you've created a server, you must register it in your `routes/ai.php` file to make it accessible. Laravel MCP provides two methods for registering servers: `web()` for HTTP-accessible servers and `local()` for command-line servers. - ### Web Servers -Web servers are accessible via HTTP POST requests, making them ideal for remote AI clients or web-based integrations. -Register a web server using the `web()` method: +Web servers are accessible via HTTP POST requests, making them ideal for remote AI clients or web-based integrations. Register a web server using the `web()` method: ```php use App\Mcp\Servers\WeatherServer; @@ -140,11 +126,9 @@ Mcp::web('/mcp/weather', WeatherServer::class) ``` - ### Local Servers -Local servers run as Artisan commands, perfect for development, testing, or local AI assistant integrations. Register a -local server using the `local()` method: +Local servers run as Artisan commands, perfect for development, testing, or local AI assistant integrations. Register a local server using the `local()` method: ```php use App\Mcp\Servers\WeatherServer; @@ -160,11 +144,9 @@ php artisan mcp:start weather ``` - ## Creating Tools -Tools enable your server to expose functionality that AI clients can call. They allow language models to perform -actions, run code, or interact with external systems. Create a tool using the `make:mcp-tool` Artisan command: +Tools enable your server to expose functionality that AI clients can call. They allow language models to perform actions, run code, or interact with external systems. Create a tool using the `make:mcp-tool` Artisan command: ```shell php artisan make:mcp-tool CurrentWeatherTool @@ -195,9 +177,7 @@ class WeatherServer extends Server ### Tool Name, Title, and Description -By default, the tool's name and title are derived from the class name. As example, `CurrentWeatherTool` will have a -`current_weather` name and `Current Weather Tool` title. You may customize these values by overriding the `$name` and -`$title` properties: +By default, the tool's name and title are derived from the class name. As example, `CurrentWeatherTool` will have a `current_weather` name and `Current Weather Tool` title. You may customize these values by overriding the `$name` and `$title` properties: ```php class CurrentWeatherTool extends Tool @@ -230,11 +210,9 @@ class CurrentWeatherTool extends Tool ``` - ### Tool Input Schemas -Tools can define input schemas to specify what arguments they accept from AI clients. Use Laravel's -`Illuminate\JsonSchema\JsonSchema` builder to define your tool's input requirements: +Tools can define input schemas to specify what arguments they accept from AI clients. Use Laravel's `Illuminate\JsonSchema\JsonSchema` builder to define your tool's input requirements: ```php - ### Validating Tool Arguments -JSON Schema definitions provide a basic structure for tool arguments, but you may also want to enforce more complex -validation rules. +JSON Schema definitions provide a basic structure for tool arguments, but you may also want to enforce more complex validation rules. -Laravel MCP integrates seamlessly with Laravel's validation system. You may validate incoming tool arguments within your -tool's `handle()` method: +Laravel MCP integrates seamlessly with Laravel's validation system. You may validate incoming tool arguments within your tool's `handle()` method: ```php - #### Tool Dependency Injection -The Laravel service container is used to resolve all tools. As a result, you are able to type-hint any dependencies your -tool may need in its constructor. The declared dependencies will automatically be resolved and injected into the -controller instance: +The Laravel service container is used to resolve all tools. As a result, you are able to type-hint any dependencies your tool may need in its constructor. The declared dependencies will automatically be resolved and injected into the controller instance: ```php - ### Tool Annotations -You may enhance your tools with annotations to provide additional metadata to AI clients. These annotations help AI -models understand the tool's behavior and capabilities: +You may enhance your tools with annotations to provide additional metadata to AI clients. These annotations help AI models understand the tool's behavior and capabilities: ```php - ### Conditional Tool Registration -You may conditionally register tools at runtime by implementing the `shouldRegister` method in your tool class. This -method allows you to determine whether a tool should be available based on application state, configuration, or request -parameters: +You may conditionally register tools at runtime by implementing the `shouldRegister` method in your tool class. This method allows you to determine whether a tool should be available based on application state, configuration, or request parameters: ```php - ### Tool Responses -Tools must return an instance of `Laravel\Mcp\Response`. The Response class provides several convenient methods for -creating different types of responses: +Tools must return an instance of `Laravel\Mcp\Response`. The Response class provides several convenient methods for creating different types of responses: #### Plain Text Responses @@ -491,11 +453,9 @@ public function handle(Request $request): array ``` - #### Streaming Responses -For long-running operations or real-time data streaming, tools can return a generator from their `handle()` method. This -enables sending intermediate updates to the client before the final response: +For long-running operations or real-time data streaming, tools can return a generator from their `handle()` method. This enables sending intermediate updates to the client before the final response: ```php - ## Creating Prompts -Prompts enable your server to share reusable prompt templates that AI clients can use to interact with language models. -They provide a standardized way to structure common queries and interactions. Create a prompt using the -`make:mcp-prompt` Artisan command: +Prompts enable your server to share reusable prompt templates that AI clients can use to interact with language models. They provide a standardized way to structure common queries and interactions. Create a prompt using the `make:mcp-prompt` Artisan command: ```shell php artisan make:mcp-prompt DescribeWeatherPrompt @@ -575,12 +531,9 @@ class WeatherServer extends Server ``` - ### Prompt Name, Title, and Description -By default, the prompt's name and title are derived from the class name. As example, `AskWeatherPrompt` will have an -`ask_weather` name and `Ask Weather Prompt` title. You may customize these values by overriding the `$name` and `$title` -properties: +By default, the prompt's name and title are derived from the class name. As example, `AskWeatherPrompt` will have an `ask_weather` name and `Ask Weather Prompt` title. You may customize these values by overriding the `$name` and `$title` properties: ```php class DescribeWeatherPrompt extends Prompt @@ -599,8 +552,7 @@ class DescribeWeatherPrompt extends Prompt } ``` -On the other hand, the prompt's description is not automatically generated. You should always provide a meaningful -description by overriding the `$description` property: +On the other hand, the prompt's description is not automatically generated. You should always provide a meaningful description by overriding the `$description` property: ```php class AskWeatherPrompt extends Prompt @@ -615,11 +567,9 @@ class AskWeatherPrompt extends Prompt ``` - ### Prompt Arguments -Prompts can define arguments that allow AI clients to customize the prompt template with specific values. Use the -`arguments()` method to define what parameters your prompt accepts: +Prompts can define arguments that allow AI clients to customize the prompt template with specific values. Use the `arguments()` method to define what parameters your prompt accepts: ```php - ### Validating Prompt Arguments -Prompt arguments are automatically validated based on their definition, but you may also want to enforce more complex -validation rules. +Prompt arguments are automatically validated based on their definition, but you may also want to enforce more complex validation rules. -Laravel MCP integrates seamlessly with Laravel's validation system. You may validate incoming prompt arguments within -your prompt's `handle()` method: +Laravel MCP integrates seamlessly with Laravel's validation system. You may validate incoming prompt arguments within your prompt's `handle()` method: ```php - ### Prompt Dependency Injection -The Laravel service container is used to resolve all prompts. As a result, you are able to type-hint any dependencies -your prompt may need in its constructor. The declared dependencies will automatically be resolved and injected into the -prompt instance: +The Laravel service container is used to resolve all prompts. As a result, you are able to type-hint any dependencies your prompt may need in its constructor. The declared dependencies will automatically be resolved and injected into the prompt instance: ```php - ### Conditional Prompt Registration -You may conditionally register prompts at runtime by implementing the `shouldRegister` method in your prompt class. This -method allows you to determine whether a prompt should be available based on application state, configuration, or -request parameters: +You may conditionally register prompts at runtime by implementing the `shouldRegister` method in your prompt class. This method allows you to determine whether a prompt should be available based on application state, configuration, or request parameters: ```php - ### Prompt Responses -Prompts must return an instance of `Laravel\Mcp\Response`. This class encapsulates the generated prompt content that -will be sent to the AI client: +Prompts must return an instance of `Laravel\Mcp\Response`. This class encapsulates the generated prompt content that will be sent to the AI client: ```php - ## Creating Resources -Resources enable your server to expose data and content that AI clients can read and use as context when interacting -with language models. They provide a way to share static or dynamic information like documentation, configuration, or -any data that helps inform AI responses. Create a resource using the `make:mcp-resource` Artisan command: +Resources enable your server to expose data and content that AI clients can read and use as context when interacting with language models. They provide a way to share static or dynamic information like documentation, configuration, or any data that helps inform AI responses. Create a resource using the `make:mcp-resource` Artisan command: ```shell php artisan make:mcp-resource WeatherGuidelinesResource @@ -873,12 +805,9 @@ class WeatherServer extends Server ``` - ### Resource Name, Title, and Description -By default, the resource's name and title are derived from the class name. As example, `WeatherGuidelinesResource` will -have a `weather_guidelines` name and `Weather Guidelines Resource` title. You may customize these values by overriding -the `$name` and `$title` properties: +By default, the resource's name and title are derived from the class name. As example, `WeatherGuidelinesResource` will have a `weather_guidelines` name and `Weather Guidelines Resource` title. You may customize these values by overriding the `$name` and `$title` properties: ```php class WeatherGuidelinesResource extends Resource @@ -897,8 +826,7 @@ class WeatherGuidelinesResource extends Resource } ``` -On the other hand, the resource's description is not automatically generated. You should always provide a meaningful -description by overriding the `$description` property: +On the other hand, the resource's description is not automatically generated. You should always provide a meaningful description by overriding the `$description` property: ```php class WeatherGuidelinesResource extends Resource @@ -913,11 +841,9 @@ class WeatherGuidelinesResource extends Resource ``` - ### Resource Content -Resources must implement the `read()` method to provide their content. This method should return the actual data that -will be sent to AI clients: +Resources must implement the `read()` method to provide their content. This method should return the actual data that will be sent to AI clients: ```php - ### Resource URI and MIME Type -Resources are identified by a URI and have an associated MIME type. You can customize these by overriding the `uri()` -and `mimeType()` methods: +Resources are identified by a URI and have an associated MIME type. You can customize these by overriding the `uri()` and `mimeType()` methods: ```php - ### Binary Resources -Resources can also serve binary content like images or other non-text files. The framework automatically detects binary -content and handles it appropriately: +Resources can also serve binary content like images or other non-text files. The framework automatically detects binary content and handles it appropriately: ```php - ### Resource Dependency Injection -The Laravel service container is used to resolve all resources. As a result, you are able to type-hint any dependencies -your resource may need in its constructor. The declared dependencies will automatically be resolved and injected into -the resource instance: +The Laravel service container is used to resolve all resources. As a result, you are able to type-hint any dependencies your resource may need in its constructor. The declared dependencies will automatically be resolved and injected into the resource instance: ```php - ### Conditional Resource Registration -You may conditionally register resources at runtime by implementing the `shouldRegister` method in your resource class. -This method allows you to determine whether a resource should be available based on application state, configuration, or -request parameters: +You may conditionally register resources at runtime by implementing the `shouldRegister` method in your resource class. This method allows you to determine whether a resource should be available based on application state, configuration, or request parameters: ```php validate([ 'code' => 'required|string', ]); - + $code = $request->string('code'); - + return Response::text("Please review this code: {$code}"); } From 223c565bf35c2937b36d2058bf327248fd6142e8 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:10:04 +0100 Subject: [PATCH 17/19] Ignore .ds_store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cc85c51..d304c48 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /phpunit.xml /.phpunit.cache .claude +.DS_Store From 732e22052447706fa23eed99ae031b973ad0451d Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: Mon, 15 Sep 2025 09:10:45 +0100 Subject: [PATCH 18/19] feat: improve static analysis --- src/Console/Commands/InspectorCommand.php | 4 +- src/Server/Middleware/ReorderJsonAccept.php | 2 +- src/Server/Registrar.php | 2 +- src/Server/Transport/HttpTransport.php | 2 +- tests/Feature/Console/StartCommandTest.php | 20 ++++----- .../Middleware/ReorderJsonAcceptTest.php | 44 +++++++++---------- 6 files changed, 35 insertions(+), 39 deletions(-) diff --git a/src/Console/Commands/InspectorCommand.php b/src/Console/Commands/InspectorCommand.php index 47852c8..aa1ca21 100644 --- a/src/Console/Commands/InspectorCommand.php +++ b/src/Console/Commands/InspectorCommand.php @@ -36,7 +36,7 @@ public function handle(Registrar $registrar): int $route = $registrar->getWebServer($handle); $servers = $registrar->servers(); - if (empty($servers)) { + if ($servers === []) { $this->components->error('No MCP servers found. Please run `php artisan make:mcp-server [name]`'); return static::FAILURE; @@ -47,7 +47,7 @@ public function handle(Registrar $registrar): int $server = array_shift($servers); [$localServer, $route] = match (true) { is_callable($server) => [$server, null], - get_class($server) === Route::class => [null, $server], + $server::class === Route::class => [null, $server], default => [null, null], }; } diff --git a/src/Server/Middleware/ReorderJsonAccept.php b/src/Server/Middleware/ReorderJsonAccept.php index 7532f5b..3c3f90e 100644 --- a/src/Server/Middleware/ReorderJsonAccept.php +++ b/src/Server/Middleware/ReorderJsonAccept.php @@ -26,7 +26,7 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - usort($accept, fn ($a, $b) => str_contains($b, 'application/json') <=> str_contains($a, 'application/json')); + usort($accept, fn ($a, $b): int => str_contains((string) $b, 'application/json') <=> str_contains((string) $a, 'application/json')); $request->headers->set('Accept', implode(', ', $accept)); return $next($request); diff --git a/src/Server/Registrar.php b/src/Server/Registrar.php index 62e6471..a14adc4 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -30,7 +30,7 @@ class Registrar public function web(string $route, string $serverClass): Route { // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server - Router::get($route, fn () => response(status: 405)); + Router::get($route, fn (): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response(status: 405)); $route = Router::post($route, fn (): mixed => $this->startServer( $serverClass, diff --git a/src/Server/Transport/HttpTransport.php b/src/Server/Transport/HttpTransport.php index a3467a8..fbf9477 100644 --- a/src/Server/Transport/HttpTransport.php +++ b/src/Server/Transport/HttpTransport.php @@ -52,7 +52,7 @@ public function run(): Response|StreamedResponse } // Must be 202 - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server - $statusCode = empty($this->reply) ? 202 : 200; + $statusCode = $this->reply === null || $this->reply === '' || $this->reply === '0' ? 202 : 200; $response = response($this->reply, $statusCode, $this->getHeaders()); assert($response instanceof Response); diff --git a/tests/Feature/Console/StartCommandTest.php b/tests/Feature/Console/StartCommandTest.php index b758e80..0c40d1c 100644 --- a/tests/Feature/Console/StartCommandTest.php +++ b/tests/Feature/Console/StartCommandTest.php @@ -169,21 +169,19 @@ expect($response->json())->toEqual(expectedListToolsResponse()); }); -it('returns 405 for GET requests to MCP web routes', function () { +it('returns 405 for GET requests to MCP web routes', function (): void { $response = $this->get('test-mcp'); $response->assertStatus(405); $response->assertSee(''); }); -it('returns OAuth WWW-Authenticate header when OAuth routes are enabled and response is 401', function () { +it('returns OAuth WWW-Authenticate header when OAuth routes are enabled and response is 401', function (): void { // Enable OAuth routes which registers the 'mcp.oauth.protected-resource' route - app('Laravel\Mcp\Server\Registrar')->oauthRoutes(); + app(\Laravel\Mcp\Server\Registrar::class)->oauthRoutes(); // Create a test route that returns 401 to trigger the middleware - Route::post('test-oauth-401', function () { - return response()->json(['error' => 'unauthorized'], 401); - })->middleware(['Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader']); + Route::post('test-oauth-401', fn() => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); $response = $this->postJson('test-oauth-401', []); @@ -195,11 +193,9 @@ expect($wwwAuth)->toContain('resource_metadata="'.url('/.well-known/oauth-protected-resource').'"'); }); -it('returns Sanctum WWW-Authenticate header when OAuth routes are not enabled and response is 401', function () { +it('returns Sanctum WWW-Authenticate header when OAuth routes are not enabled and response is 401', function (): void { // Create a test route that returns 401 to trigger the middleware - Route::post('test-sanctum-401', function () { - return response()->json(['error' => 'unauthorized'], 401); - })->middleware(['Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader']); + Route::post('test-sanctum-401', fn() => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); $response = $this->postJson('test-sanctum-401', []); @@ -210,8 +206,8 @@ expect($wwwAuth)->toBe('Bearer realm="mcp", error="invalid_token"'); }); -it('does not add WWW-Authenticate header when response is not 401', function () { - app('Laravel\Mcp\Server\Registrar')->oauthRoutes(); +it('does not add WWW-Authenticate header when response is not 401', function (): void { + app(\Laravel\Mcp\Server\Registrar::class)->oauthRoutes(); $response = $this->postJson('test-mcp', initializeMessage()); diff --git a/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php b/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php index 3f1a679..ab51a61 100644 --- a/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php +++ b/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php @@ -3,57 +3,57 @@ use Illuminate\Http\Request; use Laravel\Mcp\Server\Middleware\ReorderJsonAccept; -it('leaves single accept header unchanged', function () { +it('leaves single accept header unchanged', function (): void { $request = new Request; $request->headers->set('Accept', 'application/json'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('application/json'); }); -it('leaves non-comma separated accept header unchanged', function () { +it('leaves non-comma separated accept header unchanged', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('text/html'); }); -it('reorders multiple accept headers to prioritize json', function () { +it('reorders multiple accept headers to prioritize json', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html, application/json, text/plain'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); }); -it('handles json already first in list', function () { +it('handles json already first in list', function (): void { $request = new Request; $request->headers->set('Accept', 'application/json, text/html, text/plain'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); }); -it('handles multiple json types correctly', function () { +it('handles multiple json types correctly', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html, application/json, application/vnd.api+json, text/plain'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); $accept = $request->header('Accept'); $parts = array_map('trim', explode(',', $accept)); @@ -62,13 +62,13 @@ ->and(count($parts))->toBe(4); }); -it('handles accept header with quality values', function () { +it('handles accept header with quality values', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html;q=0.9, application/json;q=0.8, text/plain;q=0.7'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); $accept = $request->header('Accept'); $parts = array_map('trim', explode(',', $accept)); @@ -76,56 +76,56 @@ expect($parts[0])->toBe('application/json;q=0.8'); }); -it('handles whitespace in accept header', function () { +it('handles whitespace in accept header', function (): void { $request = new Request; $request->headers->set('Accept', ' text/html , application/json , text/plain '); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('application/json, text/html, text/plain'); }); -it('handles no json in accept header', function () { +it('handles no json in accept header', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html, text/plain, image/png'); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe('text/html, text/plain, image/png'); }); -it('handles empty accept header', function () { +it('handles empty accept header', function (): void { $request = new Request; $request->headers->set('Accept', ''); $middleware = new ReorderJsonAccept; - $middleware->handle($request, fn ($req) => response('test')); + $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($request->header('Accept'))->toBe(''); }); -it('handles missing accept header', function () { +it('handles missing accept header', function (): void { $request = new Request; $middleware = new ReorderJsonAccept; - $response = $middleware->handle($request, fn ($req) => response('test')); + $response = $middleware->handle($request, fn ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response('test')); expect($response->getContent())->toBe('test'); }); -it('passes request through middleware correctly', function () { +it('passes request through middleware correctly', function (): void { $request = new Request; $request->headers->set('Accept', 'text/html, application/json'); $middleware = new ReorderJsonAccept; - $response = $middleware->handle($request, function ($req) { + $response = $middleware->handle($request, function ($req): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response { expect($req->header('Accept'))->toBe('application/json, text/html'); return response('middleware worked'); From 441662de7cadfad0f5b9bc0193f67552371c8204 Mon Sep 17 00:00:00 2001 From: ashleyhindle <454975+ashleyhindle@users.noreply.github.com> Date: Mon, 15 Sep 2025 08:11:11 +0000 Subject: [PATCH 19/19] Fix code styling --- tests/Feature/Console/StartCommandTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/Console/StartCommandTest.php b/tests/Feature/Console/StartCommandTest.php index 0c40d1c..7f65981 100644 --- a/tests/Feature/Console/StartCommandTest.php +++ b/tests/Feature/Console/StartCommandTest.php @@ -181,7 +181,7 @@ app(\Laravel\Mcp\Server\Registrar::class)->oauthRoutes(); // Create a test route that returns 401 to trigger the middleware - Route::post('test-oauth-401', fn() => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); + Route::post('test-oauth-401', fn () => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); $response = $this->postJson('test-oauth-401', []); @@ -195,7 +195,7 @@ it('returns Sanctum WWW-Authenticate header when OAuth routes are not enabled and response is 401', function (): void { // Create a test route that returns 401 to trigger the middleware - Route::post('test-sanctum-401', fn() => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); + Route::post('test-sanctum-401', fn () => response()->json(['error' => 'unauthorized'], 401))->middleware([\Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader::class]); $response = $this->postJson('test-sanctum-401', []);