MCP-сервер на C#: даём ИИ-агенту доступ к своему коду

Опубликовано 06.11.2026 08:20:00 в категории Искусственный интеллект

В этой статье я показываю, как открыть ИИ-агенту (Claude Code, GitHub Copilot в VS Code) небольшую часть уже существующей доменной логики на C# через протокол контекста модели (Model Context Protocol, MCP). Пишем MCP-сервер на официальном C# SDK версии 2.x, подключаем его к двум клиентам, разбираем типичные ошибки, сравниваем «плохо», «хорошо» и «правильно», а также смотрим, что изменилось между версиями SDK 1.x и 2.x.

Зачем агенту доступ к вашему коду

ИИ-агенты отлично пишут код, но они ничего не знают о Ваших системах. Внутренний API, база данных, текущее состояние заказов — всё это для модели закрыто. И самое неприятное: модель редко отвечает «не знаю». Она отвечает уверенно, а значит — угадывает.

При этом логика, которая нужна агенту, у Вас почти наверняка уже написана. В моём примере это сервис заказов на оказание услуг: он умеет найти заказ по номеру, показать просроченные и посчитать сводку. Этой логикой уже пользуется Web API. Переписывать её на Python ради агента — плохая идея. Нужен стандартный способ открыть агенту небольшую, проверенную часть того, что уже есть.

Таким способом и стал протокол контекста модели.

Что такое протокол контекста модели

Протокол контекста модели (Model Context Protocol, MCP) — это открытая спецификация, которая описывает, как ИИ-клиент узнаёт о доступных ему инструментах (tools) и как их вызывает. Самая точная аналогия, которую я нашёл: MCP для ИИ-инструментов — это то же самое, что OpenAPI для HTTP API. Это контракт: что я умею, какие аргументы принимаю, что возвращаю.

Из этого следуют два важных свойства:

  1. Один и тот же MCP-сервер работает в любом клиенте, который понимает протокол: Claude Code, VS Code, Visual Studio и другие. Адаптер под каждый клиент писать не нужно.
  2. Для .NET есть официальный C# SDK — NuGet-пакет ModelContextProtocol, который разрабатывается совместно с Microsoft. На момент написания статьи актуальна версия 2.3.0.

Как это устроено

Архитектура: модель, клиент, MCP-сервер и доменный сервис

Схема 1. MCP-сервер переиспользует тот же доменный сервис, что и Web API

Цепочка выглядит так. Модель общается с клиентом (Claude Code или VS Code). Клиент запускает MCP-сервер как дочерний процесс и обменивается с ним сообщениями JSON-RPC через стандартные ввод и вывод — это транспорт stdio. MCP-сервер через внедрение зависимостей (dependency injection, DI) получает тот же ServiceOrderService, которым уже пользуется Web API.

Обратите внимание на заметку слева внизу: модель не видит Ваш код. Всё, что она знает об инструментах, — это их имена, текстовые описания и JSON-схемы аргументов. Из этого вытекает главная мысль всей статьи, к которой я ещё вернусь: протокол — простая часть, сложная часть — спроектировать инструменты.

Предметная область и доменный сервис

Решение ServiceOrders состоит из трёх проектов:

Проект Назначение
ServiceOrders.Domain Доменная логика: заказы, ServiceOrderService, данные в памяти
01.ServiceOrders.Api Существующий Minimal API на порту 5001
02.ServiceOrders.McpServer Новый MCP-сервер, который мы напишем

Доменный сервис возвращает результат операции через мой пакет Calabonga.Results. Ошибка — это обычное значение, а не исключение, и позже это очень пригодится:

using Calabonga.OperationResults;

namespace ServiceOrders.Domain;

public sealed class ServiceOrderService(ServiceOrderStore store, TimeProvider timeProvider)
{
    public Operation<ServiceOrder, ServiceOrderError> GetByNumber(int number)
    {
        if (number <= 0)
        {
            return Operation.Error(ServiceOrderError.InvalidNumber(number));
        }

        var order = store.Find(number);
        if (order is null)
        {
            return Operation.Error(ServiceOrderError.NotFound(number));
        }

        return Operation.Result(order);
    }

    // GetOverdue(int take) и GetStatistics() — в исходном коде решения
}

Регистрация сервисов вынесена в метод расширения AddServiceOrders(). Один и тот же вызов используется и в Web API, и в MCP-сервере — это и есть переиспользование кода, ради которого всё затевается.

Пишем MCP-сервер

MCP-сервер с транспортом stdio — это обычное консольное приложение. Веб-сервер ему не нужен:

dotnet new console -n 02.ServiceOrders.McpServer
dotnet add 02.ServiceOrders.McpServer package ModelContextProtocol --version 2.3.0
dotnet add 02.ServiceOrders.McpServer package Microsoft.Extensions.Hosting --version 10.0.13
dotnet add 02.ServiceOrders.McpServer reference ServiceOrders.Domain

Весь Program.cs умещается в несколько строк:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ServiceOrders.Domain;

var builder = Host.CreateApplicationBuilder(args);

// В режиме stdio стандартный вывод (stdout) — это канал протокола JSON-RPC.
// Все логи отправляем в стандартный поток ошибок (stderr).
builder.Logging.AddConsole(options =>
    options.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddServiceOrders();

builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly();

await builder.Build().RunAsync();

WithToolsFromAssembly() находит в сборке все классы с атрибутом [McpServerToolType] и регистрирует их методы как инструменты.

Ошибка номер один: посторонний текст в stdout

Самая частая причина, по которой первый MCP-сервер «не работает», — одна строка Console.WriteLine. В режиме stdio стандартный вывод принадлежит протоколу: клиент ждёт там только сообщения JSON-RPC. Стоит серверу напечатать «Сервер запущен», и клиент получает строку, которую не может разобрать. Итог — отключение без внятной причины.

Лог в stdout ломает протокол, лог в stderr — нет

Схема 2. Слева посторонний текст смешивается с JSON-RPC, справа логи уходят в stderr

✗ Плохо: Console.WriteLine(...) или логгер, пишущий в стандартный вывод.

✓ Хорошо: логирование через ILogger<T> с LogToStandardErrorThreshold = LogLevel.Trace — все уровни журнала уходят в stderr.

Обратите внимание: Host.CreateApplicationBuilder по умолчанию добавляет консольный логгер. Поэтому настройка порога — не перестраховка, а обязательная строка.

Инструменты: метод плюс описание

Инструмент — это публичный метод класса. Два атрибута делают его видимым для клиента: [McpServerToolType] на классе и [McpServerTool] на методе. Класс создаётся через контейнер зависимостей, поэтому доменный сервис просто приходит в конструктор:

[McpServerToolType]
public sealed class ServiceOrderTools(ServiceOrderService service)
{
    private const int MaxTake = 50;

    [McpServerTool(Name = "get_service_order", ReadOnly = true, Idempotent = true, OpenWorld = false)]
    [Description("Возвращает заказ на оказание услуги по его номеру: клиент, услуга, цена, срок и статус. " +
                 "Используй, когда пользователь спрашивает о конкретном заказе.")]
    public string GetServiceOrder(
        [Description("Номер заказа, положительное целое число, например 1002.")] int number)
    {
        var operation = service.GetByNumber(number);

        // Ошибку возвращаем понятным текстом, а не исключением:
        // модель прочитает сообщение и сможет исправить запрос сама.
        return operation.Ok
            ? ToJson(operation.Result)
            : $"{operation.Error.Message} Уточните номер у пользователя или найдите заказ через другие инструменты.";
    }

    [McpServerTool(Name = "find_overdue_service_orders", ReadOnly = true, Idempotent = true, OpenWorld = false)]
    [Description("Находит просроченные заказы: срок выполнения прошёл, а заказ не завершён и не отменён. " +
                 "Результат отсортирован по сроку, самые давние — первыми.")]
    public string FindOverdueServiceOrders(
        [Description("Сколько заказов вернуть. По умолчанию 10, максимум 50.")] int take = 10)
    {
        // Аргументы выбирает модель — проверяем их, как вход публичного API.
        var orders = service.GetOverdue(Math.Clamp(take, 1, MaxTake));

        return orders.Count == 0
            ? "Просроченных заказов нет."
            : ToJson(orders);
    }

    // get_service_order_statistics и ToJson(...) — в исходном коде решения
}

В этом классе три решения, на которые я хочу обратить Ваше внимание.

Описание ([Description]) — это не документация. Это единственное, что модель читает, когда решает, вызывать ли инструмент и что передать в аргументы. Расплывчатое описание приводит к тому, что инструмент либо игнорируется, либо вызывается с ерундой. Я пишу описания так, будто объясняю инструмент новому коллеге: что делает, когда использовать, какой формат у аргумента.

Аргументы — недоверенный ввод. Их выбираю не я и не пользователь, их выбирает модель. Если модель попросит пять тысяч записей, она забьёт ими собственное контекстное окно. Math.Clamp — та же гигиена, что и на входе публичного API.

Ошибка — текстом, а не исключением. Неизвестный номер заказа возвращает предложение на русском языке. Модель его прочитает, поймёт и сама попросит уточнить номер. Необработанное исключение просто сорвёт вызов. Здесь Operation<ServiceOrder, ServiceOrderError> из Calabonga.Results ложится идеально: ошибка уже является значением, остаётся превратить её в понятный текст.

Свойства ReadOnly, Idempotent и OpenWorld атрибута [McpServerTool] превращаются в аннотации (annotations) инструмента. Это подсказки клиенту: инструмент ничего не меняет, повторный вызов безопасен, во внешний мир он не ходит. Клиент может использовать их, например, чтобы не спрашивать подтверждение на каждый вызов.

Параметр CancellationToken, если Вы добавите его в метод, в схему инструмента не попадает — SDK подставляет его сам.

Проверяем в MCP Inspector

Прежде чем подключать сервер к IDE, я всегда проверяю его в MCP Inspector:

npx @modelcontextprotocol/inspector dotnet run --project 02.ServiceOrders.McpServer

Inspector запускает сервер, показывает список инструментов, их JSON-схемы и позволяет вызвать любой инструмент вручную. Если инструментов не видно здесь, их не увидит и IDE, а Inspector быстрее подскажет причину.

Подключаем к Claude Code и VS Code

Для Claude Code конфигурация лежит в файле .mcp.json в корне решения:

{
  "mcpServers": {
    "service-orders": {
      "command": "dotnet",
      "args": [
        "run",
        "--project",
        "02.ServiceOrders.McpServer/02.ServiceOrders.McpServer.csproj"
      ]
    }
  }
}

Для VS Code — в файле .vscode/mcp.json:

{
  "servers": {
    "service-orders": {
      "type": "stdio",
      "command": "dotnet",
      "args": [
        "run",
        "--project",
        "02.ServiceOrders.McpServer/02.ServiceOrders.McpServer.csproj"
      ]
    }
  }
}

Файлы почти одинаковые, и именно на этом легко споткнуться: в Claude Code ключ верхнего уровня — mcpServers, в VS Code — servers.

После перезапуска клиента я задаю вопрос «Какие заказы на услуги сейчас просрочены?». Вот что происходит внутри:

Последовательность вызова инструмента

Схема 3. От обнаружения сервера до ответа модели по реальным данным

Клиент запускает сервер, выясняет его возможности и получает список инструментов с JSON-схемами. Когда приходит вопрос, клиент передаёт модели вопрос вместе с описаниями инструментов. Модель решает вызвать find_overdue_service_orders, клиент выполняет вызов, сервер обращается к доменному сервису и возвращает JSON. Модель отвечает уже по реальным данным, а не угадывает.

Если агент отвечает, не вызывая инструмент, назовите инструмент в вопросе явно. Если и это не помогает — переписывайте описание: модель решила, что и так знает ответ.

Плохо, хорошо, правильно

  Подход Почему
✗ Плохо Один инструмент ExecuteSql(string sql) или доступ ко всей базе Агент может сделать всё, включая то, чего делать нельзя
✓ Хорошо 3–4 узких инструмента с понятными [Description] Агент видит ровно то, что нужно для задачи
✓ Правильно Плюс проверка аргументов, только чтение по умолчанию, ошибки текстом Аргументы выбирает модель, а значит, это недоверенный ввод

Каждый инструмент — это ещё один вариант, который модель взвешивает на каждом шаге. Несколько точных инструментов работают лучше, чем три десятка размытых.

Когда MCP-сервер не нужен

Честно о границах применимости. Если у агента уже есть доступ к Вашему REST API с документом OpenAPI, MCP-сервер добавит немного: ещё один процесс, который надо версионировать и сопровождать.

MCP-сервер оправдан, когда выполняется хотя бы одно из условий:

  • нужен локальный доступ, которого нет по сети: файлы, запущенный процесс, локальная база данных;
  • API слишком большой, и из него надо выделить несколько узких инструментов;
  • работа должна идти внутри периметра доверия, без выхода в сеть.

Что изменилось в SDK 2.x

Версия 2.0.0 официального C# SDK вышла 28 июля 2026 года и привела SDK в соответствие с ревизией спецификации MCP от 2026-07-28. Это важно знать по простой причине: большинство туториалов в интернете, как и ответы ИИ-ассистентов, обученных раньше, описывают поведение версии 1.x.

Изменения в протоколе:

Что v1.x v2.x
Состояние по HTTP Сессии, заголовок Mcp-Session-Id Без состояния (stateless) по умолчанию
Начало работы Рукопожатие initialize Сначала обнаружение server/discover, откат к initialize для старых серверов
Roots, Sampling, Logging Поддерживаются Устарели, предупреждение MCP9005
Опции, имеющие смысл только с сессиями Работают Предупреждение MCP9006

Главное изменение — HTTP-транспорт без состояния. На практике это выглядит так:

HTTP с сессиями и без состояния

Схема 4. В 1.x клиент привязан к реплике, в 2.x любая реплика обслуживает любой запрос

В 1.x клиент привязан к конкретной реплике сервера, и перед ними нужен балансировщик с липкими сессиями (sticky sessions). Упала реплика — потеряна сессия. В 2.x достаточно обычного балансировщика. Если старое поведение всё-таки нужно, его можно вернуть через Stateless = false в настройках HTTP-транспорта.

Изменения в коде:

Что v1.x v2.x
Длительные задачи (Tasks) В основном пакете, экспериментально Пакет ModelContextProtocol.Extensions.Tasks, несовместим с 1.x
Структурированный результат не-объектом Обёртка { "result": 72 } Значение как есть: 72
Tool.inputSchema при десериализации Необязательна Обязательна, иначе JsonException
OAuth-колбэк AuthorizationRedirectDelegate AuthorizationCallbackHandler, старый вариант — MCP9007

Коды MCP90xx — это предупреждения компилятора. Если в проекте включён режим «предупреждения как ошибки» (TreatWarningsAsErrors), после обновления на 2.x сборка упадёт, пока Вы не мигрируете код или не подавите предупреждения.

Хорошая новость: базовые вещи не изменились. AddMcpServer(), атрибуты [McpServerToolType] и [McpServerTool], WithStdioServerTransport() работают одинаково. Всё, что показано в этой статье, относится к обеим версиям, кроме HTTP-части.

Что упрощено ради наглядности

Пример намеренно минимальный. Вот чем «хорошо» для демонстрации отличается от «правильно» для продакшена:

Хорошо (демо) Правильно (продакшен)
Данные в памяти Реальное хранилище, запросы только на чтение, учётная запись БД с минимальными правами
dotnet run --project в конфигурации клиента Опубликованный исполняемый файл или .NET tool: быстрее запуск, нет вывода сборки
Ответ — JSON-строка Структурированный результат (UseStructuredContent) со схемой выхода
Транспорт stdio Для общего сервера — Streamable HTTP и обязательная аутентификация
Нет журнала вызовов Аудит: какой инструмент, с какими аргументами, кто вызвал

Заключение

MCP-сервер на C# — это консольное приложение, один пакет, два атрибута и несколько строк настройки хоста. SDK берёт на себя JSON-RPC, генерацию схем и отмену операций. Всё остальное — обычный C#-код, который у Вас, скорее всего, уже написан.

Работает ли сервер на самом деле, решают две неброские вещи: чистый стандартный вывод и внятные описания инструментов. И третья, о которой нельзя забывать: каждый аргумент выбирает модель, поэтому относитесь к нему как к входу публичного API.

HTTP-транспорт и аутентификацию для общего MCP-сервера я разберу отдельно. А какой инструмент Вы бы первым открыли агенту в своём проекте? Пишите в комментариях.

Пакеты и сборки

  • ModelContextProtocol 2.3.0 — официальный C# SDK для MCP: хостинг, DI, транспорт stdio, обнаружение инструментов по атрибутам
  • Microsoft.Extensions.Hosting 10.0.13 — универсальный хост для консольного MCP-сервера
  • Calabonga.Results 2.0.1 — результат операции Operation<T, TError> в доменном сервисе
  • .NET 10 SDK, C# 14

Ссылки

Видео

На этом видео немного терии со слайдами, можно сказать, продолжение данной статьи. Можно посмотреть на boosty (требуется подписка).

Исходные файлы

Исходные файлы, которые были использованы на видео и в статье можно скачать на boosty (требуется подписка).

Комментарии к статье ()

Загрузка...

Что-то пошло не по сценарию и завершилось ошибкой. Перезагрузить страницу (F5) 🗙

Переподключаем сервер...

Переподключение сломалось... Пробуем еще раз сек.

Не получилось переподключится.
Пожалуйста, обновите страницу F5

Сессия была поставлена сервером на паузу.

Восстановить сессию не получилось.
Пожалуйста, обновите страницу F5.