# Polaris MCP server — design

Date: 2026-09-02
Status: approved, ready for implementation planning

## Purpose

Give the team an in-house MCP server exposing Polaris' own operations as tools, so routine
actions can be triggered from an AI client instead of assembled by hand out of `tinker` and
`database-query` calls. Laravel Boost stays as-is; it covers generic Laravel introspection,
this server covers Polaris domain actions.

The first tool starts an actualization run. The server is built so later tools drop in beside it
without rework.

## Scope of this spec

In scope:

- The MCP server itself, its HTTP transport and its authentication.
- `run_actualization` and `actualization_status` tools.
- A single entry point (command + job + runner service) for manually started actualization.
- A new `INITIATOR_TYPE_MCP` so manual runs are distinguishable in the admin history.

Out of scope (deliberately deferred):

- The `partial` actualization scope — it carries its own heartbeat logic and the
  `partial_actualization_enabled` flag, and adds no value to the first version.
- Cancelling a running actualization.
- Retry/backoff policy beyond Laravel's defaults.
- Any tool other than the two listed above.

## Constraints and context

- PHP 8.3 / Laravel 10. `laravel/mcp` v0.5.3 is already installed (a `laravel/boost` dependency),
  together with `illuminate/json-schema` v12.53, which is what makes the package work on Laravel 10.
- `routes/ai.php` does not exist yet. `Laravel\Mcp\Server\McpServiceProvider::registerRoutes()`
  loads it with `Route::group([], $path)` — no `web` group, therefore no CSRF and no session state.
- The codebase is deployed as several country instances (`APP_INSTANCE`). Actualization branches
  per instance: Kazakhstan runs two CRMs, Colombia additionally actualizes payment periods.
- `actualization_queue` already exists in `App\Jobs\JobsQueueEnum` and is already consumed on
  production — `IncomingUpdatesController` dispatches `LoansActualizationJob` /
  `PaymentsActualizationJob` onto it for file-based ingestion.
- Locally `QUEUE_CONNECTION=sync` and no worker runs. This is accepted: a local `entire` run through
  the tool will block the call. Anyone who wants local background behaviour sets
  `QUEUE_CONNECTION=redis` and runs `php artisan queue:work --queue=actualization_queue`.

## Architecture

```
MCP client ──HTTP POST /mcp──▶ EnsureMcpToken ──▶ PolarisServer
                                                    ├── RunActualizationTool ──▶ ManualActualizationJob ──┐
                                                    └── ActualizationStatusTool ──▶ ActualizationHistory   │
                                                                                                          ▼
                                          actualization:run  ─────────────────────────────▶ ActualizationRunner
                                          actualization:entire / :loans / :payments /                     │
                                          :entire-payment-periods  ──────────────────────────────────────▶┘
                                                                                                          ▼
                                                                                       existing executors + handlers
```

### Transport and authentication

`routes/ai.php` (published via `php artisan vendor:publish --tag=ai-routes`, then edited):

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

`App\Http\Middleware\EnsureMcpToken`:

- Reads the bearer token from the `Authorization` header. This header is standard and survives
  nginx, unlike the underscore-carrying custom headers used elsewhere in this project.
- Compares it with `config('polaris.mcp.token')` using `hash_equals`.
- `abort(404)` when the configured token is empty, so an instance that has not been given a token
  does not expose the endpoint at all; `abort(401)` when a token is configured but the request's
  token does not match.

Config, in `config/polaris.php`:

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

One static token, shared, rotated by changing the env var. No Sanctum, no per-user tokens, no OAuth.

The same server is reachable locally over stdio (`php artisan mcp:start polaris`) and through
`php artisan mcp:inspector` without any code change.

### `App\Mcp\Servers\PolarisServer`

Extends `Laravel\Mcp\Server`. Sets `$name = 'polaris'`, a version, and instructions telling the
client that this server acts on one country instance, which instance that is, and that its tools
change production data.

Registers the two tools below. New base directory `app/Mcp/` — this is the package's own convention
(`make:mcp-server` generates into it).

### Actualization entry point

Today four commands each build their own executor chain, and `EntireActualizationCommand` also holds
the Kazakhstan CRM1 ordering rule in a private method. The MCP job needs exactly the same behaviour
with a different initiator type and a caller-supplied bucket uuid, so the orchestration moves into a
service rather than being copied:

`App\Services\Actualization\ActualizationRunner` with:

```php
public function runEntire(string $bucketUuid, int $initiatorType): void;
public function runLoans(string $bucketUuid, int $initiatorType): void;
public function runPayments(string $bucketUuid, int $initiatorType): void;
public function runPaymentPeriods(string $bucketUuid, int $initiatorType): void;
```

`runEntire()` keeps the current order and the current comment explaining it: Kazakhstan's legacy CRM
chain first, then loans, then payments, then payment periods on Colombia only.

The four existing commands (`actualization:entire`, `:loans`, `:payments`,
`:entire-payment-periods`) become thin callers of this service, passing
`INITIATOR_TYPE_CRON` and a freshly generated bucket uuid. Their signatures, descriptions and
observable behaviour do not change, so `app/Console/Kernel.php` needs no edit.

`actualization:run {scope} {--bucket-uuid=}` is the new manual entry point. `scope` is one of
`entire|loans|payments|payment_periods`; an unknown scope fails with a listed set of valid values.
Without `--bucket-uuid` it generates one. It records `INITIATOR_TYPE_MCP`.

`App\Jobs\Actualization\ManualActualizationJob` — `ShouldQueue`, `$timeout = 3600` (matching the
existing actualization jobs), constructor takes `string $scope, string $bucketUuid`, dispatched onto
`JobsQueueEnum::ACTUALIZATION`. It calls the runner directly rather than shelling out to Artisan.
On failure it follows the existing convention of the sibling jobs: log to the `sentry` channel and
send a Telegram notification carrying the admin-panel button for that bucket.

### `INITIATOR_TYPE_MCP`

`ActualizationHistory::INITIATOR_TYPE_MCP = 2`, alongside the existing `API = 0` and `CRON = 1`.

The label renders through `__('actualization.initiator_types.'.$this->initiator_type)`, so every
`resources/lang/*/actualization.php` gets one line: `INITIATOR_TYPE_MCP => 'MCP'`. The label is a
proper noun and stays identical in every language. Only five locales ship that file — `ar`, `en`,
`es`, `hi`, `zh-CN` — and the remaining nineteen locale directories fall back to `en`; this spec
does not add translation files the app never had.

`ScheduledTaskMonitor` filters on `INITIATOR_TYPE_CRON` explicitly, so monitoring is unaffected by
the new value.

## Tools

### `run_actualization`

Input schema:

| field | type | required | notes |
|---|---|---|---|
| `scope` | enum `entire`, `loans`, `payments`, `payment_periods` | yes | no default; the caller states intent |

Behaviour:

1. Reject `payment_periods` off Colombia with a message naming the current instance — payment periods
   only exist there.
2. Reject when an `ActualizationHistory` row is `STATUS_PROCESSING`, returning the running bucket's
   uuid and link instead of starting a second run. This mirrors the guard the executors already
   apply, but fails fast and legibly instead of recording a cancelled row.
3. Generate a bucket uuid, dispatch `ManualActualizationJob` onto `actualization_queue`.
4. Return `bucket_uuid`, `scope`, `instance`, and `url('/admin/actualization/history?bucketUuid='.$uuid)`.

The `?bucketUuid=` filter already exists on `ActualizationHistoryIndexComponent`, so one link covers
every row of the run — loans and payments together.

Annotated as a destructive, non-idempotent tool so clients can prompt before calling it.

### `actualization_status`

Input schema:

| field | type | required | notes |
|---|---|---|---|
| `bucket_uuid` | string | no | omitted means the most recent run |

Returns, per `ActualizationHistory` row of that bucket: process type (human-readable), status,
initiator, created/updated timestamps, the `downloaded` / `archived` / `synced` counters out of
`metadata`, and the same admin link. When the bucket is unknown, it says so rather than returning an
empty list.

Read-only; annotated as such.

## Error handling

- Unknown scope, wrong instance, run already in progress: returned as tool errors with a sentence
  the agent can relay, not as exceptions.
- Job failures: handled inside the job (sentry log + Telegram), and visible through the history row
  flipping to `STATUS_FAILED`, which `actualization_status` reports.
- Auth failures: 404 when no token is configured, 401 on mismatch. No detail about the expected
  token in either response.

## Testing

Feature tests, using the package's `Laravel\Mcp\Server\Testing` helpers and `Queue::fake()`:

- `run_actualization` with each valid scope dispatches `ManualActualizationJob` with the right scope
  onto `actualization_queue`, and returns a link containing the generated uuid.
- `run_actualization` refuses a second run while a `STATUS_PROCESSING` row exists, and its response
  carries the running bucket's uuid.
- `run_actualization` refuses `payment_periods` on a non-Colombia instance.
- `run_actualization` rejects an unknown scope.
- `actualization_status` reports rows for a given bucket, and falls back to the latest run when
  called without an argument.
- `EnsureMcpToken`: 404 with no configured token, 401 with a wrong token, 200 with the right one.

Unit test for `ActualizationRunner` scope dispatch is not planned — the executors it drives reach
external CRMs, so the runner is covered indirectly through the job tests with faked executors.

Note that this repository has no meaningful existing test coverage, so these tests are also the
regression net for the four refactored commands: each is asserted to call the runner with
`INITIATOR_TYPE_CRON`.

## Rollout

1. Merge with `MCP_TOKEN` unset everywhere — the endpoint returns 404, nothing is exposed.
2. Set `MCP_TOKEN` on one instance, point a client at `https://<host>/mcp`, verify with
   `actualization_status` (read-only) before ever calling `run_actualization`.
3. Roll the token out to the remaining instances.
