diff --git a/composer.json b/composer.json
index 422a094..b763416 100644
--- a/composer.json
+++ b/composer.json
@@ -31,6 +31,8 @@
"plannr/laravel-fast-refresh-database": "^1.2"
},
"suggest": {
+ "laravel/boost": "Required for AI-assisted development guidelines and skills",
+ "laravel/mcp": "Required for MCP server tools integration",
"spatie/laravel-activitylog": "Required for included activity log based stats"
},
"autoload": {
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
new file mode 100644
index 0000000..6b44aa8
--- /dev/null
+++ b/resources/boost/guidelines/core.blade.php
@@ -0,0 +1,41 @@
+## Javaabu Stats
+
+This package provides time-series statistics for Laravel applications, supporting aggregate count and sum queries with filters, formatters, date ranges, and CSV export.
+
+### Architecture
+
+Stats are managed through a central static registry `TimeSeriesStats` and follow a repository pattern:
+
+- `CountStatsRepository` — counts rows grouped by time period. Implement `query(): Builder`, `getTable(): string`, `getAggregateFieldName(): string`.
+- `SumStatsRepository` — sums a numeric field grouped by time period. Same methods as count, plus `getFieldToSum(): string`.
+
+Stat classes live in `App\Stats\TimeSeries\` and are registered via `TimeSeriesStats::register()` in a service provider's `boot()` method with snake_case metric names.
+
+### Artisan Generator
+
+Generate and auto-register a stat class: `php artisan stats:time-series {Name} {Model} --type={count|sum}`.
+
+### Filters
+
+Define allowed filters by returning `Filter` objects from `allowedFilters()` using the `StatsFilter` factory:
+
+- `StatsFilter::exact('name', 'column')` — matches a column value exactly
+- `StatsFilter::scope('name', 'scopeMethod')` — calls an Eloquent query scope
+- `StatsFilter::closure('name', fn)` — applies arbitrary query logic
+
+### Formatters
+
+Five built-in formatters: `default`, `chartjs`, `sparkline`, `flot`, `combined`. Register custom formatters via `TimeSeriesStats::registerFormatters()`.
+
+### Routes
+
+- `TimeSeriesStats::registerApiRoute()` — registers a GET endpoint returning JSON stats data
+- `TimeSeriesStats::registerRoutes()` — registers GET (web view) and POST (CSV export) endpoints
+
+### Authorization
+
+Override `canView(?Authorizable $user)` on a stat repository to control access. The default checks for `view_stats` permission. The `stats.view-time-series` middleware alias is auto-registered.
+
+### Configuration
+
+Publish with `php artisan vendor:publish --tag=stats-config`. Other publish tags: `stats-views`, `stats-stubs`. Key options in `config/stats.php`: `week_starts_on_sunday`, `date_locale`, `date_formats`, `default_time_series_mode`, `default_date_range`, `framework`, `default_layout`.
diff --git a/resources/boost/skills/stats-development/SKILL.md b/resources/boost/skills/stats-development/SKILL.md
new file mode 100644
index 0000000..735e848
--- /dev/null
+++ b/resources/boost/skills/stats-development/SKILL.md
@@ -0,0 +1,159 @@
+---
+name: stats-development
+description: Use when creating stat classes, registering metrics, adding filters, setting up stats routes, or refactoring existing stats code with javaabu/stats.
+---
+
+# Stats Development
+
+## When to use this skill
+
+Use when creating stat repositories, registering metrics, adding filters, or setting up stats routes with `javaabu/stats`.
+
+## Core Principle: Use What Exists
+
+The package provides built-in controllers, routes, middleware, formatters, and export. Your job is to generate stat repository classes for each model — not to reimplement infrastructure. Specifically:
+
+- **Use the Artisan generator** to scaffold stat classes — it auto-registers them too
+- **Use `registerApiRoute()` / `registerRoutes()`** — never write custom route handlers for stats
+- **Use built-in formatters** (`default`, `chartjs`, `sparkline`, `flot`, `combined`) before creating custom ones
+- **Use the `stats.view-time-series` middleware** — it's auto-registered, don't recreate auth logic
+- **Use `ExportsTimeSeriesStats` trait** in existing controllers for CSV export — don't build export from scratch
+
+When refactoring existing stats code, check for: direct filter class instantiation (should use `StatsFilter` factory), manual route definitions (should use `registerApiRoute`/`registerRoutes`), and reimplemented formatting or export logic.
+
+## Creating a Stat
+
+Place stat classes in `app/Stats/TimeSeries/`. Extend `CountStatsRepository` for row counts or `SumStatsRepository` for numeric sums. One class per model/metric — each stat targets a single table.
+
+**Quick path — Artisan generator (auto-registers in AppServiceProvider):**
+
+```bash
+php artisan stats:time-series OrdersCount Order --type=count
+php artisan stats:time-series PaymentAmounts Payment --type=sum
+```
+
+**Manual — Count stat with filters** (namespace `App\Stats\TimeSeries`, import `StatsFilter`, `CountStatsRepository`, `Builder`):
+
+```php
+class OrdersCount extends CountStatsRepository
+{
+ public function query(): Builder { return Order::query(); }
+ public function getTable(): string { return 'orders'; }
+ public function getAggregateFieldName(): string { return 'count'; }
+
+ public function allowedFilters(): array
+ {
+ return [
+ StatsFilter::exact('customer', 'customer_id'),
+ StatsFilter::exact('status', 'status'),
+ ];
+ }
+}
+```
+
+**For a sum stat**, extend `SumStatsRepository` instead and add one extra method:
+
+```php
+public function getFieldToSum(): string
+{
+ return 'amount';
+}
+```
+
+## Registering Stats
+
+Register in `AppServiceProvider::boot()`. Metric names must be snake_case.
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+
+public function boot(): void
+{
+ TimeSeriesStats::register([
+ 'orders_count' => OrdersCount::class,
+ 'payment_amounts' => PaymentAmounts::class,
+ ]);
+}
+```
+
+To suppress built-in user_signups/user_logins stats: `TimeSeriesStats::excludeDefaultStats();`
+
+## Filters
+
+Always use the `StatsFilter` factory. Never instantiate filter classes directly.
+
+```php
+use Javaabu\Stats\Filters\StatsFilter;
+
+public function allowedFilters(): array
+{
+ return [
+ // Exact column match
+ StatsFilter::exact('customer', 'customer_id'),
+
+ // Eloquent query scope — calls $query->whereActive()
+ StatsFilter::scope('active', 'whereActive'),
+
+ // Custom closure — receives ($query, $value, $stat)
+ StatsFilter::closure('min_amount', function (Builder $query, $value, $stat) {
+ return $query->where('amount', '>=', $value);
+ }),
+ ];
+}
+```
+
+Pass filters when creating a stat instance:
+
+```php
+$stats = TimeSeriesStats::createFromMetric('orders_count', PresetDateRanges::THIS_YEAR, [
+ 'customer' => 5,
+ 'status' => 'completed',
+]);
+```
+
+## Routes
+
+**API route (JSON) — register in `routes/api.php`:**
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+
+// IMPORTANT: Do NOT include /api in the URL — routes/api.php adds it automatically.
+TimeSeriesStats::registerApiRoute('/stats/time-series', 'stats.time-series.index');
+```
+
+**Admin routes (web view + CSV export):**
+
+```php
+TimeSeriesStats::registerRoutes('/stats/time-series', 'stats.index', 'stats.export', ['auth', 'stats.view-time-series']);
+```
+
+The `stats.view-time-series` middleware alias is auto-registered by the package.
+
+## Quick Reference
+
+| What | How |
+|------|-----|
+| Base classes | `CountStatsRepository`, `SumStatsRepository` |
+| Register metrics | `TimeSeriesStats::register(['name' => Class::class])` |
+| Create instance | `TimeSeriesStats::createFromMetric('name', $dateRange, $filters)` |
+| Time modes | `TimeSeriesModes::HOUR\|DAY\|WEEK\|MONTH\|YEAR` |
+| Date ranges | `PresetDateRanges::THIS_YEAR\|LAST_30_DAYS\|LAST_7_DAYS\|...` |
+| Custom range | `new ExactDateRange('2024-01-01', '2024-12-31')` |
+| Format output | `$stat->format('chartjs', TimeSeriesModes::DAY)` |
+| Built-in formats | `default`, `chartjs`, `sparkline`, `flot`, `combined` |
+| Get total | `$stat->total()` |
+| Custom date col | Override `getDateFieldName()` (default: `created_at`) |
+| Authorization | Override `canView(?Authorizable $user)` (default: `view_stats` permission) |
+
+See `references/` for formatters, export, authorization, and advanced features.
+
+## Verify Against Live State
+
+If the app has `laravel/mcp` installed, use the MCP tools to cross-check before writing code:
+
+- **ListMetrics** — confirm which metrics are registered, their filters, and aggregate fields
+- **ListFormatters** — confirm available formatters (including custom ones)
+- **QueryStat** — test a metric with real data before building on top of it
+
+This avoids guessing metric names or filter keys — the MCP tools reflect the actual running application.
diff --git a/resources/boost/skills/stats-development/references/advanced.md b/resources/boost/skills/stats-development/references/advanced.md
new file mode 100644
index 0000000..260b56d
--- /dev/null
+++ b/resources/boost/skills/stats-development/references/advanced.md
@@ -0,0 +1,71 @@
+# Advanced Features
+
+## Authorization
+
+Override `canView()` to customize per-stat access control. Default requires `view_stats` permission.
+
+```php
+use Illuminate\Contracts\Auth\Access\Authorizable;
+
+public function canView(?Authorizable $user = null): bool
+{
+ return $user && $user->can('view_order_stats');
+}
+```
+
+Check if any stat is viewable: `TimeSeriesStats::canViewAny($user)`
+
+## Custom Date Field
+
+Override `getDateFieldName()` to group by a column other than `created_at`:
+
+```php
+public function getDateFieldName(): string
+{
+ return 'completed_at';
+}
+```
+
+## Login & Signup Repositories
+
+Built-in abstract repos for tracking user auth activity. Extend and implement `userModelClass()`:
+
+```php
+use Javaabu\Stats\Repositories\TimeSeries\SignupsRepository;
+
+class CustomerSignups extends SignupsRepository
+{
+ public function userModelClass(): string
+ {
+ return \App\Models\Customer::class;
+ }
+}
+```
+
+For logins (requires `spatie/laravel-activitylog`), extend `LoginsRepository` the same way.
+
+The package auto-registers `user_signups` and `user_logins` for the default User model. Suppress with `TimeSeriesStats::excludeDefaultStats()`.
+
+## Configuration
+
+Publish: `php artisan vendor:publish --tag=stats-config`
+
+Key options in `config/stats.php`:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `week_starts_on_sunday` | `true` | Week grouping start day |
+| `date_locale` | `en_GB` | Date format locale |
+| `default_time_series_mode` | `DAY` | Default grouping granularity |
+| `default_date_range` | `LAST_7_DAYS` | Default date range |
+| `framework` | `material-admin-26` | CSS framework for views |
+
+Other publish tags: `stats-views`, `stats-stubs`.
+
+## MCP Tools
+
+Three read-only MCP tools for AI agent integration (requires `laravel/mcp`):
+
+- **ListMetrics** — lists all registered metrics with classes, aggregate fields, and allowed filters
+- **QueryStat** — queries a metric with parameters (mode, date range, filters, format)
+- **ListFormatters** — lists available output formats
diff --git a/resources/boost/skills/stats-development/references/querying-and-formatting.md b/resources/boost/skills/stats-development/references/querying-and-formatting.md
new file mode 100644
index 0000000..8795357
--- /dev/null
+++ b/resources/boost/skills/stats-development/references/querying-and-formatting.md
@@ -0,0 +1,96 @@
+# Querying Stats & Formatting Output
+
+## Querying Stats Programmatically
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+use Javaabu\Stats\Enums\PresetDateRanges;
+use Javaabu\Stats\Enums\TimeSeriesModes;
+
+// Create a stat instance
+$stat = TimeSeriesStats::createFromMetric('orders_count', PresetDateRanges::LAST_30_DAYS, [
+ 'status' => 'completed',
+]);
+
+// Get results grouped by time mode
+$results = $stat->results(TimeSeriesModes::DAY); // Collection of [count, day]
+
+// Get formatted output for a charting library
+$chartData = $stat->format('chartjs', TimeSeriesModes::DAY);
+
+// Get aggregate total
+$total = $stat->total();
+```
+
+## Preset Date Ranges
+
+`PresetDateRanges` enum values: `TODAY`, `YESTERDAY`, `THIS_WEEK`, `LAST_WEEK`, `THIS_MONTH`, `LAST_MONTH`, `THIS_YEAR`, `LAST_YEAR`, `LAST_7_DAYS`, `LAST_14_DAYS`, `LAST_30_DAYS`, `LAST_5_YEARS`, `LAST_10_YEARS`, `LIFETIME`.
+
+For custom ranges:
+
+```php
+use Javaabu\Stats\Support\ExactDateRange;
+
+$range = new ExactDateRange('2024-01-01', '2024-12-31');
+$stat = TimeSeriesStats::createFromMetric('orders_count', $range);
+```
+
+## Comparison Periods
+
+```php
+$current = PresetDateRanges::THIS_MONTH;
+$previous = $current->getPreviousDateRange();
+
+$currentStats = TimeSeriesStats::createFromMetric('orders_count', $current);
+$previousStats = TimeSeriesStats::createFromMetric('orders_count', $previous);
+```
+
+## Time Series Modes
+
+`TimeSeriesModes` enum: `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR`. Controls SQL grouping granularity.
+
+```php
+$stat->results(TimeSeriesModes::MONTH); // Monthly aggregation
+$stat->format('chartjs', TimeSeriesModes::WEEK); // Weekly chart data
+```
+
+## Built-in Formatters
+
+`default`, `chartjs`, `sparkline`, `flot`, `combined`. Request via API with `?format=chartjs`.
+
+## Creating a Custom Formatter
+
+Extend `AbstractTimeSeriesStatsFormatter` and implement `format()`:
+
+```php
+getAggregateFieldName();
+ $results = $stats->results($mode);
+
+ return [
+ 'labels' => $results->pluck($mode->value)->toArray(),
+ 'values' => $results->pluck($field)->toArray(),
+ 'total' => $stats->total(),
+ ];
+ }
+}
+```
+
+Register in a service provider:
+
+```php
+TimeSeriesStats::registerFormatters([
+ 'react' => \App\Formatters\ReactFormatter::class,
+]);
+```
diff --git a/resources/boost/skills/stats-development/references/routes-and-export.md b/resources/boost/skills/stats-development/references/routes-and-export.md
new file mode 100644
index 0000000..260ac50
--- /dev/null
+++ b/resources/boost/skills/stats-development/references/routes-and-export.md
@@ -0,0 +1,71 @@
+# Routes, Export & Blade Component
+
+## API Route (JSON)
+
+Register in `routes/api.php`. The `/api` prefix is added automatically — do not include it in the URL.
+
+```php
+use Javaabu\Stats\TimeSeriesStats;
+
+// Signature: registerApiRoute(string $url, string $name, array $middleware)
+TimeSeriesStats::registerApiRoute(
+ '/stats/time-series', // URL (no /api prefix)
+ 'stats.time-series.index', // Route name
+ ['auth:sanctum', 'stats.view-time-series'] // Middleware
+);
+```
+
+**Query parameters accepted:** `metric`, `mode`, `date_range`, `date_from`, `date_to`, `format`, `filters`.
+
+## Admin Routes (Web + Export)
+
+```php
+// Signature: registerRoutes(string $url, string $index_name, string $export_name, array $middleware)
+TimeSeriesStats::registerRoutes(
+ '/stats/time-series', // URL
+ 'stats.time-series.index', // GET route name (view)
+ 'stats.time-series.export', // POST route name (CSV export)
+ ['auth', 'stats.view-time-series']
+);
+```
+
+## CSV Export
+
+Use `ExportsTimeSeriesStats` trait in a controller:
+
+```php
+use Javaabu\Stats\Concerns\ExportsTimeSeriesStats;
+use Javaabu\Stats\Http\Requests\TimeSeriesStatsRequest;
+
+class StatsController extends Controller
+{
+ use ExportsTimeSeriesStats;
+
+ public function export(TimeSeriesStatsRequest $request)
+ {
+ return $this->exportStats($request);
+ }
+}
+```
+
+With pre-applied filters (e.g., model-scoped):
+
+```php
+public function export(TimeSeriesStatsRequest $request)
+{
+ return $this->exportStats($request, ['customer' => $request->route('customer')], 'Customer Stats');
+}
+```
+
+## Blade Component
+
+```blade
+
+```
diff --git a/src/Mcp/StatsMcpServer.php b/src/Mcp/StatsMcpServer.php
new file mode 100644
index 0000000..a4f84d1
--- /dev/null
+++ b/src/Mcp/StatsMcpServer.php
@@ -0,0 +1,49 @@
+>
+ */
+ protected array $tools = [
+ ListMetrics::class,
+ QueryStat::class,
+ ListFormatters::class,
+ ];
+
+ /**
+ * Get the tool classes registered with this server.
+ *
+ * @return array>
+ */
+ public static function toolClasses(): array
+ {
+ return (new \ReflectionClass(static::class))
+ ->getProperty('tools')
+ ->getDefaultValue();
+ }
+}
diff --git a/src/Mcp/Tools/ListFormatters.php b/src/Mcp/Tools/ListFormatters.php
new file mode 100644
index 0000000..cd07229
--- /dev/null
+++ b/src/Mcp/Tools/ListFormatters.php
@@ -0,0 +1,35 @@
+ $class) {
+ $formatters[] = [
+ 'name' => $name,
+ 'class' => $class,
+ ];
+ }
+
+ return Response::json($formatters);
+ }
+}
diff --git a/src/Mcp/Tools/ListMetrics.php b/src/Mcp/Tools/ListMetrics.php
new file mode 100644
index 0000000..05d2747
--- /dev/null
+++ b/src/Mcp/Tools/ListMetrics.php
@@ -0,0 +1,42 @@
+ $class) {
+ $stat = TimeSeriesStats::createFromMetric($slug);
+
+ $filters = array_map(fn ($filter) => $filter->getName(), $stat->allowedFilters());
+
+ $metrics[] = [
+ 'metric' => $slug,
+ 'name' => $stat->getName(),
+ 'class' => $class,
+ 'aggregate_field' => $stat->getAggregateFieldName(),
+ 'filters' => $filters,
+ ];
+ }
+
+ return Response::json($metrics);
+ }
+}
diff --git a/src/Mcp/Tools/QueryStat.php b/src/Mcp/Tools/QueryStat.php
new file mode 100644
index 0000000..9d6b98b
--- /dev/null
+++ b/src/Mcp/Tools/QueryStat.php
@@ -0,0 +1,138 @@
+
+ */
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'metric' => $schema->string()
+ ->description('The metric slug to query (e.g. "user_signups"). Use list-metrics to see available metrics.')
+ ->required(),
+
+ 'mode' => $schema->string()
+ ->enum(array_column(TimeSeriesModes::cases(), 'value'))
+ ->description('Time grouping mode.')
+ ->default('day'),
+
+ 'date_range' => $schema->string()
+ ->description('Preset date range (e.g. "this_year", "last_30_days", "today"). If set, date_from and date_to are ignored.'),
+
+ 'date_from' => $schema->string()
+ ->description('Custom start date (YYYY-MM-DD). Required if date_range is not set.'),
+
+ 'date_to' => $schema->string()
+ ->description('Custom end date (YYYY-MM-DD). Required if date_range is not set.'),
+
+ 'format' => $schema->string()
+ ->description('Output format. Available: ' . implode(', ', TimeSeriesStats::allowedFormats()) . '.')
+ ->default('default'),
+
+ 'filters' => $schema->object()
+ ->description('Key-value pairs of filters to apply (e.g. {"customer": 5}).'),
+ ];
+ }
+
+ /**
+ * Handle the tool request.
+ */
+ public function handle(Request $request): Response
+ {
+ $metric = (string) $request->get('metric');
+
+ if (! $metric || ! array_key_exists($metric, TimeSeriesStats::statsMap())) {
+ $available = implode(', ', array_keys(TimeSeriesStats::statsMap()));
+
+ return Response::error("Unknown metric \"{$metric}\". Available metrics: {$available}");
+ }
+
+ $mode = TimeSeriesModes::tryFrom((string) ($request->get('mode') ?? 'day')) ?? TimeSeriesModes::DAY;
+ $format = (string) ($request->get('format') ?? 'default');
+ $filters = $request->get('filters') ?? [];
+
+ if (is_string($filters)) {
+ $filters = json_decode($filters, true) ?? [];
+ }
+
+ try {
+ $range = $this->resolveDateRange($request);
+ } catch (Throwable $e) {
+ return Response::error($e->getMessage());
+ }
+
+ try {
+ $stats = TimeSeriesStats::createFromMetric($metric, $range, $filters);
+
+ $result = $stats->format($format, $mode);
+ $total = $stats->total();
+
+ return Response::json([
+ 'metric' => $metric,
+ 'name' => $stats->getName(),
+ 'mode' => $mode->value,
+ 'date_from' => $stats->getDateFrom()->toDateTimeString(),
+ 'date_to' => $stats->getDateTo()->toDateTimeString(),
+ 'total' => $total,
+ 'format' => $format,
+ 'result' => $result,
+ ]);
+ } catch (Throwable $e) {
+ return Response::error('Query failed: ' . $e->getMessage());
+ }
+ }
+
+ /**
+ * Resolve the date range from the request.
+ */
+ protected function resolveDateRange(Request $request): PresetDateRanges|ExactDateRange
+ {
+ $dateRange = $request->get('date_range');
+
+ if ($dateRange) {
+ $preset = PresetDateRanges::tryFrom((string) $dateRange);
+
+ if (! $preset) {
+ $available = implode(', ', array_column(PresetDateRanges::cases(), 'value'));
+ throw new \InvalidArgumentException("Unknown date range \"{$dateRange}\". Available: {$available}");
+ }
+
+ return $preset;
+ }
+
+ $dateFrom = $request->get('date_from');
+ $dateTo = $request->get('date_to');
+
+ if ($dateFrom && $dateTo) {
+ return new ExactDateRange($dateFrom, $dateTo);
+ }
+
+ if ($dateFrom || $dateTo) {
+ throw new \InvalidArgumentException('Both date_from and date_to are required for a custom date range.');
+ }
+
+ return PresetDateRanges::THIS_YEAR;
+ }
+}
diff --git a/src/StatsServiceProvider.php b/src/StatsServiceProvider.php
index 9199aea..22a3e45 100644
--- a/src/StatsServiceProvider.php
+++ b/src/StatsServiceProvider.php
@@ -56,6 +56,8 @@ public function boot()
if (! TimeSeriesStats::shouldExcludeDefaultStats()) {
TimeSeriesStats::registerDefaultStats();
}
+
+ $this->registerMcpTools();
}
/**
@@ -80,6 +82,31 @@ protected function registerFormatters()
]);
}
+ protected function registerMcpTools()
+ {
+ if (! class_exists(\Laravel\Mcp\Server\Tool::class)) {
+ return;
+ }
+
+ if (class_exists(\Laravel\Mcp\Facades\Mcp::class)) {
+ \Laravel\Mcp\Facades\Mcp::local('stats', \Javaabu\Stats\Mcp\StatsMcpServer::class);
+ }
+
+ $this->mergeBoostToolsConfig();
+ }
+
+ protected function mergeBoostToolsConfig()
+ {
+ if (! $this->app->configurationIsCached()) {
+ $existing = $this->app['config']->get('boost.mcp.tools.include', []);
+
+ $this->app['config']->set(
+ 'boost.mcp.tools.include',
+ array_merge($existing, \Javaabu\Stats\Mcp\StatsMcpServer::toolClasses())
+ );
+ }
+ }
+
protected function registerMiddlewareAliases()
{
app('router')->aliasMiddleware('stats.view-time-series', AbortIfCannotViewAnyTimeSeriesStats::class);
diff --git a/tests/Feature/Mcp/StatsMcpServerTest.php b/tests/Feature/Mcp/StatsMcpServerTest.php
new file mode 100644
index 0000000..77fbce5
--- /dev/null
+++ b/tests/Feature/Mcp/StatsMcpServerTest.php
@@ -0,0 +1,44 @@
+markTestSkipped('laravel/mcp is not installed.');
+ }
+ }
+
+ public function test_server_class_declares_all_tools(): void
+ {
+ $reflection = new \ReflectionClass(StatsMcpServer::class);
+ $property = $reflection->getProperty('tools');
+ $property->setAccessible(true);
+
+ $tools = $property->getDefaultValue();
+
+ $this->assertContains(ListMetrics::class, $tools);
+ $this->assertContains(QueryStat::class, $tools);
+ $this->assertContains(ListFormatters::class, $tools);
+ $this->assertCount(3, $tools);
+ }
+
+ public function test_it_merges_tools_into_boost_config(): void
+ {
+ $tools = $this->app['config']->get('boost.mcp.tools.include', []);
+
+ $this->assertContains(ListMetrics::class, $tools);
+ $this->assertContains(QueryStat::class, $tools);
+ $this->assertContains(ListFormatters::class, $tools);
+ }
+}
diff --git a/tests/Feature/Mcp/Tools/ListFormattersTest.php b/tests/Feature/Mcp/Tools/ListFormattersTest.php
new file mode 100644
index 0000000..cac94c3
--- /dev/null
+++ b/tests/Feature/Mcp/Tools/ListFormattersTest.php
@@ -0,0 +1,73 @@
+markTestSkipped('laravel/mcp is not installed.');
+ }
+ }
+
+ public function test_it_lists_registered_formatters(): void
+ {
+ TimeSeriesStats::registerFormatters([
+ 'default' => DefaultStatsFormatter::class,
+ 'chartjs' => ChartjsStatsFormatter::class,
+ ], false);
+
+ $tool = new ListFormatters;
+ $response = $tool->handle(new Request);
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertCount(2, $data);
+
+ $names = array_column($data, 'name');
+ $this->assertContains('default', $names);
+ $this->assertContains('chartjs', $names);
+ }
+
+ public function test_it_includes_formatter_class(): void
+ {
+ TimeSeriesStats::registerFormatters([
+ 'default' => DefaultStatsFormatter::class,
+ ], false);
+
+ $tool = new ListFormatters;
+ $response = $tool->handle(new Request);
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertEquals('default', $data[0]['name']);
+ $this->assertEquals(DefaultStatsFormatter::class, $data[0]['class']);
+ }
+
+ public function test_it_returns_empty_array_when_no_formatters_registered(): void
+ {
+ TimeSeriesStats::registerFormatters([], false);
+
+ $tool = new ListFormatters;
+ $response = $tool->handle(new Request);
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertIsArray($data);
+ $this->assertEmpty($data);
+ }
+}
diff --git a/tests/Feature/Mcp/Tools/ListMetricsTest.php b/tests/Feature/Mcp/Tools/ListMetricsTest.php
new file mode 100644
index 0000000..360d708
--- /dev/null
+++ b/tests/Feature/Mcp/Tools/ListMetricsTest.php
@@ -0,0 +1,91 @@
+markTestSkipped('laravel/mcp is not installed.');
+ }
+ }
+
+ public function test_it_lists_registered_metrics(): void
+ {
+ TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+ 'user_logouts' => UserLogoutsRepository::class,
+ ], false);
+
+ $tool = new ListMetrics;
+ $response = $tool->handle(new Request);
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertCount(2, $data);
+
+ $metrics = array_column($data, 'metric');
+ $this->assertContains('payments_count', $metrics);
+ $this->assertContains('user_logouts', $metrics);
+ }
+
+ public function test_it_includes_metric_details(): void
+ {
+ TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+ ], false);
+
+ $tool = new ListMetrics;
+ $response = $tool->handle(new Request);
+
+ $data = json_decode((string) $response->content(), true);
+ $metric = $data[0];
+
+ $this->assertEquals('payments_count', $metric['metric']);
+ $this->assertEquals(PaymentsCount::class, $metric['class']);
+ $this->assertEquals('count', $metric['aggregate_field']);
+ $this->assertArrayHasKey('filters', $metric);
+ $this->assertArrayHasKey('name', $metric);
+ }
+
+ public function test_it_includes_filter_names(): void
+ {
+ TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+ ], false);
+
+ $tool = new ListMetrics;
+ $response = $tool->handle(new Request);
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertContains('user', $data[0]['filters']);
+ }
+
+ public function test_it_returns_empty_array_when_no_metrics_registered(): void
+ {
+ TimeSeriesStats::register([], false);
+
+ $tool = new ListMetrics;
+ $response = $tool->handle(new Request);
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertIsArray($data);
+ $this->assertEmpty($data);
+ }
+}
diff --git a/tests/Feature/Mcp/Tools/QueryStatTest.php b/tests/Feature/Mcp/Tools/QueryStatTest.php
new file mode 100644
index 0000000..5693587
--- /dev/null
+++ b/tests/Feature/Mcp/Tools/QueryStatTest.php
@@ -0,0 +1,174 @@
+markTestSkipped('laravel/mcp is not installed.');
+ }
+
+ TimeSeriesStats::register([
+ 'payments_count' => PaymentsCount::class,
+ ], false);
+
+ TimeSeriesStats::registerFormatters([
+ 'default' => DefaultStatsFormatter::class,
+ ], false);
+ }
+
+ public function test_it_returns_error_for_unknown_metric(): void
+ {
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request(['metric' => 'nonexistent']));
+
+ $this->assertTrue($response->isError());
+ $this->assertStringContainsString('Unknown metric', (string) $response->content());
+ $this->assertStringContainsString('payments_count', (string) $response->content());
+ }
+
+ public function test_it_returns_error_for_empty_metric(): void
+ {
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([]));
+
+ $this->assertTrue($response->isError());
+ $this->assertStringContainsString('Unknown metric', (string) $response->content());
+ }
+
+ public function test_it_returns_error_for_invalid_date_range(): void
+ {
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_range' => 'invalid_range',
+ ]));
+
+ $this->assertTrue($response->isError());
+ $this->assertStringContainsString('Unknown date range', (string) $response->content());
+ }
+
+ public function test_it_returns_error_for_partial_date_range(): void
+ {
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_from' => '2024-01-01',
+ ]));
+
+ $this->assertTrue($response->isError());
+ $this->assertStringContainsString('Both date_from and date_to are required', (string) $response->content());
+ }
+
+ public function test_it_queries_stat_with_preset_date_range(): void
+ {
+ if (! extension_loaded('pdo')) {
+ $this->markTestSkipped('PDO extension required.');
+ }
+
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_range' => 'this_year',
+ 'mode' => 'month',
+ ]));
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertEquals('payments_count', $data['metric']);
+ $this->assertEquals('month', $data['mode']);
+ $this->assertEquals('default', $data['format']);
+ $this->assertArrayHasKey('total', $data);
+ $this->assertArrayHasKey('result', $data);
+ $this->assertArrayHasKey('date_from', $data);
+ $this->assertArrayHasKey('date_to', $data);
+ }
+
+ public function test_it_queries_stat_with_custom_date_range(): void
+ {
+ if (! extension_loaded('pdo')) {
+ $this->markTestSkipped('PDO extension required.');
+ }
+
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_from' => '2024-01-01',
+ 'date_to' => '2024-12-31',
+ ]));
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertEquals('payments_count', $data['metric']);
+ $this->assertStringContainsString('2024-01-01', $data['date_from']);
+ $this->assertStringContainsString('2024-12-31', $data['date_to']);
+ }
+
+ public function test_it_defaults_to_this_year_when_no_date_provided(): void
+ {
+ if (! extension_loaded('pdo')) {
+ $this->markTestSkipped('PDO extension required.');
+ }
+
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ ]));
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertStringContainsString(date('Y') . '-01-01', $data['date_from']);
+ }
+
+ public function test_it_defaults_to_day_mode(): void
+ {
+ if (! extension_loaded('pdo')) {
+ $this->markTestSkipped('PDO extension required.');
+ }
+
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_range' => 'today',
+ ]));
+
+ $this->assertFalse($response->isError());
+
+ $data = json_decode((string) $response->content(), true);
+
+ $this->assertEquals('day', $data['mode']);
+ }
+
+ public function test_it_returns_error_on_query_failure(): void
+ {
+ if (extension_loaded('pdo')) {
+ $this->markTestSkipped('This test verifies graceful error handling when DB is unavailable.');
+ }
+
+ $tool = new QueryStat;
+ $response = $tool->handle(new Request([
+ 'metric' => 'payments_count',
+ 'date_range' => 'this_year',
+ ]));
+
+ $this->assertTrue($response->isError());
+ $this->assertStringContainsString('Query failed', (string) $response->content());
+ }
+}