- BrainTools - https://www.braintools.ru -
Пока ML- и AI-специалисты усиленно создают агентские системы, разработчики тоже хотят приобщиться к созданию нового мира. Так компания Anthropic — создатели Claude Sonnet, разработали открытый протокол MCP (Model Context Protocol), который позволяет LLM взаимодействовать с любой информационной системой. Это открыло новые возможности не только для построения более сложных и продвинутых агентских AI-систем, но и для активного участия во всём этом процессе и backend-разработчиков.
Я Евгений Клецов — Go-разработчик из Cloud.ru [1]. В статье покажу, как создать свой сервер в тесной связке с вашим продуктом или решением, чтобы затем на его базе построить AI-агента и тем самым облегчить «жизнь» себе и своим клиентам.

Для работы с MCP подходит не каждая модель, а только те, которые поддерживают вызов инструментов. К таковым относятся Claude и ChatGPT, а из open source — DeepSeek, Qwen, Llama, Mistral и другие.
Сама по себе LLM, даже со встроенной поддержкой вызова инструментов, не способна делать запросы к серверам. Для этого необходима отдельная программа, которая в спецификации протокола называемая просто — Хост. Это может быть программа на вашем компьютере с консольным или графическим интерфейсом, или веб-приложение, которым вы будете пользоваться в браузере. Через Хост проходят запросы пользователя, и именно он делает запросы к MCP-серверу, а результаты отдает LLM.
Сервер по спецификации может работать в режиме stdio или http stream. В первом случае сервер представляет собой команду, исполняемую локально. Во втором — полноценный HTTP-сервер с поддержкой Server-Side Events.
Минус первого варианта в том, что он плохо масштабируется и требует, чтобы Сервер и Хост находились на одной машине. Это не подходит для полноценного использования в продакшне, поэтому в статье будем рассматривать второй вариант.
Весь код готового сервера можно посмотреть на GitHub [2].
Существует несколько официальных SDK от создателей протокола для различных языков программирования, но не для Go. Воспользуемся сторонней библиотекой [3], она реализует протокол MCP и предоставляет простые удобные методы для обработки, а также активно развивается, как и сам протокол.
В качестве примера создадим сервер с двумя инструментами: один будет возвращать список заказов клиента из базы данных, а второй — код для получения выбранного заказа. Для этого:
1. Создадим таблицу в базе данных и напишем простой код бизнес-логики для получения данных.
create table orders (
id bigint not null auto_increment primary key,
customer_id bigint not null,
status varchar(255) not null,
created_at timestamp not null,
estimated_delivery_date timestamp
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
func NewService(storage Storage) *Service {
return &Service{storage: storage}
}
func (s *Service) ListOrdersForCustomer(ctx context.Context, customerID uint64) ([]core.Order, error) {
res, err := s.storage.ListOrdersForCustomer(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("listing orders for customer %d: %w", customerID, err)
}
return res, nil
}
func (s *Service) GetQRCodeForPickUp(ctx context.Context, customerID, orderID uint64) (string, error) {
order, err := s.storage.GetOrder(ctx, customerID, orderID)
if err != nil {
return "", fmt.Errorf("getting order for customer %d: %w", customerID, err)
}
if order.Status == core.OrderStatusDelivered {
return "", OrderError{Msg: "order already delivered"}
}
if order.Status != core.OrderStatusAwaitingPickUp {
return "", OrderError{Msg: fmt.Sprintf("picking up is not available, order is %s", strings.ToLower(string(order.Status)))}
}
claim := OrderPickUpClaim{
ID: order.ID,
CustomerID: customerID,
UntilTime: today(),
}
data, _ := claim.MarshalBinary()
var png []byte
png, err = qrcode.Encode(string(data), qrcode.Medium, 256)
if err != nil {
return "", fmt.Errorf("creating qr code: %w", err)
}
return base64.StdEncoding.EncodeToString(png), nil
}
2. Инициализируем сервер и добавим обработчики запросов.
mcp := mcpserver.NewMCPServer(
"Example Server",
"0.1.0",
mcpserver.WithToolCapabilities(true),
mcpserver.WithLogging(),
mcpserver.WithRecovery(),
)
func NewServer(mcp *mcpserver.MCPServer, orderService OrderService) *Server {
srv := &Server{
mcp: mcp,
orderService: orderService,
}
srv.setupHandlers()
return srv
}
func (s *Server) setupHandlers() {
s.mcp.AddTool(mcp.NewTool(
"list_orders",
mcp.WithDescription("Shows a list of orders for the customer"),
), s.ListOrders)
s.mcp.AddTool(mcp.NewTool(
"get_qr_code",
mcp.WithNumber("order_id", mcp.Description("The order ID")),
), s.GetCodeForPickUp)
}
func (s *Server) ListOrders(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
token := getTokenFromContext(ctx)
if token.ID == 0 {
return mcp.NewToolResultError("unauthorized"), nil
}
res, err := s.orderService.ListOrdersForCustomer(ctx, token.ID)
if err != nil {
slog.ErrorContext(ctx, "failed to get customer's orders", "err", err, "customer_id", token.ID)
return nil, err
}
return &mcp.CallToolResult{
Content: mapOrdersToContent(res),
}, nil
}
func (s *Server) GetCodeForPickUp(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
token := getTokenFromContext(ctx)
if token.ID == 0 {
return mcp.NewToolResultError("unauthorized"), nil
}
orderID, err := req.RequireFloat("order_id")
if err != nil {
return mcp.NewToolResultError("invalid order id"), nil
}
res, err := s.orderService.GetQRCodeForPickUp(ctx, token.ID, uint64(orderID))
var orderErr orders.OrderError
if errors.As(err, &orderErr) {
return mcp.NewToolResultError(orderErr.Msg), nil
}
if err != nil {
slog.ErrorContext(ctx, "failed to get customer's order", "err", err, "customer_id", token.ID)
return nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.ImageContent{
Type: "image",
Data: res,
MIMEType: "image/png",
},
},
}, nil
}
3. Чтобы наш сервер понимал, для какого пользователя нужно отдавать список заказов, нам необходимо авторизовать пользователя, который общается с LLM. Действующая на момент написания статьи спецификация протокола говорит, что механизм авторизации не является обязательным для реализации, но если реализация требуется, то для варианта с использованием HTTP должна соответствовать стандарту OAuth 2.1. Клиент, или в нашем случае Хост, должен отправлять авторизационный токен в заголовке, а Сервер в свою очередь обрабатывать его и проверять права доступа. При этом реализация сервера авторизации, выпускающего токены, может быть любой. В примере мы пишем сервер для существующего продукта, так что всё необходимое для авторизации уже имеется, остается только добавить обработку токена из входящего запроса. А еще — не забыть добавить отправку токена с запросом от вашего Хоста, но это уже выходит за рамки темы этой статьи.
В библиотеке есть опция server.WithHTTPContextFunc для добавления функции модификации контекста. В ней мы можем получить доступ к непосредственно входящему HTTP-запросу с заголовками, достать наш токен, проверить его и вернуть контекст, который будет передаваться в обработчики вызовов инструментов. Применим эту опцию для внедрения в контекст информации о текущем пользователе:
func AuthMiddlewareContext(ctx context.Context, r *http.Request) context.Context {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
cl := claims{}
parser := new(jwt.Parser)
jwtToken, err := parser.ParseWithClaims(token, &cl, func(token *jwt.Token) (interface{}, error) { return []byte(jwtSecret), nil })
if err != nil {
slog.ErrorContext(ctx, "failed to parse jwt token", "err", err)
return ctx
}
if !jwtToken.Valid {
slog.ErrorContext(ctx, "jwt token is invalid", "err", err)
return ctx
}
return context.WithValue(ctx, tokenCtxKey{}, Token{
ID: cl.ID,
Name: cl.Name,
Role: cl.Role,
})
}
func (s *Server) Run(streamPort int) error {
return mcpserver.NewStreamableHTTPServer(s.mcp,
mcpserver.WithEndpointPath("/mcp"),
mcpserver.WithHTTPContextFunc(AuthMiddlewareContext),
).Start(fmt.Sprintf(":%d", streamPort))
}
4. Для выхода в прод нам не хватает инструментов для наблюдения за работой сервера, а именно — метрик и трейсинга. Для этой цели подойдет опция добавления middleware — server.WithToolHandlerMiddleware. В нашем примере мы будем собирать метрики количества успешных и ошибочных вызовов, а также время их выполнения. В трейсы обернем вызовы инструментов, и будем записывать их ошибки [4], если они возникнут. Обработчик может вернуть ошибку двумя путями: через второй аргумент error или в теле ответа с флагом IsError == true. Учтем оба варианта.
func ObserveDurationMiddleware(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
res, err := next(ctx, r)
duration := time.Since(start)
toolName := r.Params.Name
metrics.ObserveToolInvocationTime(toolName, duration.Seconds())
if err != nil || res.IsError {
metrics.IncToolInvocationFailure(toolName)
} else {
metrics.IncToolInvocationSuccess(toolName)
}
return res, err
}
}
func CollectTraceMiddleware(next mcpserver.ToolHandlerFunc) mcpserver.ToolHandlerFunc {
return func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
toolName := r.Params.Name
tCtx, span := otel.Tracer("mcp_tools").Start(ctx, fmt.Sprintf("server.Tool/%s", toolName))
defer span.End()
res, err := next(tCtx, r)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
if res.IsError {
rErr := getErrFromToolResponse(res)
span.RecordError(rErr)
span.SetStatus(codes.Error, rErr.Error())
}
return res, err
}
}
mcp := mcpserver.NewMCPServer(
"Example Server",
"0.1.0",
mcpserver.WithToolCapabilities(true),
mcpserver.WithLogging(),
mcpserver.WithRecovery(),
mcpserver.WithToolHandlerMiddleware(server.ObserveDurationMiddleware),
mcpserver.WithToolHandlerMiddleware(server.CollectTraceMiddleware),
)
Итак, мы создали слой бизнес-логики с запросами к базе данных, реализовали код обработчиков вызовов инструментов и подключили их к серверу. Внедрили в сервер middleware для сбора метрик и телеметрии, добавили обработку токена авторизации на уровне входящих HTTP-запросов. Наш сервер готов к первому запуску, осталось только поднять инфраструктуру. В проекте по ссылке на GitHub есть готовый docker-compose файл, которого вполне хватит для локального запуска — пользуйтесь.
Теперь запускаем сервер по инструкции из README и уже можем начать тестирование 🙂.
Для проверки работы сервера возьмем инструмент MCP Inspector [5] — это как Postman, только для MCP. Запустим его командой npx @modelcontextprotocol/inspector (нужен установленный NodeJS).

Инспектор предлагает перейти по ссылке с предзаполненным токеном — сделаем это. Так он сохранит в сессии информацию о нашем сервере, и при следующем запуске не придется вводить заново адрес и заголовки для запросов.

Вводим параметры как на картинке. Дальше нужно сгенерировать токен доступа. Для этого можно использовать любой онлайн-конструктор, например на сайте jwt.io [6] есть дебаггер токенов. Заголовок токена берем стандартный, секрет используем такой же, как указали в файле .env, а тело вот такое:
{
"id": 1,
"name":"John Doe",
"role":"customer"
}
Вставляем токен и нажимаем Connect.

Теперь мы можем проверить, что инструменты корректно отрабатывают и возвращают результат в правильном формате. Я, например, не сверялся со спецификацией протокола и возвращал изображение QR-кода просто набором байт как есть, без преобразования. Инспектор сразу показал ошибку, что изображение в ответе сервера должно быть кодировано в base64. Тогда я добавил преобразование и всё стало хорошо.

Когда мы убедились, что сервер работает корректно, самое время проверить его работу в связке с LLM. Для этого будем использовать mcphost [7] от разработчиков библиотеки, которую мы использовали для написания Сервера. Также нам потребуется установить Ollama [8] и скачать для неё подходящую модель с поддержкой инструментов. Я использовал Qwen3.
ollama pull qwen3:1.7b
Теперь создадим файл .mcphost.yml в домашней директории. Его также создает mcphost при первом запуске, но нам всё равно нужно изменить параметры по умолчанию, чтобы запустить Хост с выбранной моделью и нашим Сервером.
# MCPHost Configuration File
# All command-line flags can be configured here
# MCP Servers configuration
# Add your MCP servers here
mcpServers:
test:
url: "http://localhost:32900/mcp"
transport: streamable
headers:
- 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibmFtZSI6IkpvaG4gRG9lIiwicm9sZSI6ImN1c3RvbWVyIn0.9nWGdP6-R3DJXqHvwuuXvRZLKuudbq6Mj7kV6XhSBSA'
# Application settings (all optional)
model: "ollama:qwen3:1.7b"
# max-steps: 20 # Maximum agent steps (0 for unlimited)
# debug: false # Enable debug logging
# system-prompt: "/path/to/system-prompt.txt" # System prompt text file
# Model generation parameters (all optional)
# max-tokens: 4096 # Maximum tokens in response
# temperature: 0.7 # Randomness (0.0-1.0)
# top-p: 0.95 # Nucleus sampling (0.0-1.0)
# top-k: 40 # Top K sampling
# stop-sequences: ["Human:", "Assistant:"] # Custom stop sequences
Мы добавили параметры нашего сервера, токен для авторизации и выбранную модель. Остальные параметры по умолчанию оставляет, для теста они вполне подойдут.

Хост запущен, модель готова принимать наши промпты. Попробуем узнать, какие у нас есть заказы и когда будет доставлен последний заказ.

Получили очень интересный результат. Наш заказ в статусе «created», но модель считает, что заказ уже был доставлен.
Если мы посмотрим на ответ сервера в инспекторе, вот что мы увидим:
{
"id": 4,
"customer_id": 1,
"status": "CREATED",
"created_at": "2019-04-04T00:00:00Z",
"delivery_date": "2019-04-07T12:00:00Z"
}
LLM получает от сервера результат, в котором у заказов есть поле delivery_date. В этом поле должна быть предполагаемая дата доставки, для заказов, которые еще в пути, или фактическая дата доставки, если заказ выдан.
Видимо, LLM считает, что поле даты приоритетнее, чем поле статуса, и для заказа, который задержался в пути, отвечает, что заказ уже выдан такого-то числа.
Пробуем модифицировать ответ сервера. Исправляем поле delivery_date на expected_delivery_date, и дополнительно добавим поле delivered_at, чтобы совсем явно разделить эти две даты.
Теперь модель не путается, и отвечает точнее, что заказ прибудет в конкретную дату.
Также поменяем набор статусов, чтобы он был более точным. CREATED, DISPATCHED, DELIVERED не в полной мере отражают суть статуса заказа, прошедшее время показывает не столько текущее состояние, сколько предыдущее. Заменим их на PACKING, SHIPPING, AWAITING_PICK_UP, DELIVERED. Так мы будем понимать состояние заказа именно в текущий момент времени — собирается, в доставке, ожидает получения или получен.

Очевидно, ответ для модели должен быть максимально близким к естественному языку. Упрощения, понятные разработчикам, не понятны модели, она живет в пространстве естественного языка.
Следует вывод, что нужно фильтровать результаты и предоставлять минимально необходимый набор данных. В API для приложений мы обычно отдаем максимум возможного и допустимого, в случае работы с LLM наоборот — необходимо сокращать результаты, иначе весь этот набор данных будет выброшен на пользователя потоком текста, и пользовательский опыт [9] будет негативным.
Попробуем получить код для получения заказа.

Модель ушла в глубокие рассуждения и не смогла понять, что ей нужно вызвать второй инструмент у нашего сервера. Похоже одного названия инструмента недостаточно, давайте добавим к нему описание.
s.mcp.AddTool(mcp.NewTool(
"get_qr_code",
mcp.WithDescription("Shows a QR code for picking up the order. User should show it to a delivery employee to receive the order."),
mcp.WithNumber("order_id", mcp.Description("The order ID")),
), s.GetCodeForPickUp)

Теперь модель поняла, какой инструмент ей вызвать, и вернула ожидаемый ответ. И мы снова убеждаемся, что информация на естественном языке — это критично для работы с моделью.
Ошибки следует возвращать в теле ответа, можно использовать хелперы вроде NewToolResultError или сформировать обычный ответ с включенным флагом IsError. Эти ошибки будут возвращены в модель и обработаны. Не стоит использовать привычное в Go return nil, err, поскольку протокол MCP основан на JSON-RPC, и такие ошибки автоматически конвертируются в Internal Error с кодом -32603. При этом ошибка будет доступна модели, и она может отобразить ее пользователю, или использовать для обдумывания. Так что делали внутренних ошибок следует скрывать, как в любом публичном API.

Кроме инструментов, сервер может предоставлять ресурсы и промпты. Под ресурсами понимается набор данных, которые могут быть использованы как контекст для модели: документы, изображения, аудио, любые другие данные в текстовом или бинарном представлении.
В отличие от аналогичных данных, представляемых инструментами, они не доступны модели напрямую, и не могут быть запрошены моделью. Их может запросить Хост и предложить пользователю использовать их для обращения к модели. Промпты — это по сути шаблоны для пользовательских запросов к модели, своего рода инструкции и лучшие практики по работе с AI-агентом. Аналогично ресурсам, используются только Хостом и пользователем.
Также библиотека имеет возможность добавлять хуки жизненного цикла запроса, их можно использовать для сбора метрик, аудита и других целей, если это окажется удобнее, чем использовать middleware.
Как видите, написать свой MCP-сервер не сложно, но имейте в виду, что нейросеть воспринимает ответы сервера как есть. Человек использует приложение с помощью графического интерфейса, который отображает сложный JSON-ответ в виде привычных образов из полей и кнопок. У нейросети нет такого интерфейса — все ответы сервера для нее это текст, который она принимает, осмысливает и затем отвечает пользователю.
Поэтому лучше придерживаться нескольких принципов:
Не раскрывайте детали внутреннего устройства системы.
Давайте инструментам понятные естественные имена и описания.
В структурированных ответах поля и их значения пишите предельно ясно, без двусмысленности.
Возвращайте минимальный необходимый набор данных для формирования ответа пользователю.
На этом у меня, пожалуй, всё. Если вы хотите пойти еще дальше, и подключить к своему MCP-серверу собственноручно созданного AI-агента, присмотритесь к Evolution AI Agents [10].
И, кстати, в комментариях пишите, инструкцию по какому аспекту или теме вам интересно увидеть в следующей статье?
Автор: warmaris
Источник [11]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/18242
URLs in this post:
[1] Cloud.ru: http://Cloud.ru%20?utm_source=habr&utm_medium=article&utm_campaign=mcp_na_go_12082025
[2] GitHub: https://github.com/warmaris/mcp-preview
[3] сторонней библиотекой: http://github.com/mark3labs/mcp-go
[4] ошибки: http://www.braintools.ru/article/4192
[5] MCP Inspector: https://github.com/modelcontextprotocol/inspector
[6] jwt.io: http://jwt.io
[7] mcphost: https://github.com/mark3labs/mcphost
[8] Ollama: https://ollama.com/download
[9] опыт: http://www.braintools.ru/article/6952
[10] Evolution AI Agents: https://cloud.ru/products/evolution-ai-agents%20?utm_source=habr&utm_medium=article&utm_campaign=mcp_na_go_12082025
[11] Источник: https://habr.com/ru/companies/cloud_ru/articles/935390/?utm_source=habrahabr&utm_medium=rss&utm_campaign=935390
Нажмите здесь для печати.