Skip to main content
Glama
jaksa-v
by jaksa-v

mcp-lab

Una aplicación Laravel que existe para forzar cada parte de Laravel MCP. Helpdesk falso. Empresa falsa. Tírala cuando termines.

Esta es la referencia predeterminada de cómo funciona MCP en Laravel y cómo este repositorio lo usa.

Esto es un gimnasio, no un producto. Si te sorprendes eligiendo una tipografía o inventando una página de facturación, detente.

Cómo funciona Laravel MCP

Model Context Protocol es JSON-RPC. Un host de IA lista lo que tu servidor expone y luego lo llama. Cursor e Inspector son los hosts que le importan a este repositorio. Laravel MCP, laravel/mcp 0.9.x aquí, es el envoltorio.

No inventas un protocolo. Escribes clases PHP y las registras.

Servidores

Un servidor es una clase que extiende Laravel\Mcp\Server. Es un catálogo de herramientas, recursos y prompts.

#[Name('Northwind Tickets')]
#[Version('0.0.1')]
#[Instructions('...')]
class TicketsServer extends Server
{
    protected array $tools = [/* ... */];
    protected array $resources = [/* ... */];
    protected array $prompts = [/* ... */];
}

#[Name], #[Version], #[Instructions] e #[Icon] son metadatos que el host muestra al modelo. Las instrucciones son el prompt de sistema para ese servidor. Mantenlas cortas y operativas.

Crea uno con php artisan make:mcp-server. Regístralo en routes/ai.php. Laravel carga ese archivo por su cuenta. No lo agregues a bootstrap/app.php.

Local vs web

La misma clase de servidor. Dos formas de entrada.

Mcp::local('tickets', TicketsServer::class);
Mcp::web('/mcp/tickets', TicketsServer::class)->middleware(['auth:api', 'throttle:mcp']);

Local es stdio. El host ejecuta php artisan mcp:start tickets como proceso hijo. Ese es el bucle diario de Cursor. Sin sesión HTTP, sin cookies, sin Passport.

Web es HTTP JSON-RPC en esa ruta. Inspector y los hosts remotos lo usan. El middleware se aplica, y aquí es donde vive OAuth.

No envuelvas routes/ai.php en el grupo de middleware web. CSRF bloqueará a Inspector.

Herramientas, recursos y prompts

Un servidor puede exponer herramientas, recursos y prompts. Genéralos con make:mcp-tool, make:mcp-resource y make:mcp-prompt. Luego agrega la clase a los arrays del servidor. Una clase no registrada no hace nada.

Las herramientas son acciones. El modelo las llama con argumentos. schema() es el JSON Schema que el host anuncia. handle(Request $request) hace el trabajo. $request->validate() es validación ordinaria de Laravel. Escribe mensajes sobre los que un modelo pueda actuar, como Dame el id del ticket, como 12., no El campo ticket_id es obligatorio.

Los recursos son documentos legibles en una URI. Una URI estática usa #[Uri('desk://playbook')]. Las plantillas implementan HasUriTemplate y leen variables con $request->get('id'). El tipo MIME usa #[MimeType]. El host puede listarlos y leerlos sin llamar a una herramienta.

Los prompts son plantillas de mensajes reutilizables. arguments() declara lo que el host debe recopilar. handle() devuelve mensajes, generalmente una instrucción de asistente más un mensaje de usuario que incluye datos reales. Luego el modelo escribe a partir de eso.

Inyecta repositorios en el constructor. No pongas consultas en la clase de la herramienta. handle() también puede usar type-hint de servicios de Laravel.

Respuestas

handle() devuelve un Laravel\Mcp\Response, una fábrica de respuestas, un array de respuestas o un Generator.

Forma

Cómo

Texto

Response::text('...')

Error

Response::error('Permission denied.')

JSON estructurado

Response::structured($payload) más outputSchema()

Varios bloques de texto

Response::make([Response::text(...), Response::text(...)])

Enlace a recurso

Response::resourceLink(uri:, name:, mimeType:, title:)

Blob desde disco

Response::fromStorage('badge.png')

Aplicación HTML

Response::view('mcp.queue-app', [...])

Progreso

yield Response::notification('processing/progress', [...]) desde un generator

Response::structured no puede estar vacío y devuelve una fábrica. Para mezclar JSON estructurado con enlaces a recursos, construye ambos y adjunta el payload con withStructuredContent. Eso es lo que hace list_tickets.

La clase $meta es metadatos sobre la herramienta en sí. ->withMeta([...]) es metadatos sobre una respuesta. create_ticket tiene lo primero. get_ticket tiene lo segundo.

Anotaciones

Pistas para el host. No imponen nada en PHP. Las políticas sí lo hacen.

Atributo

Significado aquí

#[IsReadOnly]

No escribe

#[IsIdempotent]

Seguro de reintentar

#[IsDestructive]

Elimina o destruye estado

#[IsOpenWorld]

Puede tocar el mundo exterior. who_is_on_call lo lleva aunque la rotación sea falsa

#[Priority], #[Audience], #[LastModified]

Pistas de recurso

#[RendersApp]

Esta herramienta abre una MCP App

shouldRegister(Request $request): bool oculta una herramienta, recurso o prompt de la lista. Si el host aún intenta llamar a uno oculto, el servidor devuelve no encontrado. Úsalo para puertas de roles. delete_ticket es solo para administradores. Aún así verifica $request->user()->can(...) dentro de handle(). Listar y ejecutar son puertas diferentes.

Autorización

$request->user() es el usuario autenticado, igual que en un controlador. Llama a $user->can('update', $ticket) y devuelve Response::error('Permission denied.'). No inventes un segundo sistema de autenticación.

Los servidores locales no tienen sesión HTTP. El servidor de tickets de esta aplicación inicia sesión como Sam en boot() cuando Auth está vacío. Los servidores web obtienen el usuario de Passport.

El cliente MCP

Laravel también puede llamar a un servidor MCP. Los clientes con nombre viven en un proveedor de servicios.

Mcp::registerClient('directory', fn () => Client::local('php', [
    'artisan', 'mcp:start', 'directory',
]));

Luego el código de tickets hace Mcp::client('directory')->callTool('get_person', ['id' => $id]) o ->readResource('directory://people/'.$id).

Client::local genera un proceso. Client::web($url) es HTTP. HTTP de la misma aplicación contra php artisan serve se bloquea porque ese proceso es de un solo hilo. Usa local para llamadas de la misma aplicación.

MCP Apps

Un AppResource devuelve un documento HTML autocontenido en una URI ui://. Una herramienta marcada con #[RendersApp(resource: QueueApp::class)] le dice a un host capaz que obtenga ese HTML y lo ponga en un iframe con sandbox.

La vista Blade usa <x-mcp::app>. Ese componente incluye el SDK del cliente. Dentro del iframe, createMcpApp te da app.callServerTool(...). Vite y React no aplican. Tailwind y Alpine vienen de #[AppMeta(libraries: [Library::Tailwind, Library::Alpine])].

Visibility::App oculta una herramienta del modelo para que solo el iframe pueda llamarla. get_queue_data es esa herramienta.

Cursor lista estas herramientas. No renderiza el iframe. Pest es cómo sabes que las clases funcionan.

Autenticación en la web

La documentación de Laravel ofrece Sanctum y Passport. Sanctum es un token bearer. Passport es OAuth 2.1, que es lo que especifica el protocolo.

Esta aplicación usa Passport. Mcp::oauthRoutes() registra descubrimiento y registro dinámico de clientes. Las rutas web usan auth:api. Laravel MCP anuncia un único alcance mcp:use. Publica mcp-views y apunta Passport::authorizationView a resources/views/mcp/authorize.blade.php. Deja ese Blade en paz.

Pruebas

Inspector es para tocar. Pest es cómo sabes que las políticas se mantienen.

TicketsServer::actingAs($sam)
    ->tool(ListTicketsTool::class, ['status' => 'open'])
    ->assertOk()
    ->assertSee('...');

TicketsServer::resource(TicketResource::class, ['id' => $ticket->id]);
TicketsServer::prompt(DraftReplyPrompt::class, ['ticket_id' => $ticket->id, 'tone' => 'curt']);

Los recursos de plantilla toman las variables de URI como segundo argumento. El helper expande desk://tickets/{id}.

assertSee solo lee texto y datos estructurados. Los enlaces a recursos y _meta viven en el payload JSON-RPC crudo. mcpRpc() y mcpToolContent() de este repositorio en tests/Helpers.php leen eso. Las herramientas generadoras usan assertSentNotification y assertNotificationCount. El resultado final aún contiene los payloads de texto.

La autenticación web es una prueba HTTP. POST /mcp/tickets con mcpTicketsCall(). Las solicitudes no autenticadas deben ser 401, no una redirección de inicio de sesión.

Qué es este repositorio

Northwind Support. Una aplicación Laravel 13, PHP 8.4, SQLite. Inertia y React son solo la página de volcado. El agente es la ruta de escritura.

Dos servidores MCP, cada uno registrado dos veces, local y web.

routes/ai.php

Mcp::local('directory', DirectoryServer::class);
Mcp::local('tickets', TicketsServer::class);

Mcp::oauthRoutes();

Mcp::web('/mcp/directory', DirectoryServer::class)
    ->middleware(['auth:api', 'throttle:mcp']);

Mcp::web('/mcp/tickets', TicketsServer::class)
    ->middleware(['auth:api', 'throttle:mcp']);

DirectoryServer es solo lectura de personas y equipos. TicketsServer es el helpdesk. Las herramientas de tickets no deben consultar User o Team a través de Eloquent. Buscan personas a través del cliente con nombre directory, get_person y directory://people/{id}. Por eso hay dos servidores. Si haces User::find() desde una herramienta de tickets, te saltaste el punto.

Las consultas viven en DirectoryRepository y TicketRepository. Las herramientas los inyectan.

Dominio

Tres roles en users.role.

Rol

Qué pueden hacer

requester

Abrir tickets, comentar en los suyos, leer la KB pública

agent

Ver cada ticket, asignar, comentar, cambiar estado, leer la KB interna

admin

Todo lo que puede un agente, más eliminar tickets y el prompt de revisión semanal

Las políticas son políticas ordinarias de Laravel. TicketPolicy cubre ver, actualizar, comentar y eliminar. ArticlePolicy oculta artículos internos a los solicitantes. El personal es Role::isStaff(), agente o administrador.

Los usuarios de inicio de sesión vienen de LabSeeder. La contraseña para todos ellos es password.

Email

Rol

ada@northwind.test

requester

sam@northwind.test

agent

root@northwind.test

admin

email_verified_at está establecido. Fortify tiene verificación habilitada. User no implementa MustVerifyEmail, así que no serás bloqueado.

Jonah Hale, jonah@northwind.test, es el agente de guardia.

Tablas

Mantenlas pequeñas. Si una columna no la necesita una herramienta, no está ahí.

users. Columnas del kit de inicio más role (requester\|agent\|admin), team_id anulable, title, on_call.

teams. name, slug. Soporte, Facturación, Almacén.

tickets. subject, body, status (open\|pending\|closed), priority (low\|normal\|high\|urgent), requester_id, assignee_id anulable, team_id anulable.

comments. ticket_id, user_id, body.

articles. slug, title, body, visibility (public\|internal). Cuatro filas. Las públicas son reembolsos y envíos. Las internas son escalamiento y abuso de reembolsos.

LabSeeder se ejecuta desde DatabaseSeeder. Es lo suficientemente idempotente para volver a ejecutarse después de migrate:fresh. Veinte tickets, treinta comentarios, ocho personas, un PNG en storage/app/badge.png.

DirectoryServer

Solo lectura. Las instrucciones lo dicen. Nombre Northwind Directory, versión 0.0.1, icono verde azulado.

Herramientas

Herramienta

Qué hace

search_people

Busca por nombre, correo electrónico o cargo. Slug de equipo opcional. { people } estructurado con outputSchema. #[IsReadOnly] y #[IsIdempotent]

get_person

Consulta un id de usuario. Mensajes de validación personalizados. { person } estructurado

list_teams

Sin argumentos obligatorios. Dos bloques de texto: nombres y luego slugs

who_is_on_call

Devuelve el usuario on_call. #[IsOpenWorld] en un turno falso para que se use la anotación

Recursos

URI

Qué hace

directory://org

Markdown estático. #[MimeType], #[Priority(0.9)], #[Audience(Role::Assistant)]

directory://people/{id}

Expediente de persona. HasUriTemplate, $request->get('id')

directory://teams/{slug}

Expediente de equipo. Segundo template para que el primero no sea un caso único

directory://on-call

Quién está de guardia. #[LastModified]

directory://badge

PNG pequeño mediante Response::fromStorage('badge.png')

Aquí no hay prompts. Pertenecen al servidor de tickets, donde tienen algo que decir.

TicketsServer

Nombre Northwind Tickets, versión 0.0.1, icono oscuro.

boot() inicia sesión como sam@northwind.test cuando nadie está autenticado. Cursor local no tiene sesión HTTP. Sin esto, cada herramienta diría You must be signed in. Las peticiones web ya tienen un usuario de Passport, así que boot() retorna antes.

Herramientas

Herramienta

Qué hace

list_tickets

Lista los tickets que el usuario puede ver. Filtros de estado y prioridad. Salida estructurada más enlaces de recurso desk://tickets/{id}

get_ticket

Un ticket. Los nombres de asignado y solicitante vienen de directory://people/{id}. withMeta(['source' => 'eloquent'])

create_ticket

Abre un ticket. El $meta de la clase tiene version y author

add_comment

Añade un comentario tras la comprobación de la política comment

assign_ticket

#[IsIdempotent]. Resuelve la persona con Mcp::client('directory')->callTool('get_person', ...)

set_status

Establece open, pending o closed. Esto también es cerrar y reabrir

delete_ticket

#[IsDestructive]. shouldRegister es true solo para administradores

close_stale_tickets

Cierra todos los tickets open con más de N días. Emite processing/progress mientras avanza

search_kb

Busca artículos que el usuario actual puede ver. { articles } estructurado con desk://kb/{slug}

show_queue

Visible para el modelo. #[RendersApp(resource: QueueApp::class)]

get_queue_data

Misma app, visibility: [Visibility::App]. El iframe se refresca sin dar al modelo una segunda herramienta de listado

list_tickets no puede usar TicketResource::uri() para los enlaces. Ese método devuelve la plantilla desk://tickets/{id}. Los enlaces deben ser la cadena expandida.

assign_ticket y get_ticket se niegan a tocar User. Si el cliente de directorio está desenchufado, la asignación falla. Pest lo demuestra.

Prompts

Prompt

Qué hace

draft_reply

Toma ticket_id y tone. Carga el ticket. Mensaje de asistente más un mensaje de usuario que incluye el asunto real

triage_ticket

Toma ticket_id. Valida con una cadena de error útil

weekly_review

Resumen de tickets abiertos. shouldRegister es false para solicitantes

Recursos

URI

Qué hace

desk://playbook

Markdown estático, prioridad alta. Cómo hacer triaje

desk://queue

Lista en Markdown de tickets abiertos que el usuario actual puede ver

desk://tickets/{id}

Expediente en Markdown con comentarios

desk://kb/{slug}

Un artículo. Los slugs ausentes y prohibidos devuelven el mismo error

desk://kb/escalation

Listado solo para personal del artículo de escalado interno

ui:// QueueApp

Iframe interactivo de la cola

desk://queue es Markdown. QueueApp es el iframe. No los confundas.

QueueApp

QueueApp extiende AppResource. Blade en resources/views/mcp/queue-app.blade.php. Alpine dentro de <x-mcp::app>. El refresco llama a get_queue_data mediante app.callServerTool.

No es una página Inertia. No la reescribas en React.

Si el host no puede renderizar MCP Apps, las clases y las pruebas de Pest siguen siendo la prueba.

Autenticación y los servidores web

Tanto /mcp/tickets como /mcp/directory usan auth:api y throttle:mcp. El limitador mcp es de 60 por minuto en AppServiceProvider, con clave por id de usuario o IP.

User implementa OAuthenticatable y usa HasApiTokens de Passport. La falta de la interfaz es el error típico de configuración de Passport.

Un POST no autenticado devuelve 401 con WWW-Authenticate apuntando a los metadatos de recurso protegido de esa ruta. El descubrimiento cubre tanto /mcp/tickets como /mcp/directory. El registro dinámico es POST /oauth/register. Un solo token de acceso funciona en ambos servidores.

La pantalla de aprobar y denegar es resources/views/mcp/authorize.blade.php. Déjala.

Panel de control

Fortify e Inertia vienen del starter kit. No los reemplaces.

/dashboard son dos tablas. Los tickets muestran id, asunto, estado, prioridad, solicitante y asignado. Las personas muestran id, nombre, rol, equipo y guardia. Wayfinder nombra la ruta. DashboardController pasa props de Inertia. No hay formularios que creen tickets.

Prueba que una herramienta de escritura funcionó refrescando /dashboard. Ejecuta composer run dev cuando te importe la página React.

La autorización de Passport y el iframe de QueueApp siguen siendo Blade. El paquete es dueño de esos.

Cómo hablar con ello

Cursor, local. .cursor/mcp.json inicia ambos servidores desde la raíz del proyecto.

{
    "mcpServers": {
        "northwind-tickets": {
            "command": "php",
            "args": ["artisan", "mcp:start", "tickets"]
        },
        "northwind-directory": {
            "command": "php",
            "args": ["artisan", "mcp:start", "directory"]
        }
    }
}

Tickets es el bucle diario. No necesitas directory en Cursor para el trabajo de cliente. El proceso del servidor de tickets lo genera.

Los tickets locales se ejecutan como Sam. Para ver a Ada o Root, usa Pest actingAs o el servidor web con su token.

Inspector. php artisan mcp:inspector tickets y php artisan mcp:inspector mcp/tickets. Úsalo cuando una herramienta no haga nada y necesites el resultado crudo. El Inspector web necesita un token bearer de Passport. La UI del Inspector está en :6274, esta app en :8000. CORS para mcp/*, oauth/* y .well-known/* vive en config/cors.php. Sin esas rutas, los GET de descubrimiento devuelven 200 y el navegador igualmente los descarta.

Panel de control. Prueba que las herramientas de escritura tocan Eloquent.

Pest. php artisan test --compact tests/Feature/Mcp.

No hagas Client::web('http://127.0.0.1:8000/mcp/directory') desde una herramienta de tickets mientras artisan serve es el único proceso PHP. Se colgará. composer run dev no cambia eso. Un segundo servidor PHP en :8001 más Client::web es un experimento opcional posterior, no el predeterminado.

Probar este repositorio

Las pruebas de características viven en tests/Feature/Mcp. TestCase siempre reenlaza el cliente directory a InProcessDirectoryTransport para que SQLite :memory: sea visible. Una prueba en DirectoryClientTest usa el cliente stdio real para listar herramientas. La prueba de cliente desenchufado apunta el cliente nombrado a php -r 'exit(1);'.

Helpers en tests/Helpers.php:

  • mcpRpc($response) para el array JSON-RPC crudo

  • mcpToolContent($response) para la lista de contenido del resultado

  • mcpTicketsCall($name, $arguments) para un cuerpo HTTP de tools/call

Para afirmar que una herramienta está oculta, TicketsServer::actingAs($user) y luego (new TicketsServer(new FakeTransporter))->createContext()->tools(). No llames a handle() en una herramienta oculta esperando un error de política. Invocarla es un error JSON-RPC de no encontrado.

Lee las anotaciones de $tool->annotations(), no de toArray()['annotations']. Tool::toArray() tipa ese campo como array|object. El toArray() de Resource omite anotaciones.

Congela el tiempo con Carbon::setTestNow. Sin pest-plugin-phpstan, $this es TestCall y $this->travelTo no pasa el typecheck.

Importa Pest\Laravel\postJson y Pest\Laravel\withToken. No los llames sobre $this.

Lo que cubre la suite:

  • El solicitante no puede asignar ni eliminar

  • El agente puede asignar, no puede eliminar

  • El administrador puede eliminar

  • delete_ticket ausente al actuar como Sam, presente para Root

  • Recurso de escalado interno oculto para Ada

  • search_kb oculta artículos internos a los solicitantes

  • weekly_review solo para personal

  • assign_ticket falla si el cliente de directorio no encuentra a la persona

  • Los errores de validación son frases

  • close_stale_tickets emite notificaciones de progreso

  • Descubrimiento OAuth, registro, Blade de autorización, PKCE y luego una llamada a herramienta en ambos servidores web

Las pruebas de Fortify del starter kit se quedan. No las reescribas para probar MCP.

Estructura de archivos

app/
  Enums/Role.php Status.php Priority.php Visibility.php
  Models/User.php Team.php Ticket.php Comment.php Article.php
  Policies/TicketPolicy.php ArticlePolicy.php
  Repositories/DirectoryRepository.php TicketRepository.php ArticleRepository.php
  Http/Controllers/DashboardController.php
  Mcp/
    Servers/DirectoryServer.php TicketsServer.php
    Tools/          (15 tools)
    Resources/      (11 resources, including QueueApp)
    Prompts/        (3 prompts)
routes/ai.php
resources/js/pages/dashboard.tsx
resources/views/mcp/authorize.blade.php
resources/views/mcp/queue-app.blade.php
database/seeders/LabSeeder.php
tests/Feature/Mcp/
tests/Helpers.php
tests/Support/InProcessDirectoryTransport.php
.ai/rules/          (settled decisions for the next agent)

Trampas habituales

Estas ya están en .ai/rules. También pertenecen aquí porque es fácil volver a romperlas.

  • Las herramientas de tickets hablan con DirectoryServer mediante Client::local. Las pruebas lo sobrescriben con el transporte en proceso.

  • No consultes User ni Team desde assign_ticket o get_ticket. No muevas esa búsqueda a TicketRepository.

  • Documenta $resources del directorio como class-string<Server\Resource>. Pint convierte class-string<Resource> a minúsculas al tipo resource de PHP.

  • ai.php se mantiene fuera del grupo de middleware web.

  • QueueApp es ui://. desk://queue es Markdown.

  • Los artículos de KB pasan por ArticleRepository. Las lecturas ausentes y prohibidas de desk://kb/{slug} usan el mismo error.

Documentación

-
license - not tested
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

  • Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.

  • Hosted MCP endpoint with realistic fake data for prototyping agents. 12 tools, no setup.

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jaksa-v/mcp-lab'

If you have feedback or need assistance with the MCP directory API, please join our Discord server