Продвинутый практикум
Как тестировать согласование возможностей между MCP-клиентом и сервером
Успешное соединение еще не означает совместимость. Клиент может принять версию протокола, но затем вызвать не объявленный сервером метод; сервер — отправить запрос, для которого клиент не заявил поддержку; HTTP-прокси — удалить заголовок с согласованной версией. Контрактные тесты должны обнаруживать такие расхождения до развертывания.
Что именно требуется согласовать
Model Context Protocol (MCP) разделяет обязательный базовый протокол и необязательные возможности. Инициализация устанавливает три части контракта:
- версию протокола, используемую в соединении;
- возможности клиента, например
roots,samplingилиelicitation; - возможности сервера, например
tools,resources,prompts,loggingиcompletions.
Наличие верхнеуровневой возможности и ее подфункции — разные утверждения. Например, "resources": {} разрешает операции с ресурсами, но не подтверждает подписки. Для них требуется "resources": {"subscribe": true}. Аналогично, tools.listChanged сообщает о поддержке уведомлений об изменении списка, а не просто о наличии инструментов.
Шаг 1. Зафиксируйте политику совместимости
Не начинайте с сетевого теста. Сначала представьте политику клиента как данные. Ниже — пример, а не требование спецификации:
{
"supportedProtocolVersions": [
"2025-11-25",
"2025-06-18"
],
"requiredServerCapabilities": [
"tools"
],
"optionalServerCapabilities": [
"resources",
"resources.subscribe",
"tools.listChanged"
]
}
Разделение обязательных и необязательных возможностей предотвращает две крайности: запуск заведомо неработоспособной сессии и отказ из-за функции, без которой приложение умеет работать.
| Условие | Ожидаемое решение клиента |
|---|---|
| Версия ответа входит в поддерживаемый список | Принять ее как версию сессии |
| Версия ответа не поддерживается | Не отправлять notifications/initialized, закрыть соединение |
| Нет обязательной возможности | Завершить запуск с диагностикой |
| Нет необязательной возможности | Отключить зависимую функцию |
Подфункция явно равна false или отсутствует |
Не использовать подфункцию |
| Присутствует неизвестное поле | Не падать; сохранить совместимость с расширяемой схемой |
Шаг 2. Проверьте форму и порядок инициализации
Первым обычным взаимодействием должен быть запрос initialize. Клиент передает поддерживаемую версию, свои capabilities и clientInfo. После успешного ответа он отправляет уведомление notifications/initialized.
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "contract-test-client",
"version": "0.0.0"
}
}
}
Имена и версии в этом фрагменте — тестовые значения. Они не обозначают существующий продукт.
Зафиксируйте в тестовом транспорте все исходящие сообщения и проверьте:
initializeотправлен раньше прикладных запросов;- идентификатор ответа совпадает с идентификатором запроса;
- ответ содержит строковый
protocolVersion, объектcapabilitiesи корректныйserverInfo; notifications/initializedпоявляется только после полной валидации ответа;- при ошибке, тайм-ауте или несовместимой версии уведомление не отправляется.
Шаг 3. Вынесите решение в чистую функцию
Сетевой код трудно покрыть всей матрицей. Практичнее отдельно тестировать функцию, которая превращает ответ сервера в решение: принять соединение, включить ограниченный режим или отказать.
// negotiation.mjs — пример
function hasCapability(capabilities, path) {
let value = capabilities;
for (const part of path.split(".")) {
if (value === null || typeof value !== "object" ||
!Object.prototype.hasOwnProperty.call(value, part)) {
return false;
}
value = value[part];
}
return value !== false && value !== undefined;
}
export function negotiate(policy, initializeResult) {
if (!policy.supportedProtocolVersions.includes(
initializeResult.protocolVersion
)) {
return {
ok: false,
reason: "unsupported_protocol_version"
};
}
const capabilities = initializeResult.capabilities ?? {};
const missing = policy.requiredServerCapabilities.filter(
capability => !hasCapability(capabilities, capability)
);
if (missing.length > 0) {
return {
ok: false,
reason: "missing_required_capabilities",
missing
};
}
return {
ok: true,
protocolVersion: initializeResult.protocolVersion,
enabledOptionalCapabilities:
policy.optionalServerCapabilities.filter(
capability => hasCapability(capabilities, capability)
)
};
}
Эта функция демонстрирует архитектуру теста, но не заменяет официальную схему MCP. В рабочем коде сначала валидируйте типы ответа, а уже затем применяйте продуктовую политику.
Шаг 4. Составьте минимальную матрицу контрактных тестов
Для воспроизводимости можно использовать встроенный в Node.js модуль node:test. Команда не устанавливает пакеты и не обращается к сети:
node --test negotiation.test.mjs
// negotiation.test.mjs — пример
import test from "node:test";
import assert from "node:assert/strict";
import { negotiate } from "./negotiation.mjs";
const policy = {
supportedProtocolVersions: ["2025-11-25", "2025-06-18"],
requiredServerCapabilities: ["tools"],
optionalServerCapabilities: [
"resources",
"resources.subscribe",
"tools.listChanged"
]
};
test("принимает поддерживаемую версию и обязательную capability", () => {
const result = negotiate(policy, {
protocolVersion: "2025-11-25",
capabilities: { tools: {} },
serverInfo: { name: "fixture", version: "0.0.0" }
});
assert.equal(result.ok, true);
assert.equal(result.protocolVersion, "2025-11-25");
});
test("отклоняет неизвестную клиенту версию", () => {
const result = negotiate(policy, {
protocolVersion: "2099-01-01",
capabilities: { tools: {} },
serverInfo: { name: "fixture", version: "0.0.0" }
});
assert.deepEqual(result, {
ok: false,
reason: "unsupported_protocol_version"
});
});
test("отклоняет ответ без обязательной capability", () => {
const result = negotiate(policy, {
protocolVersion: "2025-11-25",
capabilities: { resources: {} },
serverInfo: { name: "fixture", version: "0.0.0" }
});
assert.deepEqual(result, {
ok: false,
reason: "missing_required_capabilities",
missing: ["tools"]
});
});
test("не включает subscribe по одному наличию resources", () => {
const result = negotiate(policy, {
protocolVersion: "2025-11-25",
capabilities: {
tools: {},
resources: {}
},
serverInfo: { name: "fixture", version: "0.0.0" }
});
assert.equal(result.ok, true);
assert.deepEqual(result.enabledOptionalCapabilities, ["resources"]);
});
test("переносит неизвестные поля без ошибки", () => {
const result = negotiate(policy, {
protocolVersion: "2025-11-25",
capabilities: {
tools: {},
"com.example/future": { mode: "fixture" }
},
serverInfo: {
name: "fixture",
version: "0.0.0",
extraField: true
}
});
assert.equal(result.ok, true);
});
Добавьте еще четыре интеграционных сценария на уровне транспорта:
- тайм-аут ответа на
initializeприводит к отмене ожидания и закрытию транспорта; - JSON-RPC error не интерпретируется как успешный результат;
- серверный запрос, зависящий от не объявленной клиентом capability, отклоняется предсказуемо;
- прикладной метод не отправляется до завершения рукопожатия.
Шаг 5. Отдельно протестируйте Streamable HTTP
Тесты JSON-RPC не обнаружат ошибку reverse proxy или HTTP-адаптера. Для HTTP-транспорта проверьте как минимум заголовки, тип ответа и идентификатор сессии, если сервер его выдает.
После инициализации запросы должны использовать согласованную версию в заголовке MCP-Protocol-Version. Не подставляйте автоматически последнюю известную клиенту версию: сервер мог выбрать другую поддерживаемую редакцию.
POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2025-11-25
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
Это образец формы запроса, а не команда к реальному адресу. В тесте направляйте его только на локальный стенд или контролируемый тестовый endpoint. Не помещайте токены, cookie и рабочие URL в фикстуры.
Контракт HTTP-адаптера должен проверять:
- заголовок версии отсутствует в первоначальном
initialize, но присутствует в последующих запросах; - его значение совпадает с версией из принятого ответа сервера;
Acceptдопускаетapplication/jsonиtext/event-stream;- клиент корректно обрабатывает оба разрешенных типа ответа;
- заголовки сессии, если они применяются в выбранной редакции и возвращены сервером, не смешиваются между параллельными соединениями.
Проверка результата
Набор готов, если каждый переход состояния наблюдаем и имеет отрицательный сценарий. Полезная итоговая проверка выглядит так:
NEW
→ initialize sent
→ response validated
→ version accepted
→ required capabilities present
→ notifications/initialized sent
→ READY
Любой отказ до состояния READY должен исключать прикладные вызовы. В журнале диагностики достаточно записывать выбранную версию, названия отсутствующих capabilities и категорию ошибки. Полные тела сообщений могут содержать чувствительные данные, поэтому не включайте их в логи по умолчанию.
Запустите одну и ту же матрицу минимум против локального транспорта-двойника, HTTP-адаптера и сборки, максимально близкой к production. Именно повторное использование одинаковых контрактов выявляет различия окружений.
Типовые ошибки
- Сравнение версий как дат или чисел
- Версия MCP — строковый идентификатор редакции. Поддерживайте явное множество допустимых значений, а не условие вида «не старше».
- Проверка только верхнего уровня
- Наличие
resourcesне доказывает поддержкуresources.subscribe; наличиеtoolsне доказываетtools.listChanged. - Capabilities трактуются как список методов
- Методы и capabilities связаны правилами конкретной редакции спецификации. Храните такую связь в одном слое, а не размазывайте по обработчикам.
- Неизвестное поле считается ошибкой
- Объекты возможностей расширяемы. Валидатор должен строго проверять обязательные поля, но не ломаться только из-за дополнительного корректно сформированного поля.
- Уведомление initialized отправляется слишком рано
- Сначала проверьте схему, версию и обязательные возможности. Иначе сервер считает сессию готовой, хотя клиент уже решил завершить ее.
- Моки повторяют реализацию клиента
- Фикстуры должны описывать сообщения на границе протокола. Если мок вызывает те же внутренние функции, что и production-код, тест не обнаружит ошибку сериализации или транспорта.
Ограничения подхода
Capabilities подтверждают заявленную поддержку, но не корректность реализации. Сервер может объявить tools и ошибаться на tools/list; это проверяется отдельными поведенческими тестами. Контрактные тесты также не заменяют проверки авторизации, повторного подключения, отмены, ограничений размера сообщений и безопасности данных.
Редакции MCP различаются. Поля, обязательность заголовков и экспериментальные функции следует привязывать к конкретной версии схемы. Не смешивайте правила текущей, старой и draft-редакций в одном безусловном валидаторе.
Контрольный список
- поддерживаемые версии заданы явным списком;
- обязательные и необязательные capabilities разделены;
- проверены порядок
initializeиnotifications/initialized; - есть отрицательные тесты версии, схемы, тайм-аута и capabilities;
- подвозможности проверяются отдельно;
- неизвестные расширения не вызывают аварийного отказа;
- HTTP-заголовок содержит именно согласованную версию;
- до состояния
READYприкладные методы заблокированы; - фикстуры не содержат секретов и production-адресов;
- одна матрица выполняется во всех целевых окружениях.
Материалы
Продолжить настройку интеграции можно в разделе практических гайдов, а определения протокольных терминов собраны в глоссарии Agent Lab Journal.
Для сверки контрактов используйте первичные источники: схему MCP 2025-11-25, описание жизненного цикла и согласования и требования к транспортам.