Packstub.

Guides

One tool list for your Filament assistant and your MCP server

Write a tool once, with the panel's own ability, and serve it to the in-panel chat and to Claude Code or Cursor alike. A Filament Agents tutorial with code.

Published 7 min read

A Filament panel already knows a lot: which records exist, who may see them, which actions a role may run. An AI assistant inside that panel is only useful when it works with the same knowledge and under the same rules. The same goes for the agents outside the panel, Claude Code on a developer's laptop or Claude Desktop on an operator's, that want to look things up or work a queue.

This guide builds both from one list of tools with Filament Agents, a free MIT plugin for Filament v5 built on laravel/ai and laravel/mcp. By the end you have a chat in the panel, an MCP endpoint with tokens minted in the panel, and one SearchOrders tool that serves both, gated by the ability that already gates the Orders resource.

The idea: one list, two front doors

Every capability is a laravel/mcp tool class. The MCP server lists it to external clients over HTTP. The in-panel chat calls the very same class through laravel/ai's tool bridge. There is one list, it lives on your server class, and adding a tool there puts it in both places at once.

Each tool declares an ability string, the same one that gates the resource or action it mirrors. The assistant is only ever offered the tools the signed-in person may use, and a tool checks that ability again when it runs. An access token for an external client can narrow that further, to read-only or to a handful of named tools, but never widen it.

Panel chatClaude CodeCursorAcmeServer$tools = [...]SearchOrdersorders.viewConfirmOrderorders.manage · writeDrawChart

Install the plugin

The plugin needs PHP 8.4, Laravel 13 and Filament 5. Composer brings in laravel/ai, laravel/mcp and laravel/sanctum through the engine package, Agents for Laravel.

composer require packstub/filament-agents
php artisan packstub-agents:install
php artisan filament:assets

The install command publishes config/packstub-agents.php, offers to run the migrations and scaffolds app/Ai/Agents/Assistant.php. Two more steps make it a working panel:

  1. A queue worker. Every answer is produced by a queued job, so the page never holds a request open while the model thinks, and an answer keeps streaming after a reload. Run php artisan queue:work, or set AGENT_TURN_DRIVER=sync while you try things out and the job runs inside the request.
  2. The theme. Filament v5 compiles plugin views into your panel's theme, so add @source '../../../../vendor/packstub/filament-agents/resources/views'; to your theme CSS and rebuild it.

Then put a provider key in .env. Anthropic, OpenAI, Gemini and xAI have model picker entries out of the box; any other laravel/ai text provider works, Ollama included.

AGENT_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-…

MCP clients authenticate with Sanctum personal access tokens, so the user model needs the HasApiTokens trait and the personal_access_tokens table. Skip this part if you only want the chat and set AGENT_MCP_ENABLED=false.

Write a tool

The scaffold command writes the class into app/Mcp/Tools:

php artisan packstub-agents:tool SearchOrders --ability=orders.view

Without --write the class carries #[IsReadOnly], which is what a search tool wants. Fill in the query and the schema:

namespace App\Mcp\Tools;

use App\Filament\Resources\OrderResource;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Packstub\Agents\Mcp\AgentTool;

#[IsReadOnly]
#[Description('Find orders by number, customer, status and date. Returns compact rows with a url.')]
class SearchOrders extends AgentTool
{
    /** The ability required to see and run this tool; null = any member of the workspace. */
    protected ?string $ability = 'orders.view';

    protected function run(Request $request): array
    {
        $query = Order::query()
            ->when($request->get('query'), fn ($q, $text) => $q->where('number', 'like', "%{$text}%"));

        return [
            'total' => $query->count(),
            'rows' => $query->limit($this->limit($request))->get()->map(fn (Order $order) => [
                'number' => $order->number,
                'status' => $order->status->value,
                'url' => OrderResource::getUrl('view', ['record' => $order]),
            ])->all(),
        ];
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'query' => $schema->string()->description('Order number or customer name.'),
            'limit' => $schema->integer()->description('Max rows, default 20.'),
        ];
    }
}

Three habits pay off here:

  • Describe the tool for the model. The #[Description] text is what the model reads to decide when to call it and what comes back. Say what it does, when to use it and what it returns.
  • Keep rows compact and include a url. The result is JSON the model gets to see. Small rows keep the context short, and a URL per row lets the answer link to the record.
  • Throw to explain. A RuntimeException('Order RO-00012 is already shipped.') becomes a sentence the assistant can relay. Validation failures from $request->validate() come back as "Invalid arguments: …". Nothing reaches the person as a crash.

A write tool is the same class without #[IsReadOnly], scaffolded with --write --ability=orders.manage. In the chat it becomes a proposal: the person sees the tool and its arguments and approves or rejects it before it runs. Over MCP, a write token runs it directly with the person's role.

The server class

The server holds the list. It is a laravel/mcp server with a name, a version, instructions the model reads, and the tools in the order you want them considered:

namespace App\Mcp\Servers;

use App\Mcp\Tools;
use Packstub\Agents\Mcp\AgentServer;
use Packstub\Agents\Mcp\Tools\DrawChart;
use Packstub\Agents\Mcp\Tools\ShowTable;

class AcmeServer extends AgentServer
{
    protected string $name = 'Acme';

    protected string $version = '1.0.0';

    protected string $instructions = <<<'MARKDOWN'
        The back office of an online shop. Start with search-orders; confirm-order changes data.
        MARKDOWN;

    protected array $tools = [
        Tools\SearchOrders::class,
        ShowTable::class,
        DrawChart::class,
        Tools\ConfirmOrder::class,
    ];
}

ShowTable and DrawChart ship with the plugin. show-table renders the resource's own Filament table under an answer, with its search, filters, sorting and row actions, once the resource implements AgentResource. draw-chart renders a chart from numbers the model gathered. Put the reads first: the chat agent reads the same list in the same order.

Register the server, the agent and the ability check on the panel:

use App\Ai\Agents\Assistant;
use App\Mcp\Servers\AcmeServer;
use Packstub\Agents\AgentsPlugin;

public function panel(Panel $panel): Panel
{
    return $panel
        //
        ->plugin(
            AgentsPlugin::make()
                ->name('Ask Acme')
                ->agent(Assistant::class)
                ->server(AcmeServer::class)
                ->authorizeUsing(fn (string $ability) => auth()->user()->can($ability))
                ->roleLabelUsing(fn () => auth()->user()->role?->getLabel()),
        );
}

authorizeUsing() is how the plugin asks your app whether the current person may an ability. Without it, an ability goes through Laravel's Gate when a gate of that name exists, so a panel that already uses policies works as is. roleLabelUsing() gives refusals a role name: "Your role (Viewer) is not allowed to do this."

Open the panel and press Ask Acme. Ask for the pending orders. The assistant calls search-orders, and with ShowTable in the list and an AgentResource on the Orders resource, the answer carries the live Orders table under it, filtered to what it said.

Give the assistant a persona

The scaffolded Assistant class has two slots on top of the plugin's generic working and answering rules: who it is, and what the workspace is.

namespace App\Ai\Agents;

use Packstub\Agents\Ai\Agent;

class Assistant extends Agent
{
    protected function persona(): string
    {
        return 'You are Ask Acme, the back-office assistant of an online shop. You live inside the panel and work with its data through tools.';
    }

    protected function domain(): string
    {
        return <<<'PROMPT'
        - Orders move from placed to paid to shipped; a cancelled order keeps its number.
        - Warehouse staff may confirm and ship; only managers may refund.
        PROMPT;
    }
}

The static part of the prompt and the settled history are cached by the provider. The dynamic part, the date, the person, their role, the language and the record they were looking at when they pressed Ask, rides along with each question.

Connect Claude Code

The plugin registers an Agent access page in the panel. A person mints a token for themselves there: a label, Read or Read and write, an optional expiry, and optionally a scope of named tools. The token can only narrow what the person's role allows. The picker offers only the tools the role may run, and the role is checked again on every call.

The plain token is shown once, together with the connection snippets. For Claude Code:

claude mcp add --transport http acme https://acme.test/mcp --header "Authorization: Bearer 3|…"

For Claude Desktop and Cursor, the same endpoint as a JSON entry:

{ "mcpServers": { "acme": { "type": "http", "url": "https://acme.test/mcp", "headers": { "Authorization": "Bearer 3|…" } } } }

Now ask Claude Code the same question you asked in the panel. It calls tools/list, sees search-orders and draw-chart on a read token (write tools are not on its list), and runs the search as the person who minted the token, in their workspace, with their role. In a panel with tenancy the token also carries the workspace slug, so it only works on that workspace's URL.

A useful split is two tokens: a read-only one for a reporting agent, and one scoped to search-orders and confirm-order for the agent that works the queue. Both are regular Sanctum tokens, so revocation, expiry and pruning work as they always have.

What is enforced, and where

It helps to know which rule lives where, because it decides what you still need to write.

In the chat Over MCP
Tool listed only if the person's role allows its ability only if the role and the token allow it
Read-only tool runs directly runs
Write tool a proposal the person approves or rejects a read token refuses; a write token runs it with the person's role
Turn ends provider, model, tokens, tools and duration recorded per turn same
Bill per-user burst limit, answers per day, tokens per day and month, prompt length cap same limits

What stays yours is the content of run(): what a "pending order" is, which records a role may touch inside a tool, and what a write refuses to do. The plugin's security page draws the full trust boundary, including prompt injection through record data.

Test it

The endpoint is a normal HTTP route, so a feature test drives it with a token:

$token = $user->createToken('desk', ['read'])->plainTextToken;

postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list'], [
    'Authorization' => 'Bearer '.$token,
    'Accept' => 'application/json, text/event-stream',
    'MCP-Protocol-Version' => '2025-06-18',
])->assertOk()->assertJsonPath('result.tools.0.name', 'search-orders');

A write tool must not appear on that list for a read token, and a tool the role does not allow must not appear at all. Those two assertions, repeated per role, are the test suite that matters. The testing page shows how to fake the model and run a whole turn.

When a lighter tool is the right choice

Two other plugins cover parts of this ground well, and they are the better pick when they match what you need.

  • Filament Copilot gives a panel a chat assistant that discovers copilot-enabled resources, pages and widgets on its own, with streaming, history and a management dashboard. If you want an assistant in an afternoon and do not need an MCP endpoint, it is a great choice.
  • Guava's Filament MCP turns existing resources into MCP tools generated from their form schemas, with policies and tenant scoping intact. If external agents are the goal and generated CRUD tools are all they need, start there.

Filament Agents is for the case where both doors matter and the tools are hand-written domain operations rather than generated CRUD: a search that knows what "waiting for a phone call" means, a confirm that refuses a shipped order, a chart from a reporting query. One list, one ability per tool, served to the chat and to the agents outside.

Where to go next

  • Tables and charts: AgentResource, the Filter vocabulary, and page context from the record being viewed.
  • Budgets and limits: the platform ceiling, per-workspace and per-user overrides, and what each turn cost.
  • Tenancy: the {tenant} path, workspace-bound tokens, and per-workspace provider keys.
  • Agents for Laravel: the same tools, server, turns and budgets in a plain Laravel app, no panel required.