Contents
There's a pattern that keeps showing up in Laravel apps that use AI: an action gets written once for a Filament panel or a controller endpoint, and then the same logic gets copy-pasted — or awkwardly re-routed — when a second caller appears. The second caller these days is increasingly an MCP client: Claude Desktop, Cursor, or a custom agent.
laravel-ai-action v1.04 ships an opt-in bridge that removes that duplication. The same AgentAction class that handles a Filament form submission can now answer an MCP tool call without touching the action itself.
The Problem
laravel/mcp and laravel-ai-action speak different shapes.
An AgentAction receives an AgentContext — a typed DTO carrying an Eloquent record, an optional batch, metadata, and a user instruction. It returns an AgentResult — another typed DTO wrapping the AI response, token counts, and output format.
An MCP tool receives a flat JSON object of primitives from an LLM client. It returns a Laravel\Mcp\Response.
You can't auto-wire these. The AgentContext is host-app-resolved: it knows about Eloquent, tenant scopes, model policies. MCP input knows nothing about your database — it just has whatever the LLM decided to pass. The bridge needs a translation layer, and that translation needs to be written by the action author, not inferred by the framework.
The Design Decision
The bridge introduces one new contract: ExposedAsMcpTool. Implement it alongside AgentAction and you declare exactly the MCP surface you want to expose:
interface ExposedAsMcpTool
{
public function mcpName(): string;
public function mcpDescription(): string;
public function mcpInputSchema(JsonSchema $schema): array;
public function resolveContext(array $input, ?Authenticatable $user): AgentContext;
}
mcpInputSchema() uses the same Illuminate\JsonSchema factory the package already uses for HasStructuredOutput — no new vocabulary to learn. resolveContext() is where you translate the LLM's flat input into the Eloquent-aware context your action already knows how to use.
This is explicit by design. The alternative — auto-deriving an MCP schema from the action's AgentContext shape — sounds appealing until you realise it would expose Eloquent internals to external agents, force a brittle inference layer for annotations, and make per-action customisation awkward. The ~20 lines of declaration per exposed action buys you a surface that's designed for the LLM rather than reflected from your database.
A Full Example
<?php
namespace App\Ai\Actions;
use App\Models\Invoice;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Pixelworxio\LaravelAiAction\Actions\RunAgentAction;
use Pixelworxio\LaravelAiAction\Concerns\InteractsWithAgent;
use Pixelworxio\LaravelAiAction\Contracts\AgentAction;
use Pixelworxio\LaravelAiAction\DTOs\AgentContext;
use Pixelworxio\LaravelAiAction\DTOs\AgentResult;
use Pixelworxio\LaravelAiAction\Mcp\Attributes\ExposesAsMcpTool;
use Pixelworxio\LaravelAiAction\Mcp\Concerns\BridgesAgentContextToMcp;
use Pixelworxio\LaravelAiAction\Mcp\Contracts\ExposedAsMcpTool;
#[ExposesAsMcpTool]
#[IsReadOnly]
final class SummarizeInvoice implements AgentAction, ExposedAsMcpTool
{
use InteractsWithAgent;
use BridgesAgentContextToMcp;
public function instructions(AgentContext $context): string
{
return 'Summarize the invoice for a finance reviewer.';
}
public function prompt(AgentContext $context): string
{
$invoice = $context->record;
return "Invoice #{$invoice->number}: {$invoice->line_items->toJson()}";
}
public function handle(AgentContext $context): AgentResult
{
return app(RunAgentAction::class)->execute($this, $context);
}
// ── MCP surface ──────────────────────────────────────────────────────────
public function mcpName(): string
{
return 'summarize_invoice';
}
public function mcpDescription(): string
{
return 'Summarize a single invoice and surface any red flags for finance review.';
}
public function mcpInputSchema(JsonSchema $schema): array
{
return [
'invoice_id' => $schema->integer()->required()->description('Invoice primary key.'),
];
}
public function resolveContext(array $input, ?Authenticatable $user): AgentContext
{
$invoice = $this->resolveRecord(Invoice::class, $input['invoice_id'], $user);
return AgentContext::fromRecord($invoice);
}
}
The resolveRecord() helper comes from BridgesAgentContextToMcp — it runs findOrFail through Eloquent so your global scopes, tenant scopes, and soft-delete guards apply automatically. If the record doesn't exist it throws InvalidContextException, which the bridge maps to Response::error() so the MCP client gets a structured error rather than a 500.
Register it in routes/ai.php:
use Pixelworxio\LaravelAiAction\Mcp\Facades\AiActionMcp;
AiActionMcp::tool(\App\Ai\Actions\SummarizeInvoice::class);
That's it. The same action now handles both callers. The Filament panel calls handle() with a context it builds from the selected record. Claude Desktop calls the MCP tool, the bridge resolves the context from the invoice_id primitive, and the same handle() runs.
What Ships With the Bridge
AgentResultResponder maps AgentResult → Laravel\Mcp\Response automatically. Text and Markdown results become Response::text(). Structured results are serialised to JSON text (use formatMcpResponse() on the action if you need a true Response::structured() envelope). Token counts and provider/model land in _meta on every response — observability for free.
Annotations are declared on the action class itself using Laravel MCP's native attributes (#[IsReadOnly], #[IsDestructive], #[IsIdempotent]). The adapter reads them via reflection and forwards them to the protocol layer. Zero magic on the action author's part.
Auth is the host app's job. The bridge pulls $request->user() from the MCP HTTP transport and passes it to resolveContext(). What you do with it — tenant scoping, policy checks, flat refusal — is entirely your call. The bridge delivers the user; it enforces nothing.
Auto-discovery is available as a secondary path. Set discover_in in the config and any class carrying #[ExposesAsMcpTool] is auto-registered. Explicit AiActionMcp::tool() calls always take precedence.
make:ai-action --mcp generates a stub already implementing ExposedAsMcpTool and using BridgesAgentContextToMcp. One command to a working MCP-exposed action.
Opting In
The bridge is fully opt-in and costs nothing when disabled. Install laravel/mcp, flip the env flag, and you're live:
composer require laravel/mcp
AI_ACTION_MCP_ENABLED=true
If laravel/mcp is absent or the flag is false, the service provider short-circuits before touching any bridge class. PSR-4 lazy autoload keeps every bridge file cold. The only on-disk cost is a handful of classmap entries — constant-time hash lookups, kilobytes.
The Versioning Story
The bridge is purely additive. None of AgentAction, AgentContext, AgentResult, RunAgentAction, or FakeAgentAction changed. Existing consumers don't notice anything until they opt in.
Full docs at docs/mcp.md. Source at pixelworxio/laravel-ai-action.
Building AI agents into your Laravel app, or want to expose your app's logic to Claude Desktop? Our AI development services and MCP server development cover the full stack. Get in touch.