Introducción al Protocolo MCP en Laravel e Integración con Agentes de IA

- Andrés Cruz - EN In english

Video thumbnail

En esta sección abordaremos una introducción al Model Context Protocol (MCP) en Laravel. Aunque utilizaremos este framework como base, al tratarse de un estándar abierto, los conceptos fundamentales se pueden aplicar a cualquier otra tecnología, como FastAPI. El ecosistema de desarrollo incluye múltiples componentes estructurados que se pueden revisar para profundizar en el flujo de trabajo.

Para comenzar, es necesario definir qué es el protocolo MCP. 

¿Qué es un MCP?

Se trata de un estándar abierto creado inicialmente por las organizaciones Anthropic y Cloudflare [nota: corregido de erratas originales], diseñado para conectar aplicaciones de inteligencia artificial y modelos de lenguaje (LLMs) con fuentes de datos externas, bases de datos, archivos y sistemas operativos.

Podemos visualizar el protocolo MCP como una especie de API o conector especializado. Su propósito principal es permitir que los LLMs y los agentes de inteligencia artificial que utilizamos al programar —por ejemplo, mediante entornos como OpenCode— interactúen directamente con nuestra aplicación en Laravel para recuperar información en tiempo real.

De no existir este conector, la alternativa tradicional obligaría a conectar los agentes de IA de forma manual a la base de datos o a scripts aislados. Sin embargo, dicha conexión directa suele ser insuficiente: un proyecto en Laravel procesa reglas de negocio complejas, aplica formatos específicos a los datos (como transformaciones en las fechas) y ejecuta validaciones que la IA desconoce por completo.

Al integrar un servidor MCP propio en la aplicación, logramos que el LLM obtenga los datos exactamente tal como los maneja el proyecto, respetando la lógica de negocio subyacente.

Al final del post, tienes los comandos de artisan y composer usados.

Arquitectura General y Componentes de un Servidor MCP

Para implementar un servidor MCP en Laravel, disponemos de una serie de comandos de Artisan que facilitan la creación estructurada de todos los elementos necesarios. La arquitectura general gira en torno a un contenedor principal conocido como Servidor, el cual actúa como un envoltorio (*wrapper*) que agrupa e integra los distintos componentes:

  • Tools (Herramientas): Son funciones ejecutables que permiten realizar operaciones específicas dentro de la aplicación, como consultar listados, buscar elementos o recuperar detalles concretos a partir de parámetros.
  • Resources (Recursos): Mecanismos orientados a estructurar y exponer información general del entorno o la aplicación con un formato limpio y predecible.
  • Prompts (Plantillas): Estructuras reutilizables acompañadas de argumentos opcionales, diseñadas para guiar el comportamiento y el tono del LLM al interactuar con el sistema.

Una vez creados los componentes, es necesario registrarlos adecuadamente y configurar las rutas de acceso en el archivo correspondiente (por ejemplo, routes/ai.php), permitiendo exponer el servidor tanto mediante peticiones HTTP remotas como de forma local a través de la terminal.

Implementación del Servidor Principal

El servidor principal extiende de la clase base del paquete de MCP y centraliza el registro de las herramientas, recursos y plantillas disponibles. Utiliza atributos de PHP para definir su nombre, versión e instrucciones descriptivas que el LLM interpretará automáticamente:

namespace App\Mcp\Servers;

use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;

#[Name('Post Server')]
#[Version('1.0.0')]
#[Instructions('Este servidor expone los posts del blog: puede listarlos, buscar uno por su ID y leer información general de la aplicación.')]
class PostServer extends Server
{
    protected array $tools = [
        ListPostsTool::class,
        GetPostTool::class,
    ];

    protected array $resources = [
        AppInfoResource::class,
    ];

    protected array $prompts = [
        SummarizePostsPrompt::class,
    ];
}

Puntos clave de una tool:

  • #[Description] es el texto que lee el modelo para decidir si llama a la tool. Si es vago, la tool no se usa. Es lo más importante.
  • schema() define los argumentos y sus tipos. Laravel lo convierte a JSON Schema y lo publica automáticamente en tools/list. No hace falta escribir el JSON a mano.
  • $request->validate() usa las validaciones normales de Laravel. Los mensajes personalizados se leen tal cual en el cliente.
  • Response::structured() devuelve JSON estructurado (el modelo lo parsea mejor que texto libre). Por eso el retorno es Response|ResponseFactory: structured() devuelve un ResponseFactory, no un Response. Si declaras solo : Response obtienes un TypeError en tiempo de ejecución.

Desarrollo de Herramientas (Tools) y Validación de Parámetros

Las herramientas representan la capacidad ejecutiva del modelo dentro de nuestra aplicación. Un aspecto fundamental de su diseño es el uso de metainformación y esquemas de entrada. Las propiedades descriptivas y los atributos permiten que la IA comprenda el propósito de la herramienta y decida cuándo utilizarla en función de la consulta del usuario.

A continuación se muestra la implementación completa de una herramienta para listar y filtrar publicaciones con paginación:

namespace App\Mcp\Tools;

use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Lista los posts del blog con paginación y filtro opcional por texto (título o contenido).')]
class ListPostsTool extends Tool
{
    public function handle(Request $request): Response|ResponseFactory
    {
        $validated = $request->validate([
            'search' => ['nullable', 'string', 'max:100'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
        ], [
            'per_page.min' => 'per_page debe ser al menos 1.',
            'per_page.max' => 'per_page no puede ser mayor a 50.',
        ]);

        $perPage = (int) ($validated['per_page'] ?? 10);
        $query = Post::query()->latest();

        if ($search = $validated['search'] ?? null) {
            $query->where(function ($query) use ($search) {
                $query->where('title', 'like', "%{$search}%")
                    ->orWhere('content', 'like', "%{$search}%");
            });
        }

        $posts = $query->paginate($perPage);

        if ($posts->isEmpty()) {
            return Response::text('No se encontraron posts.');
        }

        $summary = $posts->getCollection()->map(fn (Post $post) => [
            'id' => $post->id,
            'title' => $post->title,
            'posted' => $post->isPublished(),
            'created_at' => $post->created_at?->toDateTimeString(),
        ])->all();

        $header = sprintf(
            'Mostrando %d de %d posts (página %d de %d).',
            $posts->count(),
            $posts->total(),
            $posts->currentPage(),
            $posts->lastPage(),
        );

        return Response::structured([
            'summary' => $header,
            'total' => $posts->total(),
            'page' => $posts->currentPage(),
            'last_page' => $posts->lastPage(),
            'posts' => $summary,
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'search' => $schema->string()
                ->description('Texto a buscar en el título o contenido. Opcional.'),
            'per_page' => $schema->integer()
                ->description('Cantidad de posts por página (1-50). Por defecto 10.'),
        ];
    }
}

De forma complementaria, podemos implementar herramientas orientadas a recuperar registros individuales mediante identificadores específicos, aplicando validaciones estrictas y devolviendo mensajes de error controlados si el recurso no existe en la base de datos:

namespace App\Mcp\Tools;

use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Obtiene el contenido completo de un post del blog a partir de su ID.')]
class GetPostTool extends Tool
{
    public function handle(Request $request): Response|ResponseFactory
    {
        $validated = $request->validate([
            'id' => ['required', 'integer', 'min:1'],
        ], [
            'id.required' => 'Debes indicar el ID del post.',
            'id.integer' => 'El ID del post debe ser un número entero.',
        ]);

        $post = Post::find($validated['id']);

        if (! $post) {
            return Response::error("El post con ID {$validated['id']} no existe.");
        }

        return Response::structured([
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
            'description' => $post->description,
            'content' => $post->content,
            'posted' => $post->isPublished(),
            'created_at' => $post->created_at?->toDateTimeString(),
        ]);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'id' => $schema->integer()
                ->description('El ID numérico del post a obtener.')
                ->required(),
        ];
    }
}

Response::error() marca la respuesta como error (isError: true) en lugar de lanzar una excepción. Es la forma correcta de decirle al modelo "esto no funcionó, inténtalo de otra forma": el error le llega como texto y puede reaccionar, en vez de tumbar la conexión.

Exposición de Recursos y Plantillas de Prompts

Los recursos permiten exponer datos estáticos o de configuración del sistema bajo URIs personalizadas. Resultan ideales para proveer contexto general sobre el estado de la aplicación:

namespace App\Mcp\Resources;

use App\Models\Post;
use App\Models\User;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\MimeType;
use Laravel\Mcp\Server\Attributes\Uri;
use Laravel\Mcp\Server\Resource;

#[Description('Información general de la aplicación: nombre, entorno y estadísticas del blog.')]
#[Uri('app://info')]
#[MimeType('application/json')]
class AppInfoResource extends Resource
{
    public function handle(Request $request): Response
    {
        return Response::json([
            'name' => config('app.name'),
            'environment' => config('app.env'),
            'stats' => [
                'posts' => Post::count(),
                'published_posts' => Post::published()->count(),
                'users' => User::count(),
            ],
        ]);
    }
}

Los resources usan URIs (app://info) en vez de nombres. Son contexto que el cliente puede leer bajo demanda. Si el dato cambia solo, conviene marcarlo como no cacheable; si es estático, se puede cachear.

Por otro lado, los prompts proporcionan estructuras reutilizables con argumentos que moldean la directriz que recibirá el modelo al procesar información:

namespace App\Mcp\Prompts;

use App\Models\Post;
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('Genera un prompt para resumir los posts más relevantes del blog.')]
class SummarizePostsPrompt extends Prompt
{
    public function handle(Request $request): array
    {
        $tone = $request->string('tone')->toString() ?: 'neutral';
        $limit = (int) ($request->integer('limit') ?: 5);

        $titles = Post::query()
            ->published()
            ->latest()
            ->limit($limit)
            ->pluck('title')
            ->implode(', ');

        return [
            Response::text("Eres un redactor experto. Resume los siguientes posts del blog en un tono {$tone}.")->asAssistant(),
            Response::text("Posts a resumir: [{$titles}]"),
        ];
    }

    public function arguments(): array
    {
        return [
            new Argument(
                name: 'tone',
                description: 'El tono del resumen (formal, casual, técnico, etc.). Por defecto "neutral".',
                required: false,
            ),
            new Argument(
                name: 'limit',
                description: 'Cantidad de posts a considerar. Por defecto 5.',
                required: false,
            ),
        ];
    }
}

Un prompt puede devolver varios mensajes. El que lleva ->asAssistant() se presenta como mensaje del asistente (instrucciones de sistema para la conversación) y el resto como mensaje del usuario.

Registro de Rutas y Pruebas Unitarias

Para finalizar la configuración, registramos los canales de comunicación del servidor en el archivo de rutas de IA:

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

/*
|--------------------------------------------------------------------------
| Rutas MCP
|--------------------------------------------------------------------------
| Servidor accesible por HTTP (clientes remotos como Claude Desktop, Cursor, etc.).
*/
Mcp::web('/mcp/posts', PostServer::class);

/*
|--------------------------------------------------------------------------
| Servidor local
|--------------------------------------------------------------------------
| Se ejecuta como comando Artisan: php artisan mcp:start posts
*/
Mcp::local('posts', PostServer::class);

Finalmente, podemos asegurar el correcto funcionamiento del servidor mediante pruebas automatizadas que validen las herramientas, recursos y filtros de contenido:

use App\Mcp\Prompts\SummarizePostsPrompt;
use App\Mcp\Resources\AppInfoResource;
use App\Mcp\Servers\PostServer;
use App\Mcp\Tools\GetPostTool;
use App\Mcp\Tools\ListPostsTool;
use App\Models\Post;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;

beforeEach(function () {
    Artisan::call('migrate:fresh', [
        '--force' => true,
        '--realpath' => true,
        '--path' => [
            database_path('migrations/0001_01_01_000000_create_users_table.php'),
            database_path('migrations/2026_09_26_093945_create_categories_table.php'),
            database_path('migrations/2026_09_26_094316_create_posts_table.php'),
            database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
        ],
    ]);
});

function createPost(array $attributes = []): Post
{
    $user = User::first() ?? User::factory()->create();
    $categoryId = DB::table('categories')->value('id')
        ?? DB::table('categories')->insertGetId(['title' => 'General', 'slug' => 'general']);

    return Post::create(array_merge([
        'title' => 'Primer post',
        'slug' => 'primer-post',
        'description' => 'Una descripción',
        'content' => 'Contenido completo del post',
        'posted' => true,
        'category_id' => $categoryId,
        'user_id' => $user->id,
    ], $attributes));
}

it('lists posts through the list posts tool', function () {
    createPost(['title' => 'Laravel MCP']);
    createPost(['title' => 'Otro post', 'slug' => 'otro-post']);

    $response = PostServer::tool(ListPostsTool::class, ['per_page' => 10]);

    $response
        ->assertOk()
        ->assertSee('Laravel MCP')
        ->assertSee('Otro post');
});

it('filters posts by search term', function () {
    createPost(['title' => 'Introducción a MCP']);
    createPost(['title' => 'Recipes', 'slug' => 'recipes']);

    $response = PostServer::tool(ListPostsTool::class, ['search' => 'MCP']);

    $response
        ->assertOk()
        ->assertSee('Introducción a MCP')
        ->assertDontSee('Recipes');
});

La sintaxis de test es limpia: se invoca la primitiva directamente sobre el servidor y se encadena assertOk(), assertSee(), assertHasErrors(), assertHasNoErrors(), assertName(), assertDescription(). También existe ->actingAs($user) para probar autorización.

Qué se creó

app/Mcp/
├── Servers/
│   └── PostServer.php        Servidor: registra tools, resources y prompts
├── Tools/
│   ├── ListPostsTool.php     Lista posts (paginación + búsqueda)
│   └── GetPostTool.php       Obtiene un post por ID
├── Resources/
│   └── AppInfoResource.php   Resource app://info (stats de la app)
└── Prompts/
   └── SummarizePostsPrompt.php  Plantilla de prompt con argumentos
routes/ai.php                  Registra el servidor web y el local
tests/Feature/Mcp/
└── PostServerTest.php        8 tests Pest (todos pasan)

Comandos usados

$ composer require laravel/mcp
$ php artisan vendor:publish --tag=ai-routes
$ php artisan make:mcp-server PostServer
$ php artisan make:mcp-tool ListPostsTool
$ php artisan make:mcp-tool GetPostTool
$ php artisan make:mcp-resource AppInfoResource
$ php artisan make:mcp-prompt SummarizePostsPrompt
$ php artisan make:test --pest Mcp/PostServerTest

Cómo se usa

Con el MCP Inspector (lo más rápido para debuggear)

php artisan mcp:inspector mcp/posts   # servidor web
php artisan mcp:inspector posts       # servidor local

Levanta el Inspector de la tarde oficial, imprime la configuración de conexión y permite listar e invocar tools, resources y prompts a mano. Ideal para escribir la tool antes de conectarla a un cliente real.

Servidor local (línea de comandos)

php artisan mcp:start posts

Cada cliente de IA lanza este comando él mismo. Configuración típica:

{
  "mcpServers": {
    "laravel-posts": {
      "command": "php",
      "args": ["artisan", "mcp:start", "posts"],
      "cwd": "/Users/andrescruz/Herd/larapackage"
    }
  }
}

Servidor web (HTTP)

{
  "mcpServers": {
    "laravel-posts": {
      "url": "http://larapackage.test/mcp/posts"
    }
  }
}

http://larapackage.test es el dominio de Herd de este proyecto. Desde otro dispositivo o en producción hay que usar el dominio real y servir por HTTPS.

Probarlo desde Tinker (sin cliente)

El propio paquete trae un cliente MCP, así que puedes llamar a tu propio server desde código o desde tinker. Es la forma más rápida de comprobar la salida real:

php artisan tinker --execute '
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);

$c->tools();                                 // lista las tools
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
$c->callTool("get-post-tool",  ["id" => 1])->text();
$c->readResource("app://info")->content();
$c->getPrompt("summarize-posts-prompt", ["tone" => "formal"])->text();
'

Salida real verificada:

list-posts-tool | Lista los posts del blog con paginación y filtro opcional...
get-post-tool   | Obtiene el contenido completo de un post del blog...

{"summary":"Mostrando 3 de 30 posts (página 1 de 10).","total":30,
 "page":1,"last_page":10,"posts":[...]}

{"name":"Laravel","environment":"local","locale":"en",
 "stats":{"posts":30,"published_posts":16,"users":3}}

Ojo con los nombres: el cliente usa el nombre derivado de la clase (list-posts-tool, con el sufijo -tool), no el list-posts del ejemplo conceptual de la documentación.

Por qué el navegador da 405 y cómo se prueba en serio

El 405 no es un error, es la respuesta correcta

Si abres http://larapackage.test/mcp/posts en el navegador verás 405 Method Not Allowed. Es lo esperado: un GET de navegador manda Accept: text/html, y un servidor MCP no habla HTML. El protocolo exige POST con un cuerpo JSON-RPC 2.0.

$ curl -i http://larapackage.test/mcp/posts
HTTP/1.1 405 Method Not Allowed

$ curl -X POST http://larapackage.test/mcp/posts \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
         "protocolVersion":"2025-06-18","capabilities":{},
         "clientInfo":{"name":"curl","version":"1.0"}}}'

Respuesta real del servidor:

{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","protocolVersion":"2025-06-18",
 "capabilities":{"tools":{"listChanged":false},"resources":{"listChanged":false},
                 "prompts":{"listChanged":false}},
 "serverInfo":{"name":"Post Server","version":"1.0.0"},
 "instructions":"Este servidor expone los posts del blog: puede listarlos, buscar uno
                 por su ID y leer información general de la aplicación."}}

Ahí se ve el serverInfo, las capacidades declaradas y las instructions que son el "prompt de sistema" que lee el modelo. Nunca se prueba un servidor MCP con el navegador.

Probarlo de verdad: con una IA

MCP es un contrato entre una aplicación y un agente de IA. El cliente correcto es Claude Desktop, Cursor, Claude Code u opencode. Este proyecto ya tiene opencode.json, así que el servidor quedó registrado ahí:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "laravel-boost": { "type": "local", "command": ["php", "artisan", "boost:mcp"] },

    "laravel-posts": {
      "type": "local",
      "enabled": true,
      "command": ["php", "artisan", "mcp:start", "posts"]
    },

    "laravel-posts-http": {
      "type": "remote",
      "enabled": false,
      "url": "http://larapackage.test/mcp/posts"
    }
  }
}

Hay dos entradas a propósito:

  • laravel-posts (local, activa): opencode lanza php artisan mcp:start posts. No depende de Herd, de nginx ni de que el servidor esté arriba. Es la opción cómoda para desarrollo.
  • laravel-posts-http (remote, desactivada): apunta al server web. Se activa con "enabled": true para probar exactamente lo que vería un cliente remoto. Requiere php artisan serve o Herd levantado.

Para Claude Desktop o cualquier cliente externo, el equivalente es:

{
  "mcpServers": {
    "laravel-posts": {
      "command": "php",
      "args": ["artisan", "mcp:start", "posts"],
      "cwd": "/Users/andrescruz/Herd/larapackage"
    }
  }
}

Para el cliente web, la URL es http://larapackage.test/mcp/posts.

Importante: opencode carga la configuración una sola vez al arrancar. Después de editar opencode.json hay que cerrar y volver a abrir opencode; la sesión actual sigue usando la config anterior.

Qué se debería pedirle a la IA una vez conectado

La prueba real no es "responde el prompt", sino ver si el modelo elige las tools correctas. Ejercicios que revelan si el ejemplo está bien:

Pregunta a la IAQué demuestra
"¿Qué posts tiene el blog?"Debería llamar list-posts-tool sin que se lo pidas.
"Busca posts sobre Laravel"Debe pasar search: "Laravel" en los argumentos.
"Dame el post 3"Debe invocar get-post-tool con id: 3.
"Dame el post 99999"Prueba el manejo de Response::error(): el modelo debe explicar que no existe, no reintentar en bucle.
"Dame 500 posts"Prueba la validación: debe recibir el error de per_page.max y volver a pedir un valor válido.
"Resume los posts en tono formal"Debe usar el prompt summarize-posts-prompt.
"¿Cuántos posts publicados hay?"Prueba si lee el resource app://info.

Si la IA no elige una tool que debería, casi siempre el problema es la #[Description] o las #[Instructions] del servidor, no el código de la tool. Ese es el ajuste más fino de todo MCP.

Herramientas de diagnóstico cuando algo falla

php artisan mcp:inspector mcp/posts     # probar primitivas a mano
php artisan mcp:start posts             # ver si el server arranca
php artisan route:list --path=mcp       # confirmar el registro

Y para ver la salida cruda de una tool sin IA de por medio, el propio paquete incluye un cliente MCP:

php artisan tinker --execute ‘
$c = Laravel\Mcp\Client::local(PHP_BINARY, ["artisan", "mcp:start", "posts"]);
$c->callTool("list-posts-tool", ["per_page" => 3])->text();
’

Datos adicionales que conviene saber

Dos problemas reales del proyecto que hubo que sortear

Las migraciones de pgvector no corren en SQLite. El paquete pgvector/pgvector publica una migración create_vector_extension que ejecuta CREATE EXTENSION IF NOT EXISTS vector, y SQLite no entiende esa sintaxis. Con eso, RefreshDatabase revienta antes de llegar a nuestro código:

QueryException: SQLSTATE[HY000]: General error: 1 near "EXTENSION": syntax error
(SQL: CREATE EXTENSION IF NOT EXISTS vector)

El test lo resuelve migrando solo las tablas necesarias, en vez de todas:

Artisan::call('migrate:fresh', [
    '--force'    => true,
    '--realpath' => true,
    '--path'     => [
        database_path('migrations/0001_01_01_000000_create_users_table.php'),
        database_path('migrations/2026_09_26_093945_create_categories_table.php'),
        database_path('migrations/2026_09_26_094316_create_posts_table.php'),
        database_path('migrations/2026_09_26_094319_add_foreign_keys_to_posts_table.php'),
    ],
]);

Si en algún momento quieres tests con RefreshDatabase limpios, la solución de fondo es hacer la migración de documentos consciente del driver (guardar el vector solo en pgsql) o mover los tests de embeddings a una base pgvector propia. Es un cambio de app, no del ejemplo MCP, así que no se tocó.

La columna posted es un string, no un booleano.

$table->string('posted')->default('not');

Un (bool) 'not' en PHP es true (cualquier string no vacío es truthy). Si se usaba where('posted', true) o (bool) $post->posted, todos los posts CONTABAN como publicados. Por eso el ejemplo usa:

in_array((string) $post->posted, ['1', 'true', 'yes', 'si'], true)

Lo correcto a futuro es un cast en el modelo: protected function casts(): array { return ['posted' => 'boolean']; } migrando la columna a boolean, o directamente un enum. Si se cambia, este ejemplo se simplifica.

Autenticación

El server está abierto. Para protegerlo:

use Laravel\Mcp\Facades\Mcp;

Mcp::web('/mcp/posts', PostServer::class)
    ->middleware(['auth:sanctum', 'throttle:mcp']);

El cliente debe mandar Authorization: Bearer <token>. Sanctum es lo más rápido; Passport (OAuth 2.1) es lo que recomienda la especificación MCP porque es lo que más clientes soporta. Con Passport además se registra Mcp::oauthRoutes() y se aplica auth:api.

Para autorización dentro de la tool, el request trae el usuario:

public function handle(Request $request): Response
{
    abort_unless($request->user()?->can('read-posts'), 403);

    // ...
}

Evitar filtrar toda la tabla al modelo

ListPostsTool pagina y limita a 50 filas, pero una tool sin techo es un agujero: un agente puede pedir per_page: 50 cien veces. Buenas prácticas:

  • Tope duro en per_page (ya está: 1-50).
  • Cacheable cuando el dato cambia poco y la operación es cara.
  • Registrar en routes/ai.php con ->middleware('throttle:mcp').

Catálogos de tools cuando crezcan

Si el server llega a exponer muchas tools, saturas el contexto del modelo. Laravel MCP permite mover tools a un catálogo buscable:

protected array $tools = [
    CurrentWeatherTool::class,

    ToolSearch::class => [
        HistoricalWeatherTool::class,
        WeatherAlertsTool::class,
    ],
];

El cliente expone solo search_tools y execute_tools, y el modelo busca lo que necesita. Se controla con mcp.tool_search.max_tool_calls y mcp.tool_search.max_output_bytes.

Otras capacidades del paquete que no se usaron aquí

  • Registro condicional: implementa shouldRegister() en la tool para ocultarla según el usuario o el estado.
  • Inyección de dependencias: el contenedor de Laravel resuelve el constructor de la tool, así que puedes tipar repositorios o servicios directamente. También vale en handle().
  • Notificaciones: Response::notification('progreso', [...]) para reportar avance en tareas largas.
  • Metadata: ->withMeta([...]) en la respuesta, o propiedad $meta en la clase.
  • Iconos: atributo #[Icon] en server, tools, resources y prompts.
  • MCP Apps: php artisan make:mcp-app-resource genera HTML interactivo en un iframe sandboxed. Es lo interesante para dashboards.
  • Cliente MCP como fuente de tools para laravel/ai: este proyecto ya tiene laravel/ai, así que se puede consumir un MCP externo (GitHub, Linear, etc.) y que el agente use esas tools.

Detalles verificados durante la implementación

  • Response::structured() devuelve ResponseFactory, no Response. El tipo de retorno debe ser Response|ResponseFactory.
  • El nombre de la tool en el protocolo se deriva de la clase: ListPostsTool → list-posts-tool.
  • routes/ai.php se carga solo, sin tocar bootstrap/app.php.
  • Las rutas del server web aparecen en php artisan route:list --path=mcp.
  • php artisan mcp:inspector posts y php artisan mcp:start posts funcionan con el server local.
  • No existe un modelo App\Models\Category en el proyecto (la tabla sí existe), así que el test inserta la categoría con DB::table('categories').
  • Formato validado con vendor/bin/pint --dirty --format agent.

Resumen

  1. Un Server es el contenedor: registra tools, resources y prompts.
  2. Una Tool ejecuta acciones. Su #[Description] decide si el modelo la usa; su schema() define los argumentos.
  3. Un Resource expone datos de solo lectura bajo una URI.
  4. Un Prompt es una plantilla reutilizable con argumentos.
  5. Se registra con Mcp::web() (HTTP) o Mcp::local() (comando Artisan) en routes/ai.php.
  6. Se depura con php artisan mcp:inspector y se prueba con PostServer::tool(...)->assertOk().

Aprende a integrar el Model Context Protocol (MCP) en Laravel para conectar modelos de lenguaje y agentes de inteligencia artificial directamente con tus bases de datos y lógica de negocio.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.