Northwind Tickets
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.
Форма | Как |
Текст |
|
Ошибка |
|
Структурированный JSON |
|
Несколько текстовых блоков |
|
Ссылка на ресурс |
|
Блоб из диска |
|
HTML-приложение |
|
Прогресс |
|
Response::structured не может быть пустым и возвращает фабрику. Чтобы смешать структурированный JSON со ссылками на ресурсы, создайте оба и прикрепите полезную нагрузку с помощью withStructuredContent. Именно это делает list_tickets.
Класс $meta — это метаданные самого инструмента. ->withMeta([...]) — это метаданные одного ответа. create_ticket имеет первое. get_ticket имеет второе.
Аннотации
Подсказки для хоста. Они ничего не принуждают в PHP. Политики всё ещё действуют.
Атрибут | Значение здесь |
| Не пишет |
| Безопасно повторить |
| Удаляет или разрушает состояние |
| Может касаться внешнего мира. |
| Подсказки для ресурсов |
| Этот инструмент открывает 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.
Роль | Что они могут делать |
| Открывать тикеты, комментировать свои, читать публичную базу знаний |
| Видеть каждый тикет, назначать, комментировать, менять статус, читать внутреннюю базу знаний |
| Всё, что может агент, плюс удалять тикеты и промпт еженедельного обзора |
Политики — обычные политики Laravel. TicketPolicy покрывает просмотр, обновление, комментарий и удаление. ArticlePolicy скрывает внутренние статьи от requesters. Персонал — это Role::isStaff(), агент или админ.
Пользователи для входа приходят из LabSeeder. Пароль для всех — password.
Роль | |
| requester |
| agent |
| 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 |
| Поиск по имени, электронной почте или должности. Необязательный слаг команды. Структурованный |
| Поиск одного идентификатора пользователя. Пользовательские сообщения валидации. Структурованный |
| Нет обязательных аргументов. Два текстовых блока: сначала имена, затем слаги |
| Возвращает пользователя |
Ресурсы
URI | Что делает |
| Статический Markdown. |
| Досье человека. |
| Досье команды. Второй шаблон, чтобы первый не был разовым |
| Кто на дежурстве. |
| Маленький PNG через |
Здесь нет промптов. Они принадлежат серверу тикетов, где им есть что сказать.
TicketsServer
Название Northwind Tickets, версия 0.0.1, тёмная иконка.
boot() выполняет вход как sam@northwind.test, когда никто не аутентифицирован. Локальный Cursor не имеет HTTP-сессии. Без этого каждый инструмент будет говорить You must be signed in. Веб-запросы уже имеют пользователя Passport, поэтому boot() возвращается рано.
Инструменты
Tool | Что делает |
| Перечисляет тикеты, которые видит пользователь. Фильтры по статусу и приоритету. Структурированный вывод плюс ссылки на ресурсы |
| Один тикет. Имена исполнителя и запрашивающего берутся из |
| Открывает тикет. Класс |
| Добавляет комментарий после проверки политики |
|
|
| Устанавливает |
|
|
| Закрывает все тикеты |
| Ищет статьи, которые видит текущий пользователь. Структурированный |
| Виден модели. |
| То же приложение, |
list_tickets не может использовать TicketResource::uri() для ссылок. Этот метод возвращает шаблон desk://tickets/{id}. Ссылки должны быть развёрнутой строкой.
assign_ticket и get_ticket отказываются обращаться к User. Если клиент каталога отключён, назначение не сработает. Это доказывает Pest.
Промпты
Prompt | Что делает |
| Принимает |
| Принимает |
| Сводка открытых тикетов. |
Ресурсы
URI | Что делает |
| Статический Markdown, высокий приоритет. Как проводить триаж |
| Список открытых тикетов в Markdown, которые видит текущий пользователь |
| Досье в Markdown с комментариями |
| Одна статья. Отсутствующие и запрещённые слаги возвращают одну и ту же ошибку |
| Список внутренней статьи об эскалации только для персонала |
| Интерактивный 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>к нижнему регистру до типа PHPresource.ai.phpостаётся вне группы веб-посредников.QueueApp — это
ui://.desk://queue— это Markdown.Статьи базы знаний проходят через
ArticleRepository. Отсутствующие и запрещённые чтенияdesk://kb/{slug}используют одну и ту же ошибку.
Документация
This server cannot be installed
Maintenance
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.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
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