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

Немного теории
Для работы с MCP подходит не каждая модель, а только те, которые поддерживают вызов инструментов. К таковым относятся Claude и ChatGPT, а из open source — DeepSeek, Qwen, Llama, Mistral и другие.
Сама по себе LLM, даже со встроенной поддержкой вызова инструментов, не способна делать запросы к серверам. Для этого необходима отдельная программа, которая в спецификации протокола называемая просто — Хост. Это может быть программа на вашем компьютере с консольным или графическим интерфейсом, или веб-приложение, которым вы будете пользоваться в браузере. Через Хост проходят запросы пользователя, и именно он делает запросы к MCP-серверу, а результаты отдает LLM.
Сервер по спецификации может работать в режиме stdio или http stream. В первом случае сервер представляет собой команду, исполняемую локально. Во втором — полноценный HTTP-сервер с поддержкой Server-Side Events.
Минус первого варианта в том, что он плохо масштабируется и требует, чтобы Сервер и Хост находились на одной машине. Это не подходит для полноценного использования в продакшне, поэтому в статье будем рассматривать второй вариант.
Пишем сервер
Весь код готового сервера можно посмотреть на GitHub.
Существует несколько официальных SDK от создателей протокола для различных языков программирования, но не для Go. Воспользуемся сторонней библиотекой, она реализует протокол 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
. В нашем примере мы будем собирать метрики количества успешных и ошибочных вызовов, а также время их выполнения. В трейсы обернем вызовы инструментов, и будем записывать их ошибки, если они возникнут. Обработчик может вернуть ошибку двумя путями: через второй аргумент 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 — это как Postman, только для MCP. Запустим его командой npx @modelcontextprotocol/inspector
(нужен установленный NodeJS).

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

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

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

Когда мы убедились, что сервер работает корректно, самое время проверить его работу в связке с LLM. Для этого будем использовать mcphost от разработчиков библиотеки, которую мы использовали для написания Сервера. Также нам потребуется установить Ollama и скачать для неё подходящую модель с поддержкой инструментов. Я использовал 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 наоборот — необходимо сокращать результаты, иначе весь этот набор данных будет выброшен на пользователя потоком текста, и пользовательский опыт будет негативным.
Попробуем получить код для получения заказа.

Модель ушла в глубокие рассуждения и не смогла понять, что ей нужно вызвать второй инструмент у нашего сервера. Похоже одного названия инструмента недостаточно, давайте добавим к нему описание.
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-сервер через Docker, то используйте инструкцию. А чтобы подключить к нему собственноручно созданного AI-агента, можно присмотреться к Evolution AI Agents.
И, кстати, в комментариях пишите, инструкцию по какому аспекту или теме вам интересно увидеть в следующей статье?