DEV Community

Cover image for How to Build a Laravel MCP Server with Filament
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

How to Build a Laravel MCP Server with Filament

Originally published at hafiz.dev


If you've been using Claude Code or Cursor on a Filament project, you already know the frustration. You ask the agent to add a filter to your OrderResource, and it invents a column that doesn't exist. You ask it to fix a relationship display issue, and it writes code for the wrong Filament version. The agent isn't bad. It just has no idea what's actually inside your app.

That's the exact problem Laravel MCP solves. Instead of an agent guessing at your schema from vague documentation references or grep results, it queries a server you control. You define exactly what it can see. You decide what gets exposed, what stays private, and how the data is shaped before the agent ever touches it.

In this tutorial I'm going to walk through building a practical Laravel MCP server specifically for a Filament admin. The examples use an e-commerce order management setup because it's concrete and easy to follow, but the patterns apply directly to any Filament project regardless of domain. We'll create tools that expose your Eloquent data with filters, a resource that gives agents a map of your admin panel structure, and wire everything up for both local development and remote HTTP access.

If you're new to MCP in Laravel, read my earlier post on Laravel Boost and MCP servers first. This tutorial assumes you know what MCP is and why it matters. We're going straight to the implementation.

Why Filament Projects Specifically Need This

Most MCP tutorials stop at exposing raw database tables. That's useful, but it misses a big chunk of what Filament apps actually need. When you're working in a Filament project, your domain logic is split between Eloquent models and Filament Resources. The resources define how data is filtered, how forms are structured, which columns are displayed, and how permissions work. None of that lives in the database schema.

An AI agent that only has database access can tell you what columns exist. It can't tell you which OrderResource pages are registered, what the create form looks like, or how your custom widgets pull data. That's the gap a Filament-aware MCP server fills. You're giving the agent structured access to both layers, the data and the admin layer on top of it.

The second reason is practical: Filament apps tend to be complex. Multi-panel setups, custom pages, resource relations, polymorphic relationships. The more complex your app, the more the agent needs structured context rather than broad guessing. A one-hour investment in MCP setup pays back consistently across every dev session after it.

Prerequisites

You'll need:

  • Laravel 12 or 13 (the laravel/mcp package doesn't support earlier versions)
  • A running Filament v3+ installation
  • PHP 8.2 or higher

If you're starting fresh with Filament, check out this guide on building admin dashboards with Filament before continuing. It covers panel setup and resource structure in detail.

Installing Laravel MCP

Start with a single Composer command:

composer require laravel/mcp
Enter fullscreen mode Exit fullscreen mode

Then publish the routes file:

php artisan vendor:publish --tag=ai-routes
Enter fullscreen mode Exit fullscreen mode

This creates routes/ai.php. Think of it as routes/api.php but for AI clients. You'll register your MCP servers here, control which middleware runs on each one, and decide which servers are local (stdio-based) vs. web (HTTP-based). You can mix both in the same file.

Creating the Filament Server

Run the generator:

php artisan make:mcp-server FilamentAdminServer
Enter fullscreen mode Exit fullscreen mode

You'll find the new class at app/Mcp/Servers/FilamentAdminServer.php. The PHP attributes on the class matter more than most people realise. The #[Instructions] attribute is literally the first thing the AI model reads about this server. It uses it to decide whether to query this server at all, so vague instructions produce vague agent behavior.

<?php

namespace App\Mcp\Servers;

use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server;

#[Name('Filament Admin Server')]
#[Version('1.0.0')]
#[Instructions('Provides AI agents with structured access to this application\'s Filament admin resources, Eloquent data, and panel navigation. Use this server when you need to inspect or query application data, understand the resource structure, or generate code that interacts with Filament resources.')]
class FilamentAdminServer extends Server
{
    protected array $tools = [];
    protected array $resources = [];
    protected array $prompts = [];
}
Enter fullscreen mode Exit fullscreen mode

"Provides access to data" tells the agent nothing. "Provides structured access to Filament admin resources, Eloquent data, and panel navigation" tells it exactly when to reach for this server. The more precise you are here, the fewer times you'll have to manually prompt the agent to use the right tool.

Building Your First Tool: List Records

The most immediately useful tool for a Filament app is one that lets the agent query actual data with business-logic filters, not raw SQL. Let's build ListOrdersTool.

php artisan make:mcp-tool ListOrdersTool
Enter fullscreen mode Exit fullscreen mode

Here's the full implementation:

<?php

namespace App\Mcp\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;

#[Description('Lists orders from the database. Supports filtering by status and limiting results. Returns order ID, reference, status, total, and customer name.')]
#[IsReadOnly]
class ListOrdersTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'status' => 'nullable|string|in:pending,processing,completed,cancelled',
            'limit'  => 'nullable|integer|min:1|max:50',
        ], [
            'status.in'  => 'Status must be one of: pending, processing, completed, cancelled.',
            'limit.max'  => 'Limit cannot exceed 50 records.',
        ]);

        $orders = Order::query()
            ->with('customer')
            ->when(!empty($validated['status']), fn ($q) => $q->where('status', $validated['status']))
            ->latest()
            ->limit($validated['limit'] ?? 10)
            ->get();

        return Response::structured(
            $orders->map(fn ($order) => [
                'id'            => $order->id,
                'reference'     => $order->reference,
                'status'        => $order->status,
                'total'         => $order->total,
                'customer_name' => $order->customer?->name,
                'created_at'    => $order->created_at->toDateTimeString(),
            ])->toArray()
        );
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string()
                ->enum(['pending', 'processing', 'completed', 'cancelled'])
                ->description('Filter orders by status. Omit to return all statuses.'),

            'limit' => $schema->integer()
                ->description('Number of records to return. Defaults to 10, max 50.')
                ->default(10),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Three decisions here worth explaining. First, Response::structured() returns a proper JSON object the AI client can parse and reason about, not just a text blob. When you return plain text, the agent has to interpret natural language. When you return structured data, it can reliably extract the id or status and use them in the next step. Second, #[IsReadOnly] tells the client this tool won't modify any state, so it can be called freely without requesting confirmation. Third, the validation error messages are written for the AI, not for a human form. They're specific and tell the agent exactly what to fix.

Register it in your server's $tools array before moving on. The pattern here, validate inputs clearly, query with explicit field selection, return structured output, is the same pattern you'll follow for every tool you add. Once you've built two or three, each new one takes about ten minutes.

Building a Deeper Tool: Get a Single Record

A list tool is a good start, but agents often need to inspect one record in full detail before writing code that touches it. Without this, the agent calls your list tool, gets back a summary, and then has to guess what relationships and fields are available on the full record. That's where hallucinations creep back in.

php artisan make:mcp-tool GetOrderTool
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Mcp\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tool;

#[Description('Retrieves a single order with full details including line items, customer, and status history.')]
#[IsReadOnly]
class GetOrderTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'id' => 'required|integer|exists:orders,id',
        ], [
            'id.required' => 'You must provide an order ID.',
            'id.exists'   => 'No order found with that ID. Use the list-orders tool first to find valid IDs.',
        ]);

        $order = Order::with(['customer', 'items.product', 'statusHistory'])
            ->findOrFail($validated['id']);

        return Response::structured([
            'id'        => $order->id,
            'reference' => $order->reference,
            'status'    => $order->status,
            'total'     => $order->total,
            'customer'  => [
                'id'    => $order->customer?->id,
                'name'  => $order->customer?->name,
                'email' => $order->customer?->email,
            ],
            'items' => $order->items->map(fn ($item) => [
                'product_name' => $item->product?->name,
                'quantity'     => $item->quantity,
                'unit_price'   => $item->unit_price,
            ])->toArray(),
            'created_at' => $order->created_at->toDateTimeString(),
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'id' => $schema->integer()
                ->description('The database ID of the order to retrieve.')
                ->required(),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The id.exists validation message tells the agent exactly what to do next. That's a pattern worth following across every tool you build. Good MCP validation errors guide the agent's next action. They don't just report failure and leave it stuck.

Add both tools to your server:

protected array $tools = [
    ListOrdersTool::class,
    GetOrderTool::class,
];
Enter fullscreen mode Exit fullscreen mode

Adding a Resource: Expose Your Filament Navigation

Tools handle queries. Resources handle context. There's a real difference. A tool runs on demand and returns fresh data from a specific request. A resource is something the agent reads once to orient itself, like a map of your application. It answers questions like: what resources exist, what models do they manage, and what are the correct admin URLs?

This is the thing that prevents the agent from inventing route names or misidentifying which Resource class handles which model. Without it, the agent has to grep your codebase and hope it finds everything.

php artisan make:mcp-resource FilamentNavigationResource
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Mcp\Resources;

use Filament\Facades\Filament;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;

#[Description('Lists all registered Filament resources, the Eloquent models they manage, and their admin panel index URLs.')]
#[Uri('filament://resources/navigation')]
#[MimeType('application/json')]
class FilamentNavigationResource extends Resource
{
    public function handle(Request $request): Response
    {
        // Adjust 'admin' to match your panel ID if it's different
        $panel = Filament::getPanel('admin');

        $resources = collect($panel->getResources())
            ->map(fn ($resourceClass) => [
                'class'        => $resourceClass,
                'model'        => $resourceClass::getModel(),
                'plural_label' => $resourceClass::getPluralModelLabel(),
                'index_url'    => $resourceClass::getUrl('index'),
            ])
            ->values()
            ->toArray();

        return Response::structured([
            'panel_id'  => $panel->getId(),
            'resources' => $resources,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register it in your server's $resources array. When Claude Code reads this resource at the start of a session, it knows the exact class name for every resource in your admin, which model each one manages, and the correct index URL. That's a concrete, useful context dump. Compare that to an agent starting cold with no information, and the difference in code quality is noticeable immediately.

Registering the Server

Open routes/ai.php. You have two options.

For Claude Code or Cursor running locally, use local. The server runs as a subprocess your MCP client starts over stdio:

use App\Mcp\Servers\FilamentAdminServer;
use Laravel\Mcp\Facades\Mcp;

Mcp::local('filament-admin', FilamentAdminServer::class);
Enter fullscreen mode Exit fullscreen mode

For remote HTTP clients (a web-based AI assistant, an external agent, anything outside your local machine), use web with authentication middleware:

Mcp::web('/mcp/filament', FilamentAdminServer::class)
    ->middleware(['auth:sanctum', 'throttle:mcp']);
Enter fullscreen mode Exit fullscreen mode

Both can live in the same file simultaneously. Here's how the full flow looks once everything is connected:

flowchart TD
    A[Claude Code / Cursor] -->|calls tool or reads resource| B[FilamentAdminServer]
    B --> C{Request type}
    C -->|ListOrdersTool| D[Eloquent query with filters]
    C -->|GetOrderTool| E[findOrFail with relations]
    C -->|FilamentNavigationResource| F[Filament panel + resources]
    D --> G[Response::structured]
    E --> G
    F --> G
    G --> A
Enter fullscreen mode Exit fullscreen mode

Testing with the MCP Inspector

Before connecting a real client, use the built-in inspector to verify everything works. For a local server:

php artisan mcp:start filament-admin --inspector
Enter fullscreen mode Exit fullscreen mode

The inspector gives you a browser UI to call each tool manually and inspect the raw JSON responses. It's the quickest way to catch a missing eager load, a validation rule mismatch, or a Filament API method that doesn't exist in your version, before they cause confusion in a real agent session.

When your tool returns data, paste it into a JSON formatter to inspect the structure clearly before you commit to it as the tool's output contract. Changing the response shape later forces you to update any agent workflows that already depend on it.

Security: What Not to Skip

A few things to get right before any of this goes beyond local dev.

Gate write tools with shouldRegister. Any tool that creates, updates, or deletes data should check permissions before it appears in the tool list at all:

public function shouldRegister(Request $request): bool
{
    return $request?->user()?->hasRole('admin') ?? false;
}
Enter fullscreen mode Exit fullscreen mode

When shouldRegister returns false, the tool doesn't appear in the available tools list. The agent can't call what it can't see. That's much better than returning an auth error after the fact, because it gives the agent an accurate picture of what it can actually do.

Use Sanctum for web servers. The auth:sanctum middleware on your Mcp::web() registration means clients need a valid token. Issue tokens with short expiry for AI integrations, same as you would for any external API client. Don't reuse tokens across different agents or sessions.

Never return raw Eloquent models. Always explicitly select the fields you're exposing. The field mappings in the tools above are intentional, not lazy. You don't want the agent accidentally receiving password hashes, internal flags, or anything else sitting on the model that shouldn't leave the server.

When to Build a Custom Server vs. Just Using Boost

If you're doing local development with Claude Code, Laravel Boost already gives the agent schema inspection, log reading, and Tinker access out of the box. You don't need a custom MCP server for those things. Don't duplicate what Boost already does well.

Build a custom server when you need business-logic-aware queries the agent can call on demand, not just raw schema access. When you're connecting a non-developer AI client. When you need fine-grained control over data visibility. Or specifically when you're working with Filament's resource and panel structure, which Boost knows nothing about.

They're not mutually exclusive. A mature Filament project will often use both. And if you're building AI features into the app itself rather than just using AI for development, you'll want to look at the Laravel AI SDK alongside this.

Frequently Asked Questions

Does this work with Filament v3 and v4?

Yes. The MCP server doesn't depend on a specific Filament version. FilamentNavigationResource uses Filament::getPanel() and $panel->getResources(), both available in v3 and v4. On Filament v5, verify the Facades API because some method signatures changed between major versions.

Can I expose Filament form schemas as a resource?

You can, but it's rarely worth the effort. Filament form schemas are PHP class structures. Turning them into JSON via reflection is messy and the agent rarely needs that level of detail for code generation tasks. Model data and resource navigation cover 90% of what agents actually ask for.

What's the difference between Mcp::local() and Mcp::web()?

local servers run as a subprocess started by your MCP client (like Claude Code) over stdio. web servers run as HTTP endpoints, suitable for any MCP-compatible client that can make POST requests. For day-to-day development, local is simpler. For production deployments where multiple external clients need access, web is the right choice.

Should I cache tool responses?

For read-heavy tools, yes. Wrap your Eloquent query in Cache::remember() with a short TTL. 30 to 60 seconds works well for most cases. Agents often call the same tool multiple times in one session, and caching stops you from hammering the database with identical queries. Don't cache anything that changes frequently or carries sensitive state.

Can I conditionally register tools based on which Filament panel is active?

Yes. Inside shouldRegister, check Filament::getCurrentPanel()?->getId() and return false if the active panel doesn't match. This is useful in multi-panel setups where you want a clean separation of what each AI client can access based on which panel they've authenticated against.

Conclusion

Laravel MCP turns your Filament admin from a codebase AI agents have to guess about into one they can actually query. A handful of well-designed tools give any MCP-compatible client real access to your data, your resource structure, and your business logic. No hallucinated columns. No invented route names. No code written for a Filament version you're not running.

The setup takes less than an hour on an existing Filament project, and the improvement in agent output quality is immediate. If you're already making your Laravel app AI-agent friendly, this is the natural next step specifically for Filament.

Top comments (0)