Продакшн-разработка ИИ-агентов · Модуль 4 · Урок 4.1
Глава 7. Дизайн инструментов и MCP в проде
Цели главы
После этой главы вы сможете:
- Проектировать схемы инструментов (tool schemas), устойчивые под нагрузкой: строгие JSON Schema, идемпотентность, пагинация и токен-эффективные ответы.
- Возвращать модели понятные ошибки инструмента вместо падения процесса и различать восстановимые (retriable) и фатальные (fatal) сбои.
- Изолировать опасные инструменты в песочнице (sandbox) — файловую систему и выполнение кода.
- Применять авторизацию по принципу наименьших привилегий (least privilege) на уровне инструмента: scoping по пользователю и OAuth для MCP.
- Делать динамический выбор инструментов (dynamic tool selection), когда их десятки, чтобы не раздувать контекст.
- Построить MCP-сервер на Go на официальном SDK с tool и resource поверх stdio/HTTP транспорта.
Что нового (дельта к базовому курсу)
В базовом курсе вы уже разбирали азы MCP (Model Context Protocol): архитектуру клиент/сервер, виды транспортов (stdio, HTTP) и базовый дизайн инструментов — имя, описание, схема входа. Этого достаточно для прототипа, но не для прода.
Здесь мы делаем шаг от «работает на демо» к «работает под нагрузкой и не роняет агента». Новое:
- Схема инструмента рассматривается как публичный контракт (contract), который читает не человек, а модель, и который должен переживать большие результаты и частичные сбои.
- Ошибка инструмента — это сигнал для модели, а не паника процесса.
- Появляются границы безопасности (security boundary): песочница и авторизация на уровне каждого инструмента.
- Разбираем масштабирование: что делать, когда инструментов десятки.
- Пишем реальный MCP-сервер на Go на официальном SDK (
github.com/modelcontextprotocol/go-sdk, ветка v1.x), а не на самописном JSON-RPC.
Схемы инструментов под нагрузкой
Инструмент описывается тремя вещами: имя, описание и JSON Schema входных аргументов. В проде к ним добавляются неочевидные требования.
Строгая схема (strict schema). Чем жёстче схема, тем меньше у модели свободы сгенерировать невалидный вызов. Указывайте type, enum для перечислимых значений, required только для действительно обязательных полей, и additionalProperties: false (запрет лишних полей). Многие SDK поддерживают режим strict, при котором провайдер гарантирует соответствие аргументов схеме на стороне сервера — это убирает целый класс ошибок парсинга.
Идемпотентность (idempotency). Агент может вызвать инструмент повторно: из-за ретрая, из-за того, что не увидел результат, из-за пользовательского «попробуй ещё раз». Если инструмент имеет побочный эффект (создать заказ, отправить письмо), повторный вызов с теми же аргументами не должен создавать дубликат. Классический приём — ключ идемпотентности (idempotency key): клиент передаёт уникальный идентификатор операции, сервер запоминает результат и при повторе возвращает тот же ответ, а не выполняет действие заново.
Пагинация больших результатов (pagination). Инструмент list_orders, который возвращает 10 000 строк, разрушит контекст: он съест токены, вытеснит важное и замедлит модель. Возвращайте страницу фиксированного размера плюс курсор (cursor) для следующей страницы. Модель сама решит, нужна ли ей вторая страница.
Токен-эффективные ответы (token-efficient responses). Ответ инструмента читает модель, и каждый его символ стоит токенов и денег. Возвращайте только то, что нужно для следующего шага: не сырой дамп API, а отфильтрованную и сжатую проекцию. Числовые ID вместо длинных URL, плоская структура вместо вложенного JSON с метаданными, человекочитаемые сообщения вместо стек-трейсов. Эмпирическое правило: если поле не повлияет на решение модели — выкиньте его.
Обработка ошибок инструмента
Главный принцип: ошибка инструмента — это не ошибка агента. Если bash-команда вернула ненулевой код, агент не должен падать с паникой — он должен получить понятный текст ошибки и решить, что делать дальше.
В протоколе вызова это выражается флагом is_error (в Anthropic API) или полем isError в результате MCP-вызова: вы возвращаете обычный tool_result, но помечаете его как ошибочный. Модель видит сообщение и обычно либо пробует другой подход, либо переспрашивает пользователя.
Различайте retriable и fatal. Это две разные ситуации с разной стратегией:
- Восстановимая (retriable) — таймаут сети,
429 Too Many Requests, временная недоступность (503). Здесь имеет смысл ретрай с экспоненциальной задержкой (exponential backoff). Часть ретраев — внутри инструмента (прозрачно для модели), часть можно отдать модели как «попробуй позже». - Фатальная (fatal) — невалидные аргументы, отсутствие прав (
403), несуществующий ресурс (404), нарушение бизнес-правила. Ретрай бессмысленен. Верните модели чёткое объяснение, что именно не так, чтобы она исправила вызов, а не повторяла его в цикле.
Антипаттерн прода — «глотать» ошибку и возвращать пустой успех: модель решит, что всё хорошо, и поедет дальше на ложных данных. Лучше явная ошибка, чем тихий неверный результат.
Песочницы и авторизация
Опасные инструменты — те, что трогают файловую систему или выполняют код, — нельзя пускать на хост напрямую. Агент управляется недетерминированной моделью, на которую к тому же может влиять внешний текст (prompt injection через содержимое файла или веб-страницы). Поэтому такие инструменты помещают в песочницу (sandbox).
Песочница для файловой системы — это ограничение операций корневым каталогом (chroot-подобная изоляция), запрет выхода за его пределы через .. и символические ссылки. Для выполнения кода — отдельный контейнер или микро-ВМ с лимитами на CPU, память, время и без доступа в сеть по умолчанию. Подробно изоляцию исполнения разбираем в Главе 8.
Авторизация на уровне инструмента (least privilege). Каждый инструмент должен иметь ровно те права, что нужны для его задачи, и ни одним больше. Если у вас инструмент read_invoice, у него не должно быть прав на запись. Это ограничивает радиус поражения (blast radius), если агент поведёт себя неожиданно.
Scoping по пользователю. В мультипользовательской системе инструмент должен видеть только данные текущего пользователя. Идентификатор пользователя берётся не из аргументов вызова (их генерирует модель — им нельзя доверять для авторизации), а из доверенного контекста сессии. Аргумент user_id, пришедший от модели, — это запрос, а не разрешение.
OAuth для MCP. Удалённый MCP-сервер по HTTP-транспорту аутентифицирует клиента через OAuth 2.0: bearer-токен в заголовке, обновление по refresh-токену. Секреты при этом не должны попадать ни в схему инструмента, ни в системный промпт, ни в историю сообщений — только в защищённое хранилище на стороне хоста.
Динамический выбор инструментов
Когда инструментов 5 — все их схемы спокойно помещаются в контекст. Когда их десятки или сотни, загрузка всех схем сразу даёт три проблемы: раздувание контекста (и счёта за токены), снижение точности выбора (модель путается среди похожих инструментов) и инвалидация кэша промпта при любом изменении списка.
Решение — динамический выбор (dynamic tool selection), он же tool search. Вместо того чтобы держать все схемы в контексте, вы держите только лёгкий поисковый инструмент. Модель сначала ищет подходящие инструменты по описанию задачи, а затем подгружаются только схемы найденных.
Важная деталь для кэширования: правильные реализации дописывают (append) схемы найденных инструментов, а не подменяют весь список. Подмена инструментов рендерится в самом начале промпта (позиция 0) и инвалидирует весь кэш; дозапись сохраняет закэшированный префикс.
Практическое правило: до ~20 инструментов можно держать статический список; выше — рассмотрите динамический выбор или разбиение на специализированных субагентов, у каждого из которых свой узкий набор.
MCP-сервер на Go: tools и resources
MCP (Model Context Protocol) — открытый протокол, по которому хост подключает внешние возможности. Официальный Go SDK живёт в github.com/modelcontextprotocol/go-sdk (ветка v1.x, поддерживается совместно с Google); основной пакет — mcp.
Сервер выставляет два вида возможностей:
- Tool — действие, которое модель может вызвать (как функция): имя, описание, схема входа, обработчик. Регистрируется через
mcp.AddTool. SDK сам генерирует JSON Schema из вашей Go-структуры аргументов по тегам. - Resource — именованные данные, доступные по URI (как файл): модель или хост может их прочитать. Регистрируется через
server.AddResource.
Транспорт. Для локального сервера, запускаемого как дочерний процесс, используется stdio (mcp.StdioTransport) — обмен JSON-RPC по stdin/stdout. Для удалённого — HTTP (Streamable HTTP); SDK даёт mcp.StreamableHTTPHandler, который оборачивается в обычный http.Server.
Версии SDK развиваются — перед использованием сверьтесь с актуальными доками и README репозитория: сигнатуры AddTool/AddResource и имена транспортов могут уточняться между релизами.
package main
import (
"context"
"fmt"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Аргументы инструмента. Теги jsonschema задаются в обратных кавычках —
// здесь в учебном тексте опущены; в реальном файле тег поля Currency был бы:
// Currency string (тег: json:"currency" jsonschema:"описание: код валюты ISO 4217")
// SDK генерирует строгую JSON Schema из этой структуры автоматически.
type ConvertArgs struct {
Amount float64 // сумма для конвертации
Currency string // целевая валюта, например EUR
}
func main() {
// 1. Создаём сервер: имя и версия попадают в рукопожатие (handshake) с хостом.
server := mcp.NewServer(
&mcp.Implementation{Name: "billing-tools", Version: "1.0.0"},
nil,
)
// 2. Регистрируем tool. Обработчик возвращает (*mcp.CallToolResult, выход, error).
// Бизнес-ошибку отдаём через результат, а не через error — см. следующий блок.
mcp.AddTool(server, &mcp.Tool{
Name: "convert_amount",
Description: "Конвертирует сумму в указанную валюту по текущему курсу.",
}, func(ctx context.Context, req *mcp.CallToolRequest, args ConvertArgs) (*mcp.CallToolResult, any, error) {
converted := args.Amount * 0.92 // условный курс
text := fmt.Sprintf("%.2f %s", converted, args.Currency)
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}, nil, nil
})
// 3. Регистрируем resource: именованные данные по URI, доступные для чтения.
server.AddResource(&mcp.Resource{
Name: "rates-readme",
URI: "docs://rates/readme",
MIMEType: "text/markdown",
}, func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{{
URI: "docs://rates/readme",
MIMEType: "text/markdown",
Text: "# Курсы\nКурсы обновляются раз в час. Базовая валюта — USD.",
}},
}, nil
})
// 4. Запускаем на stdio-транспорте: обмен JSON-RPC по stdin/stdout.
// Run блокируется до закрытия соединения хостом.
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatalf("сервер MCP остановлен: %v", err)
}
}
package main
import (
"context"
"errors"
"fmt"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type GetInvoiceArgs struct {
InvoiceID string // идентификатор счёта
}
// errFatal — внутренняя фатальная ошибка (сбой инфраструктуры), которую
// нет смысла показывать модели как бизнес-результат.
var errFatal = errors.New("инфраструктурный сбой")
func getInvoice(ctx context.Context, req *mcp.CallToolRequest, args GetInvoiceArgs) (*mcp.CallToolResult, any, error) {
invoice, err := loadInvoice(ctx, args.InvoiceID)
// Случай 1: восстановимая/бизнес-ошибка — отдаём МОДЕЛИ через результат
// с флагом IsError. Процесс не падает, модель решает, что делать дальше.
if errors.Is(err, errNotFound) {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{
Text: fmt.Sprintf("Счёт %q не найден. Проверьте идентификатор и повторите.", args.InvoiceID),
}},
}, nil, nil
}
// Случай 2: фатальная инфраструктурная ошибка — отдаём ПРОТОКОЛУ через error.
// Это сигнал хосту/мониторингу, а не подсказка модели.
if err != nil {
return nil, nil, fmt.Errorf("loadInvoice: %w", errFatal)
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: invoice}},
}, nil, nil
}
// Заглушки для компиляции примера.
var errNotFound = errors.New("не найдено")
func loadInvoice(ctx context.Context, id string) (string, error) { return "", errNotFound }
package main
import (
"context"
"errors"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// userIDKey — ключ для пользователя, положенного в контекст ДОВЕРЕННЫМ слоем
// (мидлварью HTTP-транспорта по OAuth-токену), а не аргументами от модели.
type userIDKey struct{}
type ToolHandler[A any] func(context.Context, *mcp.CallToolRequest, A) (*mcp.CallToolResult, any, error)
// withScope оборачивает обработчик: проверяет наличие пользователя в контексте
// и требуемое право. Аргументы от модели НЕ используются для авторизации.
func withScope[A any](required string, next ToolHandler[A]) ToolHandler[A] {
return func(ctx context.Context, req *mcp.CallToolRequest, args A) (*mcp.CallToolResult, any, error) {
user, ok := ctx.Value(userIDKey{}).(string)
if !ok || user == "" {
// Нет доверенного пользователя — это фатально, дальше не пускаем.
return nil, nil, errors.New("неаутентифицированный вызов инструмента")
}
if !hasScope(user, required) {
// Нет права — бизнес-ошибка для модели, без утечки деталей.
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{
Text: "Недостаточно прав для этого действия.",
}},
}, nil, nil
}
return next(ctx, req, args)
}
}
// hasScope — проверка права у пользователя (least privilege): по умолчанию запрет.
func hasScope(user, scope string) bool { return false }
Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
| Паника процесса на ошибке инструмента | Падает весь агент из-за того, что одна команда вернула ненулевой код; пользователь теряет сессию. | Возвращайте ошибку модели через результат с флагом IsError; паникой/error отдавайте только фатальные инфраструктурные сбои. |
| Сырой дамп API в ответе инструмента | Тысячи токенов метаданных вытесняют важное из контекста, растёт счёт и падает точность. | Возвращайте отфильтрованную проекцию; пагинируйте большие списки курсором; выкидывайте поля, не влияющие на решение. |
| Авторизация по user_id из аргументов вызова | Аргументы генерирует модель — ей нельзя доверять; чужой user_id даёт доступ к чужим данным. | Берите пользователя из доверенного контекста сессии (OAuth-токен), не из аргументов. Аргумент — это запрос, не разрешение. |
| Опасный инструмент работает на хосте напрямую | Выполнение кода или запись в ФС под влиянием prompt injection компрометирует машину. | Песочница: контейнер/микро-ВМ с лимитами, изоляция ФС корневым каталогом, запрет выхода через .. и симлинки. |
| Неидемпотентный инструмент с побочным эффектом | Ретрай или повторный вызов создаёт дубликат заказа/письма. | Ключ идемпотентности: сервер запоминает результат операции и при повторе возвращает его, а не выполняет заново. |
| Все схемы десятков инструментов загружены сразу | Раздувание контекста, путаница модели среди похожих инструментов, инвалидация кэша при изменении списка. | Динамический выбор (tool search): дописывайте схемы найденных инструментов, не подменяйте весь список; либо разбейте на субагентов. |
Практическое задание (PRO-M4-G7)
- Создайте MCP-сервер на
github.com/modelcontextprotocol/go-sdk:mcp.NewServerс именем и версией. - Зарегистрируйте один tool
search_ordersчерезmcp.AddToolсо структурой аргументов (status, cursor, limit) и строгой схемой; ответ — страница + курсор следующей страницы. - Зарегистрируйте resource
orders://schemaчерезserver.AddResource, отдающий описание формата заказа (text/markdown). - Добавьте различение ошибок: «заказ не найден» и «невалидный статус» — через результат с IsError; сбой БД — через возвращаемый error.
- Оберните обработчик в
withScope("orders:read", ...): пользователь берётся из контекста, право проверяется до выполнения. - Запустите сервер на
mcp.StdioTransport, подключитесь тестовым MCP-клиентом и проверьте оба сценария ошибок и пагинацию по курсору.
Проверка знаний
Инструмент charge_card списывает деньги. Агент из-за сетевого таймаута не увидел результат первого вызова и вызвал инструмент повторно с теми же аргументами.
Какой механизм предотвратит двойное списание?
Верный ответ: B
Повторные вызовы неизбежны (ретраи, потеря ответа). Идемпотентность через ключ операции — стандартный приём: повтор с тем же ключом возвращает прежний результат вместо нового побочного эффекта. Больший таймаут не убирает причину; глобальный запрет повторов ломает легитимные сценарии; пустой успех скрывает реальное состояние.
В MCP-обработчике на Go загрузка счёта вернула ошибку «invoice not found» — пользователь указал несуществующий ID.
Как корректнее всего вернуть это из обработчика инструмента?
Верный ответ: C
«Не найдено» — это бизнес-/восстановимая ситуация: модель должна увидеть понятное сообщение и исправить вызов. Её отдают через результат с IsError, не через error (тот зарезервирован для фатальных инфраструктурных сбоев) и тем более не через panic. Пустой успех приведёт к тому, что модель поедет дальше на ложных данных.
У агента сто инструментов. Команда заметила рост счёта за токены и падение точности выбора инструмента.
Какое решение масштабирует набор инструментов, сохраняя кэш промпта?
Верный ответ: A
Динамический выбор держит в контексте только лёгкий поисковый инструмент и подгружает схемы лишь нужных. Дозапись (а не подмена) сохраняет закэшированный префикс. Сжатие описаний снижает точность выбора; загрузка всех ста схем — ровно та проблема, что вызвала рост счёта; перемешивание списка инвалидирует кэш каждый запрос.