Основы≈ 20 мин с практикой

Контракт MCP: проверьте схему и ответ своего инструмента

В первом уроке сервер вернул Draft. Теперь выясним, какие сведения клиент получает о нём до вызова: имя, описание, обязательные аргументы, схему ответа и согласованную версию протокола. Результатом будет исполняемый инспектор, который проверяет контракт через SDK-клиент, сохраняет внутренние пробелы и получает ожидаемый отказ для неизвестного инструмента.

В результате

Проверенный контракт, схема инструмента и отказ неизвестного tool.

Перед началом

Первый сервер и его окружение; в этом уроке создадим общий SDK-клиент.

Скачать руководство целиком · Markdown
Граница версии

Python SDK v1 · mcp==1.30.0 · протокол 2025-11-25. Для SDK v2 требуется отдельная миграция.

Перед началом

Завершите первый сервер. Откройте PowerShell в его папке mcp-label-lab. В ней уже должны быть .venv, requirements-label.txt, label_server.py и check_stdio.py. Установка зависимостей заново не нужна. Проверьте интерпретатор:

PowerShell
.\.venv\Scripts\python.exe -c "from importlib.metadata import version; print(version('mcp'))"
PowerShellPowerShell
.\.venv\Scripts\python.exe -c "from importlib.metadata import version; print(version('mcp'))"

Ожидается 1.30.0. При ModuleNotFoundError вернитесь к установке в первом уроке. Этот материал не требует входа в редактор или ключа модели.

1. Создайте общий клиент лаборатории

Сохраните lab_session.py рядом с сервером. Этот файл используется и в следующих уроках. Он запускает только один из трёх явно разрешённых учебных серверов, открывает stdio, выполняет initialize и закрывает сессию вместе с дочерним процессом при выходе из контекста:

lab_session.pyPython
Скачать
"""Open one supplied lab server using the pinned Python SDK's stdio client."""
from contextlib import asynccontextmanager
import os
from pathlib import Path
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


@asynccontextmanager
async def lab_session(filename: str = "label_server.py"):
    if filename not in {"label_server.py", "validation_server.py", "context_server.py"}:
        raise ValueError("Choose a supplied lab server")
    server = Path(__file__).resolve().parent / filename
    if not server.is_file():
        raise FileNotFoundError("Save " + filename + " beside lab_session.py")
    parameters = StdioServerParameters(
        command=sys.executable,
        args=["-u", str(server)],
        env={"PYTHONPATH": os.environ.get("PYTHONPATH", "")},
    )
    async with stdio_client(parameters) as (reader, writer):
        async with ClientSession(reader, writer) as session:
            initialized = await session.initialize()
            yield session, initialized
lab_session.pyPython
"""Open one supplied lab server using the pinned Python SDK's stdio client."""
from contextlib import asynccontextmanager
import os
from pathlib import Path
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


@asynccontextmanager
async def lab_session(filename: str = "label_server.py"):
    if filename not in {"label_server.py", "validation_server.py", "context_server.py"}:
        raise ValueError("Choose a supplied lab server")
    server = Path(__file__).resolve().parent / filename
    if not server.is_file():
        raise FileNotFoundError("Save " + filename + " beside lab_session.py")
    parameters = StdioServerParameters(
        command=sys.executable,
        args=["-u", str(server)],
        env={"PYTHONPATH": os.environ.get("PYTHONPATH", "")},
    )
    async with stdio_client(parameters) as (reader, writer):
        async with ClientSession(reader, writer) as session:
            initialized = await session.initialize()
            yield session, initialized

stdio_client управляет транспортом, ClientSession — MCP-сообщениями. Через один транспорт могут идти вызовы разных методов; это не означает, что tool, resource и prompt являются одним объектом. В реальном приложении host управляет пользовательским интерфейсом и разрешениями, клиент ведёт соединение, а сервер предоставляет функции и контекст. Здесь host заменён небольшим проверочным скриптом, который ничего не поручает модели.

2. Сохраните полный инспектор

Сохраните inspect_contract.py:

inspect_contract.pyPython
Скачать
"""Discover the actual MCP contract; no direct call to the server function."""
import asyncio
import json
from lab_session import lab_session


async def main():
    async with lab_session() as (session, initialized):
        listing = await session.list_tools()
        assert initialized.protocolVersion == "2025-11-25"
        assert initialized.capabilities.tools is not None
        assert [tool.name for tool in listing.tools] == ["normalize_label"]
        tool = listing.tools[0]
        assert tool.inputSchema["properties"]["text"]["type"] == "string"
        assert "text" in tool.inputSchema["required"]
        result = await session.call_tool("normalize_label", {"text": "  two  words  "})
        assert result.isError is False
        assert result.structuredContent == {"label": "two  words"}
        unknown = await session.call_tool("missing_tool", {"text": "Draft"})
        assert unknown.isError is True
        print("PASS: discovered schema and preserved internal spaces")
        print(json.dumps({"protocol": initialized.protocolVersion, "server": initialized.serverInfo.name, "tool": tool.model_dump(mode="json", exclude_none=True), "success": result.model_dump(mode="json", exclude_none=True), "unknown_tool": unknown.model_dump(mode="json", exclude_none=True)}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    asyncio.run(main())
inspect_contract.pyPython
"""Discover the actual MCP contract; no direct call to the server function."""
import asyncio
import json
from lab_session import lab_session


async def main():
    async with lab_session() as (session, initialized):
        listing = await session.list_tools()
        assert initialized.protocolVersion == "2025-11-25"
        assert initialized.capabilities.tools is not None
        assert [tool.name for tool in listing.tools] == ["normalize_label"]
        tool = listing.tools[0]
        assert tool.inputSchema["properties"]["text"]["type"] == "string"
        assert "text" in tool.inputSchema["required"]
        result = await session.call_tool("normalize_label", {"text": "  two  words  "})
        assert result.isError is False
        assert result.structuredContent == {"label": "two  words"}
        unknown = await session.call_tool("missing_tool", {"text": "Draft"})
        assert unknown.isError is True
        print("PASS: discovered schema and preserved internal spaces")
        print(json.dumps({"protocol": initialized.protocolVersion, "server": initialized.serverInfo.name, "tool": tool.model_dump(mode="json", exclude_none=True), "success": result.model_dump(mode="json", exclude_none=True), "unknown_tool": unknown.model_dump(mode="json", exclude_none=True)}, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    asyncio.run(main())

В tools/list вы увидите строковое обязательное поле text. Схема выхода dict[str, str] описывает объект со строковыми значениями, но сама по себе не закрепляет единственный ключ label. Поэтому клиент дополнительно сравнивает конкретное structuredContent. Правило длины 1–80 пока находится в коде функции и описании, а не в JSON Schema. Следующий урок покажет эту границу на дополнительном аргументе.

3. Запустите и прочитайте результат

PowerShell
.\.venv\Scripts\python.exe inspect_contract.py
PowerShellPowerShell
.\.venv\Scripts\python.exe inspect_contract.py

Первая строка должна быть:

Ожидаемый вывод
PASS: discovered schema and preserved internal spaces
Ожидаемый вывод
PASS: discovered schema and preserved internal spaces

Ниже будет JSON с protocol: "2025-11-25", server: "label-lab" и полной схемой инструмента. success.structuredContent должен совпасть с:

Ожидаемый JSONJSON
{
  "label": "two  words"
}
Ожидаемый JSONJSON
{
  "label": "two  words"
}

Два пробела внутри сохранились, крайние исчезли. В unknown_tool ожидаются isError: true и текст Unknown tool: missing_tool. Для этого SDK ошибка неизвестного tool приходит как результат вызова с флагом ошибки; клиент не должен принимать его за успешные данные.

Если проверка остановилась

  • Choose a supplied lab server означает неподдерживаемое имя в lab_session; используйте приведённый код целиком.
  • Save label_server.py beside lab_session.py означает, что файл сервера лежит в другой папке или имеет расширение .py.txt.
  • AssertionError на версии или схеме — сигнал изменения контракта. Сверьте версию пакета и восстановите учебный сервер из первого урока; не удаляйте assert ради зелёного результата.
  • Прямой запуск сервера ждёт клиента. Запускайте инспектор, который сам создаёт процесс.

Проверьте себя

В копии инспектора замените ожидаемый результат на {"label":"two words"}. Проверка должна завершиться ненулевым кодом, поскольку инструмент не склеивает внутренние пробелы. Верните точное ожидаемое значение и получите PASS. Так вы отличите работающий тест контракта от скрипта, который просто печатает произвольный ответ.

Версия и источники

Упражнение закреплено за Python 3.11 и mcp==1.30.0, SDK v1, протоколом 2025-11-25. SDK v2 требует отдельной миграции. Примеры используют синтетические подписи, без внешнего API и модели. Документация сверена 8 октября 2026 года.

Проверены реальный initialize, tools/list, tools/call и отказ неизвестного инструмента на установленном SDK 1.30.0. Интерфейс редактора здесь не проверяется.

Источники: архитектура MCP (в новой вкладке), клиент SDK v1 (в новой вкладке), контракт tools 2025-11-25 (в новой вкладке), жизненный цикл (в новой вкладке).

Вопросы по материалу

Зачем сравнивать ответ, если схема уже прочитана?

Схема dict[str, str] разрешает объект со строковыми значениями, но сама по себе не обещает поле label. Инспектор дополнительно сравнивает structuredContent с ожидаемой подписью и двумя внутренними пробелами.

Неизвестный инструмент — это успешный пустой ответ?

В примере на SDK 1.30.0 вызов missing_tool возвращает результат с isError: true. Проверка ожидает этот отказ. Не используйте его как успешные данные и не переносите это поведение на другие методы или версии без проверки.

Продукты ATOMNO

Другие задачи для AI-помощника

Это отдельные продукты ATOMNO для задач с данными. Они не входят в учебные примеры.

Все продукты ATOMNO (в новой вкладке)