Stu Mason
Stu Mason

Connecting Claude Desktop to a Laravel MCP Server via Passport OAuth

Stu Mason7 min read

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/mcp installed
  • An MCP server class (e.g. App\Mcp\Servers\RagServer)
  • Mcp::local() already working for Claude Code via php 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.key and storage/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-remote can't discover how to authenticate.
  • Mcp::web('mcp/rag', ...) — the first argument is the route path, not a name. This registers GET and POST routes at /mcp/rag.
  • auth:api — uses the Passport guard, not the session guard. If you use auth (which defaults to web), the OAuth Bearer token won't be validated and you'll get 401s after authentication.
  • Don't include verified in 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:

  1. POST /mcp/rag — gets 401 (not authenticated)
  2. GET /.well-known/oauth-protected-resource/mcp/rag — discovers the OAuth server URL
  3. GET /.well-known/oauth-authorization-server/mcp/rag — gets the authorization endpoint, token endpoint, and registration endpoint
  4. POST /oauth/register — dynamically registers an OAuth client (Passport's dynamic client registration)
  5. Opens browser to /oauth/authorize?... — the user logs in (if not already) and sees the consent screen
  6. User clicks Authorize — Passport issues an authorization code, redirects to mcp-remote's local callback
  7. POST /oauth/token — exchanges the code for an access token
  8. POST /mcp/rag with Authorization: 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

SymptomCauseFix
419 Page ExpiredCSRF blocking OAuth/MCP POSTsExclude mcp/* and oauth/* from CSRF
500 on /oauth/authorizeMissing authorization viewPublish MCP views and call Passport::authorizationView()
500 "Invalid key supplied"Passport keys not on disk or wrong formatGenerate keys, check file permissions
500 "Key path does not exist"Keys exist but unreadable by www-datachown www-data:www-data storage/oauth-*.key
401 after successful OAuth flowWrong auth guard or missing interfaceUse auth:api middleware, add OAuthenticatable interface to User
404 from Traefik/proxyApp unhealthy, pulled from load balancerCheck health check endpoint, clear stale view caches
mcp-remote can't discover OAuthMcp::oauthRoutes() not calledAdd it to routes/ai.php
Dockerfile parse error with PEM keysMultiline env vars injected as ARGUse file-based keys instead of env vars

Files Changed (Summary)

FileChange
composer.jsonAdded laravel/passport
config/auth.phpAdded api guard with passport driver
config/passport.phpPublished by passport:install
routes/ai.phpCreated — registers OAuth routes + MCP web server
app/Models/User.phpAdded HasApiTokens trait + OAuthenticatable interface
app/Providers/AppServiceProvider.phpAdded Passport::authorizationView('mcp.authorize')
bootstrap/app.phpCSRF exclusion for mcp/* and oauth/*
resources/views/mcp/authorize.blade.phpPublished MCP authorization view
database/migrations/2026_02_26_*Passport OAuth tables
docker/entrypoint.shPassport 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.