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

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

Какие инструменты можно интегрировать в контекст 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.

Как и в предыдущих статьях, в 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-сервера мы также увидим значение текущего времени. То есть LLM действительно выполнила запрос нашего инструмента.
Вы можете закомментировать строку с вызовом defaultToolCallbacks()
в конфигурации chatClient
и ещё раз спросить время. 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 сначала узнать текущее время с помощью метода, который мы сделали ранее.

Тут мы попросили создать напоминание и указали время его срабатывания как +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-сервера мы увидим, что были вызваны все три метода в правильной последовательности:
Получение цен на хлеб в разных магазинах.
Now: 2025-06-23T12:39:12.838160949
Напоминание с текстом 'Купить хлеб в магазине Пятёрочка' сработает 2025-06-24T09:00.
Заключение
Spring AI предоставляет очень простой декларативный подход для добавления любых инструментов в контекст LLM с помощью Model Context Protocol. Вам даже не требуется разбираться с форматом этого протокола. Однако очень важно делать подробные описания методов и их параметров, чтобы LLM понимала, что ей требуется вызывать и в какой последовательности.
Рассмотренные в статье примеры MCP-сервера
и MCP-клиента содержат Dockerfile
и полностью готовы к деплою.
Ещё больше статей по разработке на Java, Kotlin и Spring вы найдёте на моём сайте.
Комментарии (4)
TheJustRusik
01.07.2025 09:50А что насчёт тех функций которые будут выдавать чувствительные данные? Как их можно будет ограничить для использования этой модели? К примеру продолжая вашу идею с напоминаниями, когда мы разовьем этот сервис и у нас будет много пользователей и соответственно у каждого будут свои напоминания, как мы можем гарантировать что при обслуживании конкретного пользователя, модель не создаст напоминание другому пользователю, или не отдаст нашему пользователю напоминая других? Есть ли какие то стандарты в этом протоколе которые гарантируют безопасность?
devmark Автор
01.07.2025 09:50Вообще защита чувствительных данных - это немного другое. MCP - это по сути универсальный протокол интеграции.
Если взять конкретно ваш пример, чтобы напоминания не создавались другому пользователю - это легко обеспечить. Достаточно передавать в метод идентификатор пользователя (чем более сложный, тем лучше). Например, uid. LLM не сможет "выдумать" такой uid. Она должна будет его как-то получить или запросить. А если попытается передать какой-то левый - MCP-сервер просто вернёт ошибку и напоминание не создаст.
panzerfaust
Я правильно понял, что порядок гарантирован только "здравым смыслом" модели? Интересно, что будет, если дернуть реквест 1000 раз. Порядок сохранится? А если тулы будут иметь близкие по смыслу дескрипшны?
devmark Автор
Да, порядок в явном виде никак не определяется и отдаётся на откуп модели. Всегда когда мы работаем с LLM, то оперируем некоторой вероятностью. Если тулы будут иметь похожие описания, тогда да, велика вероятность вызова не того метода.
Поэтому я ещё раз хочу подчеркнуть, что описания инструментов MCP - чуть ли не самое главное, на что нужно сделать упор.
Но если описания более-менее подробные, то и 1000 вызовов будут выполнены корректно.
Кстати, сейчас подумал, что если нужно обеспечить 100% последовательность, то можно создать некий прокси MCP-метод, внутри которого этот порядок будет зашит. Тогда и контекст уменьшим. Эдакий паттерн "Фасад" в нейросетях)