Laravel MCP es el paquete oficial de Laravel para crear servidores MCP (Model Context Protocol), con los que un agente como Claude Code, Cursor o ChatGPT puede consultar y modificar los datos de tu aplicación a través de las tools que escribimos nosotros. La versión 1.0 salió el 14 de septiembre de 2026, un año después de la primera beta, y trae 2 novedades importantes: es compatible con la última versión del protocolo MCP, la del 28 de julio de 2026, y nos permite agrupar tools en un catálogo con buscador, para que un servidor con muchas tools no llene el contexto del agente.
En esta entrada vamos a crear desde cero un servidor MCP para los pedidos de una tienda, con una tool que el agente ve desde el principio y otras 3 guardadas en un catálogo. Lo probaremos con el inspector y con Claude Code, mediremos lo que ocupa con catálogo y sin él, y escribiremos sus tests. Todo el código está probado, en el momento de crear esta entrada, con Laravel 13.35, PHP 8.5 y Laravel MCP 1.0.1.
Crear el proyecto
Vamos a partir de un proyecto nuevo con Laravel Sail y MySQL:
composer create-project laravel/laravel tienda
cd tienda
composer require laravel/sail --dev
php artisan sail:install --with=mysql
./vendor/bin/sail up -d
Los comandos de Artisan y de Composer de esta entrada se ejecutan en el contenedor de la aplicación, así que abre una terminal en él:
./vendor/bin/sail shell
Los del inspector y los de Claude Code, en cambio, se lanzan en otra terminal de tu máquina, porque se conectan al servidor a través de http://localhost, en el puerto que publica Sail.
Instalar Laravel MCP
Laravel MCP 1.0 necesita PHP 8.2 o superior y funciona en Laravel 11, 12 y 13. Vamos a instalarlo con Composer y a publicar su archivo de rutas:
composer require laravel/mcp:^1.0
php artisan vendor:publish --tag=ai-routes INFO Publishing [ai-routes] assets.
Copying file [vendor/laravel/mcp/routes/ai.php] to [routes/ai.php] .... DONEEn routes/ai.php registraremos los servidores, igual que las rutas web se definen en routes/web.php.
Los pedidos de ejemplo
Para que las tools tengan algo con lo que trabajar, vamos a crear una tabla de pedidos con su modelo:
php artisan make:model Order -m INFO Model [app/Models/Order.php] created successfully.
INFO Migration [database/migrations/2026_10_12_133408_create_orders_table.php] created successfully.Abre la migración que se acaba de crear y cambia su contenido por el siguiente:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('reference')->unique();
$table->string('customer_email')->index();
$table->string('status')->default('pending');
$table->timestamp('shipped_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('orders');
}
};Cada pedido tiene su referencia, el email del cliente, el estado (pending, shipped, delivered o cancelled) y la fecha de envío. En el modelo, app/Models/Order.php, declaramos los campos asignables y el cast de la fecha:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['reference', 'customer_email', 'status', 'shipped_at'])]
class Order extends Model
{
protected function casts(): array
{
return [
'shipped_at' => 'datetime',
];
}
}Ahora vamos a crear un seeder para tener pedidos de prueba:
php artisan make:seeder OrderSeederY en database/seeders/OrderSeeder.php añadimos 5 pedidos de 3 clientes:
<?php
namespace Database\Seeders;
use App\Models\Order;
use Illuminate\Database\Seeder;
class OrderSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$orders = [
['reference' => 'A-1001', 'customer_email' => '[email protected]', 'status' => 'shipped', 'shipped_at' => '2026-10-08 10:00:00'],
['reference' => 'A-1002', 'customer_email' => '[email protected]', 'status' => 'pending', 'shipped_at' => null],
['reference' => 'A-1003', 'customer_email' => '[email protected]', 'status' => 'delivered', 'shipped_at' => '2026-10-01 09:30:00'],
['reference' => 'A-1004', 'customer_email' => '[email protected]', 'status' => 'pending', 'shipped_at' => null],
['reference' => 'A-1005', 'customer_email' => '[email protected]', 'status' => 'cancelled', 'shipped_at' => null],
];
foreach ($orders as $order) {
Order::create($order);
}
}
}Con todo listo, vamos a ejecutar la migración y cargar los pedidos:
php artisan migrate
php artisan db:seed --class=OrderSeederCrear el servidor
El servidor es la clase que agrupa las tools, los resources y los prompts que ve el agente. Vamos a crearlo con Artisan:
php artisan make:mcp-server ShopServerEl comando crea app/Mcp/Servers/ShopServer.php con 3 propiedades vacías, $tools, $resources y $prompts, que completaremos cuando tengamos las tools. Antes, vamos a registrarlo en routes/ai.php como servidor web, para que responda por HTTP en /mcp/shop:
<?php
use App\Mcp\Servers\ShopServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/shop', ShopServer::class);Si el servidor tuviera que arrancarse como un comando en la máquina del desarrollador, como hace Laravel Boost, lo registraríamos con Mcp::local(). Puedes comprobar que la ruta existe con este comando:
php artisan route:list --path=mcp
La salida tiene las 3 rutas del servidor:
GET|HEAD mcp/shop ......... vendor/laravel/mcp/src/Server/Registrar.php:45
DELETE mcp/shop ......... vendor/laravel/mcp/src/Server/Registrar.php:47
POST mcp/shop ......... vendor/laravel/mcp/src/Server/Registrar.php:49
Showing [3] routesLa primera tool
Vamos a crear la tool que más va a usar el agente, la que consulta el estado de un pedido:
php artisan make:mcp-tool OrderStatusToolSustituye el contenido de app/Mcp/Tools/OrderStatusTool.php por el siguiente:
<?php
namespace App\Mcp\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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;
#[Description('Devuelve el estado de un pedido a partir de su referencia, por ejemplo A-1001.')]
#[IsReadOnly]
class OrderStatusTool extends Tool
{
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$validated = $request->validate([
'reference' => ['required', 'string', 'max:20'],
]);
$order = Order::firstWhere('reference', $validated['reference']);
if (! $order) {
return Response::error("No existe ningún pedido con la referencia {$validated['reference']}.");
}
return Response::json([
'reference' => $order->reference,
'status' => $order->status,
'shipped_at' => $order->shipped_at?->toDateString(),
]);
}
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'reference' => $schema->string()
->description('Referencia del pedido, por ejemplo A-1001.')
->required(),
];
}
}Si te fijas, la tool tiene 3 partes. La descripción del atributo #[Description] es lo que lee el agente para decidir cuándo usarla, así que tiene que explicar qué hace la tool de la forma más clara posible. El método schema() define los argumentos con JSON Schema, y handle() los valida con las reglas de validación de Laravel y responde con Response::json() o, si el pedido no existe, con Response::error(), que el agente recibe como un error que puede corregir. Por último, #[IsReadOnly] le dice al cliente que la tool no cambia nada.
El nombre de la tool sale de la clase en kebab-case: order-status-tool. La documentación dice que se quita el sufijo Tool (que CurrentWeatherTool se llama current-weather), pero en la 1.0.1 no es así. Si quieres otro nombre, lo indicas con el atributo #[Name].
Las tools del catálogo
Vamos a añadir 3 tools más: los pedidos de un cliente, la cancelación de un pedido y un informe por estado. Primero la de los pedidos de un cliente:
php artisan make:mcp-tool CustomerOrdersToolCon este contenido en app/Mcp/Tools/CustomerOrdersTool.php:
<?php
namespace App\Mcp\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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;
#[Description('Lista los pedidos de un cliente a partir de su email.')]
#[IsReadOnly]
class CustomerOrdersTool extends Tool
{
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$validated = $request->validate([
'customer_email' => ['required', 'email'],
]);
$orders = Order::where('customer_email', $validated['customer_email'])
->orderBy('reference')
->get(['reference', 'status']);
return Response::json($orders);
}
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'customer_email' => $schema->string()
->description('Email del cliente.')
->required(),
];
}
}Ahora la de cancelar:
php artisan make:mcp-tool CancelOrderToolEn app/Mcp/Tools/CancelOrderTool.php solo cancelamos los pedidos que siguen pendientes:
<?php
namespace App\Mcp\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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;
#[Description('Cancela un pedido que todavía no se ha enviado.')]
#[IsDestructive]
class CancelOrderTool extends Tool
{
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$validated = $request->validate([
'reference' => ['required', 'string', 'max:20'],
]);
$order = Order::firstWhere('reference', $validated['reference']);
if ($order?->status !== 'pending') {
return Response::error("El pedido {$validated['reference']} no existe o ya no se puede cancelar.");
}
$order->update(['status' => 'cancelled']);
return Response::text("Pedido {$order->reference} cancelado.");
}
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'reference' => $schema->string()
->description('Referencia del pedido que se cancela.')
->required(),
];
}
}En este caso la anotación es #[IsDestructive], que avisa al cliente de que la tool cambia datos para que lo tenga en cuenta, por ejemplo pidiendo confirmación antes de ejecutarla. Y por último, el informe:
php artisan make:mcp-tool OrdersReportToolEn app/Mcp/Tools/OrdersReportTool.php contamos los pedidos de cada estado. No necesita argumentos, así que schema() devuelve un array vacío:
<?php
namespace App\Mcp\Tools;
use App\Models\Order;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
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;
#[Description('Informe con el número de pedidos de la tienda en cada estado.')]
#[IsReadOnly]
class OrdersReportTool extends Tool
{
/**
* Handle the tool request.
*/
public function handle(Request $request): Response
{
$totals = Order::selectRaw('status, count(*) as total')
->groupBy('status')
->orderBy('status')
->pluck('total', 'status');
return Response::json($totals);
}
/**
* Get the tool's input schema.
*
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [];
}
}Agrupar tools en un catálogo con ToolSearch
Con 4 tools, lo normal sería registrarlas todas en $tools, y el agente recibiría la definición completa de cada una (nombre, descripción y esquema) antes de empezar a trabajar. Con un catálogo, el servidor le enseña 2 tools en su lugar, search_tools para buscar en el catálogo y execute_tools para ejecutar lo que encuentra, y el agente pide la definición de las demás cuando las necesita. Los catálogos llegaron en la 0.9.4, en agosto de 2026, y la 1.0 los mantiene.
Abre app/Mcp/Servers/ShopServer.php y déjalo de la siguiente forma:
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\CancelOrderTool;
use App\Mcp\Tools\CustomerOrdersTool;
use App\Mcp\Tools\OrdersReportTool;
use App\Mcp\Tools\OrderStatusTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
use Laravel\Mcp\Server\Tools\ToolSearch;
#[Name('Tienda')]
#[Version('1.0.0')]
#[Instructions('Consulta y gestiona los pedidos de la tienda. Para el resto de operaciones, busca primero con search_tools.')]
class ShopServer extends Server
{
protected array $tools = [
OrderStatusTool::class,
ToolSearch::class => [
CustomerOrdersTool::class,
CancelOrderTool::class,
OrdersReportTool::class,
],
];
protected array $resources = [
//
];
protected array $prompts = [
//
];
}Lo único que hacemos es usar ToolSearch::class como clave de $tools, con las tools del catálogo como valor. OrderStatusTool se queda fuera porque es la que el agente usará en casi todas las conversaciones, y buscarla cada vez sería un paso de más. Las instrucciones del servidor, que el cliente recibe al conectarse, le recuerdan al agente que el resto de operaciones están en el catálogo.
execute_tools admite como mucho 10 llamadas por lote y una respuesta de 65.536 bytes. Son las claves tool_search.max_tool_calls y tool_search.max_output_bytes de config/mcp.php. Si quieres cambiarlas, publica antes la configuración:
php artisan vendor:publish --tag=mcp-configProbar el servidor con el inspector
Laravel MCP trae el comando php artisan mcp:inspector mcp/shop, que abre el MCP Inspector oficial en el navegador. Con Laravel Sail, el comando lo arranca dentro del contenedor, en 127.0.0.1:6274, y como Sail no publica ese puerto, desde el navegador no llegas a él. Lo más directo es lanzar el inspector desde tu máquina con npx, que además tiene un modo para la terminal, --cli. Necesitas Node y jq. Los comandos fijan la versión 2.10.1 del inspector, la que hemos probado: con otras, los mensajes de error pueden cambiar. Vamos a pedirle la lista de tools:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/list | jq -r '.tools[].name'order-status-tool
search_tools
execute_toolsEl agente solo ve 3 tools: la del estado del pedido y las 2 del catálogo. Las otras 3 existen, pero hay que buscarlas. Vamos a buscar la de cancelar:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/call --tool-name search_tools --tool-arg query=cancel \
| jq -r '.content[0].text | fromjson'{
"ok": true,
"tools": [
{
"name": "cancel-order-tool",
"description": "Cancela un pedido que todavía no se ha enviado.",
"inputSchema": {
"properties": {
"reference": {
"description": "Referencia del pedido que se cancela.",
"type": "string"
}
},
"type": "object",
"required": [
"reference"
]
},
"annotations": {
"destructiveHint": true
}
}
],
"hasMore": false
}La tool devuelve su resultado como texto, y con jq lo convertimos en JSON para leerlo mejor. Trae el nombre exacto de la tool, su descripción y su esquema completo, que es todo lo que el agente necesita para llamarla.
La búsqueda es por palabras
Ahora prueba la misma búsqueda con query=cancelar:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/call --tool-name search_tools --tool-arg query=cancelar \
| jq -r '.content[0].text | fromjson'{
"ok": true,
"tools": [],
"hasMore": false
}No encuentra nada. search_tools no usa embeddings ni entiende de significados: separa la consulta en palabras y puntúa cada tool según dónde aparece cada palabra. Da 4 puntos si está en el nombre, 2 si está en la descripción y 1 si está en el esquema de los argumentos, y suma 8 más si las palabras de la consulta son justo las del nombre. Cada palabra tiene que aparecer tal cual, y "cancelar" no está ni en cancel-order-tool ni en "Cancela un pedido que todavía no se ha enviado". Con "cliente" sí encuentra la de los pedidos del cliente, porque está en su descripción.
La solución es escribir las descripciones con las palabras que va a usar quien busca. En este caso basta con el infinitivo, porque "cancelar" contiene "cancela" y "cancel". Cambia el atributo de CancelOrderTool:
#[Description('Cancelar un pedido que todavía no se ha enviado.')]Si repites las 2 búsquedas, ahora tanto query=cancel como query=cancelar devuelven cancel-order-tool. Y si el agente no encuentra lo que busca, siempre puede llamar a search_tools con la consulta vacía, que devuelve el catálogo entero. La propia descripción de search_tools se lo indica.
Ejecutar varias tools en una llamada
Con los nombres que devuelve la búsqueda, el agente llama a execute_tools con una lista de llamadas, que se ejecutan en orden. Vamos a pedir los pedidos de un cliente y el informe en la misma llamada:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/call --tool-name execute_tools \
--tool-arg 'calls=[{"name":"customer-orders-tool","arguments":{"customer_email":"[email protected]"}},{"name":"orders-report-tool"}]' \
| jq -r '.content[0].text | fromjson'{
"ok": true,
"results": [
{
"name": "customer-orders-tool",
"content": [
{
"type": "text",
"text": "[{\"reference\":\"A-1001\",\"status\":\"shipped\"},{\"reference\":\"A-1002\",\"status\":\"pending\"}]"
}
],
"isError": false
},
{
"name": "orders-report-tool",
"content": [
{
"type": "text",
"text": "{\"cancelled\":1,\"delivered\":1,\"pending\":2,\"shipped\":1}"
}
],
"isError": false
}
]
}Cada llamada pasa por el mismo camino que un tools/call normal, con su validación y su shouldRegister(), así que una tool que un usuario no puede ver tampoco la puede ejecutar desde el catálogo. Las llamadas son independientes, sin forma de usar el resultado de una en la siguiente, y en cuanto una falla, se paran las demás. Por ejemplo, si intentamos cancelar un pedido que ya se ha enviado antes de pedir el informe:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/call --tool-name execute_tools \
--tool-arg 'calls=[{"name":"cancel-order-tool","arguments":{"reference":"A-1001"}},{"name":"orders-report-tool"}]' \
| jq -r '.content[0].text | fromjson'{
"ok": false,
"results": [
{
"name": "cancel-order-tool",
"content": [
{
"type": "text",
"text": "El pedido A-1001 no existe o ya no se puede cancelar."
}
],
"isError": true
}
]
}El informe ni se intenta, y la respuesta llega marcada con isError: true para que el agente lo sepa. Por eso el inspector, además del JSON, escribe en la terminal este error y termina con el código de salida 5:
{"error":{"code":"tool_is_error","message":"Tool 'execute_tools' returned isError:true."}}Lo que el agente no puede hacer es saltarse el catálogo. El inspector ni siquiera lo intenta, porque cancel-order-tool no está en la lista, así que vamos a llamarla directamente con tools/call con curl:
curl -s -X POST http://localhost/mcp/shop \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"cancel-order-tool","arguments":{"reference":"A-1002"}}}'{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Tool [cancel-order-tool] not found."}}El servidor responde con el error -32602: para él, esa tool no existe fuera del catálogo.
Cuándo compensa el catálogo
El catálogo tiene un coste, porque search_tools y execute_tools también llevan su descripción y su esquema. Vamos a medir la respuesta de tools/list de nuestro servidor, en bytes de JSON compacto, primero con las 4 tools a la vista del agente. Abre app/Mcp/Servers/ShopServer.php y cambia temporalmente $tools por la lista sin catálogo:
protected array $tools = [
OrderStatusTool::class,
CustomerOrdersTool::class,
CancelOrderTool::class,
OrdersReportTool::class,
];Y mide la respuesta:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/list | jq -jc . | wc -cDespués, vuelve a dejar $tools con el catálogo, como estaba, y repite la medición. Para ver lo que ocupa cada tool, cambia el final del comando por este:
npx @modelcontextprotocol/[email protected] --cli http://localhost/mcp/shop --transport http \
--method tools/list | jq -r '.tools[] | "\(tojson | utf8bytelength) \(.name)"'Estos son los resultados:
Con las 4 tools a la vista del agente: 1.216 bytes.
Con
OrderStatusToola la vista y las otras 3 en el catálogo: 1.790 bytes.
Con nuestras 4 tools, el catálogo ocupa más que las tools que esconde. Las 3 del catálogo suman 853 bytes, y search_tools y execute_tools, 1.428 bytes entre las 2, un coste fijo que no crece por muchas tools que añadas al catálogo. Haciendo la cuenta con tools como las nuestras, de unos 285 bytes de media, el catálogo empieza a ahorrar cuando pasa de 5 tools, y con tools reales, con más argumentos y descripciones más largas, empieza a ahorrar antes. A cambio, el agente necesita una llamada más para buscar.
El autor del paquete, Pushpak Chhajed, lo explica en A better way to build MCP servers with Laravel, en el blog de Laravel: en su medición, con 10 tools el catálogo ya recorta un 75 % la respuesta de tools/list, y su regla es que, a partir de 10 o 15 tools, enseñárselas todas al agente tiene que estar justificado.
Además, depende del cliente. Claude Code ya aplaza las definiciones de las tools MCP con su propio tool search, que viene activado por defecto y que explicamos en la lección Qué es MCP y cómo conectar servidores a Claude Code del curso de Claude Code, así que ahí el ahorro es menor. El catálogo de Laravel MCP funciona en el servidor y vale para cualquier cliente, también para los que cargan todas las definiciones al empezar.
Conectar el servidor a Claude Code
Con el servidor funcionando, conectarlo a Claude Code es un comando, y otro para comprobar que responde:
claude mcp add --transport http tienda http://localhost/mcp/shop
claude mcp listChecking MCP server health…
tienda: http://localhost/mcp/shop (HTTP) - ✔ ConnectedClaude Code ya es compatible con la versión del protocolo de julio de 2026. En nuestra prueba, con la versión 2.1.283, se conecta con server/discover y manda las cabeceras nuevas del protocolo en cada petición. A partir de aquí, en una sesión de Claude Code el agente tiene las 3 tools del servidor y llega al resto a través del catálogo.
Vamos a comprobarlo con una petición que necesita una tool escondida:
claude -p "Cancela el pedido A-1002 de la tienda y dime después qué pedidos tiene [email protected] y en qué estado." \
--allowedTools "mcp__tienda"Con -p, Claude Code no pregunta antes de usar una tool, así que solo puede usar las que permitas. --allowedTools "mcp__tienda" permite todas las del servidor tienda; sin esa opción, el agente no puede usarlas. Si te fijas en las tools que usa el agente, primero busca en el catálogo con search_tools y la consulta cancelar pedido, luego busca la de los pedidos de un cliente y después ejecuta cancel-order-tool y customer-orders-tool a través de execute_tools. Ninguna de esas 2 estaba en la lista inicial, y aun así el agente las ha encontrado y usado por su cuenta:
El pedido A-1002 ha quedado cancelado.
Pedidos de [email protected]:
- **A-1001**: enviado (shipped)
- **A-1002**: canceladoAquí se ve por qué importaba la descripción en infinitivo: la búsqueda del agente fue cancelar pedido, y con la descripción antigua no habría encontrado nada.
Este servidor no tiene autenticación y cancela pedidos, así que es solo para trabajar en local. Antes de publicar uno, protege su ruta con un middleware de autenticación, como harías con una API: la documentación de Laravel MCP explica cómo hacerlo con Sanctum o con OAuth y Passport.
Tests del servidor
Laravel MCP permite probar el servidor sin peticiones HTTP, llamando a sus tools desde los tests. Vamos a crear uno:
php artisan make:test ShopServerTestSustituye el contenido de tests/Feature/ShopServerTest.php por el siguiente:
<?php
namespace Tests\Feature;
use App\Mcp\Servers\ShopServer;
use App\Mcp\Tools\CancelOrderTool;
use App\Mcp\Tools\CustomerOrdersTool;
use App\Mcp\Tools\OrdersReportTool;
use App\Mcp\Tools\OrderStatusTool;
use Database\Seeders\OrderSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Mcp\Server\Testing\TestResponse;
use Laravel\Mcp\Server\Tools\ExecuteTools;
use Laravel\Mcp\Server\Tools\ToolSearch;
use Tests\TestCase;
class ShopServerTest extends TestCase
{
use RefreshDatabase;
protected string $seeder = OrderSeeder::class;
protected bool $seed = true;
public function test_only_order_status_is_advertised(): void
{
ShopServer::tools()
->assertRegistered(OrderStatusTool::class)
->assertNotRegistered([
CustomerOrdersTool::class,
CancelOrderTool::class,
OrdersReportTool::class,
]);
}
public function test_it_cancels_a_pending_order(): void
{
$this->executeFromCatalog('cancel-order-tool', ['reference' => 'A-1002'])
->assertOk()
->assertSee('Pedido A-1002 cancelado.');
$this->assertDatabaseHas('orders', ['reference' => 'A-1002', 'status' => 'cancelled']);
}
public function test_it_does_not_cancel_a_shipped_order(): void
{
$this->executeFromCatalog('cancel-order-tool', ['reference' => 'A-1001'])
->assertHasErrors();
$this->assertDatabaseHas('orders', ['reference' => 'A-1001', 'status' => 'shipped']);
}
private function executeFromCatalog(string $tool, array $arguments): TestResponse
{
$executeTools = new ExecuteTools(new ToolSearch([]), 1);
return ShopServer::tool($executeTools, [
'calls' => [['name' => $tool, 'arguments' => $arguments]],
]);
}
}El primer test usa assertRegistered() y assertNotRegistered(), que llegan en la 1.0, para comprobar que el agente solo ve OrderStatusTool y que las otras 3 están en el catálogo.
Para los otros 2 hay un detalle que no cuenta la documentación: ShopServer::tool(CancelOrderTool::class, [...]) falla con Tool [cancel-order-tool] not found., porque el servidor solo encuentra las tools que están a la vista del agente. Las del catálogo se prueban como las usa el agente, a través de execute_tools, y para eso está el método executeFromCatalog(). Este método crea una instancia de ExecuteTools solo para que el test sepa a qué tool llamar, y el servidor la ejecuta con su catálogo real, igual que en las pruebas con el inspector. Vamos a ejecutar los tests:
php artisan test --filter=ShopServerTest PASS Tests\Feature\ShopServerTest
✓ only order status is advertised 0.67s
✓ it cancels a pending order 0.02s
✓ it does not cancel a shipped order 0.01s
Tests: 3 passed (11 assertions)
Duration: 0.79sSi vienes de la beta
Si ya tenías un servidor con la 0.x, estos son los cambios de la guía de actualización que más te pueden afectar:
La dependencia pasa a
"laravel/mcp": "^1.0". Laravel Boost la acepta desde su versión 2.9, y el Laravel AI SDK 1.x no se instala junto a una versión de Laravel MCP anterior a la 1.0.La conexión ya no se abre con
initialize, sino conserver/discover, y cada petición lleva la versión del protocolo en_meta. Tus clases de tools, resources y prompts no cambian, y los clientes que todavía abren coninitializesiguen funcionando: el servidor les responde con la versión del protocolo que piden, la de noviembre o la de junio de 2025, y les sirve las tools sin pedir cabeceras.Los tests que llaman al servidor por HTTP con
postJson()y mandan la versión del protocolo en_metatienen que añadir las cabecerasMCP-Protocol-VersionyMcp-Method, y ademásMcp-Nameentools/call,prompts/getyresources/read. Si falta alguna, la respuesta es un 400 con el error-32020:Header mismatch: The [MCP-Protocol-Version] header is required.Desaparecen
Request::sessionId()y el eventoSessionInitialized, sin sustituto, porque ahora cada petición se procesa por separado.La 1.0.1, del 24 de septiembre de 2026, corrige un aviso de seguridad de gravedad baja en la validación de las URL de redirección de OAuth (GHSA-mx2h-h55v-pm44). En la rama 0.x, la corrección está en la 0.9.6.
Si quieres que tus tools devuelvan interfaces HTML, tienes la entrada Laravel MCP Apps. Y si quieres seguir aprendiendo a crear servidores MCP con Laravel para Claude Code, no te pierdas el curso Asistente de Voz para Desarrolladores con Laravel MCP Server y Claude Code. Espero que te sirva.
En este vídeo, grabado con la beta de Laravel MCP en septiembre de 2025, gestiono proyectos y tareas hablando con Claude Code: consulto los proyectos activos, creo tareas urgentes con fecha límite y actualizo su estado. El paquete ha cambiado mucho desde entonces, pero la idea es la misma: tools escritas en PHP que el agente usa por su cuenta.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción