Реализуем клиент
В уроке про хосты мы говорили, что программная сторона MCP — это когда цикл вызова инструментов пишете вы сами. Пора это сделать. Клиент, который мы напишем, умещается в сотню строк, и после него внутренности Claude Code перестанут быть магией. Понадобится ключ Anthropic API.
Подготовка
Добавьте в проект две зависимости:
uv add anthropic python-dotenv(pip install anthropic python-dotenv для pip.) Положите ключ в файл .env в корне проекта:
ANTHROPIC_API_KEY=ваш-ключИ убедитесь, что .env есть в .gitignore. Anthropic SDK сам подхватит переменную ANTHROPIC_API_KEY из окружения; python-dotenv нужен только для того, чтобы загрузить её из файла.
Две библиотеки, две роли
В клиенте встречаются два SDK, и важно не путать, кто за что отвечает.
mcp — общение с сервером. Client запускает сервер, спрашивает список инструментов, вызывает их. Он ничего не знает о моделях.
anthropic — общение с Claude. messages.create отправляет разговор модели вместе с описанием инструментов и получает ответ, в котором может быть просьба вызвать инструмент. Он ничего не знает об MCP.
Клиент — переводчик между ними: берёт инструменты из mcp и описывает их для anthropic, берёт просьбы модели из anthropic и выполняет через mcp.
Подключение к серверу
Создайте client.py:
import asyncio
import sys
from anthropic import Anthropic
from dotenv import load_dotenv
from mcp import Client, StdioServerParameters
from mcp_types import TextContent
load_dotenv()
MODEL = "claude-opus-5"
anthropic = Anthropic()
def text_of(result) -> str:
"""Собрать текстовые блоки ответа инструмента в одну строку."""
return "\n".join(b.text for b in result.content if isinstance(b, TextContent))StdioServerParameters описывает, как запустить сервер: команда и аргументы. Сам запуск делает Client, когда мы входим в async with. В main это будет выглядеть так:
async def main() -> None:
server = StdioServerParameters(command="uv", args=["run", "docs_server.py"])
async with Client(server) as client:
names = [t.name for t in (await client.list_tools()).tools]
print("Подключено. Инструменты:", ", ".join(names))
while True:
try:
query = (await asyncio.to_thread(input, "\n> ")).strip()
except EOFError:
break
if not query or query.lower() in {"quit", "exit"}:
break
try:
print(await run(client, [{"role": "user", "content": query}]))
except Exception as e:
print("Ошибка:", e)
if __name__ == "__main__":
asyncio.run(main())Вход в async with запускает дочерний процесс и договаривается о версии протокола; выход останавливает процесс. Ничего закрывать руками не нужно. input() блокирует поток, поэтому мы выносим его в отдельный поток через asyncio.to_thread — иначе клиент не сможет обрабатывать сообщения сервера, пока вы печатаете.
Одна деталь про окружение: дочерний процесс не наследует ваши переменные окружения целиком, только базовый набор вроде PATH и HOME. Если серверу нужен, например, токен для API, передайте его явно: StdioServerParameters(command=..., args=..., env={"API_TOKEN": "..."}).
Инструменты для модели
Anthropic API ожидает описание инструмента в виде словаря с полями name, description и input_schema. У инструмента из MCP есть ровно эти три вещи — name, description и input_schema, — так что перевод почти буквальный:
async def run(client: Client, messages: list[dict]) -> str:
"""Прогнать разговор через Claude, выполняя вызовы инструментов через сервер."""
tool_list = await client.list_tools()
tools = [
{
"name": t.name,
"description": t.description or "",
"input_schema": t.input_schema,
}
for t in tool_list.tools
]Это именно то, что делает Claude Code при старте: собирает инструменты всех серверов в один список для модели. У нас сервер один, но добавить второй — вопрос ещё одного Client и объединения списков.
Цикл вызова инструментов
Продолжение функции run:
while True:
response = anthropic.messages.create(
model=MODEL,
max_tokens=4096,
tools=tools,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "\n".join(b.text for b in response.content if b.type == "text")
results = []
for block in response.content:
if block.type != "tool_use":
continue
print(f"[вызов {block.name} {block.input}]", file=sys.stderr)
result = await client.call_tool(block.name, block.input)
results.append(
{
"type": "tool_result",
"tool_use_id": block.id,
"content": text_of(result),
"is_error": result.is_error,
}
)
messages.append({"role": "user", "content": results})Разберём по шагам, потому что это сердце любого хоста.
- Отправляем модели разговор и список инструментов. Ответ приходит с
stop_reason:end_turn— модель закончила,tool_use— просит вызвать инструмент. - Ответ модели целиком добавляем в историю как сообщение ассистента. Целиком — вместе с блоками
tool_use, иначе на следующем шаге модель не поймёт, к чему относятся результаты. - Если инструменты не нужны — возвращаем текст, и цикл окончен.
- Иначе для каждого блока
tool_useвызываем инструмент через сервер:client.call_tool(имя, аргументы). Аргументы модель уже собрала по схеме, мы передаём их как есть. - Результаты складываем в одно сообщение пользователя из блоков
tool_result.tool_use_idсвязывает результат с просьбой. Флагis_errorиз ответа сервера передаём модели: если инструмент вернулToolError, она прочитает сообщение и попробует иначе. - Возвращаемся в начало цикла: модель получает результаты и решает, что дальше — ответить или вызвать ещё что-то.
Модель может попросить несколько инструментов за раз; поэтому все результаты идут в одном сообщении, а не по одному.
Запуск
uv run client.pyПопробуйте: «Какие риски у проекта?» — в stderr появится [вызов read_doc {'name': 'risks.md'}], а затем ответ. Потом: «Исправь в оценке срок тестирования на четыре недели и покажи, что получилось» — модель вызовет edit_doc, потом read_doc, и вы увидите оба вызова. Спросите про документ, которого нет, — увидите, как модель получает ToolError и честно об этом сообщает.
Обратите внимание: никто не говорил модели, какой инструмент вызывать. Она прочитала описания и выбрала сама. Именно поэтому описания из урока про инструменты так важны.
Чего здесь нет
Наш клиент — учебный минимум. У настоящего хоста есть подтверждение вызовов человеком (мы выполняем всё подряд), несколько серверов, переподключение при обрыве, обработка уведомлений, ограничение на число шагов цикла. Каждая из этих вещей — десяток строк поверх того, что уже есть; самое важное — подтверждение — можно добавить одним input() перед call_tool.
Если вы предпочитаете не писать цикл руками, в Anthropic SDK есть готовые помощники для MCP-инструментов (anthropic.lib.tools.mcp), которые превращают инструменты сервера в объекты для встроенного исполнителя циклов. Мы написали цикл вручную, чтобы было видно, как он устроен.
Попробуйте сами
10–15 мин на рабочем месте- Добавьте подтверждение: перед
call_toolспросите у пользователя «Вызвать edit_doc с такими аргументами? (y/n)» и при отказе верните моделиtool_resultс текстом «пользователь отклонил вызов» иis_error: True. Посмотрите, как модель реагирует. - Добавьте ограничение: не больше десяти итераций цикла. Подумайте, что вернуть пользователю, если оно сработало.
- Поменяйте команду запуска сервера на путь к любому другому stdio-серверу (например, из тех, что подключены у вас в Claude Code) и убедитесь, что клиент работает с ним без изменений. Это и есть смысл протокола.
Коротко
- В клиенте два SDK:
mcpговорит с сервером,anthropic— с моделью; клиент переводит между ними. Client(StdioServerParameters(...))запускает сервер как подпроцесс;async with— вся жизнь соединения.- Инструмент MCP переводится в формат Anthropic API почти буквально:
name,description,input_schema. - Цикл:
messages.create→ еслиstop_reason == "tool_use", вызвать инструменты черезcall_tool, вернутьtool_resultсtool_use_idиis_error→ повторить. - Ответ модели добавляется в историю целиком, результаты всех инструментов — одним сообщением.
- Модель выбирает инструмент сама по описаниям; подтверждение человеком — ваша ответственность.
Видеоверсия
Сценарий озвучки · 466 слов, ≈ 4 мин
В уроке про хосты мы говорили, что программная сторона MCP — это когда цикл вызова инструментов пишете вы сами. Пора это сделать. Клиент умещается в сотню строк, и после него внутренности Claude Code перестанут быть магией.
В клиенте встречаются две библиотеки, и важно не путать роли. Библиотека эм-си-пи общается с сервером: запускает его, спрашивает список инструментов, вызывает их. Она ничего не знает о моделях. Библиотека антропик общается с Claude: отправляет разговор вместе с описанием инструментов и получает ответ, в котором может быть просьба вызвать инструмент. Она ничего не знает об MCP. Клиент — переводчик между ними.
Подключение выглядит просто. Описываем, как запустить сервер: команда и аргументы. Входим в блок «эйсинк виз» — и клиент запускает дочерний процесс, договаривается о версии протокола. Выходим — процесс останавливается. Ничего закрывать руками не нужно. Одна деталь: дочерний процесс не наследует ваши переменные окружения целиком. Если серверу нужен токен, передайте его явно.
Дальше — инструменты для модели. Anthropic API ожидает описание инструмента из трёх полей: имя, описание и схема аргументов. У инструмента из MCP есть ровно эти три вещи, так что перевод почти буквальный. Это именно то, что Claude Code делает при старте: собирает инструменты всех серверов в один список.
Теперь сердце любого хоста — цикл. Отправляем модели разговор и список инструментов. Ответ приходит с причиной остановки: либо модель закончила, либо просит вызвать инструмент. Ответ целиком добавляем в историю — вместе с блоками вызова, иначе модель потом не поймёт, к чему относятся результаты. Если инструменты не нужны — возвращаем текст, готово. Иначе для каждой просьбы вызываем инструмент через сервер, аргументы передаём как есть — модель уже собрала их по схеме. Результаты складываем в одно сообщение с идентификаторами, которые связывают результат с просьбой. И флаг ошибки из ответа сервера тоже передаём: если инструмент вернул «документа нет», модель это прочитает и попробует иначе. Возвращаемся в начало цикла. Модель получает результаты и решает, что дальше: ответить или вызвать ещё что-то.
Запускаем и спрашиваем: «Какие риски у проекта?» В потоке ошибок появляется запись о вызове инструмента чтения, потом ответ. Просим исправить срок в оценке и показать результат — модель вызывает редактирование, потом чтение, и мы видим оба вызова. Спрашиваем про документ, которого нет, — модель получает ошибку и честно об этом сообщает. Никто не говорил ей, какой инструмент вызывать. Она прочитала описания и выбрала сама.
Чего здесь нет. Настоящий хост спрашивает у человека подтверждение перед вызовом — мы выполняем всё подряд. Умеет работать с несколькими серверами, переподключаться, ограничивать число шагов. Каждая из этих вещей — десяток строк поверх того, что есть. Подтверждение — вообще одна строка перед вызовом инструмента. Попробуйте добавить её сами.
И одна практическая деталь про окружение: если вы предпочитаете не писать цикл руками, в Anthropic SDK есть готовые помощники, которые превращают инструменты MCP-сервера в объекты для встроенного исполнителя циклов. Мы написали цикл вручную, чтобы было видно, как он устроен.
В следующем уроке вернёмся к серверу и добавим ресурсы — второй примитив, которым управляет не модель, а приложение.
