
Соберём на Laravel MCP-сервер для поиска по Swagger-документации. Через него AI-ассистент сможет найти API-операцию, прочитать её параметры и получить схему ответа. Спецификацию сохраним в PostgreSQL, а доступ к ней дадим через четыре инструмента: поиск, описание операции, схему и список операций по тегу.
Небольшую спецификацию можно передать ассистенту целиком. Но ради одного метода большого API загружать в контекст весь JSON незачем: сервер вернёт нужный фрагмент.
Проект учебный: на примере laravel/mcp разберёмся, как описать инструмент на PHP и сделать его доступным AI-клиенту. Разбор Swagger нужен здесь для практики, поэтому универсальный парсер спецификаций писать не будем и ограничимся Swagger 2.0.
Возьмём документацию Swagger PetStore, демонстрационного API зоомагазина. В нём есть операции для работы с питомцами, заказами и пользователями.
После подключения спросим ассистента: «Какая операция возвращает питомца по ID и какие у неё параметры?» Ответ он сможет найти в спецификации через инструменты нашего сервера.
В статье показаны отдельные фрагменты кода. Для запуска понадобятся полные классы импортёра, инструментов, DTO и сервиса из репозитория https://github.com/mpa12/laravel-mcp-swagger.
Что здесь делает MCP
MCP, Model Context Protocol - это протокол взаимодействия AI-приложений с внешними источниками данных и инструментами.
В нашем сценарии участвуют три стороны:
AI-приложение, или host, например Claude Code. Оно ведёт диалог с пользователем и взаимодействует с моделью.
MCP-клиент внутри этого приложения: устанавливает соединение с сервером, получает описание доступных возможностей и отправляет вызовы.
MCP-сервер: наше Laravel-приложение. Оно описывает инструменты, принимает их аргументы и возвращает результаты.
Модель не подключается к PostgreSQL самостоятельно. Клиент сообщает ей, какие инструменты доступны, модель может предложить вызов, а приложение выполняет его сообразно своим настройкам и разрешениям.
У MCP есть несколько базовых примитивов:
Примитив | Для чего нужен |
Tools | Вызываемые функции с описанием и схемой аргументов |
Resources | Данные, которые сервер предоставляет через адресуемые ресурсы |
Prompts | Подготовленные шаблоны взаимодействия |
Мы используем только tools: им удобно передавать поисковую строку, фильтры и название нужного раздела документации.
Swagger в этом проекте описывает PetStore API, а MCP даёт инструменты для чтения его документации. Вызов инструмента с именем операции `deletePet` не удалит питомца. Сервер вернёт описание того, как это делает PetStore API.
Что будем строить
Стек проекта:
Laravel;
официальный пакет
laravel/mcp;Laravel Scout с драйвером
database;PostgreSQL;
Swagger JSON публичного PetStore в качестве источника.
Поток данных разделён на два независимых этапа:
Импорт: Swagger JSON → swagger:import → таблица api_docs Запрос пользователя: AI-клиент → MCP tool → ApiDocService → api_docs → результат инструмента
Сервер не скачивает всю спецификацию при каждом вопросе. Он работает с локальным снимком, который мы обновляем отдельной Artisan-командой.
Искать будем средствами PostgreSQL через Scout, без embeddings и отдельного поискового движка. Для знакомства с MCP этого хватит.
Самому Laravel-приложению ключ API модели тоже не нужен: в этом примере оно обслуживает MCP-запросы, а не вызывает LLM.
1. Подготовим приложение
В репозитории установлены Laravel 13.33.0, laravel/mcp 1.0.1 и Laravel Scout 11.8.0. Разбор соответствует этим версиям. Репозиторий требует PHP ^8.5.
Создадим приложение через Laravel Installer, затем установим два пакета: laravel/mcp для MCP-сервера и laravel/scout для поиска. Дальше исходим из того, что PHP, Composer и Laravel Installer уже установлены. Для работы с PostgreSQL понадобится расширение PHP pdo_pgsql.
laravel new laravel-mcp-swagger cd laravel-mcp-swagger composer require laravel/mcp composer require laravel/scout
Если работаете через Laravel Sail, PHP-, Composer- и Artisan-команды внутри готового проекта выполняйте через vendor/bin/sail.
Настроим .env:
APP_URL=http://localhost:8000 DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 DB_DATABASE=laravel_mcp_swagger DB_USERNAME=postgres DB_PASSWORD=your-local-password SCOUT_DRIVER=database SWAGGER_URL=https://petstore.swagger.io/v2/swagger.json
Значения подключения к БД нужно заменить своими. Для контейнерного окружения адрес БД будет отличаться от локального. APP_URL должен совпадать с адресом, по которому доступно приложение: для локального запуска ниже это http://localhost:8000, для Sail или другого окружения укажите фактический адрес.
В config/app.php добавим адрес источника:
'swagger_url' => env('SWAGGER_URL'),
Дальше импортёр будет читать config('app.swagger_url'), а не обращаться к env() из прикладного кода.
2. Представим документацию как набор записей
Не будем хранить спецификацию одной строкой. Нам нужны небольшие записи, которые можно независимо искать и отдавать клиенту.
Основных типов три:
operation: HTTP-операция на определённом пути;definition: именованная схема;tag: описание тега.
Создадим миграцию:
php artisan make:migration create_api_docs_table
Основная часть миграции:
Schema::create('api_docs', function (Blueprint $table) { $table->id(); $table->string('type')->index(); $table->string('doc_key')->unique(); $table->string('operation_id')->nullable()->index(); $table->string('method')->nullable(); $table->string('path')->nullable(); $table->string('title'); $table->longText('content'); $table->json('meta')->nullable(); $table->timestamps(); $table->fullText(['title', 'content']); });
Здесь content служит текстом для поиска и выдачи, а meta содержит дополнительные данные, например теги операции. doc_key служит уникальным внутренним ключом документа, отдельно от operation_id из спецификации.
Для операции заголовок может выглядеть так:
GET /pet/{petId}: Find pet by ID
Для операции content содержит JSON самой операции из спецификации, с уже раскрытыми $ref: summary, description, параметры, ответы, MIME-типы и остальные поля. Общий контекст (host, basePath, глобальные security-определения) в запись не копируется: записан не дубликат всей спецификации, а ровно объект операции.
Столбец content остаётся текстовым, но его содержимое для операции представляет собой валидный JSON. Scout по-прежнему ищет по тексту этого столбца. Тело запроса хранится среди параметров, без отдельной дублирующей копии.
Подключаем Scout
php artisan make:model ApiDoc
Модель из примера:
use App\Casts\MetaDtoCast; use Illuminate\Database\Eloquent\Model; use Laravel\Scout\Attributes\SearchUsingFullText; use Laravel\Scout\Searchable; final class ApiDoc extends Model { use Searchable; protected function casts(): array { return [ 'meta' => MetaDtoCast::class, ]; } #[SearchUsingFullText(['title', 'content'])] public function toSearchableArray(): array { return [ 'id' => (int) $this->id, 'title' => $this->title, 'content' => $this->content, ]; } }
Атрибут SearchUsingFullText указывает поля полнотекстового поиска. Индекс по этим же полям создаёт миграция.
Каст MetaDtoCast приводит JSON-столбец meta к простому DTO MetaDto с полем tags: при чтении он декодирует JSON, при записи кодирует обратно. Сервис при этом может фильтровать по meta->tags напрямую в SQL, не разворачивая DTO.
Драйвер database берёт из toSearchableArray() названия столбцов и ищет по значениям в таблице. Передавать документы в Elasticsearch или Meilisearch не нужно. Если в исходном тексте есть HTML, который нужно убрать, очищаем его при импорте.
3. Импортируем Swagger
php artisan make:command SwaggerImport
Команда swagger:import выполняет несколько шагов:
Загружает JSON по
SWAGGER_URL.Разбирает JSON, проверяет формат Swagger 2.0 и раскрывает поддерживаемые
$ref.Удаляет прежние документы.
Создаёт записи операций, схем и тегов через
updateOrCreate()поdoc_key.
Для операций doc_key выглядит как operation:{operationId}, а если operationId в спецификации нет, запасной вариант operation:{METHOD}:{path}. Для схем: definition:{name}, для тегов: tag:{name}. Импортёр и сервис должны собирать эти ключи одинаково.
Зачем раскрывать $ref
В Swagger вместо самой схемы ответа часто указана ссылка на неё:
{ "schema": { "$ref": "#/definitions/Pet" } }
Для Swagger UI это нормально: интерфейс умеет переходить к нужной схеме. Но если вернуть AI-клиенту только этот фрагмент, ему понадобится ещё один запрос, чтобы узнать поля Pet.
Импортёр рекурсивно подставляет объект по локальной ссылке. Здесь есть две сложности:
сегменты указателя могут быть URL-encoded, поэтому путь декодируется через
rawurldecode();схемы могут ссылаться друг на друга или на самих себя, поэтому рекурсию необходимо ограничивать.
Импортёр раскрывает и цепочки ссылок, когда объект по ссылке сам содержит $ref. Для каждой ветви он отслеживает уже пройденные ссылки и ограничивает глубину раскрытия 30 уровнями. При цикле или достижении предела оставляет нераскрытый $ref, а не продолжает рекурсию бесконечно.
Какие спецификации поддерживаем
Берём PetStore в формате Swagger 2.0, где именованные схемы лежат в definitions.
Импортёр проверяет, что поле swagger равно "2.0". Документы без этого значения, в том числе обычные спецификации OpenAPI 3.x, отклоняются с ошибкой до удаления существующих записей. Отдельной проверки на отсутствие поля openapi нет: речь идёт о проверке маркера версии, а не о полной валидации спецификации.
Для другого источника тоже нужна спецификация Swagger 2.0. Импортёр обрабатывает только методы GET, POST, PUT, DELETE и PATCH: операции HEAD и OPTIONS он пропускает. Для выбранного PetStore этого хватает, но при работе со своим API ограничение нужно учитывать.
Когда код импортёра готов:
php artisan migrate php artisan swagger:import
Полный код импортёра находится в app/Console/Commands/SwaggerImport.php. Дальше инструменты будут читать готовые записи из api_docs, и разбирать Swagger при каждом вызове им не придётся.
4. Регистрируем MCP-сервер
php artisan make:mcp-server PetStoreDocsServer
Команда создаёт класс в app/Mcp/Servers/. Зададим имя, версию и инструкции сервера, затем перечислим инструменты. Классы из массива $tools разберём дальше. Пока они не написаны, проверять сервер рано:
namespace App\Mcp\Servers; use App\Mcp\Tools\PetStoreDefinitionsDetailsTool; use App\Mcp\Tools\PetStoreOperationDetailsTool; use App\Mcp\Tools\PetStoreSearchTool; use App\Mcp\Tools\PetStoreTagDetailsTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Version; #[Name('petstore-docs')] #[Version('1.0.0')] #[Instructions('Документация PetStore API (Swagger 2.0): поиск по операциям, схемам и тегам, детали операций и схем.')] final class PetStoreDocsServer extends Server { protected array $tools = [ PetStoreSearchTool::class, PetStoreOperationDetailsTool::class, PetStoreDefinitionsDetailsTool::class, PetStoreTagDetailsTool::class, ]; protected array $resources = []; protected array $prompts = []; }
Массивы resources и prompts пусты: для нашего поиска нужны только инструменты.
HTTP-маршрут регистрируется в routes/ai.php:
use App\Mcp\Servers\PetStoreDocsServer; use Laravel\Mcp\Facades\Mcp; Mcp::web('/mcp/petstore-docs', PetStoreDocsServer::class);
Если файла маршрутов ещё нет, его можно опубликовать из пакета:
php artisan vendor:publish --tag=ai-routes
Провайдер пакета загружает routes/ai.php. Сам HTTP-эндпоинт не показывает HTML-страницу документации: с ним общается MCP-клиент.
В прикладном коде нам не приходится вручную разбирать JSON-RPC, реализовывать обнаружение инструментов или собирать протокольные ответы. Этим занимается laravel/mcp.
Альтернатива: stdio
HTTP не единственный вариант. Сервер можно зарегистрировать как локальный:
Mcp::local('petstore-docs', PetStoreDocsServer::class);
Тогда точкой запуска будет:
php artisan mcp:start petstore-docs
При работе через stdio клиент обычно сам запускает процесс и общается с ним через стандартные потоки ввода-вывода. Команду запуска нужно указать в настройках клиента; просто выполнить её в терминале недостаточно. При HTTP-подключении клиент обращается к уже работающему приложению по URL.
В статье продолжим с HTTP; локальная регистрация понадобится, только если выберете stdio.
5. Проектируем инструменты
Вместо одного инструмента «верни всю документацию» сделаем четыре узких.
Класс | Задача | Основные аргументы |
|---|---|---|
| Найти подходящие документы |
|
| Получить полное описание операции |
|
| Получить именованную схему |
|
| Получить операции по тегу |
|
Поиск вернёт короткую выдачу. За полным описанием найденной операции клиент обратится к отдельному инструменту.
Из чего состоит tool
Создать заготовку можно Artisan-командой, после чего класс появится в app/Mcp/Tools/:
php artisan make:mcp-tool PetStoreSearchTool
У инструмента три важных части:
Описание объясняет клиенту и модели, когда его стоит использовать.
schema()описывает допустимые аргументы через JSON Schema.handle()проверяет входные данные, вызывает прикладной код и формирует ответ.
Для поиска опишем аргументы так. Ниже сокращённый пример; полный класс есть в репозитории:
use Illuminate\Contracts\JsonSchema\JsonSchema; public function schema(JsonSchema $schema): array { return [ 'query' => $schema->string() ->description('Поисковый запрос (полнотекстовый поиск по документации).') ->required(), 'type' => $schema->string() ->enum(['operation', 'definition', 'tag']), 'method' => $schema->string() ->enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH']), 'path' => $schema->string() ->description('Фильтр по точному пути операции, например /pet/{petId}.'), 'limit' => $schema->integer() ->default(10) ->max(50), ]; }
В path передаём путь из спецификации, /pet/{petId}, а не /pet/42. Это уточнение стоит включить в описание аргумента, чтобы модель не подставляла конкретный ID. Параметры type, method, path и limit необязательные: в реальном классе обязателен только query.
JSON Schema не заменяет валидацию
Схема помогает клиенту подготовить аргументы, но на сервере всё равно нужна проверка. В проекте handle() начинает работу с $request->validate(), затем передаёт данные в DTO и ApiDocService.
Например, для лимита проверяем целое число от 1 до 50, для HTTP-метода допустимые значения, для поисковой строки обязательность и тип. Значение по умолчанию сервер также должен задавать самостоятельно, а не полагаться только на default в схеме: в DTO поиска limit по умолчанию равен 10. Заодно в DTO нормализуем значения: method приводим к верхнему регистру, строки обрезаем.
Ошибки валидации полезно делать понятными: «Укажите operationId или method вместе с path» лучше объясняет, как исправить вызов, чем внутреннее исключение приложения.
Описания как часть интерфейса
В описании стоит подсказать, когда вызывать инструмент и чего ждать в ответе. Например, вместо короткого «Поиск документов»:
Поиск по документации PetStore (Swagger 2.0) с фильтрами по типу документа, HTTP-методу и пути. Возвращает operationId, метод, путь, краткое описание.
Инструменты чтения помечаем атрибутом #[IsReadOnly]. Это подсказка клиенту, а не проверка прав: атрибут не запрещает PHP-коду записывать данные в БД.
6. Отделяем поиск от MCP
В примере работа с БД вынесена в ApiDocService. Инструменту не нужно знать, как устроены запросы Scout или где хранятся теги.
Метод поиска:
public function search(PetStoreSearchDto $data): Collection { return ApiDoc::search($data->query) ->take($data->limit) ->when($data->type, fn (Builder $query) => $query->where('type', $data->type)) ->when($data->method, fn (Builder $query) => $query->where('method', $data->method)) ->when($data->path, fn (Builder $query) => $query->where('path', $data->path)) ->get(); }
Через PetStoreSearchDto передаём проверенные аргументы в сервис. DTO нужен для организации кода приложения, MCP его не требует.
В ответ на поиск возвращаем идентификатор операции, метод, путь, заголовок и короткий фрагмент содержимого. Для этого подходит Response::structured().
Разница между вариантами ответа:
Response::text(): текст документа или описания;Response::structured(): структурированный результат;Response::error(): ошибка выполнения инструмента, например отсутствие запрошенной операции.
В текущем примере инструмент возвращает Response::error(), если поиск не нашёл совпадений. Это выбор контракта, а не требование MCP. Можно возвращать успешный ответ с пустым списком: корректный поисковый запрос не обязан находить документы.
Полнотекстовый поиск не значит семантический
В нашей конфигурации Scout использует полнотекстовый поиск PostgreSQL, а не embeddings. Поэтому результат зависит от слов в документации и настроек полнотекстового поиска. Сам драйвер database в Scout 11.8 поддерживает также семантический и гибридный поиск через PostgreSQL с расширением pgvector, но в этом проекте они не настроены.
Например, запрос «питомец» может ничего не найти в англоязычной спецификации. Модель может выбрать для вызова слово pet, но сам сервер запрос не переводит и синонимы не подбирает.
Для точного operationId вообще не нужно полагаться на полнотекстовый поиск: у нас есть отдельный инструмент деталей, который ищет запись прямым запросом к БД по operation_id.
7. Получаем ровно те детали, которые нужны
Операция
В PetStoreOperationDetailsTool можно передать operationId:
{ "operationId": "getPetById" }
или:
{ "method": "GET", "path": "/pet/{petId}" }
Правила Laravel required_without_all и required_without проверяют, хватает ли аргументов для поиска операции. Если operationId не передан, нужны оба поля, method и path.
Инструмент возвращает JSON всей операции через Response::text(). Это тот самый content, который записал импортёр, с уже раскрытыми $ref. Выборки отдельных разделов (params, responses) в этом примере нет: их модель может прочитать из полного описания операции, а схему отдельного определения запросить через PetStoreDefinitionsDetailsTool.
Учитывайте одно ограничение такого формата. В Swagger 2.0 параметры могут задаваться на уровне path item и наследоваться операциями. Импортёр копирует в запись только объект операции, поэтому унаследованные параметры пути в content не попадут. Для PetStore это не проблема, там все параметры объявлены в самих операциях, но для другого API этот момент стоит доработать при импорте.
Схема
PetStoreDefinitionsDetailsTool принимает имя:
{ "name": "Pet" }
Сервис находит запись по внутреннему ключу doc_key вида definition:{name} и возвращает JSON схемы через Response::text(). Клиент передаёт только имя схемы и о формате ключа ничего не знает.
Тег
PetStoreTagDetailsTool выбирает операции по тегу из meta.tags с помощью фильтра по JSON-полю:
whereJsonContains('meta->tags', $data->tag)
Сортируем операции по пути и возвращаем структурированный список с методом, путём и заголовком каждой операции:
GET /pet/{petId}: Find pet by ID
Этот инструмент удобен, когда нужно не искать отдельное слово, а просмотреть целую группу операций.
8. Проверяем контракт отдельно от модели
В MCP Inspector можно посмотреть список инструментов, их схемы и ответы напрямую, без участия LLM.
Для запуска MCP Inspector нужны Node.js, npm и доступная команда npx. При HTTP-подключении Laravel-приложение должно уже работать: команда Inspector не запускает его HTTP-сервер.
Убедимся, что в .env указан APP_URL=http://localhost:8000, как в разделе 1. По этому адресу Inspector будет обращаться к приложению.
В первом терминале запустим приложение:
php artisan serve
Оставим его работающим. Во втором терминале, из каталога проекта, запустим Inspector для маршрута /mcp/petstore-docs:
php artisan mcp:inspector mcp/petstore-docs
Если приложение уже обслуживается через Sail или другой HTTP-сервер, дополнительно запускать php artisan serve не нужно. Важно, чтобы оно было доступно по адресу из APP_URL.
Если вместо HTTP выбрана локальная регистрация Mcp::local('petstore-docs', ...), команда другая:
php artisan mcp:inspector petstore-docs
В первом случае аргументом служит URI HTTP-маршрута, во втором имя локальной регистрации. Атрибут #[Name('petstore-docs')] эту регистрацию не заменяет. URL для Inspector команда строит из APP_URL, поэтому важно, чтобы он был актуальным.
Проверять стоит не один лишь успешный поиск:
обязательный аргумент отсутствует;
limitвыходит за допустимые границы;вместо пары
method+pathпередано одно поле;операция или схема не существует;
поиск не находит совпадений;
импорт встречает рекурсивную ссылку.
Если неправильный результат возвращается уже в Inspector, причину нужно искать в коде инструмента, а не в промпте.
9. Подключаем Claude Code
Инструменты реализованы и проверены отдельно от модели. Теперь подключим к ним ассистента. Для локального примера с публичным PetStore используем HTTP-регистрацию из раздела 4; для внутренней документации сначала потребуются доработки, перечисленные в разделе 10.
Оставим приложение из раздела 8 запущенным. Если остановили его, снова выполним php artisan serve в отдельном терминале.
В другом терминале, из каталога проекта, зарегистрируем сервер:
claude mcp add --transport http petstore-docs http://localhost:8000/mcp/petstore-docs --scope project
Здесь http://localhost:8000 является адресом локального запуска из примера. Для Sail или другого окружения нужно подставить фактический адрес приложения.
--scope project сохраняет конфигурацию в .mcp.json проекта. Ею можно поделиться с командой, но секреты не стоит записывать в отслеживаемый файл.
В Claude Code состояние MCP-подключений можно проверить через /mcp. Если клиент запросит разрешение на использование проектного сервера или вызов инструмента, его нужно подтвердить в интерфейсе клиента.
Теперь можно задать вопрос:
Найди в PetStore операцию получения питомца по ID и покажи её параметры и ответы.

Возможный сценарий работы:
Клиент получает описания инструментов от сервера.
Модель предлагает поиск, например с запросом
petи фильтром по методуGET.Клиент вызывает инструмент и передаёт модели результат.
Модель выбирает операцию и запрашивает её детали через соответствующий инструмент.
Получив документацию, модель отвечает пользователю.
Это иллюстрация, а не гарантированный порядок вызовов. Если пользователь уже указал getPetById, поиск может оказаться лишним.
Ответ стоит сверять с результатом инструмента: доступ к документации не исключает ошибок модели. Кроме того, в БД может лежать устаревший снимок спецификации.
10. Что доработать перед использованием в проекте
Публичная документация PetStore подходит для локального эксперимента. Прежде чем подключать внутреннюю документацию, нужно:
Заменять снимок атомарно, чтобы при сбое импорта прежняя документация оставалась доступной. Сейчас команда удаляет старые записи только после успешной загрузки и разбора JSON, но сама вставка не обёрнута в транзакцию. Сохранять дату обновления или версию источника.
Добавить аутентификацию, проверку прав и ограничение частоты запросов. Атрибут
IsReadOnlyза это не отвечает.Ограничить длину ответов и добавить пагинацию. Даже одна схема может оказаться слишком большой, поэтому одного лимита на число результатов поиска мало.
Переносить в записи операций унаследованные от path item параметры и нужный контекст API (host, basePath, security-определения), если спецификация ими пользуется.
Что получилось
Получился сервер, который ищет по локальной копии Swagger и возвращает нужные разделы документации. Импорт, хранение и поиск реализованы обычным кодом Laravel. laravel/mcp отвечает за то, чтобы AI-клиент мог узнать об инструментах и вызвать их.
Для следующего эксперимента можно взять спецификацию своего API. Сначала проверить, что импортёр корректно её разбирает, затем вызвать инструменты через Inspector и только после этого подключить ассистента. Так будет проще понять, на каком этапе что-то пошло не так.

