Соберём на 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 выполняет несколько шагов:

  1. Загружает JSON по SWAGGER_URL.

  2. Разбирает JSON, проверяет формат Swagger 2.0 и раскрывает поддерживаемые $ref.

  3. Удаляет прежние документы.

  4. Создаёт записи операций, схем и тегов через 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. Проектируем инструменты

Вместо одного инструмента «верни всю документацию» сделаем четыре узких.

Класс

Задача

Основные аргументы

PetStoreSearchTool

Найти подходящие документы

query, type, method, path, limit

PetStoreOperationDetailsTool

Получить полное описание операции

operationId либо method + path

PetStoreDefinitionsDetailsTool

Получить именованную схему

name

PetStoreTagDetailsTool

Получить операции по тегу

tag

Поиск вернёт короткую выдачу. За полным описанием найденной операции клиент обратится к отдельному инструменту.

Из чего состоит tool

Создать заготовку можно Artisan-командой, после чего класс появится в app/Mcp/Tools/:

php artisan make:mcp-tool PetStoreSearchTool

У инструмента три важных части:

  1. Описание объясняет клиенту и модели, когда его стоит использовать.

  2. schema() описывает допустимые аргументы через JSON Schema.

  3. 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 и покажи её параметры и ответы.

Возможный сценарий работы:

  1. Клиент получает описания инструментов от сервера.

  2. Модель предлагает поиск, например с запросом pet и фильтром по методу GET.

  3. Клиент вызывает инструмент и передаёт модели результат.

  4. Модель выбирает операцию и запрашивает её детали через соответствующий инструмент.

  5. Получив документацию, модель отвечает пользователю.

Это иллюстрация, а не гарантированный порядок вызовов. Если пользователь уже указал getPetById, поиск может оказаться лишним.

Ответ стоит сверять с результатом инструмента: доступ к документации не исключает ошибок модели. Кроме того, в БД может лежать устаревший снимок спецификации.

10. Что доработать перед использованием в проекте

Публичная документация PetStore подходит для локального эксперимента. Прежде чем подключать внутреннюю документацию, нужно:

  • Заменять снимок атомарно, чтобы при сбое импорта прежняя документация оставалась доступной. Сейчас команда удаляет старые записи только после успешной загрузки и разбора JSON, но сама вставка не обёрнута в транзакцию. Сохранять дату обновления или версию источника.

  • Добавить аутентификацию, проверку прав и ограничение частоты запросов. Атрибут IsReadOnly за это не отвечает.

  • Ограничить длину ответов и добавить пагинацию. Даже одна схема может оказаться слишком большой, поэтому одного лимита на число результатов поиска мало.

  • Переносить в записи операций унаследованные от path item параметры и нужный контекст API (host, basePath, security-определения), если спецификация ими пользуется.

Что получилось

Получился сервер, который ищет по локальной копии Swagger и возвращает нужные разделы документации. Импорт, хранение и поиск реализованы обычным кодом Laravel. laravel/mcp отвечает за то, чтобы AI-клиент мог узнать об инструментах и вызвать их.

Для следующего эксперимента можно взять спецификацию своего API. Сначала проверить, что импортёр корректно её разбирает, затем вызвать инструменты через Inspector и только после этого подключить ассистента. Так будет проще понять, на каком этапе что-то пошло не так.

Ссылки