Продакшн-разработка ИИ-агентов · Модуль 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 и имена транспортов могут уточняться между релизами.

Минимальный MCP-сервер на Go: один tool и один resource поверх stdio
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 }
Обёртка авторизации: least privilege и scoping по пользователю из доверенного контекста
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

Грабли дизайна инструментов и MCP в проде
ГрабляПочему плохоКак избегать
Паника процесса на ошибке инструментаПадает весь агент из-за того, что одна команда вернула ненулевой код; пользователь теряет сессию.Возвращайте ошибку модели через результат с флагом 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 списывает деньги. Агент из-за сетевого таймаута не увидел результат первого вызова и вызвал инструмент повторно с теми же аргументами.

Какой механизм предотвратит двойное списание?

  • A Увеличить таймаут сети, чтобы ответ всегда успевал прийти
  • B Ключ идемпотентности: сервер запоминает результат операции и при повторе возвращает его, не выполняя списание заново
  • C Запретить агенту повторные вызовы любого инструмента
  • D Возвращать пустой успех при повторном вызове

В MCP-обработчике на Go загрузка счёта вернула ошибку «invoice not found» — пользователь указал несуществующий ID.

Как корректнее всего вернуть это из обработчика инструмента?

  • A Вернуть Go-error из обработчика, чтобы протокол передал сбой хосту
  • B Вызвать panic с текстом ошибки
  • C Вернуть CallToolResult с IsError: true и понятным текстом для модели
  • D Вернуть пустой успешный результат, чтобы не прерывать агента

У агента сто инструментов. Команда заметила рост счёта за токены и падение точности выбора инструмента.

Какое решение масштабирует набор инструментов, сохраняя кэш промпта?

  • A Динамический выбор (tool search): искать релевантные инструменты и дописывать их схемы, не подменяя весь список
  • B Сжать описания всех ста инструментов до одной строки каждое
  • C Грузить все сто схем, но в самом начале промпта для кэширования
  • D Перемешивать список инструментов каждый запрос для разнообразия