json-rpc-server

08 — MCP

Model Context Protocol — стандарт Anthropic для выставления tools и ресурсов LLM-клиентам (Claude Desktop, ваши собственные LLM-агенты, etc.). Бандл выставляет любой RPC-метод как MCP tool без дубляжа.

Два endpoint’а

Path Метод Возвращает
/mcp/tools GET {"tools": [{name, description, roles, inputSchema, outputSchema?, annotations?}]}
/mcp/call POST {"content": [...], "structuredContent": ...}

Body /mcp/call:

{ "name": "user.get", "arguments": { "email": "x@y" } }

Включение MCP

MCP по дефолту выключен/mcp/tools и /mcp/call не регистрируются пока вы не включите:

json_rpc_server:
  mcp:
    enabled: true

Flex-recipe приходит с mcp.enabled: false по той же причине: большинство проектов MCP не потребляют, а живой /mcp/tools — это небольшая поверхность fingerprinting для анонимных клиентов.

Opt-in методов

Когда MCP включён, экспонируются только методы с #[Rpc\Mcp]:

#[Rpc\Method('user.get')]
#[Rpc\Mcp(description: 'Найти пользователя по email.')]
final class GetUser { /* … */ }

Чтобы выставить всё, кроме нескольких:

json_rpc_server:
  mcp:
    expose_all: true
    exclude_prefixes: ['internal.', 'debug.']
    exclude_methods: ['user.delete']

Запретить всё, кроме нескольких:

json_rpc_server:
  mcp:
    whitelist_methods: ['user.get', 'user.list']

Приоритет фильтра (first match wins):

  1. exclude_methods — явный deny
  2. whitelist_methods — явный allow
  3. #[Rpc\Mcp(enabled: false)] — opt-out разработчика
  4. Метод deprecated (и нет явного #[Rpc\Mcp]) → скрыт
  5. exclude_prefixes — bulk deny
  6. expose_all: true → exposed
  7. #[Rpc\Mcp] присутствует → exposed
  8. Иначе → скрыт

Operator config (exclude_*, whitelist_*) бьёт атрибут разработчика — у владельца деплоя последнее слово.

Полное выключение MCP

mcp.enabled: false (дефолт) снимает routes и services. Чтобы отключить обратно после включения:

json_rpc_server:
  mcp:
    enabled: false

JsonSchemaBuilder остаётся доступен в любом случае — debug:rpc --openrpc работает.

Input schema

Бандл precompute-ит JSON Schema draft-07 фрагмент для input’а каждого метода на сборке контейнера. /mcp/tools отдаёт их напрямую — никакой reflection на каждый запрос.

Покрытие:

Источник JSON Schema
string, int, float, bool, array {type: "..."}
array + PHPDoc list<Dto> / Dto[] {type: "array", items: {<object-схема Dto>}}items это объект-схема, не []
array + PHPDoc array<string, Dto> {type: "object", additionalProperties: {<схема Dto>}} — соответствует JSON-объекту в params, не JSON-массиву
?T {type: ["T", "null"]}
Backed enum {type, enum: [...]}
Обычный enum {type: "string", enum: [...]}
\DateTimeInterface зависит от datetime_formatstring/date-time или integer
Type\Date {type: "string", format: "date"}
#[Assert\Length(min, max)] minLength, maxLength
#[Assert\Range(min, max)] minimum, maximum
#[Assert\Positive] exclusiveMinimum: 0
#[Assert\Email] format: email
#[Assert\Url] format: uri
#[Assert\Regex] pattern: ...
#[Assert\Choice] enum: [...]

Незнакомые констрейнты пропускаются (не угадываются).

Output schema

/mcp/tools дополнительно отдаёт outputSchema рядом с inputSchema, когда её получается вывести — MCP-клиент (и LLM за ним) заранее знает, какой формы ожидать structuredContent. Та же схема переиспользуется в OpenRPC как result.schema, так что оба контракта совпадают байт-в-байт.

Источники по убыванию приоритета:

  1. #[Rpc\Method(outputSchema: SomeDto::class)] — схематизируется через JsonSchemaBuilder (тот же путь, что и DTO в inputSchema).
  2. #[Rpc\Method(outputSchema: [...])] — литеральный JSON Schema массив, используется как есть. Удобно когда ответ — array ручной сборки и хочется показать клиентам реальные ключи.
  3. Тип возврата __invoke() — авто-детект: scalar → {type: …}, class/enum → JsonSchemaBuilder::fromClass(…).
  4. Иначе (array, mixed, void, отсутствует) → поле опускается. Клиент видит «форма не объявлена», а не бессмысленную заглушку {type: array}.
#[Rpc\Method('user.get', outputSchema: UserDto::class)]
#[Rpc\Mcp(description: 'Найти пользователя по id.')]
final class GetUser
{
    /** @return array<string, mixed> */
    public function __invoke(GetUserRequest $req): array { /* ... */ }
}

Схема только подсказка: уходит клиенту как hint и никогда не валидируется против реального ответа. Расхождение между объявленной формой и тем, что вернул __invoke, остаётся на совести разработчика — бандл ничего не приводит и не отвергает.

Аннотации тула

MCP tools/list[].annotations — это подсказки клиенту (и LLM за ним): нужно ли спросить пользователя перед вызовом, можно ли ретраить, и т.п. Они никогда не гейтят исполнение — за безопасность по-прежнему отвечают roles/авторизация.

#[Rpc\Method('user.delete')]
#[Rpc\Mcp(
    description: 'Удалить пользователя по id.',
    title: 'Удалить пользователя',
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: false,
    openWorldHint: false,
)]
final class DeleteUser { /* ... */ }
Поле Тип Дефолт (MCP-спека) Что значит
title string Human-friendly название; клиент фоллбечится на имя метода при отсутствии.
readOnlyHint bool false true если вызов не меняет состояние окружения.
destructiveHint bool true true если может удалить или деструктивно изменить state. Имеет смысл только при readOnlyHint: false.
idempotentHint bool false true если повтор вызова с теми же аргументами не даёт дополнительного эффекта.
openWorldHint bool true true если тул ходит во внешние системы (третьи API, интернет).

Если поле null (дефолт бандла) — значит «не задано»: бандл либо авто-выводит его (см. ниже), либо опускает поле, и клиент использует дефолт из MCP-спеки.

Авто-вывод из #[Rpc\Cache]

Метод с #[Rpc\Cache] по определению — функция своих аргументов и не должен мутировать наблюдаемое состояние при cache hit. Поэтому бандл выставляет:

когда ни одно из этих полей не задано явно. Явный false в атрибуте всегда побеждает — авто-вывод заполняет только null-слоты, никогда не перетирает намерение разработчика.

annotations опускается из entry целиком, если ни одно поле не задано и ни один авто-вывод не сработал — клиент видит дефолты спеки, а не пустой объект.

Форматы результата

Как __invoke output рендерится в MCP content. Дефолт — компактный JSON; выбирайте по нужде LLM:

#[Rpc\Mcp(format: McpFormat::Toon)]
Формат Wire Когда
json (default) compact JSON Большинство кейсов.
pretty_json JSON с отступами Дебаг через Claude Desktop.
markdown Markdown table если однородные ряды; JSON иначе Human-readable summaries.
plain Строковое представление scalar’ов; JSON для структур One-line scalar results.
toon TOON (token-efficient) LLM list payloads — 30–50% меньше токенов.

Плюс structuredContent (нормализованная объектная форма) всегда добавляется рядом с content для non-scalar результатов — MCP spec рекомендует это, чтобы machine-parsing клиентам не приходилось re-парсить текстовый блок.

Приоритет резолва формата

  1. X-Mcp-Format: toon header запроса
  2. ?format=toon query parameter
  3. #[Rpc\Mcp(format: McpFormat::Toon)] атрибут
  4. json_rpc_server.mcp.default_format конфиг бандла
  5. Дефолт: json

Кастомизация результата: McpResultTransformer

Когда JSON-RPC response содержит поля, которые не должна видеть LLM (внутренние IDs, debug-флаги, cache-ключи), реализуйте McpResultTransformer на handler’е:

use Knetesin\JsonRpcServerBundle\Mcp\McpResultTransformer;

#[Rpc\Method('user.getById')]
#[Rpc\Mcp]
final class GetById implements McpResultTransformer
{
    public function __invoke(GetByIdRequest $req): UserResponse { /* ... */ }

    public function transformMcpResult(mixed $result): mixed
    {
        // $result уже нормализован (array form).
        unset($result['internalDebugFlags'], $result['cacheKey']);
        return $result;
    }
}

Запускается после __invoke и после нормализации. JSON-RPC /rpc ответ не затрагивается — только /mcp/call видит трансформированный output.

Для bulk-перешейпинга нескольких методов лучше кастомный McpResultFormatter (декоратор DefaultMcpResultFormatter).

Description

#[Rpc\Mcp(description: 'Получить профиль юзера по email. Возвращает id, email, name.')]

Откатывается на #[Rpc\Method(description: ...)] если опущен.

Rate limiting для MCP

#[Rpc\RateLimit] не применяется к /mcp/call по дефолту — MCP-трафик обычно от доверенного внутреннего агента. Включите для публичного MCP:

json_rpc_server:
  mcp:
    apply_rate_limit: true

HTTP-статусы

Тип Статус Body
Parse / невалидный envelope 400 {isError: true, error: {...}, content: [text]}
Method not found / не exposed 404 то же
Body too large 413 то же
Auth, rate limit, invalid params, internal error 200 то же

200 для handler-level ошибок — MCP-конвенция. Клиенты проверяют isError: true в body, не HTTP-статус.

Подключение Claude Desktop

{
  "mcpServers": {
    "myapp": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-fetch", "https://api.example.com/mcp"]
    }
  }
}

Или любой MCP HTTP-транспорт, который вызывает /mcp/tools и /mcp/call.

Формат TOON — когда выигрывает

TOON кодирует списки однородных плоских объектов как табличную форму:

users[3]{id,name,email}:
  1,Alice,alice@example.com
  2,Bob,bob@example.com
  3,Carol,carol@example.com

vs JSON:

[{"id":1,"name":"Alice","email":"alice@example.com"},
 {"id":2,"name":"Bob","email":"bob@example.com"},
 {"id":3,"name":"Carol","email":"carol@example.com"}]

Для 100 рядов × 6 колонок JSON-версия ~2× токенов. Дефолт остаётся JSON, потому что большинство LLM ровнее round-trip’ят JSON; переключайтесь на toon для read-heavy listing-методов осознанно.