Skip to content

Commit 83bab33

Browse files
authored
Merge pull request #33 from laravel/ai-121-sanctum-auth-works-great
feat: tested Sanctum support
2 parents f06edc4 + 441662d commit 83bab33

File tree

10 files changed

+383
-23
lines changed

10 files changed

+383
-23
lines changed

.DS_Store

6 KB
Binary file not shown.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
/phpunit.xml
55
/.phpunit.cache
66
.claude
7+
.DS_Store

README.md

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -338,27 +338,68 @@ php artisan mcp:start demo
338338

339339
## Authentication
340340

341-
Web-based MCP servers can be protected using [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource.
341+
### OAuth 2.1
342342

343-
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`.
343+
The recommended way to protect your web-based MCP servers is to
344+
use [Laravel Passport](https://laravel.com/docs/passport), turning your MCP server into an OAuth2 protected resource.
345+
346+
If you already have Passport set up for your app, all you need to do is add the `Mcp::oauthRoutes()` helper to your
347+
`routes/web.php` file. This registers the required OAuth2 discovery and client registration endpoints.
348+
349+
To secure, apply Passport's `auth:api` middleware to your server registration in `routes/ai.php`:
344350

345351
```php
352+
use App\Mcp\Servers\WeatherExample;
346353
use Laravel\Mcp\Facades\Mcp;
347354

348355
Mcp::oauthRoutes();
356+
357+
Mcp::web('/mcp/weather', WeatherExample::class)
358+
->middleware('auth:api');
349359
```
350360

351-
Then, apply the `auth:api` middleware to your server registration in `routes/ai.php`:
361+
### Sanctum
362+
363+
If you'd like to protect your MCP server using Sanctum, simply add the Sanctum middleware to your server in
364+
`routes/ai.php`. Make sure MCP clients pass the usual `Authorization: Bearer token` header.
352365

353366
```php
354367
use App\Mcp\Servers\WeatherExample;
355368
use Laravel\Mcp\Facades\Mcp;
356369

357-
Mcp::web('/mcp/weather', WeatherExample::class)
358-
->middleware('auth:api');
370+
Mcp::web('/mcp/demo', WeatherExample::class)
371+
->middleware('auth:sanctum');
372+
```
373+
374+
## Authorization
375+
376+
Type hint `Authenticatable` in your primitives, or use `$request->user()` to check authorization.
377+
378+
```php
379+
public function handle(Request $request, Authenticatable $user) view.
380+
{
381+
if ($user->tokenCan('server:update') === false) {
382+
return ToolResult::error('Permission denied');
383+
}
384+
385+
if ($request->user()->can('check:weather') === false) {
386+
return ToolResult::error('Permission denied');
387+
}
388+
...
389+
}
359390
```
360391

361-
Your MCP server is now protected using OAuth.
392+
### Conditionally registering tools
393+
394+
You can hide tools from certain users without modifying your server config by using `shouldRegister`.
395+
396+
```php
397+
/** UpdateServer tool **/
398+
public function shouldRegister(Authenticatable $user): bool
399+
{
400+
return $user->tokenCan('server:update');
401+
}
402+
```
362403

363404
## Testing Servers With the MCP Inspector Tool
364405

src/Console/Commands/InspectorCommand.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
use Exception;
88
use Illuminate\Console\Command;
9+
use Illuminate\Routing\Route;
10+
use Illuminate\Support\Arr;
911
use Laravel\Mcp\Server\Registrar;
1012
use Symfony\Component\Console\Attribute\AsCommand;
1113
use Symfony\Component\Console\Input\InputArgument;
@@ -31,14 +33,33 @@ public function handle(Registrar $registrar): int
3133
$this->components->info("Starting the MCP Inspector for server [{$handle}]");
3234

3335
$localServer = $registrar->getLocalServer($handle);
34-
$webServer = $registrar->getWebServer($handle);
36+
$route = $registrar->getWebServer($handle);
3537

36-
if (is_null($localServer) && is_null($webServer)) {
37-
$this->components->error("MCP Server with name [{$handle}] not found. Did you register it using [Mcp::local()] or [Mcp::web()]?");
38+
$servers = $registrar->servers();
39+
if ($servers === []) {
40+
$this->components->error('No MCP servers found. Please run `php artisan make:mcp-server [name]`');
3841

3942
return static::FAILURE;
4043
}
4144

45+
// Only one server, we should just run it for them
46+
if (count($servers) === 1) {
47+
$server = array_shift($servers);
48+
[$localServer, $route] = match (true) {
49+
is_callable($server) => [$server, null],
50+
$server::class === Route::class => [null, $server],
51+
default => [null, null],
52+
};
53+
}
54+
55+
if (is_null($localServer) && is_null($route)) {
56+
$this->components->error('Please pass a valid MCP handle or route: '.Arr::join(array_keys($servers), ', '));
57+
58+
return static::FAILURE;
59+
}
60+
61+
$env = [];
62+
4263
if ($localServer !== null) {
4364
$artisanPath = base_path('artisan');
4465

@@ -60,11 +81,17 @@ public function handle(Registrar $registrar): int
6081
]),
6182
];
6283
} else {
63-
$serverUrl = str_replace('https://', 'http://', route('mcp-server.'.$handle));
84+
$serverUrl = url($route->uri());
85+
if (parse_url($serverUrl, PHP_URL_SCHEME) === 'https') {
86+
$env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
87+
}
6488

6589
$command = [
6690
'npx',
6791
'@modelcontextprotocol/inspector',
92+
'--transport',
93+
'http',
94+
'--server-url',
6895
$serverUrl,
6996
];
7097

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

78-
$process = new Process($command);
105+
$process = new Process($command, null, $env);
79106
$process->setTimeout(null);
80107

81108
try {
@@ -103,7 +130,7 @@ public function handle(Registrar $registrar): int
103130
protected function getArguments(): array
104131
{
105132
return [
106-
['handle', InputArgument::REQUIRED, 'The handle of the MCP server to inspect.'],
133+
['handle', InputArgument::REQUIRED, 'The handle or route of the MCP server to inspect.'],
107134
];
108135
}
109136

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Middleware;
6+
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Symfony\Component\HttpFoundation\Response;
10+
11+
class AddWwwAuthenticateHeader
12+
{
13+
/**
14+
* Handle an incoming request.
15+
*
16+
* @param Closure(\Illuminate\Http\Request): (\Illuminate\Http\Response) $next
17+
*/
18+
public function handle(Request $request, Closure $next): Response
19+
{
20+
$response = $next($request);
21+
if ($response->getStatusCode() !== 401) {
22+
return $response;
23+
}
24+
25+
$isOauth = app('router')->has('mcp.oauth.protected-resource');
26+
if ($isOauth) {
27+
$response->header(
28+
'WWW-Authenticate',
29+
'Bearer realm="mcp", resource_metadata="'.route('mcp.oauth.protected-resource').'"'
30+
);
31+
32+
return $response;
33+
}
34+
35+
// Sanctum, can't share discover URL
36+
$response->header(
37+
'WWW-Authenticate',
38+
'Bearer realm="mcp", error="invalid_token"'
39+
);
40+
41+
return $response;
42+
}
43+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Mcp\Server\Middleware;
6+
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Symfony\Component\HttpFoundation\Response;
10+
11+
class ReorderJsonAccept
12+
{
13+
/**
14+
* Handle an incoming request.
15+
*
16+
* @param Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
17+
*/
18+
public function handle(Request $request, Closure $next): Response
19+
{
20+
$accept = $request->header('Accept');
21+
if (is_string($accept) && str_contains($accept, ',')) {
22+
$accept = array_map('trim', explode(',', $accept));
23+
}
24+
25+
if (! is_array($accept)) {
26+
return $next($request);
27+
}
28+
29+
usort($accept, fn ($a, $b): int => str_contains((string) $b, 'application/json') <=> str_contains((string) $a, 'application/json'));
30+
$request->headers->set('Accept', implode(', ', $accept));
31+
32+
return $next($request);
33+
}
34+
}

src/Server/Registrar.php

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
use Illuminate\Support\Str;
1212
use Laravel\Mcp\Server;
1313
use Laravel\Mcp\Server\Contracts\Transport;
14+
use Laravel\Mcp\Server\Middleware\AddWwwAuthenticateHeader;
15+
use Laravel\Mcp\Server\Middleware\ReorderJsonAccept;
1416
use Laravel\Mcp\Server\Transport\HttpTransport;
1517
use Laravel\Mcp\Server\Transport\StdioTransport;
1618

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

22-
/** @var array<string, string> */
23-
protected array $registeredWebServers = [];
24+
/** @var array<string, Route> */
25+
protected array $httpServers = [];
2426

2527
/**
2628
* @param class-string<Server> $serverClass
2729
*/
2830
public function web(string $route, string $serverClass): Route
2931
{
30-
$this->registeredWebServers[$route] = $serverClass;
32+
// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#listening-for-messages-from-the-server
33+
Router::get($route, fn (): \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response => response(status: 405));
3134

32-
return Router::post($route, fn (): mixed => $this->startServer(
35+
$route = Router::post($route, fn (): mixed => $this->startServer(
3336
$serverClass,
3437
fn (): HttpTransport => new HttpTransport(
3538
$request = request(),
3639
// @phpstan-ignore-next-line
3740
(string) $request->header('Mcp-Session-Id')
3841
),
39-
))->name('mcp-server.'.$route);
42+
))
43+
->name($this->routeName(ltrim($route, '/')))
44+
->middleware([
45+
ReorderJsonAccept::class,
46+
AddWwwAuthenticateHeader::class,
47+
]);
48+
49+
$this->httpServers[$route->uri()] = $route;
50+
51+
return $route;
4052
}
4153

4254
/**
@@ -52,30 +64,47 @@ public function local(string $handle, string $serverClass): void
5264
);
5365
}
5466

67+
public function routeName(string $path): string
68+
{
69+
return 'mcp-server.'.Str::kebab(Str::replace('/', '-', $path));
70+
}
71+
5572
public function getLocalServer(string $handle): ?callable
5673
{
5774
return $this->localServers[$handle] ?? null;
5875
}
5976

60-
public function getWebServer(string $handle): ?string
77+
public function getWebServer(string $route): ?Route
6178
{
62-
return $this->registeredWebServers[$handle] ?? null;
79+
return $this->httpServers[$route] ?? null;
80+
}
81+
82+
/**
83+
* @return array<string, callable|Route>
84+
*/
85+
public function servers(): array
86+
{
87+
return array_merge(
88+
$this->localServers,
89+
$this->httpServers,
90+
);
6391
}
6492

6593
public function oauthRoutes(string $oauthPrefix = 'oauth'): void
6694
{
6795
Router::get('/.well-known/oauth-protected-resource', fn () => response()->json([
68-
'resource' => config('app.url'),
96+
'resource' => url('/'),
6997
'authorization_server' => url('/.well-known/oauth-authorization-server'),
70-
]));
98+
]))->name('mcp.oauth.protected-resource');
7199

72100
Router::get('/.well-known/oauth-authorization-server', fn () => response()->json([
73-
'issuer' => config('app.url'),
101+
'issuer' => url('/'),
74102
'authorization_endpoint' => url($oauthPrefix.'/authorize'),
75103
'token_endpoint' => url($oauthPrefix.'/token'),
76104
'registration_endpoint' => url($oauthPrefix.'/register'),
77105
'response_types_supported' => ['code'],
78106
'code_challenge_methods_supported' => ['S256'],
107+
'supported_scopes' => ['mcp:use'],
79108
'grant_types_supported' => ['authorization_code', 'refresh_token'],
80109
]));
81110

@@ -97,6 +126,7 @@ public function oauthRoutes(string $oauthPrefix = 'oauth'): void
97126
return response()->json([
98127
'client_id' => $client->id,
99128
'redirect_uris' => $client->redirect_uris,
129+
'scopes' => 'mcp:use',
100130
]);
101131
});
102132
}

src/Server/Transport/HttpTransport.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ public function run(): Response|StreamedResponse
5151
return response()->stream($this->stream, 200, $this->getHeaders());
5252
}
5353

54-
$response = response($this->reply, 200, $this->getHeaders());
54+
// Must be 202 - https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
55+
$statusCode = $this->reply === null || $this->reply === '' || $this->reply === '0' ? 202 : 200;
56+
$response = response($this->reply, $statusCode, $this->getHeaders());
5557

5658
assert($response instanceof Response);
5759

0 commit comments

Comments
 (0)