- BrainTools - https://www.braintools.ru -

Пишем свой MCP-сервер на Go

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

Я Евгений Клецов — Go-разработчик из Cloud.ru [1]. В статье покажу, как создать свой сервер в тесной связке с вашим продуктом или решением, чтобы затем на его базе построить AI-агента и тем самым облегчить «жизнь» себе и своим клиентам.

Пишем свой MCP-сервер на Go - 1
Немного теории

Для работы с 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).

Пишем свой MCP-сервер на Go - 2

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

Пишем свой MCP-сервер на Go - 3

Вводим параметры как на картинке. Дальше нужно сгенерировать токен доступа. Для этого можно использовать любой онлайн-конструктор, например на сайте jwt.io [6] есть дебаггер токенов. Заголовок токена берем стандартный, секрет используем такой же, как указали в файле .env, а тело вот такое:

{
  "id": 1,
  "name":"John Doe",
  "role":"customer"
}

Вставляем токен и нажимаем Connect.

Пишем свой MCP-сервер на Go - 4

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

Пишем свой MCP-сервер на Go - 5

Когда мы убедились, что сервер работает корректно, самое время проверить его работу в связке с 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

Мы добавили параметры нашего сервера, токен для авторизации и выбранную модель. Остальные параметры по умолчанию оставляет, для теста они вполне подойдут.

Пишем свой MCP-сервер на Go - 6

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

Пишем свой MCP-сервер на Go - 7

Получили очень интересный результат. Наш заказ в статусе «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. Так мы будем понимать состояние заказа именно в текущий момент времени — собирается, в доставке, ожидает получения или получен.

Пишем свой MCP-сервер на Go - 8

Очевидно, ответ для модели должен быть максимально близким к естественному языку. Упрощения, понятные разработчикам, не понятны модели, она живет в пространстве естественного языка. 

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

Попробуем получить код для получения заказа.

Пишем свой MCP-сервер на Go - 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)
Пишем свой MCP-сервер на Go - 10

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

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

Пишем свой MCP-сервер на Go - 11

Что еще умеет сервер?

Кроме инструментов, сервер может предоставлять ресурсы и промпты. Под ресурсами понимается набор данных, которые могут быть использованы как контекст для модели: документы, изображения, аудио, любые другие данные в текстовом или бинарном представлении. 

В отличие от аналогичных данных, представляемых инструментами, они не доступны модели напрямую, и не могут быть запрошены моделью. Их может запросить Хост и предложить пользователю использовать их для обращения к модели. Промпты — это по сути шаблоны для пользовательских запросов к модели, своего рода инструкции и лучшие практики по работе с 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

www.BrainTools.ru

Rambler's Top100