Skip to main content

MCP Apps

The client half of MCP Apps: declare that this side can render an app, then read back which tools have one, which of them the model may see, and what the document's security block asks for.

Enabled by the apps feature (included in client-full).

[dependencies]
neva = { version = "0.7", features = ["client", "apps"] }
A neva client is not a browser

The ui/* traffic — the handshake, the tool-result push, the theming — runs between a host and its iframe, inside a browser. neva models none of it. What it gives you is the part a host needs from an MCP library: declare the extension, find which tools have a face, fetch the HTML, and know which tools the model may see. The rendering is yours.

Declaring the 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() advertises io.modelcontextprotocol/ui with the one content type the specification defines, text/html;profile=mcp-app. A server checks this before offering a UI-bound tool instead of a text-only one.

mimeTypes is required by the specification — a client that names none has not declared support — which is why the method fills it in rather than advertising an empty object the way the server side does.

To name a different set, use 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;
}

The initial specification defines only that one type; the rest are reserved.

Declare it only if something here renders

Declaring the extension is a promise about rendering. Make it when this process embeds a webview that shows the HTML, or when it is a host handing the document on to one — not merely to read the metadata, which works without the declaration.

Where it is sent​

MCP 2026-07-28 replaced the handshake with discovery, so there is no initialize to hang a connection-wide declaration on. The declaration instead rides every request, in its _meta under io.modelcontextprotocol/clientCapabilities:

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

with_apps() is all you write; the client puts the map on each request itself. Under legacy-spec the same declaration rides the initialize handshake under capabilities.extensions instead.

A server reads it back with Context::supports_apps(), and can then vary its content by whether you can render.

Note that the declaration is only needed for a server to vary its answer. Reading the metadata off tools/list and resources/read — everything else on this page — needs no negotiation at all.

Finding the tools that have a face​

Tool::ui() reads the _meta.ui block back:

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() {
// Every tool has a `content` answer; only some have a face.
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
}
AccessorAnswers
tool.ui()The UiToolMeta block — resource_uri and visibility — or None for an ordinary tool
tool.is_model_visible()May the agent see and call this tool?
tool.is_app_visible()May the iframe call it?

Both predicates are true for a tool with no MCP Apps metadata at all, and for one whose visibility is omitted — that takes the specification's ["model", "app"] default. Only an explicit visibility leaving a scope out makes the corresponding predicate false.

ui() is deliberately lenient in one direction and strict in the other. It also accepts the deprecated flat _meta["ui/resourceUri"] key, which is what the specification asks of a reader (the nested block wins where both are present), and a malformed block reads as absent rather than failing the surrounding tools/list. The visibility predicates do not share that leniency: an explicit visibility that cannot be decoded denies, so a garbled block can never promote an app-only tool into the agent's list.

Filtering is your job

A server lists app-only tools in tools/list like any other — the metadata is the whole mechanism. A host MUST NOT put a tool is_model_visible() returns false for into the agent's tool list. Nothing enforces this for you.

Fetching the document​

This is the resources/read a host makes before it opens an 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()
);
// The block the host turns into a CSP and an `allow` attribute.
println!(" _meta.ui: {:?}", contents.ui());
}
}

client.disconnect().await
}

A ui:// read always comes back as text/html;profile=mcp-app. The _meta.ui block carries csp, permissions, domain and prefersBorder — see The security block for what each field means.

Absent is not permissive

A missing _meta.ui, or a missing csp inside one, is the restrictive default: no external access of any kind. Do not read it as "unspecified, therefore allow" — that inverts the specification's intent and hands an untrusted document the network.

ResourceContents gives a client the accessors uri, text, blob, json, mime, title and annotations; the builders are server-side.

What's next​