routewise / laravel
Turn a Laravel app into a safe MCP server: tools defined as data or generated from your routes, with kinds, a call log and a readiness scan.
Requires
- php: ^8.3
- illuminate/console: ^12.41.1|^13.0
- illuminate/http: ^12.41.1|^13.0
- illuminate/routing: ^12.41.1|^13.0
- illuminate/support: ^12.41.1|^13.0
- illuminate/validation: ^12.41.1|^13.0
- laravel/mcp: ^1.1
Requires (Dev)
- orchestra/testbench: ^10.8|^11.0
- phpunit/phpunit: ^11.5|^12.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 23:25:49 UTC
README
Turn a Laravel app into a safe MCP server for AI clients (claude.ai, Claude Code, any MCP host),
on top of laravel/mcp. No OpenAPI spec needed: the app's own
code is the source of truth.
- Tools as data. One
Fielddeclaration gives the JSON Schema the model sees and the validation rules the call is checked against. - Kinds. Every tool is
read,write,deleteorexternal. The kind sets the MCP annotations; delete tools always ask for confirmation. - Routes as tools. Existing routes become tools: arguments come from the path and the FormRequest, and a call runs through the route's own middleware, validation and policies as the signed-in user.
- Readiness scan.
php artisan routewise:scanscores how well a model can use each tool and says what to fix.--min=80gates CI. - Call log. Every call fires
ToolCalled(tool, outcome, duration, argument names, user). Listen to it, or send it to the control plane. - Rules and approvals. Block a kind or a tool in config, or let the control plane decide per kind and per tool: allow, ask a person first, or block.
- Behaviour tests.
php artisan routewise:behaviourputs your own prompts to a model with the tools on offer and checks which ones it calls, without running any. - Result view. Read results render as cards and tables in hosts that support MCP Apps.
Install
composer require routewise/laravel php artisan vendor:publish --tag=routewise-config
Define tools
use Routewise\DefinedTool as T; use Routewise\Field as F; class InvoiceTools { public function all(): array { return [ T::make('invoices_list', 'read', 'List invoices, newest first. Filter by status.', [ 'status' => F::enum(['draft', 'sent', 'paid']), 'limit' => F::int('How many to return')->min(1)->max(100)->default(20), ], fn (array $a) => Invoice::when($a['status'] ?? null, fn ($q, $s) => $q->where('status', $s))->latest()->limit($a['limit'])->get()), T::make('invoice_delete', 'delete', 'Delete an invoice permanently.', ['id' => F::id('The invoice')->required()], fn (array $a) => ['deleted' => Invoice::findOrFail($a['id'])->delete()]), ]; } }
Register the set (a service provider's boot) and hand the list to your server:
Routewise::register(InvoiceTools::class); class AppServer extends \Laravel\Mcp\Server { protected array $resources = [\Routewise\ResultView::class]; public function __construct(\Laravel\Mcp\Server\Contracts\Transport $transport) { parent::__construct($transport); $this->tools = \Routewise\Routewise::tools(); } }
A handler returns anything JSON-encodable. Throw a RuntimeException to tell the model what went wrong.
Routes as tools
// config/routewise.php 'routes' => ['enabled' => true, 'include' => ['api/*'], 'exclude' => ['api/admin/*']],
| From | Becomes |
|---|---|
| HTTP method | kind: GET = read, DELETE = delete, the rest = write |
Route name invoices.index |
tool name invoices_index |
| Path parameters | required arguments |
The action's FormRequest rules() |
arguments (type, required, enum, min/max) |
#[AgentTool(description: ...)] or the docblock's first line |
description |
#[AgentTool(description: 'Publish a draft post so readers can see it.', fields: ['post' => 'The post id'])] public function publish(Post $post) { ... } #[AgentTool(hidden: true)] // never listed public function export() { ... }
Limits: only routes that answer JSON work; nested rules (address.city) are not listed as arguments
(they are still validated by the route); rules() that need the live request are skipped.
Turning this on gives the signed-in user's AI client every matching route, with that user's permissions.
Start with a narrow include.
OAuth registration
laravel/mcp checks redirect URIs by prefix. Routewise\Http\RestrictOAuthRedirects accepts only the
exact callbacks in routewise.exact_redirect_uris, plus http loopback addresses:
Route::middleware(RestrictOAuthRedirects::class)->group(fn () => Mcp::oauthRoutes());
Control plane (optional)
ROUTEWISE_URL=https://your-control-plane.example ROUTEWISE_TOKEN=project-token
php artisan routewise:sync (run on deploy) sends the tool catalog with its scores; calls are sent after each
response. Only metadata leaves the app: results are never sent, error messages only with
ROUTEWISE_SEND_ERRORS=true, and argument values only for approvals with ROUTEWISE_APPROVAL_VALUES=true.
Rules and approvals
Hard rules live in the app and can't be overridden from anywhere:
// config/routewise.php 'policy' => ['kinds' => ['delete' => 'block'], 'tools' => ['payroll_run' => 'block']],
With the control plane connected, its dashboard sets a rule per kind and per tool: Allow, Ask first or Block. A call to an "Ask first" tool does not run. The model is told it needs approval, an approver decides on the dashboard, and the same call (same tool, user and arguments) then runs once.
- Approvers see the tool, the user id and the argument names. Set
ROUTEWISE_APPROVAL_VALUES=trueto show them the values too; that is the one case where argument values leave the app. - A retry is matched to its approval by an HMAC of the arguments made with your
APP_KEY, so the control plane can't recover the values from it. - Rules are re-read every 60 seconds (
platform.policy_ttl). If the control plane is down, the last rules keep applying and "Ask first" tools don't run.
Every call's outcome (ok, failed, blocked, pending, denied) is on the ToolCalled event and in the call log.
Behaviour tests
The readiness scan reads your tool definitions. Behaviour tests ask a model: given this prompt and these tools, which does it call, and with what? No tool is ever run, so they are safe against any database.
composer require --dev anthropic-ai/sdk
ANTHROPIC_API_KEY=sk-ant-...
Scenarios are PHP files in routewise/ (config behaviour.path) that return Scenario objects:
// routewise/invoices.php use Routewise\Behaviour\Scenario; return [ Scenario::prompt('Delete invoice 42')->expects('invoice_delete', ['id' => 42]), Scenario::prompt('Which invoices are unpaid?') ->expects('invoices_list', ['status' => 'unpaid']) ->never('invoice_delete'), // A closure accepts more than one right answer. Scenario::prompt('Show me what is overdue') ->expects('invoices_list', ['status' => fn ($status) => in_array($status, ['unpaid', 'overdue'], true)]), Scenario::prompt('Tell me a joke')->named('stays out of the tools')->expectsNoTool(), ];
php artisan routewise:behaviour # exits 1 when a scenario fails, for CI
php artisan routewise:behaviour --filter=invoice
expects(tool, [args]): the tool must be called, and each listed argument must match; other arguments are free.never(tool, ...): must not be called.expectsNoTool(): the model must answer or ask without calling anything.- Each scenario is one request to the model (
behaviour.model, defaultclaude-opus-5-5, atbehaviour.effort). The tool list is cached between scenarios, so a large catalog is paid for in full once per run. Every run costs tokens; the summary line says how many. - Set
behaviour.systemto your MCP server's own instructions so the test sees what real clients see. - With the control plane connected, the result is sent there (the scenarios' prompts, verdicts and the model's tool calls);
--no-reportkeeps it local. - To test another model, bind your own
Routewise\Behaviour\Modelin the container.
Testing
composer install && composer test