Skip to content

Adding a tool

An MCP tool is a thin connector. It only validates input, calls the same domain class the Arkana screen uses, picks fields and returns them. It never queries, recomputes, re-derives or re-labels anything. If the logic lives in a Filament resource or page, move it to app/Services/ first and make the screen consume it too.

The team rule for agents is backend/.ai/rules/mcp.md.

  1. Domain class. Put (or move) the logic in app/Services/{Domain}/ and make the Arkana screen use it. Reuse shared definitions such as Booking::scopeVisibleTo() and User::canViewInternalPricing() instead of re-checking access.

  2. Ability. For a new domain, add a case to App\Enums\McpAbility ({domain}:read or {domain}:write) mapped to the Spatie permissions the admin screen requires.

  3. Tool class. Create app/Mcp/Tools/{Domain}/{Verb}{Object}Tool.php extending App\Mcp\Tools\ArkanaTool, implementing ability(), with:

    • #[Name('{domain}_{verb}[_{object}]')];
    • #[IsReadOnly] or #[IsDestructive];
    • #[Description] written for an LLM: name the Arkana screen it matches, state units and currency, and spell out caveats (empty states, which tool to call first).
  4. Schemas. Implement schema() and outputSchema().

  5. Validation. Use $request->validate() with actionable messages (list the allowed values, show the expected format).

  6. Response. Return Response::structured([...]), or $this->paginated() for lists ({data, next_cursor}). Use $this->notFound() instead of findOrFail() and $this->asDisplayed() to round figures.

  7. Register the tool in App\Mcp\Servers\ArkanaServer::$tools.

  8. Tests in tests/Feature/Mcp/{Domain}/:

    • a parity test asserting the tool output equals the domain class output for the same record (see GetBookingCostBreakdownToolTest);
    • access tests for anything the tool gates itself (e.g. supplier managers denied pricing); token-ability and permission gating is covered once in tests/Feature/Mcp/ArkanaServerTest.php.

    Authenticate with Sanctum::actingAs(mcpAgent(), [ability]); plain actingAs() does not represent a token and the tool will not register.

  9. PHPStan. Run it from backend/ (./vendor/bin/sail composer analyse).

  10. Docs. Add the tool to the catalogue in the overview.

Both rules are registered in backend/phpstan.neon and tested in backend/tests/Unit/PHPStan/.

Identifier Rejects in App\Mcp
volare.mcp.dataAccess Static calls on Eloquent models, the DB facade, calls on query builders or relations
volare.mcp.arithmetic + - * / % ** (and assignment/increment forms), math and format functions (round, number_format, array_sum, bc*, …); the only exception is round() inside ArkanaTool::asDisplayed()
volare.mcp.filament Importing anything from App\Filament
volare.mcp.toolStructure A tool under App\Mcp\Tools\ that does not extend ArkanaTool, lacks #[Name], or lacks #[IsReadOnly] / #[IsDestructive]

The limit: PHPStan guarantees there is no logic in app/Mcp. It cannot prove the tool returns the same results as the screen; that is guaranteed by consuming the same class plus the parity test.

Sources: backend/phpstan/Rules/ForbidLogicInMcpLayerRule.php, backend/phpstan/Rules/RequireMcpToolStructureRule.php