Get started with 33% off your first certification using code: 33OFFNEW

Use Laravel to create your own MCP server

10 min read
Published on 16th June 2026

You have a Laravel application full of data and actions, and you want an AI client like Claude to use it directly: look something up, change something, answer a question grounded in your own data. The usual route is to build a REST API, add authentication, write a client, then describe all of it to the model. The Model Context Protocol (MCP) replaces that with one standard interface, and the official laravel/mcp package lets you build it with the classes, container and validation you already use.

This guide builds a working MCP server end to end: tools the model can call, resources it can read, input validation, authentication, and tests. The example is a small shop, but the shape applies to any application.

What an MCP server actually exposes

An MCP server hands three kinds of capability to a connected AI client. Tools are actions the model can call, such as searching orders or cancelling one. Resources are read-only data it can pull in for context, like a returns policy. Prompts are reusable templates that shape a request before it reaches the model.

The client and server speak JSON-RPC to each other. laravel/mcp handles that wire format, so you write ordinary PHP classes and the package turns them into protocol responses. There are two kinds of server: a web server reachable over HTTP for remote clients, and a local server that runs as an Artisan command for agents on the same machine, such as Claude Code.

Install the package

Pull it in with Composer:

composer require laravel/mcp

Then publish the routes file where your servers are registered:

php artisan vendor:publish --tag=ai-routes

That creates routes/ai.php. Think of it as the MCP equivalent of routes/web.php: every server you expose gets a line in there.

Scaffold a server

Generate a server class with Artisan:

php artisan make:mcp-server OrdersServer

This writes app/Mcp/Servers/OrdersServer.php. The class extends the package's base Server and carries its identity in attributes, plus three arrays that list what it exposes:

<?php

namespace App\Mcp\Servers;

use App\Mcp\Tools\SearchOrdersTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('Orders Server')]
#[Version('1.0.0')]
#[Instructions('Search orders, add internal notes, and cancel orders for the shop.')]
class OrdersServer extends Server
{
    /**
     * @var array<int, class-string<\Laravel\Mcp\Server\Tool>>
     */
    protected array $tools = [
        SearchOrdersTool::class,
    ];

    protected array $resources = [
        //
    ];

    protected array $prompts = [
        //
    ];
}

The Instructions attribute is worth taking seriously. It is sent to the client as guidance on what the server is for, so the model has context before it ever calls a tool.

Register the server

A server does nothing until it is registered in routes/ai.php. You have two methods. Use web for HTTP-accessible servers and local for command-line ones:

use App\Mcp\Servers\OrdersServer;
use Laravel\Mcp\Facades\Mcp;

Mcp::web('/mcp/orders', OrdersServer::class)
    ->middleware(['auth:sanctum', 'throttle:60,1']);

Mcp::local('orders', OrdersServer::class);

Web servers behave like normal routes, so any middleware you already rely on works here, including authentication and rate limiting. We come back to locking the server down later.

Build your first tool

Tools are where the real work happens. Generate one:

php artisan make:mcp-tool SearchOrdersTool

A tool has two parts. The schema method declares the arguments it accepts, and handle does the work and returns a response. Here is a read-only search over orders:

<?php

namespace App\Mcp\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;

#[IsReadOnly]
#[Description('Search recent orders, optionally filtered by status, and return their references and totals.')]
class SearchOrdersTool extends Tool
{
    /**
     * Handle the tool request.
     */
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'status' => ['nullable', 'in:pending,shipped,delivered,cancelled'],
            'limit' => ['integer', 'between:1,50'],
        ]);

        $orders = Order::query()
            ->when($validated['status'] ?? null, fn ($query, $status) => $query->where('status', $status))
            ->latest()
            ->limit($validated['limit'] ?? 10)
            ->get();

        if ($orders->isEmpty()) {
            return Response::text('No orders matched that search.');
        }

        return Response::structured([
            'count' => $orders->count(),
            'orders' => $orders->map(fn ($order) => [
                'reference' => $order->reference,
                'status' => $order->status,
                'total' => $order->total,
                'placed_at' => $order->created_at->toIso8601String(),
            ])->all(),
        ]);
    }

    /**
     * @return array<string, \Illuminate\JsonSchema\Types\Type>
     */
    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string()
                ->enum(['pending', 'shipped', 'delivered', 'cancelled'])
                ->description('Only return orders with this status.'),

            'limit' => $schema->integer()
                ->description('Maximum number of orders to return.')
                ->default(10),
        ];
    }
}

Add it to the server's $tools array, as shown above, and the client can call it.

A few things are doing work here. The schema gives the model a typed contract: status is one of four values, limit is an integer with a sensible default. The description tells the model when to reach for this tool, so write it as if briefing a colleague who has never seen your code. And #[IsReadOnly] is a promise that the tool changes nothing, which we will return to.

The name and title come from the class

By default the tool's name is derived from the class. SearchOrdersTool becomes search-orders, with a title of "Search Orders Tool". Override either with the Name and Title attributes if you want something different:

use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;

#[Name('find-orders')]
#[Title('Find Orders')]
class SearchOrdersTool extends Tool
{
    //
}

Descriptions are never generated for you. A tool with no Description is a tool the model does not know how to use, so always set one.

Return more than plain text

The example above used two response types already. Response::text() sends a string back, and Response::structured() sends a parseable data structure while keeping a text version for clients that want it. There are more:

return Response::error('That order is locked and cannot be changed.');

return Response::fromStorage('invoices/ORD-10423.pdf');

return [
    Response::text('Order found.'),
    Response::structured(['reference' => 'ORD-10423', 'status' => 'shipped']),
];

Response::error() signals that something went wrong, and the model reacts to the message you give it. fromStorage() returns a file from a filesystem disk with the MIME type detected for you. Returning an array sends several pieces of content in one response.

For long jobs, return a generator and yield progress as you go. On a web server this opens a Server-Sent Events stream automatically:

public function handle(Request $request): Generator
{
    $references = $request->array('references');

    foreach ($references as $index => $reference) {
        yield Response::notification('processing/progress', [
            'current' => $index + 1,
            'total' => count($references),
        ]);
    }

    yield Response::text('All orders processed.');
}

Validate arguments, and write errors for the model to read

The JSON schema sets the shape of the input. Laravel's validator enforces the rules, and you call it exactly as you would in a controller:

$validated = $request->validate([
    'reference' => ['required', 'string', 'max:32'],
], [
    'reference.required' => 'Provide the order reference, for example "ORD-10423".',
]);

The difference from a normal form is who reads the error. When validation fails, the message goes back to the model, and the model decides what to do next based on what it says. "The reference field is required" tells it nothing useful. "Provide the order reference, for example ORD-10423" tells it exactly how to retry. Treat error messages as instructions to a reader who will act on them.

Tell the client how a tool behaves

Annotations are metadata that describe a tool's behaviour without changing what it does. They let a client decide how to present a tool, for instance by asking the user to confirm before running anything that makes changes. Add them as attributes:

<?php

namespace App\Mcp\Tools;

use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
use Laravel\Mcp\Server\Tools\Annotations\IsIdempotent;

#[IsDestructive]
#[IsIdempotent]
#[Description('Cancel an order. An order that is already cancelled is left unchanged.')]
class CancelOrderTool extends Tool
{
    public function handle(Request $request): Response
    {
        $validated = $request->validate([
            'reference' => ['required', 'string', 'max:32'],
        ]);

        $order = Order::where('reference', $validated['reference'])->firstOrFail();

        if ($order->status !== 'cancelled') {
            $order->update(['status' => 'cancelled']);
        }

        return Response::text("Order {$order->reference} is cancelled.");
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'reference' => $schema->string()
                ->description('The reference of the order to cancel.')
                ->required(),
        ];
    }
}

The four annotations are #[IsReadOnly] (the tool changes nothing), #[IsDestructive] (it can make destructive changes), #[IsIdempotent] (calling it again with the same arguments has no further effect), and #[IsOpenWorld] (it touches systems outside your application). Cancelling an order is destructive but idempotent: running it twice leaves the order in the same place. Add CancelOrderTool::class to the server's $tools so it can be called.

Inject services into a tool

Tools are resolved through the service container, so type-hint a dependency and it is handed to you. This works in the constructor or directly in handle:

public function handle(Request $request, OrderRepository $orders): Response
{
    $order = $orders->findByReference($request->string('reference'));

    // ...
}

That keeps the tool thin and the data access testable, the same as anywhere else in Laravel.

Add resources and prompts

Tools cover actions. The other two primitives round out what a server offers.

A resource is data the model can read for context. It takes no arguments, just a handle method that returns content. Here is a returns policy the model can ground its answers in:

<?php

namespace App\Mcp\Resources;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Resource;

#[Description('The shop returns and refunds policy.')]
class RefundPolicyResource extends Resource
{
    public function handle(Request $request): Response
    {
        return Response::text(file_get_contents(resource_path('policies/refunds.md')));
    }
}

A prompt is a reusable template the client can offer to the user. It declares its arguments and returns the messages that make up the template:

<?php

namespace App\Mcp\Prompts;

use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Prompt;
use Laravel\Mcp\Server\Prompts\Argument;

#[Description('Draft a short status update to send to a customer about their order.')]
class OrderUpdatePrompt extends Prompt
{
    /**
     * @return array<int, \Laravel\Mcp\Server\Prompts\Argument>
     */
    public function arguments(): array
    {
        return [
            new Argument(
                name: 'reference',
                description: 'The order the update is about.',
                required: true,
            ),
            new Argument(
                name: 'tone',
                description: 'The tone of the message, for example formal or casual.',
                required: false,
            ),
        ];
    }

    /**
     * @return array<int, \Laravel\Mcp\Response>
     */
    public function handle(Request $request): array
    {
        $reference = $request->string('reference');
        $tone = $request->string('tone') ?: 'friendly';

        return [
            Response::text("You write customer service messages in a {$tone} tone.")->asAssistant(),
            Response::text("Draft a short update for the customer about order {$reference}."),
        ];
    }
}

Generate these with make:mcp-resource and make:mcp-prompt, then register them in the server's $resources and $prompts arrays alongside your tools.

Lock down a web server

A web MCP server is a public endpoint that can read your data and change it. Leaving it open is the same mistake as shipping an admin API with no authentication. Because web servers are ordinary routes, you protect them with middleware.

The simplest option is token authentication with Laravel Sanctum. The client sends a token in the Authorization header, and you guard the route:

Mcp::web('/mcp/orders', OrdersServer::class)
    ->middleware(['auth:sanctum', 'throttle:60,1']);

For third-party clients, OAuth 2.1 through Laravel Passport is the sturdier choice. Register the discovery routes and apply Passport's guard:

use Laravel\Mcp\Facades\Mcp;

Mcp::oauthRoutes();

Mcp::web('/mcp/orders', OrdersServer::class)
    ->middleware('auth:api');

Once a user is authenticated, the request carries them through to your tools, so $request->user() works as usual. You can even decide per request whether a tool exists at all by adding a shouldRegister method:

public function shouldRegister(Request $request): bool
{
    return $request->user()?->can('manage-orders') ?? false;
}

A tool whose shouldRegister returns false never appears in the list the client sees and cannot be called. That is how you expose different tools to different users from one server.

Inspect and test it

Two things help you check the server works before a real client touches it.

The MCP Inspector is an interactive tool that connects to a server and lists its tools, resources and prompts so you can call them by hand. Point it at a registered server by name or URI:

php artisan mcp:inspector orders

It prints the client settings to copy into your MCP client, and if the server is behind authentication you supply the header there too.

For automated coverage, write a normal Laravel test and invoke a primitive directly on the server that registers it. The response carries assertion helpers:

<?php

use App\Mcp\Servers\OrdersServer;
use App\Mcp\Tools\CancelOrderTool;
use App\Models\Order;

it('cancels an order', function () {
    $order = Order::factory()->create([
        'reference' => 'ORD-10423',
        'status' => 'shipped',
    ]);

    $response = OrdersServer::tool(CancelOrderTool::class, [
        'reference' => 'ORD-10423',
    ]);

    $response
        ->assertOk()
        ->assertSee('ORD-10423 is cancelled');

    expect($order->fresh()->status)->toBe('cancelled');
});

There are matching assertions for errors and notifications, and an actingAs helper for simulating an authenticated user, so you can test tools that depend on $request->user().

Connecting a client

A web server is now a standard HTTPS endpoint. Any MCP client, Claude included, connects to it with the URL and, if you added auth, a token. A local server is launched by the client instead: it runs php artisan mcp:start behind the scenes when the agent needs it, which is how tools like Claude Code talk to a server living inside your project.

From here the loop is short. Add a tool, give it a clear description and an honest set of annotations, validate its input with messages the model can act on, and put it behind the right middleware. The protocol, the transport and the JSON-RPC plumbing are the package's problem. Your application stays a set of plain Laravel classes.