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

MCP Apps

Клиентская половина MCP Apps: объявить, что эта сторона умеет рендерить приложение, а потом прочитать, у каких инструментов оно есть, какие из них может видеть модель и чего просит блок безопасности документа.

Включается фичей apps (входит в client-full).

[dependencies]
neva = { version = "0.7", features = ["client", "apps"] }
Клиент на neva — не браузер

Трафик ui/* — рукопожатие, доставка результата инструмента, темизация — идёт между хостом и его iframe внутри браузера. neva его не моделирует. Что она даёт — это ту часть, которая нужна хосту от MCP-библиотеки: объявить расширение, найти инструменты с лицом, забрать HTML и знать, какие инструменты может видеть модель. Рендеринг — ваш.

Объявление capability​

use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt
.with_stdio("cargo", ["run", "--manifest-path", "./server/Cargo.toml"])
.with_apps());

client.connect().await?;
client.disconnect().await
}

with_apps() объявляет io.modelcontextprotocol/ui с единственным типом содержимого, который определяет спецификация, — text/html;profile=mcp-app. Сервер смотрит на это, прежде чем предлагать инструмент с UI вместо чисто текстового.

mimeTypes обязателен по спецификации — клиент, не назвавший ни одного, не объявил поддержку, — поэтому метод его заполняет, а не объявляет пустой объект, как это делает серверная сторона.

Чтобы назвать другой набор, используйте with_app_mime_types:

use neva::prelude::*;

fn main() {
let client = Client::new()
.with_options(|opt| opt.with_app_mime_types([APP_MIME_TYPE]));
let _ = client;
}

Первая редакция спецификации определяет только этот тип; остальные зарезервированы.

Объявляйте, только если здесь что-то рендерит

Объявление расширения — это обещание рендерить. Делайте его, когда процесс встраивает webview, показывающий HTML, или когда он сам является хостом, передающим документ дальше, — но не ради чтения метаданных, которое работает и без объявления.

Куда это отправляется​

MCP 2026-07-28 заменил рукопожатие на discovery, так что вешать объявление на всё соединение стало некуда. Вместо этого оно едет в каждом запросе, в его _meta под ключом io.modelcontextprotocol/clientCapabilities:

{
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}

От вас требуется только with_apps() — карту в каждый запрос клиент кладёт сам. Под legacy-spec то же объявление едет на рукопожатии initialize внутри capabilities.extensions.

Сервер читает её через Context::supports_apps() и может менять свой content в зависимости от того, умеете ли вы рендерить.

Заметьте, что объявление нужно только для того, чтобы сервер мог менять свой ответ. Чтение метаданных из tools/list и resources/read — всё остальное на этой странице — не требует никакого согласования.

Поиск инструментов с лицом​

Tool::ui() читает блок _meta.ui обратно:

use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt
.with_stdio("cargo", ["run", "--manifest-path", "./server/Cargo.toml"])
.with_apps());

client.connect().await?;

let tools = client.tools().list(None).await?;

for tool in tools.tools.iter() {
// Текстовый ответ есть у каждого инструмента; лицо — не у каждого.
let Some(ui) = tool.ui() else {
println!("{}: no UI", tool.name);
continue;
};

let audience = if tool.is_model_visible() {
"model + app"
} else {
"app only"
};
println!("{}: {} -> {:?}", tool.name, audience, ui.resource_uri);
}

client.disconnect().await
}
МетодОтвечает на вопрос
tool.ui()Блок UiToolMeta — resource_uri и visibility — либо None для обычного инструмента
tool.is_model_visible()Может ли агент видеть и вызывать этот инструмент?
tool.is_app_visible()Может ли его вызывать iframe?

Оба предиката дают true для инструмента вообще без метаданных MCP Apps и для того, у которого visibility опущен: тогда действует умолчание спецификации ["model", "app"]. Только явный visibility, из которого область исключена, делает соответствующий предикат false.

ui() намеренно снисходителен в одну сторону и строг в другую. Он принимает и устаревший плоский ключ _meta["ui/resourceUri"] — этого спецификация ждёт от читателя (вложенный блок имеет приоритет, если есть оба), — а некорректный блок читается как отсутствующий, а не роняет весь tools/list. Предикаты видимости этой снисходительности не разделяют: явный visibility, который не удалось разобрать, запрещает, так что испорченный блок никогда не протолкнёт инструмент для приложения в список агента.

Фильтрация — ваша задача

Сервер перечисляет инструменты только для приложения в tools/list как любые другие: метаданные и есть весь механизм. Хост НЕ ДОЛЖЕН класть в список инструментов агента тот, для которого is_model_visible() вернул false. За вас этого никто не сделает.

Получение документа​

Это тот самый resources/read, который хост делает перед открытием iframe:

use neva::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Error> {
let mut client = Client::new()
.with_options(|opt| opt
.with_stdio("cargo", ["run", "--manifest-path", "./server/Cargo.toml"])
.with_apps());

client.connect().await?;

let tools = client.tools().list(None).await?;

if let Some(uri) = tools
.get("get_time")
.and_then(|tool| tool.ui())
.and_then(|ui| ui.resource_uri)
{
let result = client.resources().read(uri).await?;
for contents in result.contents.iter() {
println!(
"{} [{}] {} bytes",
contents.uri(),
contents.mime().unwrap_or("?"),
contents.text().map(str::len).unwrap_or_default()
);
// Блок, который хост превращает в CSP и атрибут `allow`.
println!(" _meta.ui: {:?}", contents.ui());
}
}

client.disconnect().await
}

Чтение ui:// всегда возвращается как text/html;profile=mcp-app. Блок _meta.ui несёт csp, permissions, domain и prefersBorder — что означает каждое поле, см. в Блоке безопасности.

Отсутствие — не разрешение

Отсутствующий _meta.ui или отсутствующий csp внутри него — это ограничительное умолчание: никакого внешнего доступа. Не читайте его как «не задано, значит можно» — это переворачивает замысел спецификации и отдаёт недоверенному документу сеть.

ResourceContents даёт клиенту методы доступа uri, text, blob, json, mime, title и annotations; билдеры — серверные.

Что дальше​