Building an AI Analytics Assistant with laravel/ai and laravel/mcp

How we added an AI chat assistant and a hosted MCP server to SimpleStats using laravel/ai and laravel/mcp, with one set of tool classes powering both. Includes the architecture, the streaming setup, and the gotchas that cost us real debugging time.

Zacharias Creutznacher Zacharias Creutznacher

Building an AI Analytics Assistant with laravel/ai and laravel/mcp

We recently shipped two AI features for SimpleStats, our server-side analytics platform for Laravel apps:

  1. An "Ask AI" chat panel inside the dashboard, where users ask questions like "Which referrer brought the most revenue last month?" and get answers grounded in their actual analytics data.
  2. A hosted MCP server, so users can connect Claude Code, Cursor, or Claude Desktop to their analytics and ask the same questions from their editor.

Both are built on Laravel's first-party AI packages: laravel/ai for the agent and streaming, laravel/mcp for the Model Context Protocol server. The part that makes the architecture worth writing about: the exact same six tool classes power both features. Write a tool once, and it serves the in-app agent, a local MCP server for development, and the public web MCP endpoint.

This post walks through the architecture and, more importantly, the gotchas that cost us real debugging time. Both packages are young (we're on laravel/ai 0.8 and laravel/mcp 0.7), so expect some APIs to shift, but the overall shape should hold.

#The architecture in one picture

 1                        ┌──────────────────────┐
 2  Dashboard chat  ───►  │  StatsAssistant       │ ──► in-process tool calls
 3  (SSE stream)          │  (laravel/ai Agent)   │
 4                        └──────────────────────┘
 5                                                        ┌─────────────────┐
 6  Claude Code /   ───►  Mcp::web('mcp', ...)      ───►  │  Six Tool        │
 7  Cursor / Desktop      (bearer token auth)             │  classes         │
 8                                                        │  (laravel/mcp)   │
 9  Local dev       ───►  Mcp::local('simplestats')  ──►  └─────────────────┘

The tools are standard Laravel\Mcp\Server\Tool classes. laravel/ai accepts them directly as agent tools, so there is no adapter layer and no duplication.

#The agent

An agent in laravel/ai is a plain class implementing a few contracts. Ours looks like this (trimmed):

 1class StatsAssistant implements Agent, Conversational, HasTools
 2{
 3    use Promptable, RemembersConversations;
 4
 5    public function __construct(public ?int $currentProjectId = null) {}
 6
 7    public function instructions(): Stringable|string
 8    {
 9        $today = now()->toDateString();
10
11        return <<<PROMPT
12        You are the analytics assistant. Never invent or estimate numbers;
13        every figure you state must come from a tool result.
14
15        Context:
16        - Today is {$today} in the team's timezone.
17        ...
18        PROMPT;
19    }
20
21    /**
22     * @return Tool[]
23     */
24    public function tools(): iterable
25    {
26        return [
27            app(ListStatsCapabilities::class),
28            app(GetKpiStats::class),
29            app(GetGroupedBreakdown::class),
30            app(GetRecurringRevenueStats::class),
31            app(GetRetentionCohorts::class),
32            app(SearchDocs::class),
33        ];
34    }
35}

A few deliberate decisions here:

  • No provider() method. The default provider comes from config('ai.default'), so we can switch between Gemini and Anthropic per environment with a single ENV variable.
  • Inject dates into the instructions. Models do not reliably know today's date. Without it, "last month" becomes a guess.
  • The "never invent numbers" line is load-bearing. An analytics assistant that estimates is worse than no assistant. Grounding every figure in a tool result is the core product requirement, so it goes first in the prompt.
  • RemembersConversations gives us persistent multi-turn conversations backed by a database table that ships with the package.

#Tools: write once, use three times

Each tool is a laravel/mcp Tool with a JSON schema and a handle() method. Here is the KPI tool, trimmed to its skeleton:

 1#[Description('Get KPI time series (visitors, registrations, conversion rate,
 2    revenue, active users, ...) for a date range, bucketed per hour/day/week/month.
 3    Also returns range totals so you never have to add numbers yourself.')]
 4class GetKpiStats extends Tool
 5{
 6    use ResolvesStatsFilters;
 7
 8    public function __construct(protected StatsDetailsService $statsDetailsService) {}
 9
10    public function handle(Request $request): Response|ResponseFactory
11    {
12        try {
13            $request->validate($this->rangeRules());
14
15            $filters = $this->resolveFilters($request);
16            $rows = $this->centsToCurrency(
17                $this->statsDetailsService->getStats($filters),
18                self::MONEY_KEYS,
19            );
20
21            return Response::structured([
22                'scope' => $this->describeScope($filters),
23                'totals' => $this->totals($rows),
24                'rows' => $rows,
25            ]);
26        } catch (ValidationException $e) {
27            return Response::error('Invalid arguments: '.collect($e->errors())->flatten()->implode(' '));
28        } catch (Throwable $e) {
29            report($e);
30
31            return Response::error('Could not load KPI stats: '.$e->getMessage());
32        }
33    }
34
35    public function schema(JsonSchema $schema): array
36    {
37        return $this->rangeSchema($schema);
38    }
39}

Three things in there are not decoration:

The try/catch is mandatory. As of laravel/ai 0.8, exceptions thrown inside a tool are not caught by the agent loop. In a streamed chat response, an uncaught tool exception kills the SSE stream mid-answer. Every tool catches everything, reports, and returns Response::error() so the model can tell the user what went wrong instead of the UI silently dying.

The return type is Response|ResponseFactory. Response::structured() returns a ResponseFactory, not a Response. If you type-hint only Response, it works until the first structured response, then fails.

Unit conversion before the model sees the data. Our revenue values are stored in cents. If you hand the model raw cents, it will happily report revenue 100x too high, or worse, sometimes convert and sometimes not. Normalize units at the tool boundary so the model only ever sees display-ready numbers, and say so in the response ('Monetary values are in the scope currency.').

The Description attribute and the note fields inside responses are prompt engineering, not documentation. "Also returns range totals so you never have to add numbers yourself" exists because models are unreliable at arithmetic over long rows, and telling them the totals are precomputed stops them from trying.

#Exposing the tools over MCP

The server class is just a manifest:

 1#[Name('SimpleStats')]
 2#[Version('1.0.0')]
 3#[Instructions('Query the authenticated team\'s analytics: KPI time series,
 4    breakdowns, recurring revenue, retention cohorts. Call
 5    list-stats-capabilities first to discover projects, dimensions, presets.')]
 6class SimpleStatsServer extends Server
 7{
 8    protected array $tools = [
 9        ListStatsCapabilities::class,
10        GetKpiStats::class,
11        GetGroupedBreakdown::class,
12        GetRecurringRevenueStats::class,
13        GetRetentionCohorts::class,
14        SearchDocs::class,
15    ];
16}

Registration happens in a routes file. Mcp::local() gives you a stdio server for development (point Claude Code at php artisan mcp:start simplestats), and Mcp::web() mounts a streamable HTTP endpoint:

 1Mcp::local('simplestats', SimpleStatsServer::class);
 2
 3Mcp::web('mcp', SimpleStatsServer::class)
 4    ->middleware([
 5        'auth:sanctum',
 6        EnsureMcpProjectToken::class,
 7        SetTeamContext::class,
 8        'throttle:mcp',
 9    ])
10    ->name('mcp.web');

The middleware chain is where multi-tenancy happens, and it is pleasantly boring: the web transport is stateless HTTP, so the bearer token travels with every request and your existing Sanctum auth just works. Our customers authenticate with the same project API token they already use for the REST API. A tiny middleware asserts the token belongs to a project (not some other token type) and stores the project ID in Laravel's Context:

 1public function handle(Request $request, Closure $next): Response
 2{
 3    $project = $request->user();
 4
 5    if (! $project instanceof Project) {
 6        return response()->json(['message' => 'The MCP endpoint requires a project API token.'], 403);
 7    }
 8
 9    Context::add('project_id', $project->id);
10
11    return $next($request);
12}

The tools read that context value to default their project scope, and our existing team-scoping middleware does the rest. No MCP-specific auth code beyond these few lines. Claude Desktop is the one client that currently insists on OAuth for remote servers; we point users at the mcp-remote npm shim, which bridges a bearer token just fine.

Rate limiting is a normal named limiter (throttle:mcp, 60 requests per minute per project). Again: it's all just Laravel routing.

#Streaming to the browser

The chat panel consumes the agent's response as an SSE stream. Two details here saved us from ugly bugs.

Create the conversation before streaming starts. The frontend needs the conversation ID to continue the thread, but response headers must be sent before the first streamed byte. So the controller creates the conversation record upfront and sends the ID as an X-Conversation-Id header, rather than trying to smuggle it into the stream.

Translate provider failures into stream events. Once the SSE headers are out, an exception can no longer become a JSON error response. Laravel's default behavior is to render its HTML error page into the open stream, which the frontend then tries to parse as an event. Our streamer wraps the event loop and converts known provider failures (rate limited, overloaded, out of credits, unreachable) into friendly error events:

 1try {
 2    foreach ($events as $event) {
 3        yield 'data: '.((string) $event)."\n\n";
 4    }
 5} catch (RateLimitedException) {
 6    yield $this->errorEvent('The AI provider\'s rate limit is reached. Please wait a minute and try again.');
 7} catch (Throwable $e) {
 8    report($e);
 9
10    yield $this->errorEvent('Something went wrong. Please try again.');
11}
12
13yield "data: [DONE]\n\n";

On the Vue side, one reactivity gotcha: the message object you mutate while tokens stream in must be wrapped in reactive() before it is pushed into the messages array. Push the raw object and mutate it afterwards, and nothing re-renders; the answer then "appears" only after a reload, via hydration from the database, which makes for a very confusing bug report.

#Bring your own key

Teams can store their own provider credentials (Anthropic, OpenAI, Gemini, and friends) instead of using our app-level key. laravel/ai reads providers from config, so a per-team key means registering a synthetic provider at runtime:

 1$config = config("ai.providers.{$team->ai_provider}");
 2$config['key'] = $team->ai_api_key;
 3
 4config()->set('ai.providers.team-byok', $config);
 5
 6app(AiManager::class)->forgetInstance('team-byok');

That forgetInstance() call is not optional: the AiManager singleton caches provider instances, and in a queue worker the previous team's provider (with the previous team's key) would otherwise survive into the next job. We also deliberately do not fail over to the app key when a team key is broken; a team that configured its own provider should see its own provider's errors, not silently burn our credits. Related limitation worth knowing: laravel/ai's failover chain keys providers by name, so the same provider cannot appear twice in one chain.

Two mundane but important details for the teams table columns: the API key column uses Laravel's encrypted cast, and it is in the model's $hidden array, because the Team model gets serialized into Inertia props on pages you would not immediately think of.

#What we would tell you before you start

  • Design tools for the model, not for your API. Return precomputed totals, state units explicitly, order rows predictably, and describe all of it in the response. Every ambiguity you leave open is a hallucination opportunity.
  • A list-capabilities tool pays for itself. One discovery tool that lists projects, dimensions, date presets, and a KPI legend means the model stops guessing parameter values and the other tools' schemas stay small.
  • Treat tool boundaries as failure boundaries. Catch everything, return errors as data. The model handles "the tool said X went wrong" gracefully; a dead stream is unrecoverable.
  • The MCP server is nearly free once the tools exist. Ours is a 30-line server class, a routes entry, and one middleware. If you are building agent tools with laravel/ai anyway, exposing them over MCP is the cheapest feature you will ship this year.

The result from the user's perspective: they ask "How did my MRR develop this year?" in the dashboard, or in Claude Code while writing a migration, and get an answer computed from their real data. If you want to see it live, it's in SimpleStats, including the MCP server docs.