Инструменты и MCP
Как описывать инструменты JSON-схемами, как устроен Model Context Protocol (хосты, клиенты, серверы, инструменты, ресурсы, промпты), как не превратить инструмент в дыру в безопасности и как всё это выглядит в Qwen-Agent.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке агент умел пользоваться калькулятором. Настоящему агенту нужны десятки инструментов: файлы, поиск, база данных, Git, браузер, корпоративные API. Качество агента зависит от инструментов не меньше, чем от модели. Если инструмент описан плохо, даже сильная модель вызовет его не вовремя, перепутает аргументы или утонет в его выводе.
Вторая проблема — интеграция. Есть M приложений (IDE, чат, ваш агент) и N сервисов (GitHub, Postgres, Notion). Если каждое приложение пишет свой адаптер к каждому сервису, получается интеграций. протокол контекста моделипротокол контекста модели (MCP)EN Model Context Protocol (MCP)Открытый протокол (Anthropic, ноябрь 2024) на JSON-RPC 2.0 для подключения инструментов, ресурсов и промптов к LLM-приложениям. решает это как USB-C: сервис один раз реализуется как MCP-сервер и работает в любом MCP-совместимом приложении. Интеграций становится .
Третья проблема — безопасность. Инструмент — это реальное действие в реальном мире. Если агента можно обмануть (а его можно), то каждый инструмент превращается в рычаг для атакующего.
Анатомия инструмента
Заголовок раздела «Анатомия инструмента»Модель видит инструмент только через описание. Оно состоит из трёх частей:
| Поле | Что это для модели | Типичная ошибка |
|---|---|---|
name | «глагол» в её словаре | tool1, do_stuff, два похожих имени у разных инструментов |
description | промпт: что делает, когда вызывать, когда нет | одна строка «gets data» |
| JSON Schema аргументов | контракт: типы, обязательные поля, допустимые значения | всё string, нет enum, нет единиц измерения |
JSON Schema за пять минут
Заголовок раздела «JSON Schema за пять минут»JSON Schema — стандартный способ описать форму JSON-объекта. Для инструментов достаточно нескольких ключевых слов:
{ "type": "object", "properties": { "city": {"type": "string", "description": "Город на русском, например «Пекин»"}, "unit": {"type": "string", "enum": ["c", "f"], "description": "Шкала температуры"}, "days": {"type": "integer", "minimum": 1, "maximum": 7}, "tags": {"type": "array", "items": {"type": "string"}} }, "required": ["city"], "additionalProperties": false}type—string,integer,number,boolean,array,object.required— без чего вызов бессмысленен.enum— закрытый список значений. Лучший друг агента: модель не придумает"kelvin".minimum/maximum,items— ограничения на числа и элементы массива.additionalProperties: false— запрет лишних полей.
Схема работает дважды. Сначала она подсказывает модели, как сформировать вызов. Потом она проверяет
вызов в вашем коде, до исполнения. Многие API (включая OpenAI-совместимые) умеют режим strict:
сервер ограничивает генерацию так, что аргументы гарантированно соответствуют схеме (ограниченное декодированиеограниченное декодированиеEN constrained decoding / guided decodingНа каждом шаге сэмплирования запрещаются токены, нарушающие грамматику (например, JSON-схему), — их логиты заменяются на минус бесконечность, и после softmax вероятность таких токенов равна нулю.;
о структурированном выводеструктурированный выводEN structured outputОтвет модели в машиночитаемом формате (обычно JSON по схеме), который код может разобрать и проверить. — в уроке 8.1.2). Но проверять у себя всё равно нужно: не все провайдеры
и не все локальные серверы это поддерживают.
Как писать хорошие инструменты
Заголовок раздела «Как писать хорошие инструменты»- Описание — для нового коллеги. Что делает инструмент, когда его использовать, когда не использовать, что он возвращает. Пример: «Текущая погода в городе. Не подходит для прогноза на будущие дни».
- Меньше, но лучше. Пять инструментов с чёткими границами лучше двадцати пересекающихся. Чем больше похожих
инструментов, тем чаще модель выбирает не тот. Если инструментов много, помогают общие префиксы-«пространства имён»:
github_search_issues,jira_search_issues— сразу видно, какой сервис за чем. - Закрытые варианты вместо свободного текста:
enum, булевы флаги, единицы в имени (timeout_seconds). - Компактный и осмысленный результат. Не 50 КБ JSON с UUID, а то, что нужно для следующего шага. Длинное — обрезать или постранично, с подсказкой, как получить продолжение. Для ориентира: Anthropic пишет, что Claude Code по умолчанию обрезает ответ инструмента до 25 000 токенов.
- Ошибки, которые помогают исправиться: не
Error 400, а «город «Шанхай» не найден; доступны: Пекин, Москва».
Описания инструментов стоят токенов
Заголовок раздела «Описания инструментов стоят токенов»Описания всех подключённых инструментов кладутся в промпт на каждом шаге. Если подключено 4 сервера по 12 инструментов, а описание одного занимает ~150 токенов, то это токенов в каждом запросе — ещё до первого слова пользователя. За 20 шагов агента это уже 144 тысячи входных токенов только на описания. Поэтому подключайте к агенту только то, что нужно для задачи.
Model Context Protocol
Заголовок раздела «Model Context Protocol»MCP представила Anthropic в ноябре 2024 года как открытый стандарт. В течение 2025 года его поддержали крупные платформы, включая OpenAI, Google и Microsoft, а в Китае — экосистема Alibaba (Qwen-Agent, ModelScope). Протокол построен на JSON-RPC 2.0 и описывает, как приложение с LLM получает инструменты и данные от внешних сервисов.
JSON-RPC — это простой формат «вызова функции на другой программе» (RPC — remote procedure call) с помощью JSON.
Запрос — объект с полями jsonrpc (всегда "2.0"), id (номер запроса), method (имя вызываемой функции)
и params (её аргументы). Ответ несёт тот же id, чтобы было понятно, на какой запрос он пришёл, и либо поле
result (успех), либо error (числовой код и сообщение). Сообщение без id называется уведомлением:
на него не отвечают. Живые примеры таких сообщений — ниже в этом разделе. Спецификация выходит ревизиями, названными по дате выпуска: 2024-11-05, 2025-03-26, 2025-06-18,
2025-11-25 и актуальная на октябрь 2026 2026-07-28 (её же называет последней схема в репозитории спецификации). Протокол быстро меняется, поэтому в документации
и SDK всегда смотрите, о какой ревизии идёт речь.
Три роли
Заголовок раздела «Три роли»flowchart LR
subgraph H["Хост: IDE, чат или ваш агент"]
LLM["LLM"]
C1["MCP-клиент 1"]
C2["MCP-клиент 2"]
end
C1 <-->|"stdio"| S1["MCP-сервер: файлы"]
C2 <-->|"Streamable HTTP"| S2["MCP-сервер: GitHub"]
S1 --- R1[("Локальный диск")]
S2 --- R2[("GitHub API")]- Хост (host) — приложение, в котором живёт модель: Claude Desktop, IDE, qwen-code, ваш агент. Хост решает, что показать модели и что разрешить.
- Клиент (client) — компонент внутри хоста, держит одно соединение с одним сервером.
- Сервер (server) — программа, которая предоставляет возможности: инструменты, данные, шаблоны.
Транспорты: stdio — хост запускает сервер как дочерний процесс и общается через stdin/stdout — стандартные
потоки ввода и вывода программы, те самые, откуда читает input() и куда пишет print() (локальные серверы);
Streamable HTTP — для удалённых серверов (в ревизии 2025-03-26 он заменил прежний вариант HTTP + SSE).
Что предоставляет сервер
Заголовок раздела «Что предоставляет сервер»| Примитив | Кто решает, когда использовать | Что это | Методы |
|---|---|---|---|
| Tools | модель | действия с аргументами: «создай issue», «выполни SQL» | tools/list, tools/call |
| Resources | приложение | данные по URI: файл, схема БД, документ | resources/list, resources/read |
| Prompts | пользователь | шаблоны, например команда /review в чате | prompts/list, prompts/get |
Ресурс адресуется URI — адресом вида «схема://путь», как привычный URL (file:///project/README.md,
postgres://db/schema): клиент получает список через
resources/list, а содержимое — через resources/read. Что из этого положить в контекст модели, решает приложение,
например подкладывает файл, который пользователь упомянул в чате.
Есть и обратное направление — возможности клиента, к которым обращается сервер. Elicitation — сервер просит пользователя уточнить данные («в каком городе?»). Sampling — сервер просит LLM хоста что-то сгенерировать (своя модель серверу не нужна). Roots — клиент сообщает, с какими папками серверу разрешено работать. В ревизии 2026-07-28 Sampling и Roots объявлены устаревшими: папки советуют передавать аргументами или настройками сервера, а к LLM обращаться напрямую через API провайдера. Встречные запросы сервера заменил приём «запрос в несколько заходов» (multi round-trip): сервер отвечает «нужны дополнительные данные», клиент добывает их и повторяет исходный запрос уже с ответами.
Обмен сообщениями по шагам
Заголовок раздела «Обмен сообщениями по шагам»В ревизии 2026-07-28 MCP стал протоколом без состояния: сервер не помнит, что было в прошлых запросах,
а каждый запрос сам сообщает версию протокола и возможности клиента в служебном поле _meta. Сценарий короткий:
- (Необязательно) Клиент →
server/discover: сервер называет поддерживаемые версии, свои возможности и имя. - Клиент →
tools/list: сервер возвращает список инструментов сinputSchemaи подсказку, сколько этот список можно кэшировать. - Хост превращает их в описания
toolsдля модели. Модель решает вызвать инструмент. - Клиент →
tools/callсnameиarguments; сервер возвращаетcontent(текст, картинки, ссылки на ресурсы).
Если сервер не поддерживает запрошенную версию, он отвечает ошибкой со списком своих версий, и клиент повторяет запрос с подходящей. Вызов и ответ на проводе выглядят так:
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_weather", "arguments": {"city": "Пекин", "unit": "f"}, "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientCapabilities": {}}}}
{"jsonrpc": "2.0", "id": 3, "result": {"resultType": "complete", "content": [{"type": "text", "text": "Пекин: 70°F"}], "isError": false}}Поле resultType: "complete" значит «ответ окончательный». Второе возможное значение, "input_required", —
это и есть просьба сервера о дополнительных данных.
Важная тонкость — два вида ошибок. Если сломан сам запрос (нет такого метода, неизвестный инструмент, запрос
не по схеме), сервер отвечает объектом error с кодом JSON-RPC. Если же инструмент отработал с ошибкой (город
не найден, недопустимое значение аргумента, сбой внешнего API), это обычный result с isError: true. Такую ошибку
хост передаст модели, и она сможет исправить вызов — ровно как в нашем ReAct-цикле. С ревизии 2025-11-25
спецификация прямо относит сюда и ошибки проверки аргументов (формат даты, значение вне диапазона): их следует
возвращать через isError, потому что по ним модель исправляется лучше всего.
Инструменты могут нести подсказки-аннотации: readOnlyHint, destructiveHint, idempotentHint, openWorldHint.
Хост может, например, спрашивать подтверждение перед «деструктивными» вызовами. Но спецификация прямо говорит:
аннотациям от недоверенного сервера доверять нельзя.
Практика: MCP своими руками
Заголовок раздела «Практика: MCP своими руками»Соберём в одном файле всё, что делает MCP-сервер по ревизии 2026-07-28: инструмент из обычной функции (схема строится по сигнатуре, как в официальном SDK), проверку аргументов по схеме и обработчик JSON-RPC. Клиент проходит тот же сценарий, что настоящий. Только стандартная библиотека (Python 3.9+) — запускайте как есть.
import inspect, jsonfrom typing import Literal, get_args, get_origin# inspect — модуль, который умеет «заглядывать» в функцию: её параметры, аннотации типов, докстринг.# Literal["c", "f"] — аннотация типа «значение — одна из перечисленных строк»; get_origin/get_args# разбирают такую аннотацию на «что это» (Literal) и «какие значения» (("c", "f")).
# ── 1. Инструмент = функция + JSON Schema ────────────────────────────────────PY2JSON = {str: "string", int: "integer", float: "number", bool: "boolean"}REGISTRY = {} # имя инструмента → функция
def tool(fn): """Декоратор (урок 0.2.3): строит описание инструмента из сигнатуры и докстринга (как в SDK).""" props, required = {}, [] for name, p in inspect.signature(fn).parameters.items(): # p.annotation — тип, p.default — значение по умолчанию if get_origin(p.annotation) is Literal: # Literal["c", "f"] → enum props[name] = {"type": "string", "enum": list(get_args(p.annotation))} else: props[name] = {"type": PY2JSON[p.annotation]} if p.default is inspect.Parameter.empty: required.append(name) # Функция в Python — тоже объект, ей можно добавить атрибут: схему храним прямо в fn.schema fn.schema = {"name": fn.__name__, "description": inspect.getdoc(fn), "inputSchema": {"type": "object", "properties": props, "required": required, "additionalProperties": False}} REGISTRY[fn.__name__] = fn return fn
@tooldef get_weather(city: str, unit: Literal["c", "f"] = "c") -> str: """Текущая погода в городе. Не подходит для прогноза на будущие дни.""" temps = {"Пекин": 21, "Москва": 9} # заглушка вместо реального API if city not in temps: raise ValueError(f"город «{city}» не найден; доступны: {', '.join(temps)}") t = temps[city] if unit == "c" else round(temps[city] * 9 / 5 + 32) return f"{city}: {t}°{unit.upper()}"
# ── 2. Проверка аргументов по схеме ДО вызова ────────────────────────────────JSON2PY = {"string": str, "integer": int, "number": (int, float), "boolean": bool}
def validate(args, schema): errors = [f"нет обязательного поля «{k}»" for k in schema["required"] if k not in args] for k, v in args.items(): spec = schema["properties"].get(k) if spec is None: errors.append(f"лишнее поле «{k}»") elif not isinstance(v, JSON2PY[spec["type"]]): errors.append(f"«{k}» должно быть {spec['type']}") elif "enum" in spec and v not in spec["enum"]: errors.append(f"«{k}» должно быть одним из {spec['enum']}") return errors
# ── 3. Мини-сервер MCP (ревизия 2026-07-28): JSON-RPC 2.0 без рукопожатия ────VERSION = "2026-07-28"P = "io.modelcontextprotocol/" # префикс служебных ключей в _meta
def handle(req): method, params = req["method"], req.get("params", {}) if "id" not in req: # уведомление: ответа не бывает return None def error(code, message, data=None): # a | b — слияние словарей (Python 3.9+): ключи из b добавляются к a err = {"code": code, "message": message} | ({"data": data} if data else {}) return {"jsonrpc": "2.0", "id": req["id"], "error": err} meta = params.get("_meta", {}) # каждый запрос несёт версию и возможности клиента if P + "protocolVersion" not in meta or P + "clientCapabilities" not in meta: return error(-32602, "в _meta нет версии протокола или возможностей клиента") if meta[P + "protocolVersion"] != VERSION: return error(-32022, "Unsupported protocol version", {"supported": [VERSION], "requested": meta[P + "protocolVersion"]}) if method == "server/discover": result = {"supportedVersions": [VERSION], "capabilities": {"tools": {}}, "_meta": {P + "serverInfo": {"name": "weather-demo", "version": "0.1"}}, "ttlMs": 300_000, "cacheScope": "public"} # обязательны и здесь, как у tools/list elif method == "tools/list": # список можно кэшировать: срок жизни 5 минут result = {"tools": [f.schema for f in REGISTRY.values()], "ttlMs": 300_000, "cacheScope": "public"} elif method == "tools/call": fn = REGISTRY.get(params["name"]) if fn is None: return error(-32602, f"Unknown tool: {params['name']}") args = params.get("arguments", {}) errors = validate(args, fn.schema["inputSchema"]) try: if errors: raise ValueError("; ".join(errors)) text, is_error = fn(**args), False except Exception as e: # ошибка инструмента → видна модели text, is_error = f"Ошибка: {e}", True result = {"content": [{"type": "text", "text": text}], "isError": is_error} else: return error(-32601, f"Method not found: {method}") return {"jsonrpc": "2.0", "id": req["id"], "result": {"resultType": "complete", **result}}
# ── 4. Клиент: та же последовательность, что у настоящего MCP-клиента ─────────META = {P + "protocolVersion": VERSION, P + "clientCapabilities": {}, P + "clientInfo": {"name": "my-agent", "version": "0.1"}}next_id = 0
def call(method, params=None, meta=META): global next_id next_id += 1 resp = handle({"jsonrpc": "2.0", "id": next_id, "method": method, "params": (params or {}) | {"_meta": meta}}) body = resp.get("error") or {k: v for k, v in resp["result"].items() if k != "resultType"} out = json.dumps(body, ensure_ascii=False) # resultType="complete" не печатаем print(f"→ {method}: {out if len(out) <= 90 else out[:89] + '…'}") return resp
call("server/discover") # необязательно: узнать версии и возможностиtools = call("tools/list")["result"]["tools"]print("инструменты:", [t["name"] for t in tools])call("tools/call", {"name": "get_weather", "arguments": {"city": "Пекин", "unit": "f"}})call("tools/call", {"name": "get_weather", "arguments": {"city": "Пекин", "unit": "kelvin"}})call("tools/call", {"name": "get_weather", "arguments": {"city": "Шанхай"}})call("tools/call", {"name": "get_forecast", "arguments": {"city": "Пекин"}})call("tools/list", meta={P + "protocolVersion": "2025-06-18", P + "clientCapabilities": {}})Вывод:
→ server/discover: {"supportedVersions": ["2026-07-28"], "capabilities": {"tools": {}}, "_meta": {"io.modelc…→ tools/list: {"tools": [{"name": "get_weather", "description": "Текущая погода в городе. Не подходит д…инструменты: ['get_weather']→ tools/call: {"content": [{"type": "text", "text": "Пекин: 70°F"}], "isError": false}→ tools/call: {"content": [{"type": "text", "text": "Ошибка: «unit» должно быть одним из ['c', 'f']"}],…→ tools/call: {"content": [{"type": "text", "text": "Ошибка: город «Шанхай» не найден; доступны: Пекин,…→ tools/call: {"code": -32602, "message": "Unknown tool: get_forecast"}→ tools/list: {"code": -32022, "message": "Unsupported protocol version", "data": {"supported": ["2026-…Вызов с "kelvin" отсёк валидатор, город «Шанхай» — сам инструмент. Оба раза это isError: true: модель получит
понятный текст и сможет исправиться. Неизвестный инструмент get_forecast — уже ошибка самого запроса, она приходит
объектом error с кодом JSON-RPC. А последний запрос пришёл со старой версией протокола, и сервер ответил кодом
-32022 и списком версий, которые он понимает.
То же на официальном SDK
Заголовок раздела «То же на официальном SDK»В реальном проекте берите SDK (pip install "mcp[cli]"). Его декоратор делает то же, что наш tool, плюс транспорт,
версии протокола (обе «эпохи») и всё остальное. Пример для SDK 2.x, вышедшего вместе с ревизией 2026-07-28
(проверен на версии 2.3.0). Главный класс здесь называется MCPServer, в SDK 1.x он назывался FastMCP:
from typing import Literalfrom mcp.server import MCPServer # в SDK 1.x: from mcp.server.fastmcp import FastMCPfrom mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("weather")
@mcp.tool()def get_weather(city: str, unit: Literal["c", "f"] = "c") -> str: """Текущая погода в городе. Не подходит для прогноза на будущие дни.""" temps = {"Пекин": 21, "Москва": 9} if city not in temps: # текст ToolError дойдёт до модели как isError: true raise ToolError(f"город «{city}» не найден; доступны: {', '.join(temps)}") t = temps[city] if unit == "c" else round(temps[city] * 9 / 5 + 32) return f"{city}: {t}°{unit.upper()}"
@mcp.resource("notes://{name}")def read_note(name: str) -> str: """Заметка пользователя как ресурс.""" return f"Заметка «{name}» пока пуста."
if __name__ == "__main__": mcp.run() # по умолчанию транспорт stdioСхему SDK строит по аннотациям сам: Literal["c", "f"] превращается в enum, как в нашем декораторе, а значение
"kelvin" отсекается проверкой с понятным модели сообщением. Обратите внимание на ToolError. Его текст SDK
передаст модели, а текст обычного исключения (ValueError и т. п.) спрячет за безликим «Error executing tool
get_weather», чтобы наружу не утекли внутренние подробности. Хотите, чтобы модель могла исправиться, — бросайте ToolError.
Проверить сервер без LLM можно в отладчике MCP Inspector: команда mcp dev server.py (или
npx @modelcontextprotocol/inspector) показывает список инструментов и даёт вызвать их руками.
Если в старом туториале встретите ModuleNotFoundError: No module named 'mcp.server.fastmcp', это код для SDK 1.x:
замените импорт на MCPServer или поставьте pip install "mcp<2".
Безопасность инструментов
Заголовок раздела «Безопасность инструментов»Спецификация MCP формулирует это жёстко: инструменты — это исполнение произвольного кода, и относиться к ним нужно соответственно. Вот главные классы проблем.
1. Избыточные права
Заголовок раздела «1. Избыточные права»Агенту дали bash и токен доступа с правами администратора «на всякий случай» (здесь «токен» — не кусочек
текста, а секретная строка-пропуск к сервису, как API-ключ). Одна неудачная мысль модели — и rm -rf
(удалить файлы без вопросов) или DROP TABLE (удалить таблицу в базе данных). Защита — принцип наименьших привилегийпринцип наименьших привилегийEN principle of least privilege: только нужные инструменты, токены
с минимальными правами, режим «только чтение» по умолчанию. Код агента исполняйте в песочницапесочницаEN sandboxИзолированная среда (контейнер, ВМ), где агент исполняет код и команды, не рискуя основной системой.:
контейнер без сети и секретов, с лимитами CPU, памяти и времени.
2. Инъекции через аргументы
Заголовок раздела «2. Инъекции через аргументы»Аргументы пишет модель, а модель может ошибиться или быть обманутой. Поэтому аргументы — это недоверенный ввод,
как данные из веб-формы. Нельзя подставлять их в команду терминала (аргумент ; rm -rf ~ допишет к ней вторую
команду — удаление домашней папки), в текст SQL-запроса или в путь к файлу без проверки. Классика — выход из
рабочей папки через .. («папка уровнем выше», урок 0.1.1):
from pathlib import Path
ROOT = Path("/srv/agent_workspace")
def naive_check(user_path: str) -> bool: return str(ROOT / user_path).startswith(str(ROOT)) # ← так делать НЕЛЬЗЯ
def safe_path(user_path: str) -> Path: p = (ROOT / user_path).resolve() # раскрываем «..» и симлинки (файлы-ярлыки) if not p.is_relative_to(ROOT.resolve()): raise PermissionError(f"выход за пределы рабочей папки: {user_path}") return p
for q in ["notes/todo.txt", "../../etc/passwd", "/etc/passwd", "../agent_workspace_evil/key"]: try: verdict = f"OK → {safe_path(q)}" except PermissionError as e: verdict = f"ЗАПРЕТ ({e})" print(f"{q:30} наивная проверка: {'пропустит' if naive_check(q) else 'запретит':9} | {verdict}")Вывод (на Linux/macOS):
notes/todo.txt наивная проверка: пропустит | OK → /srv/agent_workspace/notes/todo.txt../../etc/passwd наивная проверка: пропустит | ЗАПРЕТ (выход за пределы рабочей папки: ../../etc/passwd)/etc/passwd наивная проверка: запретит | ЗАПРЕТ (выход за пределы рабочей папки: /etc/passwd)../agent_workspace_evil/key наивная проверка: пропустит | ЗАПРЕТ (выход за пределы рабочей папки: ../agent_workspace_evil/key)Наивная проверка по строке пропустила два из трёх опасных путей. Заметьте и коварство ROOT / "/etc/passwd":
абсолютный путь справа в pathlib заменяет левую часть целиком.
3. Необратимые действия
Заголовок раздела «3. Необратимые действия»Отправка письма, оплата, удаление, git push --force. Такие инструменты должны требовать подтверждения
(человек в контуречеловек в контуре (подтверждение человеком)EN human-in-the-loop) или работать в режиме «черновик → человек нажимает кнопку».
Хорошая практика — разделять draft_email и send_email, и второй давать агенту только с подтверждением.
4. Недоверенные серверы и «отравленные» описания
Заголовок раздела «4. Недоверенные серверы и «отравленные» описания»Описание инструмента попадает прямо в промпт модели. Значит, автор MCP-сервера может спрятать там инструкцию.
В 2025 году исследователи безопасности (в частности, Invariant Labs) показали атаку tool poisoning: в описании
безобидного инструмента «сложи два числа» спрятана просьба прочитать ~/.ssh/id_rsa (секретный SSH-ключ,
которым пользователь входит на серверы и в GitHub) и передать содержимое
в «служебный» аргумент. Пользователь в интерфейсе видит только «add», модель — всё описание.
Родственные атаки: rug pull (сервер меняет описание после того, как вы его одобрили) и затенение
(инструмент одного сервера в описании «переопределяет» поведение инструментов другого).
Защита: ставить серверы только из доверенных источников и фиксировать версии; показывать пользователю полные описания; хранить хеш одобренного описания и предупреждать при изменении; не смешивать в одном агенте непроверенные серверы с доступом к секретам.
Qwen-Agent
Заголовок раздела «Qwen-Agent»Qwen-Agent — официальный фреймворк команды Qwen (Alibaba, Apache-2.0). В README сказано, что он служит бэкендом чата Qwen Chat. Он умеет вызывать функции, подключать MCP-серверы, запускать встроенный интерпретатор кода и работать с документами (RAG). Модель можно взять из облака DashScope или любую через OpenAI-совместимый сервер, например Ollama или vLLM.
Набросок по мотивам README (API фреймворка меняется, сверяйтесь с актуальной версией;
нужны pip install "qwen-agent[mcp]" и uv для запуска MCP-серверов). uv — быстрый менеджер Python-пакетов
(ставится pip install uv), а его команда uvx имя-пакета скачивает пакет во временное окружение и сразу запускает.
json5 — библиотека, которая читает JSON «снисходительно» (прощает лишние запятые и т. п.):
import json5from qwen_agent.agents import Assistantfrom qwen_agent.tools.base import BaseTool, register_tool
@register_tool("tea_price") # свой инструментclass TeaPrice(BaseTool): description = "Цена чая по сорту, юаней за 100 г. Только для сортов из списка." parameters = [{"name": "sort", "type": "string", "required": True, "description": "Сорт: лунцзин или пуэр"}]
def call(self, params: str, **kwargs) -> str: sort = json5.loads(params)["sort"] # аргументы приходят строкой JSON prices = {"лунцзин": 120, "пуэр": 45} return str(prices.get(sort, "нет такого сорта; доступны: лунцзин, пуэр"))
llm_cfg = {"model": "qwen3:8b", # локальная Ollama "model_server": "http://localhost:11434/v1", "api_key": "EMPTY"}tools = [ "tea_price", {"mcpServers": { # MCP-серверы в привычном формате конфигурации "time": {"command": "uvx", "args": ["mcp-server-time", "--local-timezone=Asia/Shanghai"]}, }},]bot = Assistant(llm=llm_cfg, function_list=tools)messages = [{"role": "user", "content": "Сколько стоят 300 г лунцзина и который час в Пекине?"}]for responses in bot.run(messages=messages): # генератор: отдаёт ответ по мере стриминга passprint(responses[-1]["content"])Обратите внимание на формат mcpServers — тот же, что в Claude Desktop и большинстве MCP-хостов:
команда и аргументы для запуска сервера по stdio. Ещё один урок безопасности — история встроенного
инструмента code_interpreter. В ранних версиях Qwen-Agent он исполнял код прямо в вашем окружении, о чём README
честно предупреждал. В актуальной версии код запускается в локальном Docker-контейнере (Docker запускает программу
в изолированной «коробке» со своей файловой системой), куда монтируется только рабочая папка. Но и сейчас README называет эту изоляцию базовой и просит осторожности в продакшене.
Проверь себя
Заголовок раздела «Проверь себя»- Инструмент = имя + описание (это промпт!) + JSON Schema. Хорошие инструменты немногочисленны, с
enum, компактным выводом и полезными ошибками. - Описания инструментов занимают контекст на каждом шаге — подключайте только нужное.
- MCP: хост, клиент, сервер; JSON-RPC 2.0; транспорты stdio и Streamable HTTP; примитивы Tools, Resources, Prompts.
Ошибки инструмента (и проверки аргументов) возвращаются в
resultсisError: true, чтобы их видела модель. - С ревизии 2026-07-28 MCP работает без состояния: вместо рукопожатия
initializeкаждый запрос несёт версию и возможности клиента в_meta. Многие клиенты пока говорят на старых ревизиях, и SDK 2.x понимает обе. - Безопасность: наименьшие привилегии, песочница, аргументы как недоверенный ввод, подтверждение необратимых действий, недоверие к чужим описаниям.
- Qwen-Agent подключает свои инструменты через
BaseToolи MCP-серверы через конфигурациюmcpServers.
Схема инструмента — это публичный контракт, и бизнес-правила за ним должен проверять код, а не модель. Как инварианты агрегата защищают систему от ошибок агента и почему схема инструмента — это «общедоступный язык» из DDD, разобрано в уроке DDD.4.4.
Дальше: что делать, когда задача требует десятков шагов: планирование, рефлексия, память и мультиагентные схемы.