Перейти к основному содержимому

Как отдать инструменты модели

MCP-сервер описывает инструменты, модель их вызывает. Между ними — цикл, который предлагает инструменты модели, выполняет её вызовы и возвращает результаты. svir — сторона модели в этом цикле: описания инструментов, вызовы и результаты для function calling и трейт Toolbox, из которого запрос берёт свои инструменты. Своего MCP у svir нет.

Фича svir — это мост: она реализует Toolbox поверх MCP, так что инструменты сервера — удалённого или вашего собственного — можно отдать модели как есть.

ToolboxЧто предлагаетВызов — это
RemoteTools::new(client)Инструменты подключённого сервераtools/call через клиента
App::into_toolbox()Инструменты этого сервера; по MCP приложение ничего не обслуживаетВыполнение в этом процессе, через конвейер сервера
App::with_toolbox()То же, пока сервер продолжает обслуживать MCPВыполнение в этом процессе
ctx.tools().toolbox()Внутри обработчика — остальные инструменты сервера, в тех пределах, в каких их может вызвать этот запросВыполнение в этом процессе

Промпты и ресурсы — не инструменты: в MCP промпт выбирает пользователь, а ресурс — приложение, поэтому они превращаются в сообщения svir. А на запрос сервера на сэмплирование можно ответить моделью.

Как включить мост​

[dependencies]
neva = { version = "0.7", features = ["client-full", "svir"] } # или server-full
svir = "0.1.4"
tokio = { version = "1", features = ["full"] }

svir не входит ни в один пресет — ни в full, ни в server-full, ни в client-full. svir пока в версии 0.x, и пресет, включающий его, превращал бы каждый ломающий релиз svir в ломающий релиз neva.

neva подключает svir без его компонентов по умолчанию: типы и трейт Toolbox, без HTTP-клиента. Модель вызывается напрямую через svir, поэтому добавьте svir в свои зависимости — его компоненты по умолчанию приносят клиент и TLS.

svir::Error, которым завершается вызов модели, преобразуется в Error neva, так что ? передаёт его дальше. В преобразованном сообщении — собственное описание сбоя от svir: сообщение сервера модели, в котором может фигурировать учётная запись, остаётся в source ошибки svir и никогда не уходит MCP-узлу в ответ.

Инструменты удалённого сервера​

RemoteTools принимает подключённый Client — сам клиент или Arc с ним, которым пользуется остальная программа, — и предлагает инструменты сервера:

use neva::{Client, svir::RemoteTools};
use svir::Toolbox;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new()
.with_options(|opt| opt.with_http(|http| http.bind("127.0.0.1:3000").with_endpoint("/mcp")));
client.connect().await?;

// Инструменты сервера, предложенные как `board_add_task` и т. д.
let tools = RemoteTools::new(client).with_prefix("board_").load().await?;

let model = svir::Client::openai("http://127.0.0.1:1234").build()?;
let mut request = svir::Request::new("qwen3")
.system("You keep the user's task board with the tools you have.")
.tools(&tools)
.user("Put three tasks on the board for the release, then show it to me.");

// Отвечаем на вызовы модели, пока она не ответит пользователю.
for _ in 0..8 {
let done = model.complete(&request).await?;
if done.calls.is_empty() {
println!("{}", done.text);
return Ok(());
}
// Каждый вызов — это `tools/call`; вызовы одного хода идут параллельно.
let results = tools.call_all(&done.calls).await;
request = request.assistant(done).tool_results(results);
}

Err("the model kept calling tools".into())
}

Описания инструментов — это снимок: Toolbox::tools синхронный, а список приходит по сети постранично. load() делает первый снимок, а refresh() обновляет его — например, когда сервер присылает notifications/tools/list_changed. Вызвать можно только инструменты из снимка: модели, назвавшей другой, сообщат, что такого инструмента нет.

tools.client() снова даёт доступ к клиенту — к остальному, что предлагает сервер: его промптам и ресурсам.

Что предлагать модели​

Какими инструментами может пользоваться модель, решаете вы, а не сервер:

let tools = RemoteTools::new(client)
.filter(|tool| !tool.name.starts_with("admin_"))
.rename(|name| name.replace('.', "_"))
.with_prefix("gh_")
.load()
.await?;
  • filter оставляет инструмент, только если его оставляет каждый фильтр. Каждый фильтр видит инструмент таким, каким его перечислил сервер, под серверным именем — где бы фильтр ни стоял среди переименований.
  • rename предлагает инструмент под другим именем, а вызов уходит под тем, которое знает сервер. Переименования применяются по порядку, каждое — к имени, полученному от предыдущих. with_prefix — переименование, добавляющее префикс; так в одном запросе разводят инструменты двух серверов.

Имя функции, которое принимает API модели, — [a-zA-Z0-9_-]{1,64}, тогда как MCP допускает ещё . и до 128 символов. Инструмент, чьё имя после переименований не проходит, или два инструмента с одним именем проваливают снимок, а не переименовываются за вашей спиной: молчаливое переименование заставило бы модель вызывать инструмент по имени, на которое никто не отзывается. Переименуйте его или отфильтруйте. Неудачный refresh() сохраняет предыдущий снимок.

Некоторые инструменты не предлагаются никогда:

  • инструмент, который можно вызвать только как задачу (task_support = "required");
  • с фичей apps — инструмент, который MCP Apps скрывает от модели;
  • при вызове в процессе — инструмент, требующий ролей или разрешений, которых у вызывающего нет.

Что сообщается модели​

Для модели результат инструмента — строка, поэтому содержимое сводится в одну:

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

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

Аргументы модели уходят так, как их принимает tools/call. Вызов без аргументов приходит примерно одинаково часто как {}, пустая строка или null, и все три означают «без аргументов»; всё остальное, что не является JSON-объектом, — ошибка модели, о которой ей и сообщают.

Свои инструменты, в том же процессе​

Инструмент, написанный один раз через #[tool], можно отдать модели без MCP-транспорта посередине. App::into_toolbox поглощает сервер и возвращает его инструменты как LocalTools:

use neva::prelude::*;

#[tool(descr = "Adds two numbers")]
fn add(a: i64, b: i64) -> i64 {
a + b
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = App::new();

// По MCP ничего не обслуживается: настроенный здесь транспорт не стартует.
let tools = app.into_toolbox().load().await?;

let request = svir::Request::new("qwen3")
.tools(&tools)
.user("What is 2 + 40?");
// ... тот же цикл, что и выше.
let _ = request;
Ok(())
}

Каждый вызов — это tools/call, выполняемый через тот же конвейер, что и вызов по MCP. Его видят промежуточные обработчики сервера — авторизация, ограничения частоты, аудит применяются к модели так же, как к любому другому вызывающему, — а инструмент получает свой Context и внедрённые зависимости (Dc<T>) так же, как по MCP. filter, rename и with_prefix работают как у RemoteTools, а refresh() подхватывает инструменты, добавленные или удалённые после снимка.

Чего нет, так это узла на той стороне. Инструмент, который запрашивает ввод — получение данных, сэмплирование, корневые каталоги, — отклоняется сразу, а не ждёт, а его уведомления о прогрессе и записи журнала уходят в никуда.

Обслуживать и вызывать одновременно​

App::with_toolbox делает и то и другое: сервер работает как настроен, а toolbox вызывает те же инструменты в этом процессе.

use neva::prelude::*;

#[tool(descr = "Adds two numbers")]
fn add(a: i64, b: i64) -> i64 {
a + b
}

#[tokio::main]
async fn main() -> Result<(), Error> {
let app = App::new().with_options(|opt| opt.with_default_http());

let (app, tools) = app.with_toolbox();
tokio::spawn(app.run());

// Ждёт, пока `run` соберёт сервер.
let tools = tools.load().await?;
// Обслуживается по HTTP и предлагается модели здесь.
let _ = tools;
Ok(())
}

Toolbox привязывается к серверу, когда его собрал run, поэтому load() этого ждёт. Он завершается ошибкой, если сервер удалён, так и не запустившись, или остановился раньше.

Инструмент, который управляет моделью​

Внутри обработчика ctx.tools().toolbox() предлагает остальные инструменты сервера — это форма оркестратора, инструмента, который передаёт задачу модели вместе со всем остальным сервером:

use neva::{di::Dc, prelude::*};
use svir::Toolbox;

#[tool(descr = "Plans the week, keeping the board up to date")]
async fn plan_week(ctx: Context, model: Dc<svir::Client>, focus: String) -> Result<String, Error> {
// Все остальные инструменты сервера — в тех пределах, в каких их может вызвать этот запрос.
let tools = ctx.tools().toolbox().load().await?;

let mut request = svir::Request::new("qwen3")
.tools(&tools)
.user(format!("Plan my week around {focus}."));

for _ in 0..8 {
let done = model.complete(&request).await?;
if done.calls.is_empty() {
return Ok(done.text);
}
let results = tools.call_all(&done.calls).await;
request = request.assistant(done).tool_results(results);
}

Err(Error::new(ErrorCode::InternalError, "the model kept calling tools"))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = svir::Client::openai("http://127.0.0.1:1234").build()?;

App::new()
.with_options(|opt| opt.with_default_http())
.add_singleton(model)
.run()
.await;
Ok(())
}

Два ограничения не дают такому инструменту вызывать себя бесконечно:

  • Инструмент, в котором взят toolbox, исключается. Модель, вызвавшая plan_week изнутри plan_week, начала бы весь цикл заново внутри всё ещё идущего вызова. Инструмент, которому рекурсия нужна намеренно, оставляет себя через with_caller().
  • Вызовы в процессе вкладываются не глубже четырёх уровней. a вызывает b, который снова вызывает a: исключение вызывающего останавливает прямую петлю, глубина ограничивает остальное. Вызов за пределом не выполняется, а модели объясняют почему. with_max_depth(n) меняет предел для этого toolbox.

Глубина считается по цепочке, каким бы toolbox ни делались вызовы: toolbox из App::into_toolbox, сохранённый и вызванный внутри обработчика, считается таким же глубоким, как взятый из Context обработчика. В задаче, которую обработчик запускает отдельно, глубину знает только toolbox из Context.

Что несёт вызов в процессе​

Toolbox из ctx.tools().toolbox() несёт claims текущего запроса: инструмент, до которого эти claims не дотягиваются, не предлагается, а проверки сервера видят их при каждом вызове. Toolbox из App::into_toolbox или App::with_toolbox взят вне какого-либо запроса и claims не несёт, поэтому инструмент, требующий ролей или разрешений, он не предлагает вовсе.

Промпты и ресурсы​

prompt_messages превращает промпт в сообщения, которыми он открывает разговор, а resource_parts превращает ресурс в части, которые к разговору прикрепляются:

use neva::Client;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut client = Client::new().with_options(|opt| opt.with_default_http());
client.connect().await?;

// Разговор открывается собственным промптом сервера.
let prompt = client.prompts().get("plan_week", [("focus", "the release")]).await?;
let request = neva::svir::prompt_messages(prompt)?
.into_iter()
.fold(svir::Request::new("qwen3"), svir::Request::message);

// Ресурс, прикреплённый к вопросу.
let notes = client.resources().read("file:///notes.md").await?;
let question = neva::svir::resource_parts(notes)?
.into_iter()
.fold(svir::Message::user("Summarise these notes."), svir::Message::with);

let _request = request.message(question);
client.disconnect().await?;
Ok(())
}

Промпт сохраняет роли, а идущие подряд сообщения одной роли становятся одним ходом. Текст остаётся текстом, изображение — изображением, встроенный ресурс прикрепляется так же, как его прикрепляет resource_parts, а ссылка на ресурс — это её имя и URI. Текст ресурса становится текстовым файлом, названным по его URI, — так модель узнаёт, откуда он взялся, — и его JSON тоже; бинарное изображение — изображением. Аудио и прочее бинарное содержимое — ошибка, а не молчаливый пропуск. Описание промпта предназначено пользователю, а не модели, и опускается.

Ответ на сэмплирование моделью​

Сервер, запрашивающий у клиента сэмпл, хочет ровно того, что делает svir. sampling_request превращает sampling/createMessage в запрос к модели, а sampling_result превращает ответ модели в сэмпл, который получает сервер:

use neva::prelude::*;
use neva::types::sampling::CreateMessageRequestParams;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let model = svir::Client::openai("http://127.0.0.1:1234").build()?;

let mut client = Client::new().with_options(|opt| opt.with_default_http());

// Сэмплирование устарело в MCP 2026-07-28 (см. страницу «Сэмплирование»).
#[allow(deprecated)]
client.map_sampling(move |params: CreateMessageRequestParams| {
let model = model.clone();
async move {
let request = neva::svir::sampling_request("qwen3", params)?;
// Недоступная модель проваливает сэмпл, а не клиента.
let done = model.complete(&request).await?;
neva::svir::sampling_result(&request, done)
}
});

client.connect().await?;
let result = client.tools().call("summarize_report", [("topic", "EMEA")]).await?;
println!("{:?}", result.content);
client.disconnect().await?;
Ok(())
}

Обработчик может завершиться ошибкой: в MCP 2026-07-28 ошибкой завершается вызов, который запросил сэмпл, а под legacy-spec ошибка становится ответом серверу.

Что переносится: системный промпт, сообщения, инструменты, maxTokens и temperature. Роли сохраняются; блок tool_use — это вызов в ходе ассистента, а каждый tool_result — отдельное сообщение инструмента, сведённое так же, как ответ инструмента сводится для модели. toolChoice: none убирает инструменты.

Аудио, стоп-последовательности и toolChoice: required передать нельзя — это ошибка. То, что спецификация оставляет клиенту, опускается: includeContext, modelPreferences — модель выбираете вы — и metadata провайдера.

Сэмпл — это текст модели, а за ним блок tool_use на каждый вызов, каждый со своим идентификатором, чтобы tool_result, который пришлёт сервер, его нашёл. Причина остановки — toolUse, если модель вызвала инструмент, а иначе следует тому, почему она остановилась: endTurn, maxTokens или contentFilter.

Обучение на примерах​

examples/svir — доска задач, написанная один раз через #[tool] и #[prompt] и отданная модели двумя способами: по MCP с вызовами из клиента через RemoteTools или в том же процессе через App::into_toolbox. Подойдёт любой OpenAI-совместимый сервер модели — LM Studio, llama.cpp, vLLM, облачный эндпоинт.

О стороне модели — потоковой передаче, слоях, собственном Toolbox — см. документацию svir.