Инструменты
Model Context Protocol (MCP) позволяет серверам предоставлять инструменты, которые могут вызываться языковыми моделями. Инструменты позволяют моделям взаимодействовать с внешними системами: делать запросы к базам данных, вызывать API, выполнять вычисления. Каждый инструмент уникально идентифицируется по имени и содержит метаданные с описанием его схемы.
В главе Основы мы научились объявлять простой инструмент:
use neva::prelude::*;
#[tool(descr = "A simple 'say hello' tool")]
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
Того же результата можно добиться без использования процедурного макроса:
use neva::prelude::*;
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
#[tokio::main]
async fn main() {
let mut mcp_server = App::new()
.with_options(|opt| opt
.with_stdio()
.with_name("Sample MCP Server")
.with_version("1.0.0"));
mcp_server
.map_tool("hello", hello)
.with_description("A simple 'say hello' tool");
mcp_server.run().await;
}
В примере выше имя инструмента должно быть задано явно.
При использовании атрибутного макроса #[tool] имя инструмента автоматически выводится из имени функции.
Все остальные параметры инструмента, доступные в атрибутном макросе, можно настроить с помощью методов with_* (например, with_description()).
Метод map_tool() регистрирует обработчик инструмента под указанным именем и возвращает изменяемую ссылку на зарегистрированный инструмент.
Схема входных данных
Для инструмента можно явно задать схему входных данных. Если схема не указана, Neva автоматически генерирует её на основе сигнатуры функции-обработчика.
Схемы — это полноценные документы JSON Schema 2020-12 (InputSchema
поверх serde_json::Value), и макрос #[tool] формирует полные документы
2020-12 автоматически.
Для переопределения сгенерированной схемы укажите её в виде JSON-строки:
#[tool(
descr = "A simple 'say hello' tool",
input_schema = r#"{
"properties": {
"name": {
"type": "string",
"description": "The name to greet"
}
},
"required": ["name"]
}"#
)]
async fn hello(name: String) -> String {
format!("Hello, {name}!")
}
Написанная вами схема публикуется дословно. Все ключевые слова, которые
neva не моделирует сама — default, pattern, examples, $schema,
$defs, $ref, additionalProperties, allOf/anyOf,
if/then/else, — попадают в список нетронутыми, как в корне, так и
глубже, поэтому клиент, проверяющий данные по опубликованной схеме,
принимает ровно то же, что принимает инструмент.
"integer" — самостоятельный тип, а не синоним "number": поле, объявленное
как integer, отклоняет 1.5, но по-прежнему принимает 1.0, потому что
проверяется само значение, а не то, как оно записано.
Схема выходных данных
Если инструмент возвращает структурированные данные (например, JSON-объект), Neva автоматически генерирует схему выходных данных на основе возвращаемого типа.
Как и в случае схемы входных данных, её можно переопределить вручную:
#[tool(
descr = "A 'say hello' tool with structured output",
output_schema = r#"{
"properties": {
"message": {
"type": "string",
"description": "The generated greeting message"
}
},
"required": ["message"]
}"#
)]
async fn hello(say: String, name: String) -> Json<Results> {
let result = Results {
message: format!("{say}, {name}!")
};
result.into()
}
Необязательные аргументы
Аргумент, объявленный как Option<T>, публикуется со своим внутренним типом
T, но не попадает в required; если вызов его не передал, обработчик
получает None, а не ошибку:
#[tool(descr = "Greets a person, by nickname when there is one")]
async fn greet(name: String, alias: Option<String>) -> String {
format!("Hello, {}!", alias.unwrap_or(name))
}
Инструмент, у которого все аргументы необязательные, вообще не публикует
ключ required. Правило работает по разрешённому типу, поэтому псевдоним
типа (type MaybeFloor = Option<i32>;) ведёт себя так же, а
Option<Json<T>> по-прежнему описывает T целиком.
С промптами всё устроено так же — см. Промпты → Необязательные аргументы.
Имена аргументов
Аргументы вызова читаются из arguments по имени, а не по позиции —
значит, имена, по которым читает обработчик, обязаны совпадать с именами,
которые публикует inputSchema.
С #[tool] делать ничего не нужно: макрос берёт имена параметров самой
функции. Исключение — «голое» замыкание: Rust не сохраняет имена его
параметров, поэтому такой инструмент публикует и читает позиционные arg0,
arg1, … Макрос map_tool! считывает имена с замыкания за вас:
use neva::{App, map_tool};
#[tokio::main]
async fn main() {
let mut app = App::new();
map_tool!(app, "greet", |name: String, age: i32| async move {
format!("Hello, {name}! You are {age}.")
})
.with_description("Greets a person");
app.run().await;
}
with_arg_names() —
то же самое в явном виде, для именованной функции или обработчика, который
вы объявили не по месту:
use neva::App;
async fn greet(name: String, age: i32) -> String {
format!("Hello, {name}! You are {age}.")
}
#[tokio::main]
async fn main() {
let mut app = App::new();
app.map_tool("greet", greet)
.with_arg_names(["name", "age"]);
app.run().await;
}
Любой из двух вызовов переименовывает сгенерированную схему и имена для
извлечения вместе, так что разойтись они не могут. Именуются только
параметры, несущие значения: Context, Meta<_> и внедрённый через DI
Dc<T> пропускаются здесь ровно так же, как пропускаются в схеме.
Option<T> именуется — он занимает слот аргумента, просто не является
обязательным.
Схема, заданная через input_schema = "..." или
with_input_schema(),
берётся дословно — каждый ключ в ней выбран намеренно. Называйте её свойства
так же, как называете аргументы. Порядок этих двух вызовов не важен.
Проверка при старте
Инструмент или промпт, публикующий аргументы, которые его обработчик не
читает, не сможет успешно вызвать никто, поэтому App::run отказывается
стартовать при таком расхождении, а не падает на первом же вызове клиента —
это касается неверного количества объявленных имён, дубликата имени или
свойства схемы, которое обработчик не ищет.
Context::add_tool
и add_prompt выполняют ту же проверку и возвращают ошибку: у примитива,
зарегистрированного на работающем сервере, старта, на котором можно упасть,
уже не осталось.
Инструмент, зарегистрированный из «голого» замыкания, теперь объявляет
arg0, arg1, … там, где раньше ключами свойств были имена типов, а
|a: i32, b: i32| публикует два свойства там, где два слота i32 раньше
схлопывались в одно. Инструментов, объявленных через #[tool], это не
касается. Если вы регистрируете инструменты из замыканий и хотите вернуть
прежние имена в протоколе, задайте их явно через map_tool! или
with_arg_names().
Дублирование аргумента в заголовок
Инструмент может попросить, чтобы один из его аргументов дополнительно
передавался в HTTP-заголовке — тогда прокси и шлюзы смогут маршрутизировать
и ограничивать трафик по нему, не разбирая тело запроса. Пометьте свойство в
inputSchema аннотацией x-mcp-header, и клиенты продублируют значение в
заголовок Mcp-Param-{name} при tools/call:
#[tool(
descr = "Fetches a tenant's dashboard",
input_schema = r#"{
"properties": {
"tenant": {
"type": "string",
"description": "Tenant identifier",
"x-mcp-header": true
}
},
"required": ["tenant"]
}"#
)]
async fn dashboard(tenant: String) -> String {
format!("Dashboard for {tenant}")
}
Серверы могут использовать аннотацию; клиенты обязаны её соблюдать.
Собственный клиент neva запоминает аннотации из tools/list и добавляет
заголовки автоматически, а сервер отклоняет tools/call, у которого
заголовок расходится с телом, с ошибкой HeaderMismatch (-32020).
Регистрации живут ровно столько, сколько список
То, что клиент узнал из tools/list, действительно только в течение ttlMs
этого списка, а отсутствующий ttlMs читается как 0 — то есть аннотации
годны на этот обмен и не дольше. Когда они истекли, HeaderMismatch
заставляет клиента заново запросить список и один раз повторить вызов; этот
свежий список годится для повтора независимо от собственного TTL — только
для отклонённого инструмента и только для этого обмена.
Это важно, если вы меняете аннотации x-mcp-header инструмента на лету:
задавайте такой ttlMs, которого готовы придерживаться, и рассчитывайте на
один лишний round-trip tools/list после изменения — вместо навсегда
неверного заголовка.
Определение, нарушающее ограничения спецификации — имя не является токеном,
дубликат, непримитивный тип или свойство, недостижимое статически через
properties, — исключает из списка весь инструмент. Это сделано
намеренно: одно неверное определение не должно менять то, что отправляет
корректное. Правило действует для Streamable HTTP; другие транспорты вправе
игнорировать аннотацию.
Порядок в списке
Реестры инструментов, промптов и ресурсов основаны на BTreeMap, поэтому
tools/list возвращает записи упорядоченными по имени, и порядок
стабилен между вызовами. Именно это делает безопасной курсорную
пагинацию — при произвольном порядке
записи могли бы пропадать или дублироваться между страницами, — а ещё
позволяет промпт-кэшам LLM попадать по неизменившемуся списку инструментов.
MCP-контекст
В более сложных сценариях — например, когда инструменту нужен доступ к ресурсам, объявленным на том же MCP-сервере, — можно внедрить Context в обработчик инструмента:
#[tool(descr = "Fetches resource metadata")]
async fn read_resource(ctx: Context, res: Uri) -> Result<Content, Error> {
let result = ctx.resource(res).await?;
let resource = result.contents
.into_iter()
.next()
.expect("No resource contents");
Ok(Content::resource(resource))
}
Обучение на примерах
Полный пример доступен здесь.