diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/.DS_Store differ 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 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 diff --git a/src/Console/Commands/InspectorCommand.php b/src/Console/Commands/InspectorCommand.php index e0e1b1e..aa1ca21 100644 --- a/src/Console/Commands/InspectorCommand.php +++ b/src/Console/Commands/InspectorCommand.php @@ -6,6 +6,8 @@ use Exception; use Illuminate\Console\Command; +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; @@ -31,14 +33,33 @@ public function handle(Registrar $registrar): int $this->components->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->components->error("MCP Server with name [{$handle}] not found. Did you register it using [Mcp::local()] or [Mcp::web()]?"); + $servers = $registrar->servers(); + if ($servers === []) { + $this->components->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], + $server::class === Route::class => [null, $server], + default => [null, null], + }; + } + + if (is_null($localServer) && is_null($route)) { + $this->components->error('Please pass a valid MCP handle or route: '.Arr::join(array_keys($servers), ', ')); + + return static::FAILURE; + } + + $env = []; + if ($localServer !== null) { $artisanPath = base_path('artisan'); @@ -60,11 +81,17 @@ public function handle(Registrar $registrar): int ]), ]; } 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, ]; @@ -75,7 +102,7 @@ public function handle(Registrar $registrar): int ]; } - $process = new Process($command); + $process = new Process($command, null, $env); $process->setTimeout(null); try { @@ -103,7 +130,7 @@ public function handle(Registrar $registrar): 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/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/Middleware/ReorderJsonAccept.php b/src/Server/Middleware/ReorderJsonAccept.php new file mode 100644 index 0000000..3c3f90e --- /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): 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 12587e4..a14adc4 100644 --- a/src/Server/Registrar.php +++ b/src/Server/Registrar.php @@ -11,6 +11,8 @@ use Illuminate\Support\Str; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Contracts\Transport; +use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader; +use Laravel\Mcp\Server\Middleware\ReorderJsonAccept; use Laravel\Mcp\Server\Transport\HttpTransport; use Laravel\Mcp\Server\Transport\StdioTransport; @@ -19,24 +21,34 @@ class Registrar /** @var array */ protected array $localServers = []; - /** @var array */ - protected array $registeredWebServers = []; + /** @var array */ + protected array $httpServers = []; /** * @param class-string $serverClass */ public function web(string $route, string $serverClass): Route { - $this->registeredWebServers[$route] = $serverClass; + // https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server + Router::get($route, fn (): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response(status: 405)); - return Router::post($route, fn (): mixed => $this->startServer( + $route = Router::post($route, fn (): mixed => $this->startServer( $serverClass, fn (): HttpTransport => new HttpTransport( $request = request(), // @phpstan-ignore-next-line (string) $request->header('Mcp-Session-Id') ), - ))->name('mcp-server.'.$route); + )) + ->name($this->routeName(ltrim($route, '/'))) + ->middleware([ + ReorderJsonAccept::class, + AddWwwAuthenticateHeader::class, + ]); + + $this->httpServers[$route->uri()] = $route; + + return $route; } /** @@ -52,30 +64,47 @@ public function local(string $handle, string $serverClass): void ); } + 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; } - public function getWebServer(string $handle): ?string + public function getWebServer(string $route): ?Route { - return $this->registeredWebServers[$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 { Router::get('/.well-known/oauth-protected-resource', fn () => 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', fn () => 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'], ])); @@ -97,6 +126,7 @@ public function oauthRoutes(string $oauthPrefix = 'oauth'): void return response()->json([ 'client_id' => $client->id, 'redirect_uris' => $client->redirect_uris, + 'scopes' => 'mcp:use', ]); }); } diff --git a/src/Server/Transport/HttpTransport.php b/src/Server/Transport/HttpTransport.php index 78be932..fbf9477 100644 --- a/src/Server/Transport/HttpTransport.php +++ b/src/Server/Transport/HttpTransport.php @@ -51,7 +51,9 @@ public function run(): Response|StreamedResponse return response()->stream($this->stream, 200, $this->getHeaders()); } - $response = response($this->reply, 200, $this->getHeaders()); + // Must be 202 - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server + $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 08e512d..7f65981 100644 --- a/tests/Feature/Console/StartCommandTest.php +++ b/tests/Feature/Console/StartCommandTest.php @@ -1,5 +1,6 @@ json())->toEqual(expectedListToolsResponse()); }); +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 (): void { + // Enable OAuth routes which registers the 'mcp.oauth.protected-resource' route + 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]); + + $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 (): 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]); + + $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 (): void { + app(\Laravel\Mcp\Server\Registrar::class)->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()); diff --git a/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php b/tests/Unit/Server/Middleware/ReorderJsonAcceptTest.php new file mode 100644 index 0000000..ab51a61 --- /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): \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 (): void { + $request = new Request; + $request->headers->set('Accept', 'text/html'); + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + $request->headers->set('Accept', 'text/html, application/json, text/plain'); + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + $request->headers->set('Accept', 'application/json, text/html, text/plain'); + + $middleware = new ReorderJsonAccept; + + $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 (): 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): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => 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 (): 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): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => 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 (): void { + $request = new Request; + $request->headers->set('Accept', ' text/html , application/json , text/plain '); + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + $request->headers->set('Accept', 'text/html, text/plain, image/png'); + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + $request->headers->set('Accept', ''); + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + + $middleware = new ReorderJsonAccept; + + $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 (): void { + $request = new Request; + $request->headers->set('Accept', 'text/html, application/json'); + + $middleware = new ReorderJsonAccept; + + $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'); + }); + + expect($response->getContent())->toBe('middleware worked'); +});