# 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.

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](https://packstub.dev/plugins/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.

```mermaid
flowchart LR
    subgraph clients [ ]
        direction TB
        chat("Panel chat")
        claude("Claude Code")
        cursor("Cursor")
    end
    server("AcmeServer\n$tools = [...]")
    subgraph tools [ ]
        direction TB
        search("SearchOrders\norders.view")
        confirm("ConfirmOrder\norders.manage · write")
        chart("DrawChart")
    end
    chat --> server
    claude --> server
    cursor --> server
    server --> search
    server --> confirm
    server --> chart
    classDef client fill:#fcfcfb,stroke:#dddeda,color:#17191e
    classDef server fill:#17191e,stroke:#17191e,color:#fcfcfb
    classDef tool fill:#e3fcf1,stroke:#008c5e,color:#00613e
    classDef group fill:transparent,stroke:transparent
    class chat,claude,cursor client
    class server server
    class search,confirm,chart tool
    class clients,tools group
```

## 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](https://packstub.dev/docs/agents).

```bash
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.

```dotenv
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`:

```bash
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:

```php
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:

```php
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:

```php
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.

```php
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:

```bash
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:

```json
{ "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](https://packstub.dev/docs/filament-agents/security) 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:

```php
$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](https://packstub.dev/docs/filament-agents/testing) 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](https://filamentphp.com/plugins/eslam-reda-ragheb-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](https://filamentphp.com/plugins/guava-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](https://packstub.dev/docs/filament-agents/tables-and-charts): `AgentResource`, the `Filter` vocabulary, and page context from the record being viewed.
- [Budgets and limits](https://packstub.dev/docs/filament-agents/budgets-and-limits): the platform ceiling, per-workspace and per-user overrides, and what each turn cost.
- [Tenancy](https://packstub.dev/docs/filament-agents/tenancy): the `{tenant}` path, workspace-bound tokens, and per-workspace provider keys.
- [Agents for Laravel](https://packstub.dev/docs/agents): the same tools, server, turns and budgets in a plain Laravel app, no panel required.
