В статье Spring AI: retrieval augmented generation мы научились добавлять в контекст модели произвольные данные из векторного хранилища. Теперь давайте пойдём ещё дальше и посмотрим, как можно добавлять в контекст модели сторонние инструменты.

Spring AI: Model Context Protocol
Spring AI: Model Context Protocol

Протокол контекста модели (Model Context Protocol, MCP) — это открытый стандарт, разработанный и представленный компанией Anthropic 25 ноября 2024 года. Основная цель MCP — создание унифицированного протокола взаимодействия между большими языковыми моделями (LLM) и внешними источниками данных и инструментами. MCP унифицирует определения вызовов интерфейса для доступа к возможностям различных инструментов.

Архитектура с использованием MCP состоит из MCP-клиента, который обращается к одному или нескольким MCP-серверам. Эти сервера интегрированы с целевыми инструментами и источниками данных. Spring AI позволяет выполнить эту интеграцию в простом декларативном стиле. Вам даже не потребуется разбираться с протоколом, т.к. Spring будет генерить описания инструментов автоматически. Также MCP-клиент одновременно является связующим звеном с LLM.

Пример архитектуры с использованием MCP
Пример архитектуры с использованием MCP

Какие инструменты можно интегрировать в контекст LLM? Давайте рассмотрим простой пример. При этом он очень хорошо иллюстрирует плюсы, которые вы получаете от использования MCP.

Как известно, модель в общем случае вещь статическая. Её тренировали на каком-то наборе данных, который был актуальным на определённую дату. Отсюда следует, что LLM не обладает текущим контекстом времени. Если мы спросим LLM, сколько сейчас времени и��и какой сегодня день, она вам ответить не сможет или что-то попытается нафантазировать. Но мы можем создать MCP-инструмент, возвращающий текущую дату и время.

MCP-сервер

Создадим пустой спринговый проект (например, с помощью Spring Initializr). Выбираем тип проекта - Gradle-Kotlin, язык - Kotlin и версию Java - 21. Из зависимостей добавим только spring-ai-starter-mcp-server-webmvc. В качестве альтернативы также можно использовать spring-ai-starter-mcp-server-webflux, если вы хотите использовать неблокирующий стек. И тот, и другой стартер автоматически поднимает mcp-сервер и делает доступными для интеграции все методы, помеченные специальными аннотациями. Пример MCP-сервера и MCP-клиента вы можете посмотреть на моём github.

Чтобы в дальнейшем запускать и mcp-сервер и mcp-клиент на одной машине, давайте сразу в application.yml переопределим дефолтный порт на 8081.

server:
  port: 8081

Теперь создадим спринговый сервис и в нём метод, возвращающий текущую дату и время.

@Service
class ToolService {
    @Tool(description = "Получить текущую дату и время.")
    fun getTime(): LocalDateTime {
        val now = LocalDateTime.now()
        println("Now: $now")
        return now
    }
}

Этот метод мы снабжаем аннотацией @Tool, которая как раз и указывает, что данный метод должен быть доступен как MCP-инструмент. В параметрах аннотации обязательно определяем description - именно на это описание будет опираться LLM, чтобы понять, какой именно метод ей нужно вызывать.

Вторым шагом нам нужно определить бин toolsProvider, в котором будут перечислены все mcp-сервисы.

@Configuration
class ToolConfig {
    @Bean
    fun toolsProvider(toolService: ToolService): ToolCallbackProvider =
        MethodToolCallbackProvider.builder()
            .toolObjects(toolService)
            .build()
}

Созданный выше toolService мы сюда инжектим через параметр стандартными средствами Spring. Внутри конструируем ToolCallbackProvider с помощью билдера, подставляя этот сервис.

Теперь запускаем приложение и если всё сделано правильно, в логах увидим сообщение "Registered tools: 1". Наш MCP-сервер готов!

MCP-клиент

Создадим второе приложение с помощью Spring Initializr. Добавляем в проект 3 зависимости: Spring Web, OpenAI и Model Context Protocol Client.

Создаём MCP-клиент с помощью Spring Initializr
Создаём MCP-клиент с помощью Spring Initializr

Как и в предыдущих статьях, в application.yml настраиваем параметры подключения к LLM.

spring:
  ai:
    openai:
      api-key: ${OPEN_AI_API_KEY}
      base-url: ${OPEN_AI_BASE_URL:https://api.openai.com}

Для подключения к OpenAI нам потребуется Api-Key, который можно сгенерировать в личном кабинете https://platform.openai.com/api-keys. Также, если вы подключаетесь через какой-либо прокси-сервис OpenAI или используете любую другую LLM с совместимым протоколом, надо ещё прописать целевой хост в переменной OPEN_AI_BASE_URL. Если же подключаетесь напрямую - этот параметр можно вообще не указывать и будет использовано значение по умолчан��ю.

Далее пропишем MCP-сервер (их может быть несколько, но в нашем случае один).

spring:
  ai:
    # ...
    mcp:
      client:
        sse:
          connections:
            mcp-server-example:
              url: http://localhost:8081

Каждому серверу мы присваиваем произвольное имя (в данном случае mcp-server-example) и прописываем его урл. Обратите внимание, что тут мы указываем ровно тот порт, который переопределили выше, т.е. 8081. Взаимодействие с MCP-серверами происходит через SSE (server-side events). Каждый сервер должен предоставлять определённый эндпоинт, куда клиент будет слать сообщения. Но всё это взаимодействие берёт на себя Spring.

Конфигурация chatClient

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

@Configuration
class AiConfig {
    @Bean
    fun chatClient(
        builder: ChatClient.Builder,
        toolCallbackProvider: ToolCallbackProvider,
    ): ChatClient =
        builder
            .defaultAdvisors(
                SimpleLoggerAdvisor(),
            )
            .defaultToolCallbacks(toolCallbackProvider)
            .build()
}

Помимо уже традиционного SimpleLoggerAdvisor, который логирует запрос и ответ LLM, добавляем с помощью метода defaultToolCallback() бин toolCallbackProvider. Именно этот бин представляет собой реестр всех MCP-инструментов, доступных для LLM.

Сервис для взаимодействия с LLM

Теперь создадим AiService, который отвечает за взаимодействие с LLM.

@Service
class AiService(
    private val chatClient: ChatClient,
) {
    fun processUserMessage(userMessage: String): String {
        // ...
    }
}

В этот сервис подтягиваем chatClient, который сконфигурировали выше. Создадим здесь единственный метод processUserMessage(), принимающий текстовый запрос от пользователя и возвращающий ответ от LLM.

fun processUserMessage(userMessage: String): String {
    val responseFormat = ResponseFormat.builder()
        .type(ResponseFormat.Type.TEXT)
        .build()

    val chatOptions = OpenAiChatOptions.builder() // или другая реализация ToolCallingChatOptions
        .model(OpenAiApi.ChatModel.GPT_4_1_MINI)
        .temperature(0.0)
        .responseFormat(responseFormat)
        .build()

    return chatClient.prompt(Prompt(SystemMessage(SYSTEM_PROMPT), chatOptions))
        .user(userMessage)
        .call()
        .content()
        ?: "Не удалось получить ответ"
}

Внутри этого метода делаем всё очень похожим образом, как мы делали в других статьях про Spring AI: указываем, что ответ ожидается в виде текста без форматирования, затем указываем целевую модель и температуру выставляем в 0, чтобы ответы были максимально точными. Затем указываем какой-то системный промт с базовыми инструкциями для LLM и передаём сюда chatOptions.

Тут важно отметить, что для корректной работы MCP сюда нужно передавать не просто какой-то объект, реализующий ChatOptions, а его более частный случай - ToolCallingChatOptions. Если сделаете иначе - ошибки не будет, но и MCP не заработает. Благо OpenAiChatOptions реализует нужный нам интерфейс, а также поддерживает перечисление OpenAiApi.ChatModel со всеми доступными в OpenAI моделями.

Тестируем работу MCP

В целях демонстрации создадим rest-контроллер, чтобы взаимодействовать с LLM.

@RestController
@RequestMapping("/ai")
class AiController(
    private val aiService: AiService,
) {
    @PostMapping("/tools")
    fun processUserMessage(@RequestBody message: MessageDto): MessageDto =
        MessageDto(
            text = aiService.processUserMessage(message.text)
        )
}

Теперь можно приступать к тестированию. Сначала запускаем mcp-сервер, затем mcp-клиент. С помощью Postman отправляем POST-запрос на эндпоинт http://127.0.0.1:8080/ai/tools.

Получаем текущее время с помощью MCP
Получаем текущее время с помощью MCP

В это время в логах mcp-сервера мы также увидим значение текущего времени. То есть LLM действительно выполнила запрос нашего инструмента.

Вы можете закомментировать строку с вызовом defaultToolCallbacks() в конфигурации chatClient и ещё раз спросить время. MCP-сервер выйдет из контекста и нейросеть ответит ожидаемо.

Без MCP нейросеть не знает текущее время
Без MCP нейросеть не знает текущее время

MCP-метод с параметрами

Получение текущего времени не предполагает наличие каких-то параметров запроса. Давайте сделаем более комплексный пример и создадим метод, который будет создавать напоминание с определённым текстом на определённое время. Здесь нас не интересует сама логика создания такого напоминания, а только факт вызова метода и параметры, которые придут на вход.

Добавим метод createReminder() в сервис ToolService в mcp-сервере:

@Service
class ToolService {
    // ...
    @Tool(description = "Создать напоминание на определённое время.")
    fun createReminder(
        @ToolParam(description = "Текст напоминания")
        description: String,
        @ToolParam(description = "Время срабатывания напоминания")
        dateTime: LocalDateTime,
    ): String {
        // логика создания напоминания
        val message = "Напоминание с текстом '$description' сработает $dateTime."
        println(message)
        return message
    }
}

Тут мы снабжаем описанием не только сам метод, но и каждый его параметр с помощью аннотаций @ToolParam. Эти описания также крайне важны, чтобы LLM понимала, что от неё ожидается.

Теперь мы можем попросить LLM, чтобы она создала напоминание. Причём мы можем указывать не абсолютное время, а относительное. Это вынудит LLM сначала узнать текущее время с помощью метода, который мы сделали ранее.

Создаём напоминание с помощью MCP-метода и указываем относительно время
Создаём напоминание с помощью MCP-метода и указываем относительно время

Тут мы попросили создать напоминание и указали время его срабатывания как +2 часа от текущего. В логах сервера убеждаемся, что были вызваны оба метода:

Now: 2025-06-23T11:34:44.300088719
Напоминание с текстом 'Купить хлеб' сработает 2025-06-23T13:34:44.

Комплексный пример с тремя методами

Прибавить два часа к текущему времени - это далеко не всё, на что способна LLM. Давайте создадим ещё более комплексный пример, в котором LLM будет принимать решение. Например, мы хотим купить хлеб по самой низкой цене. Для этого добавим третий метод, возвращающий цены на хлеб в разных магазинах.

@Service
class ToolService {
    // ...
    @Tool(description = "Получить цены на хлеб в разных магазинах.")
    fun getBreadPrices(): Map<String, BigDecimal> {
        println("Получение цен на хлеб в разных магазинах.")
        return mapOf(
            "Лента" to BigDecimal("60.00"),
            "Пятёрочка" to BigDecimal("50.00"),
            "Азбука Вкуса" to BigDecimal("100.00"),
        )
    }
}

Метод просто возвращает мапу, где ключом является название магазина, а значением - цена на хлеб.

Тогда LLM должна выяснить название магазина с самой низкой ценой, затем запросить текущее время и создать соответствующее напоминание.

Ищем где самый дешёвый хлеб с помощью MCP
Ищем где самый дешёвый хлеб с помощью MCP

В логах mcp-сервера мы увидим, что были вызваны все три метода в правильной последовательности:

Получение цен на хлеб в разных магазинах.
Now: 2025-06-23T12:39:12.838160949
Напоминание с текстом 'Купить хлеб в магазине Пятёрочка' сработает 2025-06-24T09:00.

Заключение

Spring AI предоставляет очень простой декларативный подход для добавления любых инструментов в контекст LLM с помощью Model Context Protocol. Вам даже не требуется разбираться с форматом этого протокола. Однако очень важно делать подробные описания методов и их параметров, чтобы LLM понимала, что ей требуется вызывать и в какой последовательности.

Рассмотренные в статье примеры MCP-сервера
и MCP-клиента содержат Dockerfile и полностью готовы к деплою.

Ещё больше статей по разработке на Java, Kotlin и Spring вы найдёте на моём сайте.