Every AI assistant your clients use — Claude, Cursor, ChatGPT's desktop app, a custom agent built on the Laravel AI SDK — has the same limitation: it knows a lot about the world and nothing about your application. The Model Context Protocol (MCP) is the standard that closes that gap. It is a small JSON-RPC protocol that lets an assistant discover and call tools you expose, read resources you publish, and use prompts you have written, all under your authentication and your business rules.
Laravel has a first-party package for this, laravel/mcp. This tutorial builds a working MCP server on top of an existing Laravel application — one that lets an assistant look up orders and issue a refund — and covers the parts that matter in production: schemas, validation, authorisation, transports and testing.
You will need a Laravel 12 or 13 application on PHP 8.2+ and an MCP-capable client to test with.
Why an MCP server instead of "just an API"
You almost certainly already have a REST or GraphQL API. MCP is not a replacement for it; it is a different consumer with different needs.
- Discovery. An MCP client asks the server what it can do and gets back tool names, descriptions and JSON schemas. No SDK, no OpenAPI import step, no prompt full of hand-written endpoint documentation.
- Descriptions are the interface. With a REST API, a human reads the docs and writes the call. With MCP, a model reads your
$descriptionand decides. The wording of that string is functionally part of your code. - Scope is deliberately small. A good MCP server exposes five to fifteen well-named operations, not two hundred CRUD routes.
If you are exposing an existing internal API, put a thin MCP layer in front of it rather than reflecting every endpoint. The narrower surface is the point.
Install
composer require laravel/mcp
php artisan vendor:publish --tag=ai-routes
That publishes routes/ai.php, which is where MCP servers are registered — the AI equivalent of routes/web.php.
Create the server
php artisan make:mcp-server OrdersServer
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\FindOrder;
use App\Mcp\Tools\RefundOrder;
use App\Mcp\Resources\RefundPolicy;
use Laravel\Mcp\Server;
class OrdersServer extends Server
{
protected string $name = 'Acme Orders';
protected string $version = '1.0.0';
protected string $instructions = 'Look up customer orders and issue refunds. '
.'Always confirm the order total with the user before refunding. '
.'Refunds above the policy limit will be rejected — do not retry them.';
protected array $tools = [
FindOrder::class,
RefundOrder::class,
];
protected array $resources = [
RefundPolicy::class,
];
}
The instructions string is loaded into the assistant's context when it connects. Treat it as onboarding documentation for a new contractor who is fast, literal and has no judgement: state the constraints explicitly.
Register it in routes/ai.php:
use App\Mcp\Servers\OrdersServer;
use Laravel\Mcp\Facades\Mcp;
// Streamable HTTP transport, for remote clients.
Mcp::web('orders', OrdersServer::class)
->middleware(['auth:sanctum']);
// STDIO transport, for a locally running assistant.
Mcp::local('orders', OrdersServer::class);
Mcp::web() exposes an HTTP endpoint at /mcp/orders — this is the one you use for remote clients, and it goes through normal Laravel middleware, so authentication, rate limiting and tenancy scoping all work exactly as they do elsewhere. Mcp::local() registers an Artisan-driven STDIO server for tools running on the same machine as the code, which is mostly useful for developer tooling.
Write a tool
php artisan make:mcp-tool FindOrder
<?php
namespace App\Mcp\Tools;
use App\Models\Order;
use Illuminate\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
class FindOrder extends Tool
{
protected string $description = 'Find a single order by its order number '
.'(format: ACME-12345) and return its status, total and line items. '
.'Use this before any refund. Returns an error if the order does not '
.'belong to the authenticated customer.';
public function schema(JsonSchema $schema): array
{
return [
'order_number' => $schema->string()
->description('The order number, e.g. ACME-12345.')
->required(),
];
}
public function handle(Request $request): Response
{
$validated = $request->validate([
'order_number' => ['required', 'string', 'regex:/^ACME-\d+$/'],
]);
$order = Order::query()
->where('user_id', $request->user()->id)
->where('number', $validated['order_number'])
->with('items')
->first();
if (! $order) {
return Response::error('No order with that number was found for this customer.');
}
return Response::json([
'number' => $order->number,
'status' => $order->status,
'total' => $order->total->format(),
'placed_at' => $order->created_at->toDateString(),
'items' => $order->items->map(fn ($item) => [
'sku' => $item->sku,
'name' => $item->name,
'quantity' => $item->quantity,
])->all(),
]);
}
}
Four things are doing real work here:
- The description. It says what the tool does, the exact input format, when to use it, and what failure looks like. Vague descriptions produce tools the model calls at the wrong moment or not at all.
- The schema.
schema()is what the client advertises to the model. Mark required fields, describe each one, and use enums ($schema->string()->enum([...])) instead of free text wherever the domain is closed. - Validation. Schemas are advisory — a model can and will send garbage.
$request->validate()insidehandle()is your real boundary and behaves like any other Laravel validation. - Scoping. The query is scoped to
$request->user(). BecauseMcp::web()runs through your middleware stack, the authenticated user is the one whose token the client presented. Never trust an identifier that arrives as a tool argument.
Tools with side effects
Reading data is low risk. Changing it is not. A refund tool needs a hard server-side limit, because "the model was told not to" is not a control:
public function handle(Request $request): Response
{
$validated = $request->validate([
'order_number' => ['required', 'string'],
'amount' => ['required', 'numeric', 'min:0.01'],
'reason' => ['required', 'string', 'max:500'],
]);
$order = Order::where('user_id', $request->user()->id)
->where('number', $validated['order_number'])
->firstOr(fn () => null);
if (! $order) {
return Response::error('Order not found.');
}
if (! $request->user()->can('refund', $order)) {
return Response::error('You are not permitted to refund this order.');
}
if ($validated['amount'] > $order->refundableAmount()) {
return Response::error(
'Requested amount exceeds the refundable balance of '
.$order->refundableAmount().'. No refund was issued.'
);
}
$refund = $order->refund(
amount: $validated['amount'],
reason: $validated['reason'],
issuedBy: $request->user(),
);
return Response::text("Refund {$refund->id} for {$refund->amount} has been issued.");
}
Rules we apply to every write tool on client projects:
- The policy is authoritative. Reuse the same
Gate/policy the web UI uses. One authorisation path, not two. - Fail with a message the model can act on.
Response::error()returns text to the assistant; a good error tells it what went wrong and whether retrying will help. - Make it idempotent or make it logged. Accept a client-supplied idempotency key, or at minimum record who invoked what with which arguments. When something goes wrong you need to be able to answer "which assistant did this, on whose behalf, and when".
- Rate limit the route.
->middleware(['auth:sanctum', 'throttle:60,1'])on the registration is one line and prevents a looping agent from hammering your payment provider.
Resources and prompts
Not everything should be a tool. A resource is content the client can read — a document, a schema, a policy — with no side effects and no arguments:
class RefundPolicy extends Resource
{
protected string $description = 'The current customer refund policy, including time limits and exclusions.';
public function handle(): Response
{
return Response::text(Storage::disk('local')->get('policies/refunds.md'));
}
}
A prompt is a reusable, parameterised instruction template you ship with the server, so every client asks the question the same way instead of each user inventing their own phrasing. Both are registered in the $resources and $prompts arrays on the server class.
Try it locally
The package ships with the MCP inspector, which is by far the fastest way to see what a client sees:
php artisan mcp:inspector orders
It lists your tools with their schemas and lets you invoke them by hand. Most bugs at this stage are schema bugs — a field the model cannot fill in because you never described it.
To connect a desktop assistant to a local server, point it at the Artisan command; for a remote server, give it the /mcp/orders URL and a bearer token. If the client supports OAuth, laravel/mcp integrates with Passport and Sanctum so you can issue scoped tokens per client rather than sharing one API key.
Test the tools
Tools are ordinary Laravel classes with an ordinary request/response shape, so they test like anything else:
use App\Mcp\Servers\OrdersServer;
use App\Models\Order;
use App\Models\User;
it('finds an order belonging to the customer', function () {
$user = User::factory()->create();
$order = Order::factory()->for($user)->create(['number' => 'ACME-12345']);
$response = OrdersServer::actingAs($user)
->tool('find-order', ['order_number' => 'ACME-12345']);
$response->assertOk()
->assertSee('ACME-12345');
});
it('does not leak another customer\'s order', function () {
$order = Order::factory()->create(['number' => 'ACME-99999']);
OrdersServer::actingAs(User::factory()->create())
->tool('find-order', ['order_number' => 'ACME-99999'])
->assertHasErrors();
});
it('refuses refunds above the refundable balance', function () {
// ...assert no refund row was created and the payment gateway was never called.
});
Write the negative tests first. The interesting failures in an MCP server are all authorisation and limit failures, and they are exactly the ones a model will find for you in production if you do not find them first. Pair these with browser-level coverage of the UI that consumes the same policies — see our Pest browser testing guide once it is live.
Deployment notes
- The HTTP transport is stateless per request but conversations are long-running. Keep tool handlers fast; push anything slow onto a queue and return a job reference the assistant can poll with a second tool. Our queues in production guide covers the worker side.
- Log every invocation — server, tool, arguments, authenticated user, result status. Store it like an audit trail, because that is what it is.
- Version your tools. Renaming a tool or changing its schema breaks every assistant configured against it. Add new tools rather than mutating old ones, and deprecate in the description.
- Watch the protocol. MCP is young and moving quickly; pin
laravel/mcpand read the changelog before upgrading rather than trackingdev-main.
Where this fits
An MCP server is the cleanest way we have found to let AI assistants act inside a business system without rewriting the system. It reuses your models, policies, validation and middleware, and it keeps the decision about what an assistant is allowed to do in your codebase where it can be reviewed and tested.
If you are pairing this with in-app AI features, the same domain logic can back both — see our guide to the Laravel AI SDK.
We design and build MCP servers and AI features for Laravel applications. If you have a system you want an assistant to work with safely, see our AI feature development service or get in touch.