Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
53d7ca3
feat: add reorder JSON accept middleware
ashleyhindle Sep 8, 2025
fa776b1
feat: add reorder json accept middleware to http servers
ashleyhindle Sep 8, 2025
2dc6c0e
Fix code styling
ashleyhindle Sep 8, 2025
e8739a0
feat: fix registrar static analysis issues
ashleyhindle Sep 8, 2025
b1edabe
feat: adds test for 405 response to GET /mcp
ashleyhindle Sep 8, 2025
501bc81
feat: correctly return 202 status if response is empty
ashleyhindle Sep 8, 2025
7c5f353
feat: improve mcp inspector command
ashleyhindle Sep 8, 2025
c2dee35
feat: add www-authenticate header for OAuth and Sanctum
ashleyhindle Sep 8, 2025
91da1b4
docs: add basic sanctum docs to README
ashleyhindle Sep 8, 2025
58335d4
feat: slight oauth improvement
ashleyhindle Sep 8, 2025
5cb372f
Merge branch 'main' into ai-121-sanctum-auth-works-great
ashleyhindle Sep 8, 2025
c8e4468
Merge branch 'main' into ai-121-sanctum-auth-works-great
ashleyhindle Sep 15, 2025
022d64d
docs: README: add basic oauth/sanctum/auth docs back
ashleyhindle Sep 15, 2025
5ed1045
Fix PHPStorm breaking formatting
ashleyhindle Sep 15, 2025
2eeffee
Fix PHPStorm breaking formatting
ashleyhindle Sep 15, 2025
1fa4366
Fix PHPStorm breaking formatting
ashleyhindle Sep 15, 2025
4eb3aff
Fix PHPStorm breaking formatting
ashleyhindle Sep 15, 2025
319070b
Fix phpstorm breaking formatting
ashleyhindle Sep 15, 2025
223c565
Ignore .ds_store
ashleyhindle Sep 15, 2025
732e220
feat: improve static analysis
ashleyhindle Sep 15, 2025
441662d
Fix code styling
ashleyhindle Sep 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
/phpunit.xml
/.phpunit.cache
.claude
.DS_Store
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 33 additions & 6 deletions src/Console/Commands/InspectorCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');

Expand All @@ -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,
];

Expand All @@ -75,7 +102,7 @@ public function handle(Registrar $registrar): int
];
}

$process = new Process($command);
$process = new Process($command, null, $env);
$process->setTimeout(null);

try {
Expand Down Expand Up @@ -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.'],
];
}

Expand Down
43 changes: 43 additions & 0 deletions src/Server/Middleware/AddWwwAuthenticateHeader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AddWwwAuthenticateHeader
{
/**
* Handle an incoming request.
*
* @param Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->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;
}
}
34 changes: 34 additions & 0 deletions src/Server/Middleware/ReorderJsonAccept.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Laravel\Mcp\Server\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ReorderJsonAccept
{
/**
* Handle an incoming request.
*
* @param Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$accept = $request->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);
}
}
50 changes: 40 additions & 10 deletions src/Server/Registrar.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,24 +21,34 @@ class Registrar
/** @var array<string, callable> */
protected array $localServers = [];

/** @var array<string, string> */
protected array $registeredWebServers = [];
/** @var array<string, Route> */
protected array $httpServers = [];

/**
* @param class-string<Server> $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;
}

/**
Expand All @@ -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<string, callable|Route>
*/
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'],
]));

Expand All @@ -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',
]);
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/Server/Transport/HttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading