Connecting Claude Desktop to a Laravel MCP Server via Passport OAuth
How to expose a Laravel MCP server over HTTPS with OAuth authentication, so tools like Claude Desktop can connect remotely using . The Goal That's it. Claude Desktop connects to your production Laravel app, authenticates via OAuth, and gets access to your MCP tools. No API keys to manage, no tokens...
How to expose a Laravel MCP server over HTTPS with OAuth authentication, so tools like Claude Desktop can connect remotely using mcp-remote.
The Goal
{
"mcpServers": {
"rag": {
"command": "npx",
"args": [
"mcp-remote@latest",
"https://your-app.example.com/mcp/rag",
"--transport",
"http-only"
]
}
}
}
That's it. Claude Desktop connects to your production Laravel app, authenticates via OAuth, and gets access to your MCP tools. No API keys to manage, no tokens to rotate manually — the OAuth flow handles it.
Prerequisites
- Laravel 12 with
laravel/mcpinstalled - An MCP server class (e.g.
App\Mcp\Servers\RagServer) Mcp::local()already working for Claude Code viaphp artisan mcp:serve- Your app deployed and accessible over HTTPS
Step 1: Install Passport
composer require laravel/passport
php artisan passport:install --no-interaction
This creates:
- OAuth database migrations (auth codes, access tokens, refresh tokens, clients, device codes)
- RSA key pair in
storage/oauth-private.keyandstorage/oauth-public.key - A default OAuth client
Step 2: Configure the User Model
Your User model needs two things: the HasApiTokens trait and the OAuthenticatable interface.
use Laravel\Passport\Contracts\OAuthenticatable;
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable implements OAuthenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}
The OAuthenticatable interface is required for Passport's authorization server to work with your User model. Missing this causes a 401 after the OAuth flow completes — the token gets issued but can't be validated against the user.
Step 3: Add the API Guard
In config/auth.php, add a passport guard:
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
],
Without this, there's no guard that knows how to validate Passport Bearer tokens. The default web guard only handles session cookies.
Step 4: Publish the AI Routes File
php artisan vendor:publish --tag=ai-routes
This creates routes/ai.php. The MCP package's service provider loads this file automatically — it's where you register web-accessible MCP servers.
Step 5: Register OAuth Routes and Your MCP Server
In routes/ai.php:
<?php
use App\Mcp\Servers\RagServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::oauthRoutes();
Mcp::web('mcp/rag', RagServer::class)
->middleware(['auth:api', 'throttle:60,1']);
Key details:
Mcp::oauthRoutes()registers the OAuth discovery endpoints (/.well-known/oauth-authorization-server,/.well-known/oauth-protected-resource) and the dynamic client registration endpoint (/oauth/register). Without this,mcp-remotecan't discover how to authenticate.Mcp::web('mcp/rag', ...)— the first argument is the route path, not a name. This registersGETandPOSTroutes at/mcp/rag.auth:api— uses the Passport guard, not the session guard. If you useauth(which defaults toweb), the OAuth Bearer token won't be validated and you'll get 401s after authentication.- Don't include
verifiedin the middleware — the OAuth flow doesn't have access to email verification state via Bearer tokens.
Step 6: Set the Authorization View
In AppServiceProvider::boot():
use Laravel\Passport\Passport;
public function boot(): void
{
Passport::authorizationView('mcp.authorize');
}
Then publish the MCP package's authorize view:
php artisan vendor:publish --tag=mcp-views
This copies a Blade view to resources/views/mcp/authorize.blade.php — a standalone consent screen that doesn't depend on Inertia or your SPA. It shows the user which client is requesting access and lets them approve or deny.
If you skip this, Passport will try to render its default Vue-based authorization view, which doesn't exist in a typical Laravel 12 app.
Step 7: Exclude OAuth Routes from CSRF
In bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['mcp/*', 'oauth/*']);
// ...
})
The MCP and OAuth endpoints receive POST requests from mcp-remote (a Node.js process), not from your browser with a CSRF token. Without this exclusion, every POST returns 419 Page Expired.
Step 8: Configure Claude Desktop
Add to your Claude Desktop config (~/.claude/settings.json or the project's .claude/settings.json):
{
"mcpServers": {
"rag": {
"command": "npx",
"args": [
"mcp-remote@latest",
"https://your-app.example.com/mcp/rag",
"--transport",
"http-only"
]
}
}
}
How the OAuth Flow Works
When Claude Desktop starts, mcp-remote performs this sequence:
- POST
/mcp/rag— gets 401 (not authenticated) - GET
/.well-known/oauth-protected-resource/mcp/rag— discovers the OAuth server URL - GET
/.well-known/oauth-authorization-server/mcp/rag— gets the authorization endpoint, token endpoint, and registration endpoint - POST
/oauth/register— dynamically registers an OAuth client (Passport's dynamic client registration) - Opens browser to
/oauth/authorize?...— the user logs in (if not already) and sees the consent screen - User clicks Authorize — Passport issues an authorization code, redirects to
mcp-remote's local callback - POST
/oauth/token— exchanges the code for an access token - POST
/mcp/ragwithAuthorization: Bearer <token>— connected
After initial authorization, mcp-remote caches the token and refreshes it automatically. You only see the browser consent screen once.
Docker / Production Considerations
Passport Keys
Passport needs RSA keys at storage/oauth-private.key and storage/oauth-public.key. In a containerized deployment, these won't exist in the image (they're gitignored).
Option A: Generate at startup (recommended for ephemeral containers)
# In your entrypoint script, before php artisan optimize
if [ ! -f storage/oauth-private.key ] || [ ! -f storage/oauth-public.key ]; then
php artisan passport:keys --force --no-interaction
chown www-data:www-data storage/oauth-private.key storage/oauth-public.key
fi
Note the chown — the entrypoint runs as root but PHP-FPM runs as www-data. Without this, Passport can't read the keys and you get "Key path does not exist or is not readable".
New keys mean existing tokens become invalid on each deploy. For an MCP connection this is fine — mcp-remote will re-authenticate automatically.
Option B: Environment variables
Passport supports PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY env vars. However, multiline PEM keys in environment variables can break Docker builds if your platform injects them as ARG directives (Coolify does this). If your platform supports runtime-only env vars with proper multiline handling, this works. Otherwise, use Option A.
Cache Ordering
If your entrypoint runs php artisan optimize, make sure Passport keys exist before the optimize step. The config cache resolves key paths at cache time:
# 1. Generate Passport keys
php artisan passport:keys --force --no-interaction
# 2. Clear any stale caches from previous deploys
php artisan optimize:clear
# 3. Cache everything fresh
php artisan optimize
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 419 Page Expired | CSRF blocking OAuth/MCP POSTs | Exclude mcp/* and oauth/* from CSRF |
500 on /oauth/authorize | Missing authorization view | Publish MCP views and call Passport::authorizationView() |
| 500 "Invalid key supplied" | Passport keys not on disk or wrong format | Generate keys, check file permissions |
| 500 "Key path does not exist" | Keys exist but unreadable by www-data | chown www-data:www-data storage/oauth-*.key |
| 401 after successful OAuth flow | Wrong auth guard or missing interface | Use auth:api middleware, add OAuthenticatable interface to User |
| 404 from Traefik/proxy | App unhealthy, pulled from load balancer | Check health check endpoint, clear stale view caches |
mcp-remote can't discover OAuth | Mcp::oauthRoutes() not called | Add it to routes/ai.php |
| Dockerfile parse error with PEM keys | Multiline env vars injected as ARG | Use file-based keys instead of env vars |
Files Changed (Summary)
| File | Change |
|---|---|
composer.json | Added laravel/passport |
config/auth.php | Added api guard with passport driver |
config/passport.php | Published by passport:install |
routes/ai.php | Created — registers OAuth routes + MCP web server |
app/Models/User.php | Added HasApiTokens trait + OAuthenticatable interface |
app/Providers/AppServiceProvider.php | Added Passport::authorizationView('mcp.authorize') |
bootstrap/app.php | CSRF exclusion for mcp/* and oauth/* |
resources/views/mcp/authorize.blade.php | Published MCP authorization view |
database/migrations/2026_02_26_* | Passport OAuth tables |
docker/entrypoint.sh | Passport key generation + chown at startup |
Get the Friday email
What I shipped this week, what I learned, one useful thing.
No spam. Unsubscribe anytime. Privacy policy.