The demo MCP tool works beautifully.
It queries a database, formats a report, and returns a tidy JSON summary in under a second. Then production happens: the same tool needs to scan three million rows, call an external billing API, generate a CSV, upload it to storage, and maybe notify a user.
Now the MCP client times out. The agent looks broken. The queue of support tickets starts growing.
The mistake is not that the tool is slow. The mistake is treating a slow operation as if it were a synchronous request-response call.
Laravel queues are a natural fix, but the integration is not as simple as “dispatch a job.” MCP tools still need to return something useful to the model or client. The client needs to know how to check status, retrieve results, handle failures, and avoid duplicate work.
This article is about building that bridge properly: connecting MCP tool execution to Laravel queues so slow tools become durable asynchronous operations instead of timeouts.
TL;DR
If you want MCP tools to survive slow work:
- Classify tools by expected latency.
- Model each slow tool call as a durable operation.
- Enqueue a Laravel job and return an operation handle immediately.
- Expose a status tool for polling.
- Return summaries and artifact references, not giant payloads.
- Make operations idempotent and safe to retry.
- Apply timeouts, rate limits, and failure policies.
- Choose the right MCP-to-Laravel boundary.
- Observe async operations like business workflows.
The core pattern is simple:
MCP tool call
→ create ToolOperation
→ dispatch Laravel job
→ return operation_id
→ client polls status tool
→ job finishes
→ status tool returns result or error
📋 Table of Contents
- The Real Problem
- 1. Stop Treating Every MCP Tool as a Synchronous Function
- 2. Model Each Slow Tool Call as a Durable Operation
- 3. Enqueue a Laravel Job and Return a Job Handle Immediately
- 4. Make the Status Tool the Heart of the Protocol
- 5. Design Results for Polling Not Giant Payloads
- 6. Make Retries Safe With Idempotency and Uniqueness
- 7. Apply Timeouts Rate Limits and Failure Policies
- 8. Choose the Integration Boundary Between MCP and Laravel
- 9. Observe Async Tool Operations Like Business Workflows
- Choosing Between Sync and Async MCP Tools
- Production Checklist
The Real Problem
MCP tools are usually invoked as request-response operations. A client asks the server to call a tool. The server executes it. The server returns the result.
That model works well for fast operations:
get_customer
search_orders
list_recent_invoices
It starts to break down when tools do real work:
generate_monthly_billing_report
import_legacy_customers
sync_external_inventory
render_large_pdf
run_data_backfill
Those tools may take seconds, minutes, or longer. They may depend on external APIs. They may produce large artifacts. They may fail halfway through. They may need retries. They may need cancellation.
If you keep those operations synchronous, you get:
- MCP client timeouts
- blocked agent loops
- poor user experience
- duplicated work when the model retries
- no visibility into progress
- no durable record of what happened
Laravel queues solve the execution problem, but they do not automatically solve the protocol problem. The MCP client still needs a way to understand that the work is asynchronous.
That is the part most people miss.
The tool should not pretend to finish. It should say:
“I accepted the request. Here is the operation. Ask me about it later.”
1. Stop Treating Every MCP Tool as a Synchronous Function
Scenario
You expose a tool called generate_customer_report. In development, it runs in 700 milliseconds. In production, it takes 45 seconds because the customer has years of activity. The MCP client times out. The model retries. Now you have three duplicate reports running.
Why the obvious solution is insufficient
The first instinct is often to raise the timeout.
Sometimes that helps. But it only delays the problem.
Long synchronous tool calls still:
- block the client
- consume server resources while waiting
- make retries dangerous
- hide progress
- fail poorly under load
- create inconsistent user experiences
An MCP tool that takes five minutes should not behave like a database lookup.
Solution
Classify your MCP tools by execution profile.
A useful split is:
| Tool type | Example | Recommended behavior |
|---|---|---|
| Instant | get_user, list_orders | Synchronous |
| Fast but variable | search_logs | Synchronous with strict timeout |
| Slow read-only | generate_report | Async operation |
| Slow mutating | import_customers | Async operation with idempotency |
| Long workflow | monthly_close | Async operation with progress and approval |
For slow tools, the MCP tool call becomes the start of an operation, not the completion of it.
The tool returns something like:
{
"operation_id": "8f7c1d6c-7a3f-4d64-b5f2-1c3f6f6a7d21",
"status": "queued",
"tool": "generate_customer_report",
"next": {
"tool": "get_tool_operation",
"arguments": {
"operation_id": "8f7c1d6c-7a3f-4d64-b5f2-1c3f6f6a7d21"
},
"wait_ms": 2000
}
}
That response tells the client three things:
- The work was accepted.
- It is not finished.
- There is a clear next step.
Why this works
You stop fighting the nature of the work.
Fast tools remain synchronous because synchronous tools are simpler. Slow tools become durable operations because asynchronous execution is a better fit for long-running work.
This also gives you a clean place to add:
- authorization
- rate limiting
- auditing
- retries
- cancellation
- progress reporting
- artifact storage
💡 Practical note: Do not queue every tool. If a tool usually finishes in under one or two seconds, keeping it synchronous is usually simpler and gives the model a better experience.
2. Model Each Slow Tool Call as a Durable Operation
Scenario
Your MCP tool dispatches a Laravel job and returns a job ID. Then the worker restarts. Or the job fails. Or the client asks for status twenty minutes later.
You now need to know:
- Which tool was called?
- Who called it?
- What arguments were supplied?
- Is it queued, running, completed, failed, or cancelled?
- Where is the result?
- What error occurred?
If that state lives only in logs or cache, you are going to have a bad time.
Why this matters
A queue is a transport mechanism, not a business record.
Laravel’s queue system is excellent at dispatching and executing jobs, but the user-facing state of a slow MCP tool should be stored explicitly.
You need a durable operation record.
Solution
Create a tool_operations table.
Migration:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tool_operations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignUuid('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('tool');
$table->string('status')->default('queued');
$table->unsignedTinyInteger('progress')->nullable();
$table->json('input');
$table->json('output')->nullable();
$table->json('meta')->nullable();
$table->text('error')->nullable();
$table->string('idempotency_key')->nullable()->unique();
$table->timestamp('scheduled_at')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['tool', 'status']);
$table->index(['user_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('tool_operations');
}
};
Model:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class ToolOperation extends Model
{
use HasUuids;
protected $guarded = [];
protected $casts = [
'input' => 'array',
'output' => 'array',
'meta' => 'array',
'progress' => 'integer',
'scheduled_at' => 'datetime',
'started_at' => 'datetime',
'completed_at' => 'datetime',
];
public function markRunning(): void
{
$this->forceFill([
'status' => 'running',
'started_at' => now(),
])->save();
}
public function markCompleted(array $output): void
{
$this->forceFill([
'status' => 'completed',
'progress' => 100,
'output' => $output,
'completed_at' => now(),
])->save();
}
public function markFailed(string $error): void
{
$this->forceFill([
'status' => 'failed',
'error' => $error,
'completed_at' => now(),
])->save();
}
public function markCancelled(): void
{
$this->forceFill([
'status' => 'cancelled',
'completed_at' => now(),
])->save();
}
}
Why this works
The operation record becomes the source of truth for the MCP client.
It lets you answer:
- What is the current status?
- How far along is the job?
- What input triggered it?
- What result was produced?
- What failed?
- Who owns the operation?
- Can this operation be retried or cancelled?
It also survives restarts. Queue workers can die and restart. The operation record remains.
Gotcha
Do not treat the operation ID as a permission.
An unguessable UUID is nice, but authorization still matters. If a user asks for an operation, your status tool should verify that the operation belongs to that user or tenant.
3. Enqueue a Laravel Job and Return a Job Handle Immediately
Scenario
Your MCP tool handler receives a request to generate a large report. You know the work will take minutes. You need to return quickly, but you also need the work to actually happen.
Why this matters
The MCP response should not wait for the slow operation. It should acknowledge the operation and hand the work to Laravel’s queue.
The key is to separate three moments:
tool call received
→ operation created
→ job dispatched
→ operation handle returned
Solution
Create a job that performs the slow work.
<?php
namespace App\Jobs;
use App\Models\ToolOperation;
use App\Services\ReportGenerator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
class GenerateCustomerReport implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 900;
public function __construct(
public ToolOperation $operation
) {}
public function handle(ReportGenerator $generator): void
{
$this->operation->refresh();
if ($this->operation->status === 'cancelled') {
return;
}
$this->operation->markRunning();
try {
$output = $generator->generate(
$this->operation->input,
progress: function (int $percent): void {
$this->operation->update([
'progress' => $percent,
]);
}
);
$this->operation->markCompleted($output);
} catch (Throwable $e) {
$this->operation->markFailed($e->getMessage());
throw $e;
}
}
public function failed(Throwable $e): void
{
if (! $this->operation->exists) {
return;
}
if (! in_array($this->operation->status, ['completed', 'cancelled'], true)) {
$this->operation->markFailed($e->getMessage());
}
}
}
Then your MCP tool handler creates the operation and dispatches the job:
<?php
use App\Jobs\GenerateCustomerReport;
use App\Models\ToolOperation;
use Illuminate\Contracts\Security\Authenticatable;
function handleGenerateCustomerReport(
array $args,
?Authenticatable $user
): array {
$operation = ToolOperation::create([
'user_id' => $user?->getAuthIdentifier(),
'tool' => 'generate_customer_report',
'status' => 'queued',
'input' => $args,
]);
GenerateCustomerReport::dispatch($operation)
->onQueue('mcp')
->afterCommit();
return [
'operation_id' => $operation->id,
'tool' => $operation->tool,
'status' => $operation->status,
'next' => [
'tool' => 'get_tool_operation',
'arguments' => [
'operation_id' => $operation->id,
],
'wait_ms' => 2000,
],
];
}
Why this works
The MCP tool returns immediately, but the work is now durable.
The queue worker handles the slow part. The MCP server only handles coordination.
The next hint is useful because it gives the client a machine-readable instruction for what to do next. Some models or orchestration layers will use it directly. Even if they do not, it makes your API self-describing.
Important part
The afterCommit() call matters if you are dispatching the job inside a database transaction.
Without it, the queue worker may receive the job before the tool_operations row is committed. Then the worker tries to load an operation that does not exist yet.
If your handler is not transactional, afterCommit() is harmless. If it is transactional, it can save you from a confusing race condition.
4. Make the Status Tool the Heart of the Protocol
Scenario
Your slow tool correctly enqueues a job. The client receives:
{
"operation_id": "8f7c1d6c-7a3f-4d64-b5f2-1c3f6f6a7d21",
"status": "queued"
}
Now what?
If the client has no reliable way to check status, the async design is incomplete.
Why this matters
Most MCP integrations cannot assume push notifications, webhooks, or streaming updates are available everywhere. Polling is boring, portable, and reliable.
A good async MCP design needs a dedicated status tool.
Solution
Expose a tool like:
get_tool_operation
Its job is to return the current state of an operation.
<?php
use App\Models\ToolOperation;
use Illuminate\Contracts\Security\Authenticatable;
use Illuminate\Database\Eloquent\ModelNotFoundException;
function handleGetToolOperation(
string $operationId,
?Authenticatable $user
): array {
$operation = ToolOperation::query()
->when($user, function ($query) use ($user) {
$query->where('user_id', $user->getAuthIdentifier());
})
->find($operationId);
if (! $operation) {
throw new ModelNotFoundException("Operation not found.");
}
$payload = [
'operation_id' => $operation->id,
'tool' => $operation->tool,
'status' => $operation->status,
'progress' => $operation->progress,
'created_at' => $operation->created_at?->toIso8601String(),
'started_at' => $operation->started_at?->toIso8601String(),
'completed_at' => $operation->completed_at?->toIso8601String(),
];
if ($operation->status === 'completed') {
$payload['result'] = $operation->output;
}
if ($operation->status === 'failed') {
$payload['error'] = $operation->error;
}
if (in_array($operation->status, ['queued', 'running'], true)) {
$payload['next'] = [
'tool' => 'get_tool_operation',
'arguments' => [
'operation_id' => $operation->id,
],
'wait_ms' => 2000,
];
}
return $payload;
}
The corresponding MCP tool description should be explicit:
Get the status of a previously started asynchronous tool operation.
Returns operation status, progress, error details, and final result when completed.
Why this works
The status tool gives the MCP client a stable polling surface.
It also gives you a place to expose:
- progress
- estimated wait time
- partial results
- retry hints
- cancellation state
- artifact references
Optional: bounded waiting
Some clients are bad at implementing polling loops. For those, you can provide a bounded wait tool:
wait_for_tool_operation
It can block for a short time, such as five or ten seconds, then return the latest state.
Conceptually:
function handleWaitForToolOperation(
string $operationId,
int $maxWaitMs,
?Authenticatable $user
): array {
$deadline = now()->addMilliseconds(min($maxWaitMs, 10_000));
do {
$payload = handleGetToolOperation($operationId, $user);
if (! in_array($payload['status'], ['queued', 'running'], true)) {
return $payload;
}
usleep(1_000_000);
} while (now()->lessThan($deadline));
return $payload;
}
Use this carefully. It can be convenient, but it ties up a request handler while waiting. For high-traffic systems, plain polling is usually better.
🧠 The important part: The async contract is not complete until the client knows how to ask, “Is it done yet?”
5. Design Results for Polling Not Giant Payloads
Scenario
Your report tool finishes. It produces a 40 MB CSV and a 12 MB JSON summary. You stuff the whole thing into the MCP tool result.
Now the client struggles to parse it. The model context explodes. The dashboard freezes. The agent appears to fail at the very end.
Why this matters
MCP tool results are consumed by clients and often by models. Large payloads are expensive, awkward, and sometimes unsafe to place directly into a model context.
A slow tool may produce:
- large files
- binary artifacts
- thousands of rows
- expensive summaries
- downloadable exports
- audit logs
Those should usually become references, not inline blobs.
Solution
Return a small structured summary and store the large artifact elsewhere.
For example:
$output = [
'summary' => [
'rows_processed' => 183_204,
'warnings' => 12,
'generated_at' => now()->toIso8601String(),
],
'artifact' => [
'type' => 'csv',
'bytes' => 41_203_318,
'download_expires_at' => now()->addMinutes(30)->toIso8601String(),
'download_route' => 'tool_operations.download',
'operation_id' => $operation->id,
],
];
Then the status tool returns that summary.
If you need an actual download URL, Laravel signed URLs are a good fit:
<?php
use Illuminate\Support\Facades\URL;
$url = URL::temporarySignedRoute(
'tool_operations.download',
now()->addMinutes(30),
['operation' => $operation->id]
);
Route:
<?php
use App\Models\ToolOperation;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\Storage;
Route::get(
'/tool-operations/{operation}/download',
function (ToolOperation $operation) {
abort_unless($operation->status === 'completed', 404);
$path = $operation->meta['artifact_path'] ?? null;
abort_unless($path && Storage::disk('s3')->exists($path), 404);
return Response::stream(
function () use ($path) {
echo Storage::disk('s3')->get($path);
},
200,
[
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="export.csv"',
]
);
}
)->middleware('signed')->name('tool_operations.download');
Why this works
The MCP result stays small and structured.
The model or client can decide what to do with the artifact reference. If a human wants the file, they can download it. If another tool needs a subset of the data, it can request that subset through another tool.
This also makes permission handling cleaner. You can authorize the download separately from the MCP tool call.
Practical note
If you store sensitive data in input or output, consider Laravel’s encrypted casts:
protected $casts = [
'input' => 'encrypted:array',
'output' => 'encrypted:array',
];
This is not a substitute for proper access control, but it reduces accidental exposure if someone queries the table directly.
6. Make Retries Safe With Idempotency and Uniqueness
Scenario
The MCP client times out while calling import_customers. The tool actually succeeded and enqueued the job, but the client never received the response. The model retries.
Now two import jobs are running.
Why this matters
Queues are usually at-least-once delivery systems. Network failures, worker crashes, and client retries can all cause duplicate execution.
For read-only tools, duplicates may be annoying. For mutating tools, duplicates can be dangerous.
You need idempotency.
Solution
Give slow operations an idempotency key.
The key can come from the client:
{
"idempotency_key": "import-2026-02-14-batch-7"
}
Or it can be derived from the tool name, user, and arguments:
<?php
function buildIdempotencyKey(
string $tool,
?string $userId,
array $args
): string {
return hash('sha256', json_encode([
'tool' => $tool,
'user' => $userId,
'args' => $args,
], JSON_THROW_ON_ERROR));
}
Then create the operation only if the key is new:
<?php
use App\Jobs\ImportCustomers;
use App\Models\ToolOperation;
function handleImportCustomers(
array $args,
?string $userId
): array {
$tool = 'import_customers';
$idempotencyKey = $args['idempotency_key']
?? buildIdempotencyKey($tool, $userId, $args);
$operation = ToolOperation::firstOrCreate(
[
'idempotency_key' => $idempotencyKey,
'tool' => $tool,
],
[
'user_id' => $userId,
'status' => 'queued',
'input' => $args,
]
);
if ($operation->wasRecentlyCreated) {
ImportCustomers::dispatch($operation)
->onQueue('mcp')
->afterCommit();
}
return [
'operation_id' => $operation->id,
'tool' => $operation->tool,
'status' => $operation->status,
'created' => $operation->wasRecentlyCreated,
'next' => [
'tool' => 'get_tool_operation',
'arguments' => [
'operation_id' => $operation->id,
],
'wait_ms' => 2000,
],
];
}
Add queue-level uniqueness too
Laravel can also prevent duplicate jobs from being queued:
<?php
namespace App\Jobs;
use App\Models\ToolOperation;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
class ImportCustomers implements ShouldQueue, ShouldBeUnique
{
public function __construct(
public ToolOperation $operation
) {}
public function uniqueId(): string
{
return $this->operation->id;
}
public function uniqueFor(): int
{
return 3600;
}
}
Why this works
The operation record becomes the deduplication boundary.
If the same request comes in again, the client receives the same operation instead of creating a second job.
This is especially important for:
- imports
- billing operations
- notification sends
- external API mutations
- file generation
- data backfills
⚠️ Gotcha: Idempotency keys are only useful if the underlying work is actually safe to deduplicate. If your job calls an external API that creates a payment, you need an external idempotency mechanism too.
7. Apply Timeouts Rate Limits and Failure Policies
Scenario
One slow MCP tool becomes popular. It enqueues hundreds of long-running jobs. Your queue workers are stuck generating reports, while emails, webhooks, and other jobs pile up.
Why this matters
Slow tools can monopolize queue infrastructure.
If you put everything on the default queue, one heavy MCP tool can affect unrelated parts of your application.
Solution
Use a dedicated queue for MCP work.
Dispatch to a named queue:
GenerateCustomerReport::dispatch($operation)->onQueue('mcp');
Run workers for that queue separately:
php artisan queue:work redis \
--queue=mcp \
--tries=1 \
--timeout=900 \
--sleep=3
If you use Laravel Horizon, define the queue and process limits there.
Add job timeouts
A job timeout prevents a broken tool from running forever.
public int $timeout = 900;
Choose the timeout based on the real worst case, not the happy path. If a report should never take more than fifteen minutes, make that explicit.
Add rate limiting
Laravel jobs can use rate-limiting middleware.
Define a limiter:
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('mcp-tools', function ($job) {
$userId = $job->operation->user_id ?? 'global';
return Limit::perMinute(120)->by($userId);
});
}
}
Use it in the job:
<?php
use Illuminate\Queue\Middleware\RateLimited;
public function middleware(): array
{
return [
new RateLimited('mcp-tools'),
];
}
Handle failure deliberately
For slow tools, decide what failure means.
Some tools can retry safely:
- report generation
- file rendering
- read-only sync operations
Some tools should not retry automatically:
- payments
- notification sends
- destructive imports
- external API mutations
- operations with human-visible side effects
For non-idempotent work, use:
public int $tries = 1;
For idempotent work, you can allow retries:
public int $tries = 3;
public int $backoff = [10, 60, 300];
Also update the operation in the failed() method so the MCP status tool reflects the failure:
public function failed(Throwable $e): void
{
if (! $this->operation->exists) {
return;
}
if (! in_array($this->operation->status, ['completed', 'cancelled'], true)) {
$this->operation->markFailed($e->getMessage());
}
}
Cancellation
Cancellation is often ignored until someone asks for it.
Add a cancellation tool:
function handleCancelToolOperation(
string $operationId,
?string $userId
): array {
$operation = ToolOperation::query()
->when($userId, fn ($query) => $query->where('user_id', $userId))
->findOrFail($operationId);
if ($operation->status === 'queued') {
$operation->markCancelled();
return [
'operation_id' => $operation->id,
'status' => 'cancelled',
];
}
if ($operation->status === 'running') {
// Cooperative cancellation only.
$operation->update(['meta' => ['cancel_requested' => true]]);
return [
'operation_id' => $operation->id,
'status' => 'running',
'message' => 'Cancellation requested.',
];
}
return [
'operation_id' => $operation->id,
'status' => $operation->status,
];
}
Then long jobs should periodically check:
if ($this->operation->refresh()->meta['cancel_requested'] ?? false) {
$this->operation->markCancelled();
return;
}
Cancellation is cooperative unless you have deeper process-level control. Be honest about that in your tool description.
8. Choose the Integration Boundary Between MCP and Laravel
Scenario
Your MCP server is written in TypeScript. Your application is Laravel. You need to dispatch Laravel queue jobs from MCP tool calls.
Where does that boundary live?
Why this matters
The integration boundary determines:
- authentication model
- deployment complexity
- error handling
- observability
- how much Laravel context is available
- whether you can dispatch jobs directly
There are three common architectures.
Option A: Laravel-native MCP server
Your MCP server runs inside PHP and boots Laravel.
This is the cleanest option if your MCP server can be implemented in PHP.
Advantages:
- direct access to Laravel models
- direct job dispatch
- shared configuration
- shared middleware and policies
- easier testing
Trade-offs:
- you need an MCP server implementation for PHP
- the MCP process must boot Laravel
- stdio-based PHP processes need careful logging discipline
This is usually my preferred option when the MCP server is tightly coupled to the Laravel application.
Option B: MCP gateway calling Laravel HTTP endpoints
Your MCP server is separate, perhaps written in TypeScript or Python. It calls internal Laravel API endpoints.
For example:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/internal/mcp/tools/generate-customer-report', function (Request $request) {
$args = $request->validate([
'customer_id' => ['required', 'uuid'],
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after_or_equal:from'],
'idempotency_key' => ['nullable', 'string', 'max:255'],
]);
return handleGenerateCustomerReport(
$args,
$request->user()
);
})->middleware(['auth:sanctum', 'throttle:120,1']);
The MCP server calls this endpoint and returns the response as the MCP tool result.
Advantages:
- MCP server can remain in your preferred language
- Laravel stays the authority for queues and business logic
- easier to expose multiple clients
- standard HTTP observability
Trade-offs:
- extra network hop
- more authentication surface
- more deployment pieces
- more places to handle timeouts
Option C: CLI adapter
The MCP server executes a constrained CLI command, such as:
php artisan mcp:dispatch-tool generate_customer_report --args=...
This can work, but I would be careful.
Advantages:
- simple for internal tools
- no HTTP service required
Trade-offs:
- easy to accidentally expose shell-like flexibility
- harder to authenticate per user
- harder to return structured errors
- process startup overhead
- weaker observability
If you use this pattern, make the command extremely narrow. Validate all input. Do not allow arbitrary command construction.
Architecture comparison
| Approach | Best for | Complexity | Security surface | Direct Laravel access |
|---|---|---|---|---|
| Laravel-native MCP server | Tight integration | Medium | Lower | Yes |
| MCP gateway to Laravel API | Polyglot MCP server | Medium to high | Medium | Via HTTP |
| CLI adapter | Internal utilities | Low to medium | Higher if careless | Via artisan |
For most production systems, I would choose either a Laravel-native MCP server or an internal HTTP gateway. The CLI approach should be a last resort unless the tool is tightly controlled.
9. Observe Async Tool Operations Like Business Workflows
Scenario
A user says their report failed. You check the queue logs. You see a job ID. You check the MCP logs. You see a tool call. But you cannot connect the two.
Why this matters
Async MCP tools are not just infrastructure. They are user-facing workflows.
You need to observe them as operations, not only as queue jobs.
Solution
Attach structured context to every stage.
At minimum, log:
- operation ID
- tool name
- user or tenant ID
- status transition
- queue connection
- job class
- attempt number
- duration
- error message
For example, when marking an operation completed:
use Illuminate\Support\Facades\Log;
public function markCompleted(array $output): void
{
$this->forceFill([
'status' => 'completed',
'progress' => 100,
'output' => $output,
'completed_at' => now(),
])->save();
Log::info('Tool operation completed', [
'operation_id' => $this->id,
'tool' => $this->tool,
'user_id' => $this->user_id,
'duration_ms' => $this->started_at
? $this->started_at->diffInMilliseconds($this->completed_at)
: null,
]);
}
You can also dispatch domain events:
<?php
namespace App\Events;
use App\Models\ToolOperation;
class ToolOperationCompleted
{
public function __construct(
public ToolOperation $operation
) {}
}
Then fire the event:
event(new ToolOperationCompleted($operation));
That gives you hooks for:
- notifications
- webhooks
- audit logs
- analytics
- cache invalidation
- Slack alerts
- billing events
Use Laravel queue tooling
If you use Redis, Horizon gives you useful queue visibility:
- queue length
- wait time
- job runtime
- failures
- throughput
- process memory
But Horizon does not know your MCP semantics. It knows jobs.
That is why the tool_operations table is still important. It gives you the business-level view:
select
tool,
status,
count(*) as total
from tool_operations
where created_at >= now() - interval '1 hour'
group by tool, status
order by total desc;
Metrics worth tracking
Useful async MCP metrics include:
- queued operations by tool
- running operations by tool
- average wait time before running
- average execution time
- failure rate by tool
- cancellation rate
- retry count
- result payload size
- polling calls per completed operation
- queue depth for the
mcpqueue - job timeout count
If you do not track wait time separately from execution time, you will struggle to diagnose whether the problem is queue congestion or slow work.
🔍 Why this matters: A slow MCP tool usually has two delays: time spent waiting for a worker, and time spent doing the work. You need to see them separately.
Choosing Between Sync and Async MCP Tools
Not every MCP tool should be asynchronous. The wrong choice can make the agent experience worse.
Use synchronous MCP tools when
- the tool usually finishes in under one or two seconds
- the result is small
- the operation is read-only
- failure is easy to report immediately
- the model needs the result to continue immediately
- there is no meaningful progress to report
Examples:
get_customer
search_products
list_open_tickets
get_invoice_summary
Use asynchronous MCP tools when
- the tool may exceed client timeouts
- the tool produces large artifacts
- the tool performs heavy computation
- the tool calls slow external systems
- the tool mutates important data
- the tool should be auditable
- the tool may need cancellation
- the tool may need retries with idempotency
Examples:
generate_monthly_report
import_customers
sync_inventory
render_video_preview
run_bulk_validation
export_transactions
Avoid async if
- the operation is trivial
- the client cannot poll or handle pending states
- the result must be immediately available for the next reasoning step and no orchestration layer exists
- the added complexity is not justified
Async is not automatically better. It is better for the right class of work.
My default rule
If a tool might take longer than five seconds under production conditions, I start designing it as asynchronous.
If it mutates state or produces artifacts, I design it as asynchronous even earlier.
The cost of adding async later is usually much higher than adding the operation model early.
Production Checklist
Before shipping slow MCP tools through Laravel queues, verify these.
Operation model
- Slow tools create a
ToolOperationrecord. - Operations use stable UUIDs.
- Operations store tool name, input, status, error, and timestamps.
- Operations include user or tenant ownership where needed.
- Sensitive input/output is encrypted or redacted.
- Operation status transitions are explicit.
MCP tool contract
- Slow tools return immediately.
- Response includes
operation_id. - Response includes current status.
- Response includes a clear next polling tool.
- Tool descriptions explain the async behavior.
- Status tool is available and documented.
- Cancellation tool exists where appropriate.
Laravel queue execution
- Jobs dispatch to a dedicated MCP queue.
- Jobs have explicit timeouts.
- Jobs have explicit retry policy.
- Jobs update operation status.
- Failed jobs mark operations failed.
- Long jobs support cooperative cancellation.
- Jobs dispatch after database commit where relevant.
Idempotency
- Mutating tools use idempotency keys.
- Duplicate tool calls return existing operations.
- Queue-level uniqueness is considered.
- External APIs have their own idempotency where needed.
- Retry behavior is safe for side effects.
Results
- Large outputs are stored as artifacts.
- MCP results contain summaries, not giant payloads.
- Download URLs are authorized or signed.
- Artifact expiration is explicit.
- Partial results are supported where useful.
Observability
- Operation lifecycle events are logged.
- Queue metrics are collected.
- Wait time and run time are tracked separately.
- Failures include operation ID.
- Polling load is monitored.
- Stuck operations can be identified.
Security
- Status tool checks ownership or tenant scope.
- Operation IDs are not treated as permissions.
- Internal HTTP endpoints are authenticated.
- Rate limits apply to tool start and status polling.
- Signed URLs are used for artifact downloads when appropriate.
- Audit logs record who started and cancelled operations.
If you get those right, Laravel queues stop being just a background execution mechanism and become a proper async layer for MCP tools.
The mental model is worth keeping:
An MCP tool call should start work, not necessarily finish it.
Once your MCP server and Laravel queues agree on that contract, slow tools become manageable instead of fragile.