# Polaris MCP Server Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Expose Polaris' own operations to MCP clients over authenticated HTTP, starting with a tool that launches an actualization run and a tool that reports its progress.

**Architecture:** A `laravel/mcp` server class (`PolarisServer`) is mounted at `POST /mcp` behind a static-token middleware. Its `run_actualization` tool dispatches `ManualActualizationJob` onto the existing `actualization_queue` and immediately returns the run's bucket uuid and an admin-history link; `actualization_status` reads `ActualizationHistory` back. The orchestration the existing cron commands hold is extracted into `ActualizationRunner` so the job and the commands drive the same code.

**Tech Stack:** PHP 8.3, Laravel 10, `laravel/mcp` v0.5.3, `illuminate/json-schema` v12.53, PHPUnit 10, Postgres (`collection` connection), Redis queues.

**Spec:** `docs/superpowers/specs/2026-09-02-polaris-mcp-server-design.md`

## Global Constraints

- Run every Artisan/Composer/PHPUnit/Pint command inside the container: `docker exec -i polaris-service-new …`.
- `vendor/bin/pint --dirty` finds nothing in this container — always pass explicit paths, e.g. `docker exec -i polaris-service-new vendor/bin/pint app/Mcp`.
- **Commit each task on the feature branch.** Work happens on `feature/polaris-mcp-server`, branched from `develop`. Each task ends with one commit of its own files. Never commit to `develop`, never push, never merge — the branch owner does that.
- The git repository root is `…/polaris-service/source_code`; this Laravel app lives at `modulo_drpeso/laravel` inside it. Run git from the repo root, and everything else from the app directory.
- Laravel 10: casts go in `protected $casts = []`, never a `casts()` method.
- Curly braces on every control structure; explicit return types; constructor property promotion; PHPDoc over inline comments.
- No `env()` outside `config/`. No `DB::` raw queries — Eloquent on the right connection.
- Create files with `php artisan make:*` and `--no-interaction` where a generator exists.
- Tests that touch the database wrap themselves in a transaction and roll it back in `tearDown()`, following `tests/Feature/Actualization/ActualizationRunStatsRecordingTest.php`. Do not use `RefreshDatabase` — these connections point at real schemas.
- Instance predicates (`isColombia()`, `getInstance()`) read `config('polaris.instance')`, so tests switch instance with `config(['polaris.instance' => INSTANCE_PERU])`.
- Scope vocabulary, fixed across every layer: `entire`, `loans`, `payments`, `payment_periods`.

---

### Task 1: `INITIATOR_TYPE_MCP` and its labels

MCP-started runs must be distinguishable from cron and file-upload runs in the admin history. While in these language files, also add the payment-periods process type, which every one of them is missing today — `actualization_status` (Task 7) renders `process_type_text` and would otherwise print a raw translation key for Colombia's payment-period rows.

`resources/lang/` holds 24 locale directories but only five of them ship an `actualization.php`: `ar`, `en`, `es`, `hi`, `zh-CN`. The other nineteen fall back to `en` (`config/app.php` → `fallback_locale`). Touch only the five that exist — creating the missing nineteen is not this plan's job.

**Files:**
- Modify: `app/Models/Collection/ActualizationHistory.php:35-37` (constants block)
- Modify: `resources/lang/{ar,en,es,hi,zh-CN}/actualization.php`
- Test: `tests/Feature/Actualization/ActualizationLabelsTest.php`

**Interfaces:**
- Consumes: nothing.
- Produces: `ActualizationHistory::INITIATOR_TYPE_MCP` (int `2`), used by Tasks 2, 3, 4, 6, 7.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Actualization/ActualizationLabelsTest.php`:

```php
<?php

namespace Tests\Feature\Actualization;

use App\Models\Collection\ActualizationHistory;
use Tests\TestCase;

/**
 * The history table renders these through __(), so a locale missing a key would print the raw
 * translation string to the user.
 */
class ActualizationLabelsTest extends TestCase
{
    /**
     * Only five locales ship an actualization.php; the other nineteen fall back to en. Globbing the
     * files rather than the locale directories is what keeps this test honest about that.
     *
     * @return array<int, string>
     */
    private function labelFiles(): array
    {
        $files = glob(resource_path('lang/*/actualization.php'));

        $this->assertNotEmpty($files, 'no actualization label files found at all');

        return $files;
    }

    public function test_every_shipped_locale_labels_the_mcp_initiator(): void
    {
        foreach ($this->labelFiles() as $path) {
            $labels = require $path;

            $this->assertArrayHasKey(
                ActualizationHistory::INITIATOR_TYPE_MCP,
                $labels['initiator_types'],
                "no MCP initiator label in {$path}"
            );
        }
    }

    public function test_every_shipped_locale_labels_the_payment_periods_process(): void
    {
        foreach ($this->labelFiles() as $path) {
            $labels = require $path;

            $this->assertArrayHasKey(
                ActualizationHistory::PROCESS_TYPE_PAYMENT_PERIODS_ENTIRE,
                $labels['process_types'],
                "no payment periods process label in {$path}"
            );
        }
    }

    public function test_the_mcp_initiator_renders_as_mcp(): void
    {
        $history = new ActualizationHistory();
        $history->initiator_type = ActualizationHistory::INITIATOR_TYPE_MCP;

        $this->assertSame('MCP', $history->initiator_type_text);
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ActualizationLabelsTest.php`
Expected: FAIL — `Undefined constant App\Models\Collection\ActualizationHistory::INITIATOR_TYPE_MCP`.

- [ ] **Step 3: Add the constant**

In `app/Models/Collection/ActualizationHistory.php`, directly after `INITIATOR_TYPE_CRON`:

```php
    public const INITIATOR_TYPE_MCP = 2;
```

- [ ] **Step 4: Add both labels to the five locales that ship the file**

Those are `ar`, `en`, `es`, `hi` and `zh-CN`. Do not create `actualization.php` for the other nineteen locales — they fall back to `en`, and inventing translations for a file the app never shipped is outside this plan. In each of the five, add one line to `initiator_types`:

```php
        ActualizationHistory::INITIATOR_TYPE_MCP => 'MCP',
```

and one line to `process_types`, using that file's own wording for the existing entries as a guide — for `en` it reads:

```php
        ActualizationHistory::PROCESS_TYPE_PAYMENT_PERIODS_ENTIRE => 'Payment Periods Entire',
```

`MCP` is a proper noun and stays `'MCP'` in all five. The payment-periods label is translated: `en` → `'Payment Periods Entire'`, `es` → `'Períodos de Pago Completo'`. For `ar`, `hi` and `zh-CN`, follow the wording that file already uses for `PROCESS_TYPE_PAYMENTS_ENTIRE` and render "payment periods" in its style.

- [ ] **Step 5: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ActualizationLabelsTest.php`
Expected: PASS, 3 tests.

- [ ] **Step 6: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Models/Collection/ActualizationHistory.php resources/lang tests/Feature/Actualization/ActualizationLabelsTest.php`

---

### Task 2: `ActualizationRunner` and the commands that now delegate to it

The order in which a full run executes — Kazakhstan's legacy CRM chain first, then loans, then payments, then Colombia's payment periods — currently lives in a private method of `EntireActualizationCommand`. The MCP job needs the same behaviour with a different initiator type and a caller-supplied bucket uuid, so it moves into a service and the four commands become callers.

**Files:**
- Create: `app/Services/Actualization/ActualizationRunner.php`
- Modify: `app/Console/Commands/Cron/Actualization/EntireActualizationCommand.php`
- Modify: `app/Console/Commands/Cron/Actualization/LoanActualizationCommand.php`
- Modify: `app/Console/Commands/Cron/Actualization/PaymentActualizationCommand.php`
- Modify: `app/Console/Commands/Cron/Actualization/EntirePaymentPeriodsActualizationCommand.php`
- Test: `tests/Feature/Actualization/ActualizationRunnerTest.php`

**Interfaces:**
- Consumes: `ActualizationHistory::INITIATOR_TYPE_MCP` (Task 1).
- Produces:
  - `ActualizationRunner::SCOPE_ENTIRE|SCOPE_LOANS|SCOPE_PAYMENTS|SCOPE_PAYMENT_PERIODS` (string constants `'entire'`, `'loans'`, `'payments'`, `'payment_periods'`)
  - `ActualizationRunner::SCOPES` — `array<int, string>` of the four above, in that order
  - `ActualizationRunner::run(string $scope, string $bucketUuid, int $initiatorType): void` — throws `InvalidArgumentException` on an unknown scope
  - `ActualizationRunner::runEntire|runLoans|runPayments|runPaymentPeriods(string $bucketUuid, int $initiatorType): void`

- [ ] **Step 1: Write the failing test**

The executors reach external CRMs, so the test binds mocks into the container and asserts which ones are driven, with which initiator type and bucket uuid. Create `tests/Feature/Actualization/ActualizationRunnerTest.php`:

```php
<?php

namespace Tests\Feature\Actualization;

use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use App\Services\Actualization\Executors\Loans\EntireCrm1LoanActualizationExecutor;
use App\Services\Actualization\Executors\Loans\EntireLoanActualizationExecutor;
use App\Services\Actualization\Executors\PaymentPeriods\EntirePaymentPeriodsExecutor;
use App\Services\Actualization\Executors\Payments\EntireCrm1PaymentActualizationExecutor;
use App\Services\Actualization\Executors\Payments\EntirePaymentActualizationExecutor;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;

class ActualizationRunnerTest extends TestCase
{
    /**
     * An executor is a fluent builder: every setter returns itself and execute() ends the chain.
     * The mock records the bucket uuid and initiator it was handed so the test can assert on them.
     */
    private function fakeExecutor(string $class, ?string &$bucketUuid = null, ?int &$initiatorType = null): MockInterface
    {
        $executor = Mockery::mock($class);

        $executor->shouldReceive('setInitiatorType')->andReturnUsing(function (int $type) use ($executor, &$initiatorType) {
            $initiatorType = $type;

            return $executor;
        });

        $executor->shouldReceive('setBucketUuid')->andReturnUsing(function (string $uuid) use ($executor, &$bucketUuid) {
            $bucketUuid = $uuid;

            return $executor;
        });

        $executor->shouldReceive('setHandler')->andReturn($executor);
        $executor->shouldReceive('execute')->andReturn(new ActualizationHistory());

        $this->instance($class, $executor);

        return $executor;
    }

    public function test_loans_scope_drives_only_the_loan_executor_with_the_given_bucket(): void
    {
        $loansBucket = null;
        $loansInitiator = null;

        $this->fakeExecutor(EntireLoanActualizationExecutor::class, $loansBucket, $loansInitiator);
        $payments = $this->fakeExecutor(EntirePaymentActualizationExecutor::class);

        resolve(ActualizationRunner::class)->run(
            ActualizationRunner::SCOPE_LOANS,
            'bucket-loans',
            ActualizationHistory::INITIATOR_TYPE_MCP
        );

        $this->assertSame('bucket-loans', $loansBucket);
        $this->assertSame(ActualizationHistory::INITIATOR_TYPE_MCP, $loansInitiator);
        $payments->shouldNotHaveReceived('execute');
    }

    public function test_payments_scope_drives_only_the_payment_executor(): void
    {
        $paymentsBucket = null;

        $loans = $this->fakeExecutor(EntireLoanActualizationExecutor::class);
        $this->fakeExecutor(EntirePaymentActualizationExecutor::class, $paymentsBucket);

        resolve(ActualizationRunner::class)->run(
            ActualizationRunner::SCOPE_PAYMENTS,
            'bucket-payments',
            ActualizationHistory::INITIATOR_TYPE_CRON
        );

        $this->assertSame('bucket-payments', $paymentsBucket);
        $loans->shouldNotHaveReceived('execute');
    }

    public function test_an_entire_run_on_colombia_also_actualizes_payment_periods(): void
    {
        config(['polaris.instance' => INSTANCE_COLOMBIA]);

        $this->fakeExecutor(EntireLoanActualizationExecutor::class);
        $this->fakeExecutor(EntirePaymentActualizationExecutor::class);
        $periods = $this->fakeExecutor(EntirePaymentPeriodsExecutor::class);

        resolve(ActualizationRunner::class)->run(
            ActualizationRunner::SCOPE_ENTIRE,
            'bucket-entire-co',
            ActualizationHistory::INITIATOR_TYPE_MCP
        );

        $periods->shouldHaveReceived('execute');
    }

    public function test_an_entire_run_off_colombia_skips_payment_periods(): void
    {
        config(['polaris.instance' => INSTANCE_PERU]);

        $this->fakeExecutor(EntireLoanActualizationExecutor::class);
        $this->fakeExecutor(EntirePaymentActualizationExecutor::class);
        $periods = $this->fakeExecutor(EntirePaymentPeriodsExecutor::class);

        resolve(ActualizationRunner::class)->run(
            ActualizationRunner::SCOPE_ENTIRE,
            'bucket-entire-pe',
            ActualizationHistory::INITIATOR_TYPE_MCP
        );

        $periods->shouldNotHaveReceived('execute');
    }

    public function test_an_entire_run_on_kazakhstan_drives_both_crms_under_one_bucket(): void
    {
        config(['polaris.instance' => INSTANCE_KAZAKHSTAN]);

        $crm1LoansBucket = null;

        $this->fakeExecutor(EntireCrm1LoanActualizationExecutor::class, $crm1LoansBucket);
        $crm1Payments = $this->fakeExecutor(EntireCrm1PaymentActualizationExecutor::class);
        $this->fakeExecutor(EntireLoanActualizationExecutor::class);
        $this->fakeExecutor(EntirePaymentActualizationExecutor::class);

        resolve(ActualizationRunner::class)->run(
            ActualizationRunner::SCOPE_ENTIRE,
            'bucket-entire-kz',
            ActualizationHistory::INITIATOR_TYPE_MCP
        );

        $this->assertSame('bucket-entire-kz', $crm1LoansBucket);
        $crm1Payments->shouldHaveReceived('execute');
    }

    public function test_an_unknown_scope_is_rejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        resolve(ActualizationRunner::class)->run('nonsense', 'bucket', ActualizationHistory::INITIATOR_TYPE_MCP);
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ActualizationRunnerTest.php`
Expected: FAIL — `Class "App\Services\Actualization\ActualizationRunner" does not exist`.

- [ ] **Step 3: Create the runner**

Write `app/Services/Actualization/ActualizationRunner.php` directly — `make:class` is a Laravel 11+ generator and does not exist in this Laravel 10 app. The Kazakhstan ordering docblock is moved verbatim from `EntireActualizationCommand` — it is the only place that rule is written down:

```php
<?php

namespace App\Services\Actualization;

use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\Executors\Loans\EntireCrm1LoanActualizationExecutor;
use App\Services\Actualization\Executors\Loans\EntireLoanActualizationExecutor;
use App\Services\Actualization\Executors\PaymentPeriods\EntirePaymentPeriodsExecutor;
use App\Services\Actualization\Executors\Payments\EntireCrm1PaymentActualizationExecutor;
use App\Services\Actualization\Executors\Payments\EntirePaymentActualizationExecutor;
use App\Services\Actualization\Handlers\Loans\Abstract\AbstractDatabaseLoanUpdateHandler;
use App\Services\Actualization\Handlers\Loans\KazakhstanCrm1LoanUpdateHandler;
use App\Services\Actualization\Handlers\PaymentPeriods\Abstract\AbstractPaymentPeriodsHandler;
use App\Services\Actualization\Handlers\Payments\Abstract\AbstractDatabasePaymentUpdateHandler;
use App\Services\Actualization\Handlers\Payments\KazakhstanCrm1PaymentUpdateHandler;
use InvalidArgumentException;

/**
 * The single place that knows which executors a run consists of. Both the cron commands and the
 * MCP job drive it, so a rule like Kazakhstan's CRM ordering is written down once.
 */
class ActualizationRunner
{
    public const SCOPE_ENTIRE = 'entire';

    public const SCOPE_LOANS = 'loans';

    public const SCOPE_PAYMENTS = 'payments';

    public const SCOPE_PAYMENT_PERIODS = 'payment_periods';

    /**
     * @var array<int, string>
     */
    public const SCOPES = [
        self::SCOPE_ENTIRE,
        self::SCOPE_LOANS,
        self::SCOPE_PAYMENTS,
        self::SCOPE_PAYMENT_PERIODS,
    ];

    public function run(string $scope, string $bucketUuid, int $initiatorType): void
    {
        match ($scope) {
            self::SCOPE_ENTIRE => $this->runEntire($bucketUuid, $initiatorType),
            self::SCOPE_LOANS => $this->runLoans($bucketUuid, $initiatorType),
            self::SCOPE_PAYMENTS => $this->runPayments($bucketUuid, $initiatorType),
            self::SCOPE_PAYMENT_PERIODS => $this->runPaymentPeriods($bucketUuid, $initiatorType),
            default => throw new InvalidArgumentException(
                "Unknown actualization scope [{$scope}]. Expected one of: ".implode(', ', self::SCOPES).'.'
            ),
        };
    }

    public function runEntire(string $bucketUuid, int $initiatorType): void
    {
        $this->runKazakhstanCrm1($bucketUuid, $initiatorType);
        $this->runLoans($bucketUuid, $initiatorType);
        $this->runPayments($bucketUuid, $initiatorType);

        if (isColombia()) {
            $this->runPaymentPeriods($bucketUuid, $initiatorType);
        }
    }

    public function runLoans(string $bucketUuid, int $initiatorType): void
    {
        resolve(EntireLoanActualizationExecutor::class)
            ->setInitiatorType($initiatorType)
            ->setBucketUuid($bucketUuid)
            ->setHandler(AbstractDatabaseLoanUpdateHandler::class)
            ->execute();
    }

    public function runPayments(string $bucketUuid, int $initiatorType): void
    {
        resolve(EntirePaymentActualizationExecutor::class)
            ->setInitiatorType($initiatorType)
            ->setBucketUuid($bucketUuid)
            ->setHandler(AbstractDatabasePaymentUpdateHandler::class)
            ->execute();
    }

    public function runPaymentPeriods(string $bucketUuid, int $initiatorType): void
    {
        resolve(EntirePaymentPeriodsExecutor::class)
            ->setInitiatorType($initiatorType)
            ->setBucketUuid($bucketUuid)
            ->setHandler(AbstractPaymentPeriodsHandler::class)
            ->execute();
    }

    /**
     * Kazakhstan is served by two CRMs. The legacy one runs first and completes its whole chain
     * (archive → clients → loans → phones → emails) before the current CRM reloads the archive
     * buffer, so both sources reach the target tables, which are only ever upserted into.
     */
    private function runKazakhstanCrm1(string $bucketUuid, int $initiatorType): void
    {
        if (! isKazakhstan()) {
            return;
        }

        resolve(EntireCrm1LoanActualizationExecutor::class)
            ->setInitiatorType($initiatorType)
            ->setBucketUuid($bucketUuid)
            ->setHandler(KazakhstanCrm1LoanUpdateHandler::class)
            ->execute();

        resolve(EntireCrm1PaymentActualizationExecutor::class)
            ->setInitiatorType($initiatorType)
            ->setBucketUuid($bucketUuid)
            ->setHandler(KazakhstanCrm1PaymentUpdateHandler::class)
            ->execute();
    }
}
```

- [ ] **Step 4: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ActualizationRunnerTest.php`
Expected: PASS, 6 tests.

- [ ] **Step 5: Point the four cron commands at the runner**

Behaviour, signatures and descriptions stay exactly as they are — `app/Console/Kernel.php` must need no edit. `EntireActualizationCommand` becomes:

```php
<?php

namespace App\Console\Commands\Cron\Actualization;

use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Illuminate\Console\Command;

class EntireActualizationCommand extends Command
{
    protected $signature = 'actualization:entire';

    protected $description = 'Command for entire actualization';

    public function handle(ActualizationRunner $runner): void
    {
        $runner->runEntire(generateBucketUuid(), ActualizationHistory::INITIATOR_TYPE_CRON);
    }
}
```

`LoanActualizationCommand`:

```php
    public function handle(ActualizationRunner $runner): void
    {
        $runner->runLoans(generateBucketUuid(), ActualizationHistory::INITIATOR_TYPE_CRON);
    }
```

`PaymentActualizationCommand`:

```php
    public function handle(ActualizationRunner $runner): void
    {
        $runner->runPayments(generateBucketUuid(), ActualizationHistory::INITIATOR_TYPE_CRON);
    }
```

`EntirePaymentPeriodsActualizationCommand`:

```php
    public function handle(ActualizationRunner $runner): void
    {
        $runner->runPaymentPeriods(generateBucketUuid(), ActualizationHistory::INITIATOR_TYPE_CRON);
    }
```

Drop each command's now-unused `process()` method and imports. Leave `PartialActualizationCommand` alone — partial actualization is out of scope.

- [ ] **Step 6: Add the command delegation test**

Append to `tests/Feature/Actualization/ActualizationRunnerTest.php`:

```php
    public function test_the_cron_commands_run_through_the_runner_as_cron(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);
        $this->instance(ActualizationRunner::class, $runner);

        $runner->shouldReceive('runEntire')->once()
            ->with(Mockery::type('string'), ActualizationHistory::INITIATOR_TYPE_CRON);
        $runner->shouldReceive('runLoans')->once()
            ->with(Mockery::type('string'), ActualizationHistory::INITIATOR_TYPE_CRON);
        $runner->shouldReceive('runPayments')->once()
            ->with(Mockery::type('string'), ActualizationHistory::INITIATOR_TYPE_CRON);
        $runner->shouldReceive('runPaymentPeriods')->once()
            ->with(Mockery::type('string'), ActualizationHistory::INITIATOR_TYPE_CRON);

        $this->artisan('actualization:entire')->assertSuccessful();
        $this->artisan('actualization:loans')->assertSuccessful();
        $this->artisan('actualization:payments')->assertSuccessful();
        $this->artisan('actualization:entire-payment-periods')->assertSuccessful();
    }
```

- [ ] **Step 7: Run the whole file and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ActualizationRunnerTest.php`
Expected: PASS, 7 tests.

- [ ] **Step 8: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Services/Actualization/ActualizationRunner.php app/Console/Commands/Cron/Actualization tests/Feature/Actualization/ActualizationRunnerTest.php`

---

### Task 3: `actualization:run` command

A manual entry point that takes a scope and an optional bucket uuid. Useful on its own from a shell, and the thing a person falls back to when the MCP path misbehaves.

**Files:**
- Create: `app/Console/Commands/Actualization/RunActualizationCommand.php`
- Test: `tests/Feature/Actualization/RunActualizationCommandTest.php`

**Interfaces:**
- Consumes: `ActualizationRunner::run()`, `ActualizationRunner::SCOPES` (Task 2); `ActualizationHistory::INITIATOR_TYPE_MCP` (Task 1).
- Produces: the Artisan command `actualization:run {scope} {--bucket-uuid=}`.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Actualization/RunActualizationCommandTest.php`:

```php
<?php

namespace Tests\Feature\Actualization;

use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Mockery;
use Tests\TestCase;

class RunActualizationCommandTest extends TestCase
{
    public function test_it_runs_the_given_scope_with_the_given_bucket(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);
        $this->instance(ActualizationRunner::class, $runner);

        $runner->shouldReceive('run')->once()
            ->with('loans', 'bucket-from-cli', ActualizationHistory::INITIATOR_TYPE_MCP);

        $this->artisan('actualization:run', ['scope' => 'loans', '--bucket-uuid' => 'bucket-from-cli'])
            ->expectsOutputToContain('bucket-from-cli')
            ->assertSuccessful();
    }

    public function test_it_generates_a_bucket_uuid_when_none_is_given(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);
        $this->instance(ActualizationRunner::class, $runner);

        $runner->shouldReceive('run')->once()
            ->with('entire', Mockery::type('string'), ActualizationHistory::INITIATOR_TYPE_MCP);

        $this->artisan('actualization:run', ['scope' => 'entire'])->assertSuccessful();
    }

    public function test_it_rejects_an_unknown_scope_without_touching_the_runner(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);
        $this->instance(ActualizationRunner::class, $runner);

        $runner->shouldNotReceive('run');

        $this->artisan('actualization:run', ['scope' => 'nonsense'])
            ->expectsOutputToContain('entire')
            ->assertFailed();
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/RunActualizationCommandTest.php`
Expected: FAIL — `The command "actualization:run" does not exist`.

- [ ] **Step 3: Create the command**

Run: `docker exec -i polaris-service-new php artisan make:command Actualization/RunActualizationCommand --no-interaction`

```php
<?php

namespace App\Console\Commands\Actualization;

use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Illuminate\Console\Command;

/**
 * Manual entry point for an actualization run. Lives outside Cron/ because it is never scheduled —
 * app/Console/Kernel.php must not pick it up.
 */
class RunActualizationCommand extends Command
{
    protected $signature = 'actualization:run {scope} {--bucket-uuid=}';

    protected $description = 'Run an actualization manually: entire, loans, payments or payment_periods';

    public function handle(ActualizationRunner $runner): int
    {
        $scope = (string) $this->argument('scope');

        if (! in_array($scope, ActualizationRunner::SCOPES, true)) {
            $this->error("Unknown scope [{$scope}]. Expected one of: ".implode(', ', ActualizationRunner::SCOPES).'.');

            return self::FAILURE;
        }

        $bucketUuid = (string) ($this->option('bucket-uuid') ?: generateBucketUuid());

        $this->info("Running {$scope} actualization under bucket {$bucketUuid}.");

        $runner->run($scope, $bucketUuid, ActualizationHistory::INITIATOR_TYPE_MCP);

        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/RunActualizationCommandTest.php`
Expected: PASS, 3 tests.

- [ ] **Step 5: Confirm the scheduler did not pick it up**

Run: `docker exec -i polaris-service-new php artisan schedule:list`
Expected: no `actualization:run` entry — `app/Console/Kernel.php` schedules only what `LEAD_TIME` lists.

- [ ] **Step 6: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Console/Commands/Actualization tests/Feature/Actualization/RunActualizationCommandTest.php`

---

### Task 4: `ManualActualizationJob`

The queued carrier for a manual run. Its failure path follows the sibling jobs in `app/Jobs/Actualization/`: log to the `sentry` channel, notify Telegram with an admin-panel button, rethrow so the queue records the failure.

**Files:**
- Create: `app/Jobs/Actualization/ManualActualizationJob.php`
- Test: `tests/Feature/Actualization/ManualActualizationJobTest.php`

**Interfaces:**
- Consumes: `ActualizationRunner::run()` (Task 2), `ActualizationHistory::INITIATOR_TYPE_MCP` (Task 1).
- Produces: `ManualActualizationJob::__construct(string $scope, string $bucketUuid)`, dispatched onto `JobsQueueEnum::ACTUALIZATION`. Used by Task 6.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Actualization/ManualActualizationJobTest.php`:

```php
<?php

namespace Tests\Feature\Actualization;

use App\Jobs\Actualization\ManualActualizationJob;
use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Mockery;
use RuntimeException;
use Tests\TestCase;

class ManualActualizationJobTest extends TestCase
{
    public function test_it_runs_the_scope_as_an_mcp_initiated_run(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);

        $runner->shouldReceive('run')->once()
            ->with('payments', 'bucket-job', ActualizationHistory::INITIATOR_TYPE_MCP);

        (new ManualActualizationJob('payments', 'bucket-job'))->handle($runner);
    }

    public function test_a_failing_run_is_rethrown_so_the_queue_records_it(): void
    {
        $runner = Mockery::mock(ActualizationRunner::class);
        $runner->shouldReceive('run')->once()->andThrow(new RuntimeException('CRM unreachable'));

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('CRM unreachable');

        (new ManualActualizationJob('loans', 'bucket-job'))->handle($runner);
    }

    public function test_it_carries_an_hour_long_timeout_like_its_siblings(): void
    {
        $this->assertSame(3600, (new ManualActualizationJob('loans', 'bucket-job'))->timeout);
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ManualActualizationJobTest.php`
Expected: FAIL — `Class "App\Jobs\Actualization\ManualActualizationJob" does not exist`.

- [ ] **Step 3: Create the job**

Run: `docker exec -i polaris-service-new php artisan make:job Actualization/ManualActualizationJob --no-interaction`

```php
<?php

namespace App\Jobs\Actualization;

use App\Helpers\Notify\TelegramNotifyHelper;
use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;

/**
 * Carries an actualization started by hand — today from the MCP tool, tomorrow from anywhere else
 * that needs the run to happen off the caller's thread.
 */
class ManualActualizationJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $timeout = 3600;

    public function __construct(protected string $scope, protected string $bucketUuid)
    {
    }

    public function handle(ActualizationRunner $runner): void
    {
        try {
            $runner->run($this->scope, $this->bucketUuid, ActualizationHistory::INITIATOR_TYPE_MCP);
        } catch (Throwable $exception) {
            Log::channel('sentry')->error($exception);

            TelegramNotifyHelper::sendCriticalError(
                "Manual {$this->scope} actualization failed. Error: {$exception->getMessage()}",
                TelegramNotifyHelper::getAdminPanelButton(
                    url('/admin/actualization/history?bucketUuid='.$this->bucketUuid)
                )
            );

            throw $exception;
        }
    }
}
```

- [ ] **Step 4: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Actualization/ManualActualizationJobTest.php`
Expected: PASS, 3 tests. If the Telegram helper attempts a real call during the failure test, assert it is unreachable in `testing` first — `TelegramNotifyHelper` short-circuits on missing config, so no HTTP should leave the container.

- [ ] **Step 5: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Jobs/Actualization/ManualActualizationJob.php tests/Feature/Actualization/ManualActualizationJobTest.php`

---

### Task 5: The server, its route and its token

Mount an MCP server at `POST /mcp` behind a static bearer token. The server has no tools yet — that is deliberate: this task proves the transport and the lock before anything can be called through them.

**Files:**
- Create: `app/Mcp/Servers/PolarisServer.php`
- Create: `routes/ai.php`
- Create: `app/Http/Middleware/EnsureMcpToken.php`
- Modify: `config/polaris.php` (new top-level `mcp` block)
- Modify: `.env.example` (document `MCP_TOKEN`)
- Test: `tests/Feature/Mcp/McpEndpointTest.php`

**Interfaces:**
- Consumes: nothing from earlier tasks.
- Produces: `App\Mcp\Servers\PolarisServer` (its `protected array $tools` is where Tasks 6 and 7 register), `config('polaris.mcp.token')`, route `POST /mcp`.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Mcp/McpEndpointTest.php`. The request body is a minimal JSON-RPC `ping`, which every MCP server answers:

```php
<?php

namespace Tests\Feature\Mcp;

use Tests\TestCase;

class McpEndpointTest extends TestCase
{
    /**
     * @return array<string, mixed>
     */
    private function pingPayload(): array
    {
        return ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'];
    }

    public function test_the_endpoint_hides_itself_when_no_token_is_configured(): void
    {
        config(['polaris.mcp.token' => '']);

        $this->postJson('/mcp', $this->pingPayload())->assertNotFound();
    }

    public function test_a_wrong_token_is_rejected(): void
    {
        config(['polaris.mcp.token' => 'the-real-token']);

        $this->withHeader('Authorization', 'Bearer wrong-token')
            ->postJson('/mcp', $this->pingPayload())
            ->assertUnauthorized();
    }

    public function test_a_missing_token_is_rejected(): void
    {
        config(['polaris.mcp.token' => 'the-real-token']);

        $this->postJson('/mcp', $this->pingPayload())->assertUnauthorized();
    }

    public function test_the_right_token_reaches_the_server(): void
    {
        config(['polaris.mcp.token' => 'the-real-token']);

        $this->withHeader('Authorization', 'Bearer the-real-token')
            ->postJson('/mcp', $this->pingPayload())
            ->assertOk();
    }

    public function test_the_server_announces_itself_as_polaris(): void
    {
        $this->assertSame('polaris', (new \ReflectionClass(\App\Mcp\Servers\PolarisServer::class))
            ->getDefaultProperties()['name']);
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/McpEndpointTest.php`
Expected: FAIL — 404 on every case, because `/mcp` is not routed yet.

- [ ] **Step 3: Add the config block**

In `config/polaris.php`, next to the other top-level blocks:

```php
    'mcp' => [
        'token' => env('MCP_TOKEN', ''),
    ],
```

In `.env.example`, near the other integration credentials:

```
# Static bearer token for the /mcp endpoint. Empty means the endpoint returns 404.
MCP_TOKEN=
```

- [ ] **Step 4: Write the middleware**

Run: `docker exec -i polaris-service-new php artisan make:middleware EnsureMcpToken --no-interaction`

```php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * One shared static token guards the MCP endpoint. An instance that was never given a token does
 * not answer at all, so a deploy that forgets MCP_TOKEN cannot leave the endpoint open.
 */
class EnsureMcpToken
{
    public function handle(Request $request, Closure $next): Response
    {
        $expected = (string) config('polaris.mcp.token');

        if ($expected === '') {
            abort(404);
        }

        $provided = (string) $request->bearerToken();

        if ($provided === '' || ! hash_equals($expected, $provided)) {
            abort(401);
        }

        return $next($request);
    }
}
```

- [ ] **Step 5: Create the server**

Run: `docker exec -i polaris-service-new php artisan make:mcp-server PolarisServer --no-interaction`

Then replace its body:

```php
<?php

namespace App\Mcp\Servers;

use Laravel\Mcp\Server;

class PolarisServer extends Server
{
    protected string $name = 'polaris';

    protected string $version = '1.0.0';

    protected string $instructions = <<<'MARKDOWN'
        Polaris debt-collection service. This server acts on ONE country instance — the instance it
        is deployed to — and its tools report which one in their responses. Tools that start work
        change production data; ask the user before calling them.
        MARKDOWN;

    /**
     * @var array<int, class-string<\Laravel\Mcp\Server\Tool>>
     */
    protected array $tools = [
        //
    ];
}
```

- [ ] **Step 6: Publish and write the route file**

Run: `docker exec -i polaris-service-new php artisan vendor:publish --tag=ai-routes --no-interaction`

Replace the published `routes/ai.php` with:

```php
<?php

use App\Http\Middleware\EnsureMcpToken;
use App\Mcp\Servers\PolarisServer;
use Laravel\Mcp\Facades\Mcp;

Mcp::web('/mcp', PolarisServer::class)->middleware(EnsureMcpToken::class);
```

The package loads this file with `Route::group([], $path)` — outside the `web` group, so there is no CSRF token and no session to worry about.

- [ ] **Step 7: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/McpEndpointTest.php`
Expected: PASS, 5 tests.

- [ ] **Step 8: Confirm the route is registered**

Run: `docker exec -i polaris-service-new php artisan route:list --path=mcp`
Expected: `GET|POST /mcp` present.

- [ ] **Step 9: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Mcp app/Http/Middleware/EnsureMcpToken.php config/polaris.php routes/ai.php tests/Feature/Mcp/McpEndpointTest.php`

---

### Task 6: `run_actualization` tool

**Files:**
- Create: `app/Mcp/Tools/RunActualizationTool.php`
- Modify: `app/Mcp/Servers/PolarisServer.php` (register the tool)
- Test: `tests/Feature/Mcp/RunActualizationToolTest.php`

**Interfaces:**
- Consumes: `ManualActualizationJob` (Task 4), `ActualizationRunner::SCOPES` (Task 2), `PolarisServer` (Task 5).
- Produces: MCP tool `run_actualization`, returning JSON with keys `bucket_uuid`, `scope`, `instance`, `history_url`.

Note on the in-progress guard: `AbstractActualizationExecutor` treats a `STATUS_PROCESSING` row older than 20 minutes as dead and marks it failed. The tool uses the same 20-minute window, so it never refuses a run the executor would have gone ahead with anyway.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Mcp/RunActualizationToolTest.php`:

```php
<?php

namespace Tests\Feature\Mcp;

use App\Jobs\Actualization\ManualActualizationJob;
use App\Mcp\Servers\PolarisServer;
use App\Mcp\Tools\RunActualizationTool;
use App\Models\Collection\ActualizationHistory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class RunActualizationToolTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        Queue::fake();
        DB::connection((new ActualizationHistory())->getConnectionName())->beginTransaction();
    }

    protected function tearDown(): void
    {
        DB::connection((new ActualizationHistory())->getConnectionName())->rollBack();

        parent::tearDown();
    }

    public function test_it_queues_the_run_and_answers_with_a_history_link(): void
    {
        $response = PolarisServer::tool(RunActualizationTool::class, ['scope' => 'loans']);

        $response->assertOk()->assertHasNoErrors()->assertSee('/admin/actualization/history?bucketUuid=');

        Queue::assertPushedOn('actualization_queue', ManualActualizationJob::class);
    }

    public function test_it_reports_the_instance_it_acted_on(): void
    {
        config(['polaris.instance' => INSTANCE_PERU]);

        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'payments'])
            ->assertOk()
            ->assertSee(INSTANCE_PERU);
    }

    /**
     * No model in this codebase declares $fillable and nothing calls Model::unguard(), so rows are
     * built by assignment — the same way tests/Feature/Actualization/ActualizationRunTotalsTest.php
     * does it.
     */
    private function record(string $bucketUuid, int $status): ActualizationHistory
    {
        $history = new ActualizationHistory();
        $history->bucket_uuid = $bucketUuid;
        $history->process_type = ActualizationHistory::PROCESS_TYPE_LOANS_ENTIRE;
        $history->initiator_type = ActualizationHistory::INITIATOR_TYPE_MCP;
        $history->status = $status;
        $history->metadata = ['method' => 'test'];
        $history->saveOrFail();

        return $history;
    }

    public function test_it_refuses_a_second_run_while_one_is_in_progress(): void
    {
        $this->record('already-running', ActualizationHistory::STATUS_PROCESSING);

        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'loans'])
            ->assertHasErrors()
            ->assertSee('already-running');

        Queue::assertNothingPushed();
    }

    public function test_a_stale_in_progress_row_does_not_block_a_new_run(): void
    {
        $stale = $this->record('stale-bucket', ActualizationHistory::STATUS_PROCESSING);
        $stale->created_at = now()->subHours(3);
        $stale->saveOrFail();

        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'loans'])->assertHasNoErrors();

        Queue::assertPushedOn('actualization_queue', ManualActualizationJob::class);
    }

    public function test_payment_periods_are_refused_off_colombia(): void
    {
        config(['polaris.instance' => INSTANCE_PERU]);

        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'payment_periods'])
            ->assertHasErrors()
            ->assertSee('Colombia');

        Queue::assertNothingPushed();
    }

    public function test_payment_periods_are_allowed_on_colombia(): void
    {
        config(['polaris.instance' => INSTANCE_COLOMBIA]);

        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'payment_periods'])
            ->assertHasNoErrors();

        Queue::assertPushedOn('actualization_queue', ManualActualizationJob::class);
    }

    public function test_an_unknown_scope_is_refused(): void
    {
        PolarisServer::tool(RunActualizationTool::class, ['scope' => 'nonsense'])->assertHasErrors();

        Queue::assertNothingPushed();
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/RunActualizationToolTest.php`
Expected: FAIL — `Class "App\Mcp\Tools\RunActualizationTool" does not exist`.

- [ ] **Step 3: Create the tool**

Run: `docker exec -i polaris-service-new php artisan make:mcp-tool RunActualizationTool --no-interaction`

```php
<?php

namespace App\Mcp\Tools;

use App\Jobs\Actualization\ManualActualizationJob;
use App\Jobs\JobsQueueEnum;
use App\Models\Collection\ActualizationHistory;
use App\Services\Actualization\ActualizationRunner;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;

#[IsDestructive]
class RunActualizationTool extends Tool
{
    protected string $name = 'run_actualization';

    protected string $description = <<<'MARKDOWN'
        Start an actualization run on this instance: pull loans and/or payments from the country's
        CRM into Polaris. The run is queued, so this returns immediately with the run's bucket uuid
        and a link to its history page. Use actualization_status to follow it.

        Scopes: entire (everything), loans, payments, payment_periods (Colombia only).
        MARKDOWN;

    /**
     * A run already in progress is only respected while it is fresh: the executors themselves treat
     * a processing row older than this as dead and mark it failed.
     */
    private const IN_PROGRESS_TIMEOUT_MINUTES = 20;

    public function handle(Request $request): Response
    {
        $scope = (string) $request->get('scope', '');

        if (! in_array($scope, ActualizationRunner::SCOPES, true)) {
            return Response::error(
                "Unknown scope [{$scope}]. Expected one of: ".implode(', ', ActualizationRunner::SCOPES).'.'
            );
        }

        if ($scope === ActualizationRunner::SCOPE_PAYMENT_PERIODS && ! isColombia()) {
            return Response::error(
                'Payment periods exist only on the Colombia instance; this one is '.getInstance().'.'
            );
        }

        $running = ActualizationHistory::query()
            ->where('status', ActualizationHistory::STATUS_PROCESSING)
            ->where('created_at', '>=', now()->subMinutes(self::IN_PROGRESS_TIMEOUT_MINUTES))
            ->orderByDesc('id')
            ->first();

        if ($running !== null) {
            return Response::error(
                "An actualization is already running under bucket {$running->bucket_uuid}. "
                .'Follow it at '.$this->historyUrl($running->bucket_uuid)
                .' and start a new run once it finishes.'
            );
        }

        $bucketUuid = generateBucketUuid();

        ManualActualizationJob::dispatch($scope, $bucketUuid)
            ->onQueue(JobsQueueEnum::ACTUALIZATION->value);

        return Response::json([
            'bucket_uuid' => $bucketUuid,
            'scope' => $scope,
            'instance' => getInstance(),
            'history_url' => $this->historyUrl($bucketUuid),
        ]);
    }

    /**
     * @return array<string, \Illuminate\Contracts\JsonSchema\JsonSchema>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'scope' => $schema->string()
                ->description('What to actualize: entire, loans, payments or payment_periods (Colombia only).')
                ->enum(ActualizationRunner::SCOPES)
                ->required(),
        ];
    }

    private function historyUrl(string $bucketUuid): string
    {
        return url('/admin/actualization/history?bucketUuid='.$bucketUuid);
    }
}
```

- [ ] **Step 4: Register the tool on the server**

In `app/Mcp/Servers/PolarisServer.php`:

```php
    protected array $tools = [
        \App\Mcp\Tools\RunActualizationTool::class,
    ];
```

- [ ] **Step 5: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/RunActualizationToolTest.php`
Expected: PASS, 7 tests.

- [ ] **Step 6: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Mcp tests/Feature/Mcp`

---

### Task 7: `actualization_status` tool

**Files:**
- Create: `app/Mcp/Tools/ActualizationStatusTool.php`
- Modify: `app/Mcp/Servers/PolarisServer.php` (register the tool)
- Test: `tests/Feature/Mcp/ActualizationStatusToolTest.php`

**Interfaces:**
- Consumes: `PolarisServer` (Task 5), `ActualizationHistory` labels (Task 1).
- Produces: MCP tool `actualization_status`, returning JSON `{bucket_uuid, instance, history_url, processes: [{process_type, status, initiator, started_at, updated_at, downloaded, archived, synced}]}`.

- [ ] **Step 1: Write the failing test**

Create `tests/Feature/Mcp/ActualizationStatusToolTest.php`:

```php
<?php

namespace Tests\Feature\Mcp;

use App\Mcp\Servers\PolarisServer;
use App\Mcp\Tools\ActualizationStatusTool;
use App\Models\Collection\ActualizationHistory;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class ActualizationStatusToolTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        DB::connection((new ActualizationHistory())->getConnectionName())->beginTransaction();
    }

    protected function tearDown(): void
    {
        DB::connection((new ActualizationHistory())->getConnectionName())->rollBack();

        parent::tearDown();
    }

    /**
     * No model in this codebase declares $fillable and nothing calls Model::unguard(), so rows are
     * built by assignment — the same way tests/Feature/Actualization/ActualizationRunTotalsTest.php
     * does it.
     */
    private function recordRun(string $bucketUuid, int $status): ActualizationHistory
    {
        $history = new ActualizationHistory();
        $history->bucket_uuid = $bucketUuid;
        $history->process_type = ActualizationHistory::PROCESS_TYPE_LOANS_ENTIRE;
        $history->initiator_type = ActualizationHistory::INITIATOR_TYPE_MCP;
        $history->status = $status;
        $history->metadata = ['downloaded' => 120, 'archived' => 118, 'synced' => 115];
        $history->saveOrFail();

        return $history;
    }

    public function test_it_reports_the_processes_of_a_given_bucket(): void
    {
        $this->recordRun('bucket-under-test', ActualizationHistory::STATUS_COMPLETED);

        PolarisServer::tool(ActualizationStatusTool::class, ['bucket_uuid' => 'bucket-under-test'])
            ->assertOk()
            ->assertHasNoErrors()
            ->assertSee('bucket-under-test')
            ->assertSee('120');
    }

    public function test_it_falls_back_to_the_latest_run(): void
    {
        $this->recordRun('older-bucket', ActualizationHistory::STATUS_COMPLETED);
        $this->recordRun('newest-bucket', ActualizationHistory::STATUS_PROCESSING);

        PolarisServer::tool(ActualizationStatusTool::class)
            ->assertOk()
            ->assertSee('newest-bucket')
            ->assertDontSee('older-bucket');
    }

    public function test_an_unknown_bucket_says_so(): void
    {
        PolarisServer::tool(ActualizationStatusTool::class, ['bucket_uuid' => 'no-such-bucket'])
            ->assertHasErrors()
            ->assertSee('no-such-bucket');
    }
}
```

- [ ] **Step 2: Run the test and watch it fail**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/ActualizationStatusToolTest.php`
Expected: FAIL — `Class "App\Mcp\Tools\ActualizationStatusTool" does not exist`.

- [ ] **Step 3: Create the tool**

Run: `docker exec -i polaris-service-new php artisan make:mcp-tool ActualizationStatusTool --no-interaction`

```php
<?php

namespace App\Mcp\Tools;

use App\Models\Collection\ActualizationHistory;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Database\Eloquent\Collection;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[IsReadOnly]
class ActualizationStatusTool extends Tool
{
    protected string $name = 'actualization_status';

    protected string $description = <<<'MARKDOWN'
        Report how an actualization run went: every process of the run with its status, counters and
        timings. Pass the bucket uuid returned by run_actualization, or omit it for the latest run
        on this instance.
        MARKDOWN;

    public function handle(Request $request): Response
    {
        $bucketUuid = (string) $request->get('bucket_uuid', '');

        if ($bucketUuid === '') {
            $latest = ActualizationHistory::query()->orderByDesc('id')->first();

            if ($latest === null) {
                return Response::error('This instance has no actualization history yet.');
            }

            $bucketUuid = (string) $latest->bucket_uuid;
        }

        /** @var Collection<int, ActualizationHistory> $processes */
        $processes = ActualizationHistory::query()
            ->where('bucket_uuid', $bucketUuid)
            ->orderBy('id')
            ->get();

        if ($processes->isEmpty()) {
            return Response::error("No actualization found for bucket {$bucketUuid}.");
        }

        return Response::json([
            'bucket_uuid' => $bucketUuid,
            'instance' => getInstance(),
            'history_url' => url('/admin/actualization/history?bucketUuid='.$bucketUuid),
            'processes' => $processes->map(fn (ActualizationHistory $process): array => [
                'process_type' => $process->process_type_text,
                'status' => $process->status_text,
                'initiator' => $process->initiator_type_text,
                'started_at' => $process->created_at?->toDateTimeString(),
                'updated_at' => $process->updated_at?->toDateTimeString(),
                'downloaded' => $process->metadata['downloaded'] ?? null,
                'archived' => $process->metadata['archived'] ?? null,
                'synced' => $process->metadata['synced'] ?? null,
            ])->all(),
        ]);
    }

    /**
     * @return array<string, \Illuminate\Contracts\JsonSchema\JsonSchema>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'bucket_uuid' => $schema->string()
                ->description('The run to report on. Omit for the most recent run on this instance.'),
        ];
    }
}
```

The three `*_text` accessors used here (`status_text`, `process_type_text`, `initiator_type_text`) are defined at `app/Models/Collection/ActualizationHistory.php:197-210` and all render through `__('actualization.…')`, which is why Task 1 had to fill the missing label keys.

- [ ] **Step 4: Register the tool on the server**

In `app/Mcp/Servers/PolarisServer.php`:

```php
    protected array $tools = [
        \App\Mcp\Tools\RunActualizationTool::class,
        \App\Mcp\Tools\ActualizationStatusTool::class,
    ];
```

- [ ] **Step 5: Run the test and watch it pass**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp/ActualizationStatusToolTest.php`
Expected: PASS, 3 tests.

- [ ] **Step 6: Run every test this plan touched**

Run: `docker exec -i polaris-service-new php artisan test --compact tests/Feature/Mcp tests/Feature/Actualization`
Expected: PASS across all files.

- [ ] **Step 7: Format**

Run: `docker exec -i polaris-service-new vendor/bin/pint app/Mcp tests/Feature/Mcp`

- [ ] **Step 8: Try it end to end against the local container**

Run:

```bash
docker exec -i polaris-service-new php artisan tinker --execute="config(['polaris.mcp.token' => 'local-dev-token']); echo 'ok';"
docker exec -i polaris-service-new php artisan mcp:inspector polaris
```

Expected: the inspector lists `run_actualization` and `actualization_status` with their schemas. Call `actualization_status` — it is read-only and safe — and confirm it answers with the latest local run. Do **not** call `run_actualization` against a local container that cannot reach the country's CRM.

---

## Notes for whoever runs this

- Nothing is exposed until `MCP_TOKEN` is set — an instance without it answers `/mcp` with 404. Roll out by setting the token on one instance, pointing a client at `https://<host>/mcp`, and exercising `actualization_status` before ever calling `run_actualization`.
- Locally `QUEUE_CONNECTION=sync` and no worker runs, so a local `run_actualization` executes inline and blocks the call. To mimic production, set `QUEUE_CONNECTION=redis` and run `php artisan queue:work --queue=actualization_queue`.
- The same server works over stdio without any change: `docker exec -i polaris-service-new php artisan mcp:start polaris`, which is what an `.mcp.json` entry would call.
