Getting an MCP server running locally takes an afternoon. Running one reliably in production — with proper authentication, scoped tool permissions, rate limiting, audit trails, and observability — is a different challenge. At ZIRA Software, we've moved several Laravel MCP servers from demo to production and this post covers everything the getting-started guides skip. If you're new to MCP, start with our intro guide first.
Production MCP Architecture
Production Laravel MCP Server
┌─────────────────────────────────────────────────────────┐
│ Load Balancer │
│ (SSL termination) │
└─────────────────────┬───────────────────────────────────┘
│ HTTPS
┌─────────────────────▼───────────────────────────────────┐
│ Laravel Application │
│ ┌───────────────┐ ┌─────────────┐ ┌───────────────┐ │
│ │ MCP Auth │ │ Tool │ │ Rate │ │
│ │ Middleware │ │ Registry │ │ Limiter │ │
│ └───────────────┘ └─────────────┘ └───────────────┘ │
│ ┌───────────────┐ ┌─────────────┐ ┌───────────────┐ │
│ │ Audit Logger │ │ Eloquent │ │ Redis Cache │ │
│ └───────────────┘ └─────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────┘
Step 1: OAuth 2.0 Authentication for Remote MCP
The MCP specification recommends OAuth 2.0 for remote servers. Here's how to implement it cleanly in Laravel using Passport:
// routes/mcp.php
Route::prefix('mcp')->group(function () {
// OAuth token endpoint (Passport handles this)
Route::post('/oauth/token', [McpOAuthController::class, 'token']);
// MCP endpoints — require valid Bearer token
Route::middleware(['mcp.auth'])->group(function () {
Route::get('/sse', [McpController::class, 'sse']); // SSE stream
Route::post('/call', [McpController::class, 'call']); // Tool calls
});
});
// app/Http/Middleware/McpAuth.php
class McpAuth
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (! $token) {
return response()->json([
'error' => 'unauthorized',
'error_description' => 'Bearer token required',
], 401);
}
// Validate against mcp_api_keys table (faster than Passport for machine tokens)
$apiKey = McpApiKey::where('token_hash', hash('sha256', $token))
->where('active', true)
->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
->first();
if (! $apiKey) {
return response()->json(['error' => 'invalid_token'], 401);
}
$apiKey->update(['last_used_at' => now()]);
app()->instance('mcp.key', $apiKey);
return $next($request);
}
}
// database/migrations/create_mcp_api_keys_table.php
Schema::create('mcp_api_keys', function (Blueprint $table) {
$table->id();
$table->string('name'); // "Claude Desktop - John"
$table->string('token_hash')->unique(); // sha256 of the actual token
$table->json('allowed_tools'); // ["get_user", "list_orders"]
$table->integer('rate_limit')->default(60); // calls per minute
$table->boolean('active')->default(true);
$table->timestamp('expires_at')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
Step 2: Per-Key Tool Scoping
Every API key should only access the tools it needs — principle of least privilege:
// app/MCP/ToolRegistry.php
class ToolRegistry
{
private array $tools = [];
public function register(string $name, McpTool $tool): void
{
$this->tools[$name] = $tool;
}
public function forKey(McpApiKey $key): array
{
$allowed = $key->allowed_tools; // ['get_user', 'list_orders']
return array_filter(
$this->tools,
fn ($name) => in_array($name, $allowed),
ARRAY_FILTER_USE_KEY
);
}
public function call(string $name, array $args, McpApiKey $key): McpResult
{
if (! in_array($name, $key->allowed_tools)) {
return McpResult::error("Tool '{$name}' not authorized for this key");
}
if (! isset($this->tools[$name])) {
return McpResult::error("Unknown tool: {$name}");
}
return $this->tools[$name]->execute($args);
}
}
// app/Providers/McpServiceProvider.php
class McpServiceProvider extends ServiceProvider
{
public function boot(ToolRegistry $registry): void
{
$registry->register('get_user', new GetUserTool());
$registry->register('list_orders', new ListOrdersTool());
$registry->register('create_order', new CreateOrderTool());
$registry->register('run_report', new RunReportTool());
// read-only tools: generally safe to expose broadly
// mutation tools: scope carefully per key
}
}
Step 3: Rate Limiting
MCP servers are synchronous from the AI's perspective — rate limiting prevents runaway agent loops from hammering your database:
// app/MCP/McpRateLimiter.php
class McpRateLimiter
{
public function __construct(private readonly Cache $cache) {}
public function check(McpApiKey $key): void
{
$cacheKey = "mcp:rate:{$key->id}:" . now()->format('Y-m-d H:i');
$current = (int) $this->cache->get($cacheKey, 0);
if ($current >= $key->rate_limit) {
throw new McpRateLimitException(
"Rate limit exceeded: {$key->rate_limit} calls/minute for key '{$key->name}'"
);
}
$this->cache->put($cacheKey, $current + 1, 61); // expire after 61s
}
}
// app/Http/Controllers/McpController.php
class McpController extends Controller
{
public function call(
Request $request,
ToolRegistry $registry,
McpRateLimiter $rateLimiter,
McpAuditLogger $logger,
): JsonResponse {
$key = app('mcp.key');
try {
$rateLimiter->check($key);
} catch (McpRateLimitException $e) {
return response()->json([
'jsonrpc' => '2.0',
'error' => ['code' => -32029, 'message' => $e->getMessage()],
], 429);
}
$body = $request->json()->all();
$tool = $body['params']['name'] ?? null;
$args = $body['params']['arguments'] ?? [];
$startedAt = now();
$result = $registry->call($tool, $args, $key);
// Log every call — visibility is everything
$logger->log(
key: $key,
tool: $tool,
args: $args,
result: $result,
durationMs: $startedAt->diffInMilliseconds(now()),
);
return response()->json([
'jsonrpc' => '2.0',
'id' => $body['id'] ?? null,
'result' => ['content' => [['type' => 'text', 'text' => $result->toJson()]]],
]);
}
}
Step 4: Audit Logging
Every tool call in production must be logged. Not just failures — everything:
// app/MCP/McpAuditLogger.php
class McpAuditLogger
{
public function log(
McpApiKey $key,
?string $tool,
array $args,
McpResult $result,
int $durationMs,
): void {
McpAuditLog::create([
'api_key_id' => $key->id,
'tool' => $tool,
'args' => $args, // full arguments, JSON cast
'success' => $result->isSuccess(),
'error' => $result->error,
'duration_ms' => $durationMs,
'ip_address' => request()->ip(),
]);
}
public function dailySummary(): array
{
return McpAuditLog::whereDate('created_at', today())
->selectRaw('tool, count(*) as calls, avg(duration_ms) as avg_ms, sum(success = 0) as errors')
->groupBy('tool')
->orderByDesc('calls')
->get()
->toArray();
}
}
Step 5: SSE Transport for Real-Time Streaming
For long-running tool calls (reports, data exports), streaming via SSE keeps the connection alive:
// app/Http/Controllers/McpController.php
public function sse(Request $request): StreamedResponse
{
$key = app('mcp.key');
return response()->stream(function () use ($key) {
// Send server info on connect
$this->sseEvent('endpoint', json_encode([
'uri' => url('/mcp/call'),
]));
// Keep connection alive
while (true) {
if (connection_aborted()) break;
$this->sseEvent('ping', json_encode(['timestamp' => now()->toIso8601String()]));
ob_flush();
flush();
sleep(15);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // disable nginx buffering
]);
}
private function sseEvent(string $event, string $data): void
{
echo "event: {$event}\n";
echo "data: {$data}\n\n";
}
Deployment: Nginx + Laravel Octane
For production MCP servers handling concurrent AI agents, Octane (Swoole or RoadRunner) is strongly recommended over traditional PHP-FPM:
# nginx config for MCP server
location /mcp/sse {
proxy_pass http://octane:8000;
proxy_buffering off; # critical for SSE
proxy_cache off;
proxy_read_timeout 3600s; # keep SSE connections alive
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
location /mcp/call {
proxy_pass http://octane:8000;
proxy_read_timeout 30s;
proxy_set_header X-Real-IP $remote_addr;
}
# Laravel Octane with Swoole
composer require laravel/octane
php artisan octane:install --server=swoole
php artisan octane:start --workers=8 --port=8000
Monitoring: What to Track
MCP Production Metrics Dashboard
├── Tool call volume (per tool, per key, per hour)
├── Error rate (% of failed tool calls)
├── P50 / P95 / P99 latency per tool
├── Rate limit hits (signals runaway agents)
├── Top callers by key name
└── Tools with zero calls in 7 days (candidate for cleanup)
Frequently Asked Questions
Do I need OAuth for a local MCP server? No. Local MCP servers (stdio transport, running as a CLI process on the same machine) rely on OS-level user permissions. OAuth is only needed for remote MCP servers exposed over HTTP, where the AI client connects over a network.
How many tool calls per minute can a Laravel MCP server handle? With Laravel Octane (Swoole), a single server can handle hundreds of concurrent tool calls. With traditional PHP-FPM, each tool call ties up a worker for its duration. For high-throughput deployments, Octane + Redis-backed rate limiting handles thousands of calls/minute comfortably.
Should MCP tools be synchronous or use queues?
Short-running tools (< 2 seconds) should be synchronous — the MCP client expects a response. Long-running operations (report generation, data exports, bulk operations) should start a queued job and return a job ID, then expose a separate check_job_status tool the AI can poll.
How do I rotate API keys without downtime?
Issue the new key, set a future expires_at on the old key (e.g., +7 days), and notify the key holder to update their config. Both keys work during the rotation window. After the expiry date, the old key automatically stops working.
Is it safe to give an MCP server access to my production database? With proper scoping: yes. Each API key should only have access to the minimum set of tools it needs. Read-only tools (SELECT queries) are safe to expose broadly. Mutation tools (INSERT, UPDATE, DELETE) should require explicit per-key authorization and be logged with full argument capture.
Conclusion
Running MCP in production is fundamentally a reliability and security engineering problem, not an AI problem. OAuth tokens, tool scoping, rate limiting, audit logging, and SSE-friendly infrastructure are the same patterns you'd apply to any mission-critical API — just applied to the AI integration layer. Get these foundations right and your MCP server becomes a stable, auditable, and scalable interface between your Laravel application and any AI system that supports the protocol.
Need help designing or deploying a production MCP server on Laravel? Contact ZIRA Software — we've built and shipped MCP-native architectures for enterprise clients.