Практика · HTTP API

Свой MCP tool поверх HTTP API: клиент, схема и один безопасный запрос

Соберите инструмент get_artifact, который читает одну запись inventory. HTTP-клиент отвечает за запрос и разбор ответа, MCP — за аргументы и контракт. Все ответы в этой практике синтетические.

В результате

Три полных файла: HTTP-клиент, тонкий MCP tool и проверка tools/list → tools/call на настоящем SDK, без сети.

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

Python 3.11, первый рабочий MCP tool и понимание входной схемы. Начните с уроков о первом сервере и валидации.

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

Python 3.11 · mcp==1.30.0 · httpx==0.28.1 · pydantic==2.13.5. MCP-клиент работает в памяти, HTTP — через MockTransport, без внешнего API, модели и реального ключа.

1. Зафиксируйте небольшой контракт

Учебный inventory хранит оборудование. Вход — item_id вида KIT-42. Выход — item_id, title и status: ready или maintenance. Это выдуманный сервис на домене .invalid, а не действующий поставщик. Мы читаем одну запись GET-запросом; операций записи и персональных данных здесь нет.

СлойЕго задачаЧем проверяем
HTTP-клиентАдрес, заголовок, предел ожидания, статус и форма JSONMockTransport и тесты отказов
MCP toolИмя, схема аргументов и структурированный результатНастоящий SDK-клиент в памяти
Ваш реальный сервисСеть, права доступа, договорённости APIОтдельная проверка после адаптации

Не принимайте URL от пользователя вместо item_id. Фиксированный адрес и узкий идентификатор не дают превратить этот tool в произвольный HTTP-прокси. Клиент ниже повторно проверяет идентификатор, потому что его можно вызвать и без MCP.

2. Подготовьте отдельную папку

Сохраните все файлы этой практики в одной новой папке. Не заменяйте файлы первой лаборатории. Используйте отдельное окружение Python 3.11; зависимости закреплены за проверенной версией.

requirements-http-adapter.txtText
Скачать
mcp==1.30.0
httpx==0.28.1
pydantic==2.13.5
requirements-http-adapter.txtText
mcp==1.30.0
httpx==0.28.1
pydantic==2.13.5
PowerShell
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements-http-adapter.txt
PowerShellPowerShell
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements-http-adapter.txt

Эти команды предназначены для вашей новой учебной папки. Автор проверил код в уже установленной среде и не выполнял новую установку. Возможность установки пакетов на другом компьютере не входит в сохранённый результат.

3. Отделите HTTP-клиент от MCP

Сохраните http_api_client.py. В него передаётся AsyncClient, поэтому тест выбирает транспорт, а вызывающий код закрывает клиент через async with. Нет глобального сетевого клиента или скрытого чтения переменных окружения.

http_api_client.pyPython
Скачать
"""Read one fictional inventory record using an injected HTTP client."""
import asyncio
import re

import httpx
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing import Literal

ID_PATTERN = r"^[A-Z]{2,8}-[1-9][0-9]{0,5}$"
API_ROOT = "https://inventory.example.invalid/v1/"
HTTP_TIMEOUT = httpx.Timeout(5.0, connect=2.0, pool=1.0)


class Artifact(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    item_id: str = Field(pattern=ID_PATTERN)
    title: str = Field(min_length=1, max_length=80)
    status: Literal["ready", "maintenance"]


class AdapterError(Exception):
    """A stable public error without response bodies, URLs or credentials."""

    def __init__(self, code: str, message: str):
        self.code = code
        super().__init__(f"{code}: {message}")


class ArtifactClient:
    def __init__(self, http: httpx.AsyncClient, token: str | None = None, deadline: float = 8.0):
        if not 0 < deadline <= 60:
            raise ValueError("deadline must be between 0 and 60 seconds")
        self._http = http
        self._token = token
        self._deadline = deadline

    async def get_artifact(self, item_id: str) -> Artifact:
        """Validate the identifier, read once and return an explicit output model."""
        if not isinstance(item_id, str) or re.fullmatch(ID_PATTERN, item_id) is None:
            raise AdapterError("INVALID_ID", "Expected an identifier such as KIT-42")
        headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
        try:
            async with asyncio.timeout(self._deadline):
                response = await self._http.get(
                    API_ROOT + "items/" + item_id,
                    headers=headers,
                    timeout=HTTP_TIMEOUT,
                    follow_redirects=False,
                )
        except (TimeoutError, httpx.TimeoutException):
            raise AdapterError("TIMEOUT", "The inventory did not answer in time") from None
        except httpx.RequestError:
            raise AdapterError("NETWORK", "The inventory connection failed") from None
        status = response.status_code
        if status in (401, 403):
            raise AdapterError("AUTH", "Check inventory access outside the tool")
        if status == 404:
            raise AdapterError("NOT_FOUND", "No record with this identifier")
        if status == 429:
            raise AdapterError("RATE_LIMIT", "Request limit reached; do not retry in a loop")
        if status >= 500:
            raise AdapterError("UPSTREAM", "The inventory is temporarily unavailable")
        if status != 200:
            raise AdapterError("HTTP_STATUS", "Unexpected inventory response")
        try:
            result = Artifact.model_validate(response.json())
        except (ValueError, ValidationError):
            raise AdapterError("BAD_RESPONSE", "The inventory response has the wrong shape") from None
        if result.item_id != item_id:
            raise AdapterError("BAD_RESPONSE", "The inventory returned a different identifier")
        return result
http_api_client.pyPython
"""Read one fictional inventory record using an injected HTTP client."""
import asyncio
import re

import httpx
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from typing import Literal

ID_PATTERN = r"^[A-Z]{2,8}-[1-9][0-9]{0,5}$"
API_ROOT = "https://inventory.example.invalid/v1/"
HTTP_TIMEOUT = httpx.Timeout(5.0, connect=2.0, pool=1.0)


class Artifact(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    item_id: str = Field(pattern=ID_PATTERN)
    title: str = Field(min_length=1, max_length=80)
    status: Literal["ready", "maintenance"]


class AdapterError(Exception):
    """A stable public error without response bodies, URLs or credentials."""

    def __init__(self, code: str, message: str):
        self.code = code
        super().__init__(f"{code}: {message}")


class ArtifactClient:
    def __init__(self, http: httpx.AsyncClient, token: str | None = None, deadline: float = 8.0):
        if not 0 < deadline <= 60:
            raise ValueError("deadline must be between 0 and 60 seconds")
        self._http = http
        self._token = token
        self._deadline = deadline

    async def get_artifact(self, item_id: str) -> Artifact:
        """Validate the identifier, read once and return an explicit output model."""
        if not isinstance(item_id, str) or re.fullmatch(ID_PATTERN, item_id) is None:
            raise AdapterError("INVALID_ID", "Expected an identifier such as KIT-42")
        headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
        try:
            async with asyncio.timeout(self._deadline):
                response = await self._http.get(
                    API_ROOT + "items/" + item_id,
                    headers=headers,
                    timeout=HTTP_TIMEOUT,
                    follow_redirects=False,
                )
        except (TimeoutError, httpx.TimeoutException):
            raise AdapterError("TIMEOUT", "The inventory did not answer in time") from None
        except httpx.RequestError:
            raise AdapterError("NETWORK", "The inventory connection failed") from None
        status = response.status_code
        if status in (401, 403):
            raise AdapterError("AUTH", "Check inventory access outside the tool")
        if status == 404:
            raise AdapterError("NOT_FOUND", "No record with this identifier")
        if status == 429:
            raise AdapterError("RATE_LIMIT", "Request limit reached; do not retry in a loop")
        if status >= 500:
            raise AdapterError("UPSTREAM", "The inventory is temporarily unavailable")
        if status != 200:
            raise AdapterError("HTTP_STATUS", "Unexpected inventory response")
        try:
            result = Artifact.model_validate(response.json())
        except (ValueError, ValidationError):
            raise AdapterError("BAD_RESPONSE", "The inventory response has the wrong shape") from None
        if result.item_id != item_id:
            raise AdapterError("BAD_RESPONSE", "The inventory returned a different identifier")
        return result

Предел HTTPX задан отдельно для соединения, чтения, записи и ожидания свободного соединения. Read timeout ограничивает ожидание очередной порции данных, а не всю длительность запроса. Вокруг одного запроса дополнительно стоит asyncio.timeout: по умолчанию общий предел 8 секунд. Это не планировщик повторов.

Ответ проверяет строгая модель: обязательные поля, допустимый status, отсутствие неизвестных полей и совпадение item_id с запросом. Мы не передаём весь JSON поставщика ассистенту. Ошибки превращаются в короткие коды без тела ответа, адреса или токена.

4. Оставьте MCP-слой тонким

Сохраните http_api_tool.py. Аннотация item_id задаёт входную схему. Возвращаемая модель Artifact задаёт структурированный ответ. ToolError сообщает ожидаемый отказ клиенту; здесь он не заменяется успешным пустым объектом.

http_api_tool.pyPython
Скачать
"""A thin MCP tool; the caller owns the HTTP client and its lifetime."""
from typing import Annotated

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import Field

from http_api_client import Artifact, ArtifactClient, AdapterError, ID_PATTERN


def make_server(client: ArtifactClient) -> FastMCP:
    server = FastMCP("Inventory practice")

    @server.tool()
    async def get_artifact(
        item_id: Annotated[str, Field(pattern=ID_PATTERN, strict=True)],
    ) -> Artifact:
        """Read one inventory item by an identifier such as KIT-42."""
        try:
            return await client.get_artifact(item_id)
        except AdapterError as error:
            raise ToolError(str(error)) from None

    return server
http_api_tool.pyPython
"""A thin MCP tool; the caller owns the HTTP client and its lifetime."""
from typing import Annotated

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import Field

from http_api_client import Artifact, ArtifactClient, AdapterError, ID_PATTERN


def make_server(client: ArtifactClient) -> FastMCP:
    server = FastMCP("Inventory practice")

    @server.tool()
    async def get_artifact(
        item_id: Annotated[str, Field(pattern=ID_PATTERN, strict=True)],
    ) -> Artifact:
        """Read one inventory item by an identifier such as KIT-42."""
        try:
            return await client.get_artifact(item_id)
        except AdapterError as error:
            raise ToolError(str(error)) from None

    return server

Фабрика make_server получает готовый клиент. Не вызывайте run() для этой проверки: следующий файл соединит MCP-сервер и клиент в памяти. Это подтверждает контракт SDK, но не stdio, HTTP-транспорт MCP или авторизацию удалённого сервера.

5. Получите реальный MCP-ответ без внешнего API

Сохраните check_http_adapter.py и запустите его. MockTransport подставляет JSON для KIT-42 и 404 для KIT-43. Проверка также посылает неверный идентификатор и убеждается, что HTTP-запрос не появился.

check_http_adapter.pyPython
Скачать
"""Exercise real SDK discovery and calls with an entirely synthetic HTTP transport."""
import asyncio
from datetime import timedelta
import json

import httpx
from mcp.shared.memory import create_connected_server_and_client_session

from http_api_client import ArtifactClient, ID_PATTERN
from http_api_tool import make_server


async def main():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(str(request.url))
        if request.url.path.endswith("KIT-43"):
            return httpx.Response(404, text="private upstream diagnostic")
        return httpx.Response(200, json={"item_id": "KIT-42", "title": "Demo kit", "status": "ready"})

    async with httpx.AsyncClient(transport=httpx.MockTransport(handler), trust_env=False) as http:
        server = make_server(ArtifactClient(http))
        async with create_connected_server_and_client_session(
            server, read_timeout_seconds=timedelta(seconds=5), raise_exceptions=True
        ) as session:
            tools = await session.list_tools()
            tool = next(tool for tool in tools.tools if tool.name == "get_artifact")
            assert tool.inputSchema["properties"]["item_id"]["pattern"] == ID_PATTERN
            assert "item_id" in tool.inputSchema["required"]
            assert tool.outputSchema is not None
            assert {"item_id", "title", "status"} <= set(tool.outputSchema["required"])
            success = await session.call_tool("get_artifact", {"item_id": "KIT-42"})
            assert not success.isError
            assert success.structuredContent == {"item_id": "KIT-42", "title": "Demo kit", "status": "ready"}
            missing = await session.call_tool("get_artifact", {"item_id": "KIT-43"})
            assert missing.isError
            assert "NOT_FOUND" in missing.content[0].text
            before = len(requests)
            invalid = await session.call_tool("get_artifact", {"item_id": "../secret"})
            assert invalid.isError and len(requests) == before
            print(json.dumps({"tool": tool.name, "success": success.structuredContent, "not_found_is_error": missing.isError, "invalid_input_http_requests": len(requests) - before}, ensure_ascii=False))
    assert http.is_closed
    print("PASS: real SDK list/call, mocked HTTP, client closed")


if __name__ == "__main__":
    asyncio.run(main())
check_http_adapter.pyPython
"""Exercise real SDK discovery and calls with an entirely synthetic HTTP transport."""
import asyncio
from datetime import timedelta
import json

import httpx
from mcp.shared.memory import create_connected_server_and_client_session

from http_api_client import ArtifactClient, ID_PATTERN
from http_api_tool import make_server


async def main():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append(str(request.url))
        if request.url.path.endswith("KIT-43"):
            return httpx.Response(404, text="private upstream diagnostic")
        return httpx.Response(200, json={"item_id": "KIT-42", "title": "Demo kit", "status": "ready"})

    async with httpx.AsyncClient(transport=httpx.MockTransport(handler), trust_env=False) as http:
        server = make_server(ArtifactClient(http))
        async with create_connected_server_and_client_session(
            server, read_timeout_seconds=timedelta(seconds=5), raise_exceptions=True
        ) as session:
            tools = await session.list_tools()
            tool = next(tool for tool in tools.tools if tool.name == "get_artifact")
            assert tool.inputSchema["properties"]["item_id"]["pattern"] == ID_PATTERN
            assert "item_id" in tool.inputSchema["required"]
            assert tool.outputSchema is not None
            assert {"item_id", "title", "status"} <= set(tool.outputSchema["required"])
            success = await session.call_tool("get_artifact", {"item_id": "KIT-42"})
            assert not success.isError
            assert success.structuredContent == {"item_id": "KIT-42", "title": "Demo kit", "status": "ready"}
            missing = await session.call_tool("get_artifact", {"item_id": "KIT-43"})
            assert missing.isError
            assert "NOT_FOUND" in missing.content[0].text
            before = len(requests)
            invalid = await session.call_tool("get_artifact", {"item_id": "../secret"})
            assert invalid.isError and len(requests) == before
            print(json.dumps({"tool": tool.name, "success": success.structuredContent, "not_found_is_error": missing.isError, "invalid_input_http_requests": len(requests) - before}, ensure_ascii=False))
    assert http.is_closed
    print("PASS: real SDK list/call, mocked HTTP, client closed")


if __name__ == "__main__":
    asyncio.run(main())
PowerShell
.\.venv\Scripts\python.exe check_http_adapter.py
$LASTEXITCODE
PowerShellPowerShell
.\.venv\Scripts\python.exe check_http_adapter.py
$LASTEXITCODE
Ожидаемый вывод
{"tool": "get_artifact", "success": {"item_id": "KIT-42", "title": "Demo kit", "status": "ready"}, "not_found_is_error": true, "invalid_input_http_requests": 0}
PASS: real SDK list/call, mocked HTTP, client closed
0
Ожидаемый вывод
{"tool": "get_artifact", "success": {"item_id": "KIT-42", "title": "Demo kit", "status": "ready"}, "not_found_is_error": true, "invalid_input_http_requests": 0}
PASS: real SDK list/call, mocked HTTP, client closed
0

Ожидайте этот JSON, строку PASS и код завершения 0. SDK может писать диагностические INFO в stderr — это не ответ инструмента. Сохранённая авторская проверка прошла на SDK 1.30.0; исходящие сокеты были запрещены, попыток внешнего соединения не было.

6. Разберите отказ до подключения своего API

КодЧто произошлоСледующее действие
INVALID_IDНеверный идентификаторИсправить аргумент; HTTP не вызывался
AUTH401 или 403Проверить права и ключ вне инструмента
NOT_FOUND404Проверить существование записи
RATE_LIMIT429Остановить поток запросов, изучить условия своего API
TIMEOUT / NETWORKНе дождались ответа или оборвалось соединениеСначала выяснить причину и проверить пределы
UPSTREAM5xxНе выдавать пустой ответ за найденную запись
BAD_RESPONSEНеверный JSON или чужой item_idСверить контракт поставщика

Токен в ArtifactClient необязателен и используется только в Authorization. Учебная проверка не передаёт реальный ключ. В своём проекте загрузите секрет из переменной окружения вне tool и передайте клиенту: не храните его в аргументах, URL, результате или файле примера.

Клиент делает один запрос, не следует редиректам и не повторяет 429/5xx автоматически. Для реального API повторы требуют отдельного решения: какие операции допустимо повторять, сколько попыток и как учитывать Retry-After. Этот пример ничего не записывает; гарантии повторной записи и ключи идемпотентности здесь не реализованы.

Прежде чем заменить выдуманный endpoint настоящим, выполните следующую практику с семью тестами. После замены адреса, модели или авторизации эти тесты нужно дополнить примерами именно вашего контракта.

Документация, на которой основан пример

Прочитана 12 октября 2026 года. Синтетический inventory — наш учебный контракт; документация описывает MCP, HTTPX и unittest. Пример не проверяет реальный HTTP-сервис или авторизацию удалённого MCP.

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

Достаточно заменить учебный адрес на адрес своего API?

Нет. Учебный клиент ожидает конкретные поля item_id, title и status, проверяет типы и совпадение идентификатора. Для другого API нужно согласовать путь, модель ответа, доступ и проверки; затем отдельно проверить реальное соединение.

Повторит ли адаптер запрос после 429 или 5xx?

Нет. В примере нет автоматических повторов: 429 превращается в RATE_LIMIT, а 5xx — в UPSTREAM. План повторов и обработку Retry-After для своего API нужно определить отдельно.