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

mcp-lab

Приложение на Laravel, которое существует, чтобы задействовать каждую часть Laravel MCP. Фейковая служба поддержки. Фейковая компания. Выбросьте его, когда закончите.

Это эталон по умолчанию для того, как MCP работает в Laravel и как этот репозиторий его использует.

Это тренажёрный зал, а не продукт. Если вы поймали себя на выборе шрифта или придумывании страницы оплаты — остановитесь.

Как работает Laravel MCP

Model Context Protocol — это JSON-RPC. ИИ-хост перечисляет, что ваш сервер предоставляет, а затем вызывает это. Cursor и Inspector — хосты, которые важны для этого репозитория. Laravel MCP, здесь laravel/mcp 0.9.x, — это обёртка.

Вы не изобретаете протокол. Вы пишете PHP-классы и регистрируете их.

Серверы

Сервер — это класс, который расширяет Laravel\Mcp\Server. Это каталог инструментов, ресурсов и промптов.

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

#[Name], #[Version], #[Instructions] и #[Icon] — это метаданные, которые хост показывает модели. Instructions — это системный промпт для этого сервера. Держите их короткими и операционными.

Создайте его с помощью php artisan make:mcp-server. Зарегистрируйте его в routes/ai.php. Laravel загружает этот файл сам. Не добавляйте его в bootstrap/app.php.

Локально и через веб

Тот же класс сервера. Два способа входа.

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

Локально — это stdio. Хост запускает php artisan mcp:start tickets как дочерний процесс. Это ежедневный цикл Cursor. Никакой HTTP-сессии, никаких куки, никакого Passport.

Веб — это HTTP JSON-RPC по этому пути. Inspector и удалённые хосты используют его. Применяются middleware, и именно здесь живёт OAuth.

Не оборачивайте routes/ai.php в группу middleware web. CSRF заблокирует Inspector.

Инструменты, ресурсы и промпты

Сервер может предоставлять инструменты, ресурсы и промпты. Сгенерируйте их с помощью make:mcp-tool, make:mcp-resource и make:mcp-prompt. Затем добавьте класс в массивы сервера. Незарегистрированный класс ничего не делает.

Инструменты — это действия. Модель вызывает их с аргументами. schema() — это JSON Schema, которую рекламирует хост. handle(Request $request) выполняет работу. $request->validate() — это обычная валидация Laravel. Пишите сообщения, на которые модель может действовать, например «Дай мне id тикета, например 12», а не «Поле ticket_id обязательно».

Ресурсы — это читаемые документы по URI. Статический URI использует #[Uri('desk://playbook')]. Шаблоны реализуют HasUriTemplate и читают переменные с помощью $request->get('id'). MIME-тип задаётся через #[MimeType]. Хост может перечислять их и читать без вызова инструмента.

Промпты — это переиспользуемые шаблоны сообщений. arguments() объявляет, что хост должен собрать. handle() возвращает сообщения, обычно инструкцию ассистента плюс пользовательское сообщение, включающее реальные данные. Затем модель пишет на основе этого.

Внедряйте репозитории через конструктор. Не помещайте запросы в класс инструмента. handle() также может принимать сервисы Laravel через type-hint.

Ответы

handle() возвращает Laravel\Mcp\Response, фабрику ответов, массив ответов или Generator.

Форма

Как

Текст

Response::text('...')

Ошибка

Response::error('Permission denied.')

Структурированный JSON

Response::structured($payload) плюс outputSchema()

Несколько текстовых блоков

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

Ссылка на ресурс

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

Блоб из диска

Response::fromStorage('badge.png')

HTML-приложение

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

Прогресс

yield Response::notification('processing/progress', [...]) из генератора

Response::structured не может быть пустым и возвращает фабрику. Чтобы смешать структурированный JSON со ссылками на ресурсы, создайте оба и прикрепите полезную нагрузку с помощью withStructuredContent. Именно это делает list_tickets.

Класс $meta — это метаданные самого инструмента. ->withMeta([...]) — это метаданные одного ответа. create_ticket имеет первое. get_ticket имеет второе.

Аннотации

Подсказки для хоста. Они ничего не принуждают в PHP. Политики всё ещё действуют.

Атрибут

Значение здесь

#[IsReadOnly]

Не пишет

#[IsIdempotent]

Безопасно повторить

#[IsDestructive]

Удаляет или разрушает состояние

#[IsOpenWorld]

Может касаться внешнего мира. who_is_on_call носит это, даже если ротация фейковая

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

Подсказки для ресурсов

#[RendersApp]

Этот инструмент открывает MCP App

shouldRegister(Request $request): bool скрывает инструмент, ресурс или промпт из списка. Если хост всё же попытается вызвать скрытый, сервер вернёт not-found. Используйте это для ролевых ограничений. delete_ticket доступен только администраторам. Всё равно проверяйте $request->user()->can(...) внутри handle(). Перечисление и выполнение — разные двери.

Авторизация

$request->user() — это вошедший пользователь, как в контроллере. Вызывайте $user->can('update', $ticket) и возвращайте Response::error('Permission denied.'). Не придумывайте вторую систему аутентификации.

Локальные серверы не имеют HTTP-сессии. Сервер тикетов этого приложения входит как Sam в boot(), когда Auth пуст. Веб-серверы получают пользователя из Passport.

MCP-клиент

Laravel также может вызывать MCP-сервер. Именованные клиенты живут в сервис-провайдере.

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

Затем код тикетов делает Mcp::client('directory')->callTool('get_person', ['id' => $id]) или ->readResource('directory://people/'.$id).

Client::local запускает процесс. Client::web($url) — это HTTP. HTTP в том же приложении против php artisan serve приводит к взаимоблокировке, потому что этот процесс однопоточный. Используйте local для вызовов в том же приложении.

MCP Apps

AppResource возвращает самодостаточный HTML-документ по URI ui://. Инструмент, помеченный #[RendersApp(resource: QueueApp::class)], сообщает способному хосту, что нужно получить этот HTML и поместить его в песочницу iframe.

Blade-представление использует <x-mcp::app>. Этот компонент поставляет клиентский SDK. Внутри iframe createMcpApp даёт вам app.callServerTool(...). Vite и React не применяются. Tailwind и Alpine приходят из #[AppMeta(libraries: [Library::Tailwind, Library::Alpine])].

Visibility::App скрывает инструмент от модели, чтобы только iframe мог его вызывать. get_queue_data — это такой инструмент.

Cursor перечисляет эти инструменты. Он не рендерит iframe. Pest — это то, как вы узнаёте, что классы работают.

Аутентификация в вебе

Документация Laravel предлагает Sanctum и Passport. Sanctum — это bearer-токен. Passport — это OAuth 2.1, что и предписывает протокол.

Это приложение использует Passport. Mcp::oauthRoutes() регистрирует обнаружение и динамическую регистрацию клиентов. Веб-маршруты используют auth:api. Laravel MCP рекламирует единственный scope mcp:use. Опубликуйте mcp-views и укажите Passport::authorizationView на resources/views/mcp/authorize.blade.php. Оставьте этот Blade в покое.

Тестирование

Inspector — для тыканья. Pest — это то, как вы знаете, что политики соблюдаются.

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']);

Шаблонные ресурсы принимают переменные URI вторым аргументом. Хелпер разворачивает desk://tickets/{id}.

assertSee читает только текст и структурированные данные. Ссылки на ресурсы и _meta живут в сыром JSON-RPC-полезной нагрузке. mcpRpc() и mcpToolContent() этого репозитория в tests/Helpers.php читают это. Инструменты-генераторы используют assertSentNotification и assertNotificationCount. Финальный результат всё ещё содержит текстовые полезные нагрузки.

Веб-аутентификация — это HTTP-тест. POST /mcp/tickets с mcpTicketsCall(). Неаутентифицированные запросы должны возвращать 401, а не редирект на логин.

Что такое этот репозиторий

Northwind Support. Одно приложение Laravel 13, PHP 8.4, SQLite. Inertia и React — только для страницы-дамп. Агент — это путь записи.

Два MCP-сервера, каждый зарегистрирован дважды: локально и через веб.

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 — это read-only люди и команды. TicketsServer — это служба поддержки. Инструменты тикетов не должны запрашивать User или Team через Eloquent. Они ищут людей через именованный клиент directory, get_person и directory://people/{id}. Вот почему есть два сервера. Если вы делаете User::find() из инструмента тикета, вы пропустили суть.

Запросы живут в DirectoryRepository и TicketRepository. Инструменты внедряют их.

Домен

Три роли в users.role.

Роль

Что они могут делать

requester

Открывать тикеты, комментировать свои, читать публичную базу знаний

agent

Видеть каждый тикет, назначать, комментировать, менять статус, читать внутреннюю базу знаний

admin

Всё, что может агент, плюс удалять тикеты и промпт еженедельного обзора

Политики — обычные политики Laravel. TicketPolicy покрывает просмотр, обновление, комментарий и удаление. ArticlePolicy скрывает внутренние статьи от requesters. Персонал — это Role::isStaff(), агент или админ.

Пользователи для входа приходят из LabSeeder. Пароль для всех — password.

Email

Роль

ada@northwind.test

requester

sam@northwind.test

agent

root@northwind.test

admin

email_verified_at установлен. Fortify имеет включённую верификацию. User не реализует MustVerifyEmail, поэтому вас не заблокируют.

Джона Хейл, jonah@northwind.test, — дежурный агент.

Таблицы

Держите их маленькими. Если колонка не нужна инструменту, её нет.

users. Колонки стартового набора плюс role (requester\|agent\|admin), team_id nullable, title, on_call.

teams. name, slug. Support, Billing, Warehouse.

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

comments. ticket_id, user_id, body.

articles. slug, title, body, visibility (public\|internal). Четыре строки. Публичные — про возвраты и доставку. Внутренние — про эскалацию и злоупотребление возвратами.

LabSeeder запускается из DatabaseSeeder. Он достаточно идемпотентен, чтобы перезапускаться после migrate:fresh. Двадцать тикетов, тридцать комментариев, восемь человек, один PNG в storage/app/badge.png.

DirectoryServer

Только чтение. Инструкции так говорят. Имя Northwind Directory, версия 0.0.1, бирюзовая иконка.

Инструменты

Tool

What it does

search_people

Поиск по имени, электронной почте или должности. Необязательный слаг команды. Структурованный { people } с outputSchema. #[IsReadOnly] и #[IsIdempotent]

get_person

Поиск одного идентификатора пользователя. Пользовательские сообщения валидации. Структурованный { person }

list_teams

Нет обязательных аргументов. Два текстовых блока: сначала имена, затем слаги

who_is_on_call

Возвращает пользователя on_call. #[IsOpenWorld] на фиктивном расписании, чтобы аннотация использовалась

Ресурсы

URI

Что делает

directory://org

Статический Markdown. #[MimeType], #[Priority(0.9)], #[Audience(Role::Assistant)]

directory://people/{id}

Досье человека. HasUriTemplate, $request->get('id')

directory://teams/{slug}

Досье команды. Второй шаблон, чтобы первый не был разовым

directory://on-call

Кто на дежурстве. #[LastModified]

directory://badge

Маленький PNG через Response::fromStorage('badge.png')

Здесь нет промптов. Они принадлежат серверу тикетов, где им есть что сказать.

TicketsServer

Название Northwind Tickets, версия 0.0.1, тёмная иконка.

boot() выполняет вход как sam@northwind.test, когда никто не аутентифицирован. Локальный Cursor не имеет HTTP-сессии. Без этого каждый инструмент будет говорить You must be signed in. Веб-запросы уже имеют пользователя Passport, поэтому boot() возвращается рано.

Инструменты

Tool

Что делает

list_tickets

Перечисляет тикеты, которые видит пользователь. Фильтры по статусу и приоритету. Структурированный вывод плюс ссылки на ресурсы desk://tickets/{id}

get_ticket

Один тикет. Имена исполнителя и запрашивающего берутся из directory://people/{id}. withMeta(['source' => 'eloquent'])

create_ticket

Открывает тикет. Класс $meta содержит version и author

add_comment

Добавляет комментарий после проверки политики comment

assign_ticket

#[IsIdempotent]. Находит человека с помощью Mcp::client('directory')->callTool('get_person', ...)

set_status

Устанавливает open, pending или closed. Это также закрытие и повторное открытие

delete_ticket

#[IsDestructive]. shouldRegister равен true только для администраторов

close_stale_tickets

Закрывает все тикеты open старше N дней. По ходу выдаёт processing/progress

search_kb

Ищет статьи, которые видит текущий пользователь. Структурированный { articles } с desk://kb/{slug}

show_queue

Виден модели. #[RendersApp(resource: QueueApp::class)]

get_queue_data

То же приложение, visibility: [Visibility::App]. Iframe обновляется, не передавая модели второй инструмент списка

list_tickets не может использовать TicketResource::uri() для ссылок. Этот метод возвращает шаблон desk://tickets/{id}. Ссылки должны быть развёрнутой строкой.

assign_ticket и get_ticket отказываются обращаться к User. Если клиент каталога отключён, назначение не сработает. Это доказывает Pest.

Промпты

Prompt

Что делает

draft_reply

Принимает ticket_id и tone. Загружает тикет. Сообщение ассистента плюс сообщение пользователя, включающее реальную тему

triage_ticket

Принимает ticket_id. Проверяет с полезной строкой ошибки

weekly_review

Сводка открытых тикетов. shouldRegister равен false для запрашивающих

Ресурсы

URI

Что делает

desk://playbook

Статический Markdown, высокий приоритет. Как проводить триаж

desk://queue

Список открытых тикетов в Markdown, которые видит текущий пользователь

desk://tickets/{id}

Досье в Markdown с комментариями

desk://kb/{slug}

Одна статья. Отсутствующие и запрещённые слаги возвращают одну и ту же ошибку

desk://kb/escalation

Список внутренней статьи об эскалации только для персонала

ui:// QueueApp

Интерактивный iframe очереди

desk://queue — это Markdown. QueueApp — это iframe. Не путайте их.

QueueApp

QueueApp расширяет AppResource. Blade-шаблон находится в resources/views/mcp/queue-app.blade.php. Alpine внутри <x-mcp::app>. Обновление вызывает get_queue_data через app.callServerTool.

Это не страница Inertia. Не переписывайте её на React.

Если хост не может отображать MCP Apps, классы и Pest-тесты всё равно являются доказательством.

Auth и веб-серверы

Оба /mcp/tickets и /mcp/directory используют auth:api и throttle:mcp. Лимитер mcp — 60 в минуту в AppServiceProvider, ключ — идентификатор пользователя или IP.

User реализует OAuthenticatable и использует Passport HasApiTokens. Отсутствие интерфейса — обычная ошибка настройки Passport.

Неаутентифицированный POST возвращает 401 с WWW-Authenticate, указывающим на метаданные защищённого ресурса этого пути. Обнаружение охватывает оба /mcp/tickets и /mcp/directory. Динамическая регистрация — POST /oauth/register. Один токен доступа работает на обоих серверах.

Экран одобрения и отклонения — resources/views/mcp/authorize.blade.php. Оставьте его.

Панель управления

Fortify и Inertia поставляются из стартового набора. Не заменяйте их.

/dashboard — это две таблицы. Тикеты показывают id, тему, статус, приоритет, запрашивающего и исполнителя. Люди показывают id, имя, роль, команду и дежурного. Wayfinder называет маршрут. DashboardController передаёт Inertia-пропсы. Нет форм, создающих тикеты.

Подтвердите, что инструмент записи сработал, обновив /dashboard. Запустите composer run dev, когда вам важна React-страница.

Passport authorize и iframe QueueApp остаются на Blade. Пакет владеет ими.

Как вы с ним общаетесь

Cursor, локально. .cursor/mcp.json запускает оба сервера из корня проекта.

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

Тикеты — это ежедневный цикл. Вам не нужен каталог в Cursor для клиентской работы. Процесс сервера тикетов порождает его.

Локальные тикеты запускаются от имени Sam. Чтобы увидеть Ada или Root, используйте Pest actingAs или веб-сервер с их токеном.

Инспектор. php artisan mcp:inspector tickets и php artisan mcp:inspector mcp/tickets. Используйте это, когда инструмент ничего не делает и вам нужен сырой результат. Веб-инспектору нужен Passport bearer-токен. Интерфейс инспектора находится на :6274, это приложение — на :8000. CORS для mcp/*, oauth/* и .well-known/* находится в config/cors.php. Без этих путей GET-запросы обнаружения возвращают 200, но браузер всё равно их отбрасывает.

Панель управления. Подтверждение, что инструменты записи обращаются к Eloquent.

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

Не вызывайте Client::web('http://127.0.0.1:8000/mcp/directory') из инструмента тикетов, пока artisan serve — единственный PHP-процесс. Это зависнет. composer run dev этого не меняет. Второй PHP-сервер на :8001 плюс Client::web — это необязательный более поздний эксперимент, а не значение по умолчанию.

Тестирование этого репозитория

Функциональные тесты находятся в tests/Feature/Mcp. TestCase всегда перепривязывает клиент directory к InProcessDirectoryTransport, чтобы SQLite :memory: был виден. Один тест в DirectoryClientTest использует реальный stdio-клиент для перечисления инструментов. Тест с отключённым клиентом указывает именованному клиенту на php -r 'exit(1);'.

Помощники в tests/Helpers.php:

  • mcpRpc($response) для сырого JSON-RPC массива

  • mcpToolContent($response) для списка содержимого результата

  • mcpTicketsCall($name, $arguments) для HTTP-тела tools/call

Чтобы утверждать, что инструмент скрыт, используйте TicketsServer::actingAs($user), затем (new TicketsServer(new FakeTransporter))->createContext()->tools(). Не вызывайте handle() на скрытом инструменте и не ожидайте ошибку политики. Его вызов — это ошибка JSON-RPC «не найдено».

Читайте аннотации из $tool->annotations(), а не из toArray()['annotations']. Tool::toArray() типизирует это поле как array|object. toArray() ресурса опускает аннотации.

Заморозьте время с помощью Carbon::setTestNow. Без pest-plugin-phpstan $this — это TestCall, и $this->travelTo не проходит проверку типов.

Импортируйте Pest\Laravel\postJson и Pest\Laravel\withToken. Не вызывайте их на $this.

Что покрывает набор тестов:

  • Запрашивающий не может назначать или удалять

  • Агент может назначать, но не может удалять

  • Администратор может удалять

  • delete_ticket отсутствует при действии от имени Sam, присутствует для Root

  • Внутренний ресурс эскалации скрыт от Ada

  • search_kb скрывает внутренние статьи от запрашивающих

  • weekly_review только для персонала

  • assign_ticket не срабатывает, если клиент каталога не может найти человека

  • Ошибки валидации — это предложения

  • close_stale_tickets выдаёт уведомления о прогрессе

  • OAuth обнаружение, регистрация, authorize Blade, PKCE, затем вызов инструмента на обоих веб-серверах

Тесты Fortify из стартового набора остаются. Не переписывайте их, чтобы доказать MCP.

Структура файлов

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)

Постоянные ловушки

Они уже есть в .ai/rules. Они также принадлежат здесь, потому что их легко снова сломать.

  • Инструменты тикетов общаются с DirectoryServer через Client::local. Тесты переопределяют это внутрипроцессным транспортом.

  • Не запрашивайте User или Team из assign_ticket или get_ticket. Не переносите этот поиск в TicketRepository.

  • PHPDoc для $resources каталога как class-string<Server\Resource>. Pint приводит class-string<Resource> к нижнему регистру до типа PHP resource.

  • ai.php остаётся вне группы веб-посредников.

  • QueueApp — это ui://. desk://queue — это Markdown.

  • Статьи базы знаний проходят через ArticleRepository. Отсутствующие и запрещённые чтения desk://kb/{slug} используют одну и ту же ошибку.

Документация

-
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