Инструменты
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] имя инструмента автоматически выводится из имени функции.
asyncОбработчик инструмента может быть и обычной fn, возвращающей значение
напрямую, а тот, который блокируется, можно перенести на blocking-пул Tokio
через neva::blocking или #[tool(blocking)]. Публикуемая схема, слоты
аргументов и ответ в обоих случаях одинаковы. См.
Формы обработчиков.
Все остальные параметры инструмента, доступные в атрибутном макросе, можно настроить с помощью методов 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 отказывается
стартовать при таком расхождении, а не падает на первом же вызове клиента —
это касается неверного количества объявленных имён, дубликата имени или
свойства схемы, которое обработчик не ищет.
ctx.tools().add
и ctx.prompts().add выполняют ту же проверку и возвращают ошибку: у примитива,
зарегистрированного на работающем сервере, старта, на котором можно упасть,
уже не осталось.
Дублирование аргумента в заголовок
Инструмент может попросить, чтобы один из его аргументов дополнительно
передавался в 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; другие транспорты вправе
игнорировать аннотацию.
Неизвестные атрибуты отклоняются
#[tool], #[resource], #[resources], #[prompt] и #[handler]
отклоняют атрибут, которого не знают:
#[tool(descr = "…", visibilty = ["app"])] // ошибка: неизвестный атрибут `visibilty`
Молча отброшенный атрибут хуже, чем упавший: опечатка в visibility опубликовала
бы агенту инструмент только для приложения — важную для
безопасности настройку, которая выглядит применённой и таковой не является.
Отклонение написания как раз и не даёт этому собраться.
Как дать инструменту UI
Инструмент может указывать на HTML-документ, который хост рендерит в песочнице
iframe, — это MCP Apps, за фичей apps:
#[tool(descr = "Current weather", ui = "ui://weather/dashboard")]
async fn get_weather(city: String) -> String {
format!("Sunny in {city}.")
}
Инструмент при этом всё равно возвращает фразу: инструмент с UI ОБЯЗАН
вернуть осмысленный массив content, потому что модель читает content, а
iframe есть не у каждого клиента. visibility = ["app"] помечает инструмент,
который может вызывать iframe и не должна видеть модель. Половину с ресурсом
см. в MCP Apps.
Порядок в списке
Реестры инструментов, промптов и ресурсов основаны на BTreeMap, поэтому
tools/list возвращает записи упорядоченными по имени, и порядок
стабилен между вызовами. Именно это делает безопасной курсорную
пагинацию — при произвольном порядке
записи могли бы пропадать или дублироваться между страницами, — а ещё
позволяет промпт-кэшам LLM попадать по неизменившемуся списку инструментов.
MCP-контекст
В более сложных сценариях — например, когда инструменту нужен доступ к ресурсам, объявленным на том же MCP-сервере, — можно внедрить Context в обработчик инструмента:
use neva::prelude::*;
#[tool(descr = "Fetches resource metadata")]
async fn read_resource(ctx: Context, res: Uri) -> Result<Content, Error> {
let result = ctx.resources().read(res).await?;
let resource = result.contents
.into_iter()
.next()
.ok_or_else(|| Error::new(ErrorCode::InternalError, "no resource contents"))?;
Ok(Content::resource(resource))
}
Собственные примитивы сервера сгруппированы так же, как их видит клиент, — по
одному пространству имён на вид. Каждое читает реестр, выполняет то, что в нём
есть, и изменяет его, а изменение отправляет соответствующий list_changed
каждому подписчику:
| Пространство имён | Чтение | Выполнение | Изменение |
|---|---|---|---|
ctx.tools() | list(), find(name), find_many(names) | call(tool_use), call_all(tool_uses) | add(tool), remove(name) |
ctx.resources() | is_subscribed(&uri) | read(uri) | add(resource), remove(uri), notify_updated(uri) |
ctx.prompts() | list() | get(name, args) | add(prompt), remove(name) |
Вызов проходит через обработчик, который обслуживает примитив, поэтому read
получает то же, что получил бы клиент, читающий ресурс. С фичей svir
ctx.tools().toolbox() отдаёт модели остальные инструменты сервера — см.
мост svir.
Методы Context принимают &self, поэтому параметр обработчика — просто
ctx: Context; mut ctx лишь вызывает предупреждение unused_mut.
Обучение на примерах
Полный пример доступен здесь.