diff --git a/Cargo.lock b/Cargo.lock
index 1e5a6ae2..78687c36 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -1188,6 +1197,35 @@ dependencies = [
"syn 3.0.3",
]
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
[[package]]
name = "reqwest"
version = "0.12.28"
@@ -1716,6 +1754,7 @@ dependencies = [
"dirs",
"futures-util",
"parking_lot",
+ "regex",
"reqwest",
"rusqlite",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index 56e672eb..badf9792 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -71,6 +71,8 @@ anyhow = "1"
url = "2"
# Percent-encoded spellings of a secret, for the same scrubber.
urlencoding = "2.1"
+# URL extraction from tool results in the `ui` feature.
+regex = { version = "1", default-features = false, features = ["std", "unicode-perl", "unicode-case"] }
# The Streamable HTTP transport. `rustls-tls` by default so a build needs no
# system OpenSSL; `stream` is load-bearing rather than convenience — the SSE
# reader consumes the response body incrementally so a server that holds its
diff --git a/README.md b/README.md
index 2ed83d53..003c053d 100644
--- a/README.md
+++ b/README.md
@@ -205,6 +205,42 @@ Enable the `tools` feature to expose each server tool as a
`tinytools` is a git dependency so a host that links another checkout of it can
`[patch]` the two into one package.
+## UI presentation for hosts
+
+Enable the `ui` feature for `tinymcp::ui`, the rules that turn one answered
+tool call into what a host shows for it. The vocabulary (`McpUiPresentation`,
+`UiLink`, `LinkClass`, `UiResource`, `WidgetCallPolicy`, `UiRendering`) is in
+`tinymcp_bus::ui`, so every host reads and writes the same metadata.
+
+- **Widget template.** `resolve_presentation` takes a `UiCallView` (build one
+ with `view_from_raw_result` or `view_from_envelope`) and reads the tool's
+ `_meta.ui.resourceUri`, the legacy `ui/resourceUri`, or the Apps SDK
+ `openai/outputTemplate`; only `ui://` URIs count. A `ui://` HTML document the
+ result embedded comes back as `inline_document` (no template) or
+ `prefetched` (the template's own document) for the host to keep.
+- **Data.** Tool input, `structuredContent` and result `_meta` are carried
+ only up to `MAX_INPUT_BYTES`, `MAX_STRUCTURED_BYTES` and `MAX_META_BYTES`.
+- **Documents.** `resource_from_contents` accepts HTML up to
+ `MAX_WIDGET_BYTES` (text or base64 blob) and reduces declared CSP origins to
+ `https`.
+- **Links.** `classify_link` sorts a URL into `Web`, `Handoff` (`upi://`,
+ `phonepe://`, `intent://`, any other app scheme), `Image` (image extensions,
+ `/image/upload/` paths) or `Blocked` (`javascript:`, `data:`, `file:`, host
+ schemes). `extract_links` surfaces at most five web and handoff links.
+- **Widget tool calls.** `widget_call_policy` refuses calls from host-supplied
+ pages and to tools whose `_meta.ui.visibility` excludes `app`, runs a tool
+ annotated `readOnlyHint: true`, and asks the user for anything else.
+- **Rendering.** `rendering(renders_widgets, &presentation)` answers `Widget`,
+ `Links` or `Nothing`.
+
+**A host without a sandboxed web view** (a messaging or email channel) should
+not advertise `client_capabilities()` (the `io.modelcontextprotocol/ui`
+extension), so servers that follow the MCP Apps spec answer with text alone.
+Where a server attaches UI regardless, call `rendering(false, ..)` and render
+the classified links: a web link as a URL, a handoff link as a redirect or QR
+the user opens on their phone. Never forward an image or blocked link as an
+action.
+
## Browsing the catalogs
`McpRegistry::registry_search` lists the official MCP registry, plus Smithery
diff --git a/crates/tinymcp-bus/src/agent_tools/mod.rs b/crates/tinymcp-bus/src/agent_tools/mod.rs
index 9f303dae..1cd0590e 100644
--- a/crates/tinymcp-bus/src/agent_tools/mod.rs
+++ b/crates/tinymcp-bus/src/agent_tools/mod.rs
@@ -14,7 +14,10 @@
//! [`McpCallOutcome`] is what a forwarded call reports back to the host, beside
//! the text the model reads: which server and tool, whether the server
//! answered, and the classified error when it did not. It travels as a tool
-//! result's metadata tagged with [`MCP_CALL_RESULT_KIND`].
+//! result's metadata tagged with [`MCP_CALL_RESULT_KIND`], or nested under
+//! `outcome` in an [`McpResultEnvelope`] (tagged [`MCP_RESULT_KIND`]) when the
+//! server answered. The envelope also carries what the model-facing rendering
+//! drops: `structuredContent`, `_meta`, and embedded resources.
//!
//! # Why this is in the contract crate
//!
@@ -37,9 +40,14 @@ mod types;
pub use arguments::normalize_tool_arguments;
pub use registry_tools::{RegistryTool, registry_tool_specs};
pub use types::{
- AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, McpCallError, McpCallOutcome,
+ AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, McpCallError,
+ McpCallOutcome, McpResultEnvelope,
};
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
+
+#[cfg(test)]
+#[path = "mod_envelope_tests.rs"]
+mod envelope_test;
diff --git a/crates/tinymcp-bus/src/agent_tools/mod_envelope_tests.rs b/crates/tinymcp-bus/src/agent_tools/mod_envelope_tests.rs
new file mode 100644
index 00000000..6ff686ed
--- /dev/null
+++ b/crates/tinymcp-bus/src/agent_tools/mod_envelope_tests.rs
@@ -0,0 +1,112 @@
+//! Unit tests for the `mcp_result` envelope and for reading a call outcome out
+//! of either metadata shape.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::json;
+
+use super::{
+ MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, McpCallError, McpCallOutcome, McpResultEnvelope,
+};
+use crate::McpToolResult;
+
+fn rich_result() -> McpToolResult {
+ let mut result = McpToolResult::success("ok");
+ result.structured_content = Some(json!({ "n": 1 }));
+ result.meta = Some(json!({ "ui": { "resourceUri": "ui://card" } }));
+ result.resources =
+ vec![serde_json::from_value(json!({ "uri": "ui://card", "text": "
" })).unwrap()];
+ result
+}
+
+#[test]
+fn the_envelope_wire_form_is_pinned() {
+ let envelope = McpResultEnvelope::new("docs", "search", &rich_result())
+ .with_outcome(McpCallOutcome::answered("docs", "search"));
+
+ assert_eq!(
+ envelope.to_metadata(),
+ json!({
+ "kind": MCP_RESULT_KIND,
+ "server": "docs",
+ "tool": "search",
+ "structured_content": { "n": 1 },
+ "meta": { "ui": { "resourceUri": "ui://card" } },
+ "resources": [{ "uri": "ui://card", "text": "
" }],
+ "outcome": {
+ "kind": MCP_CALL_RESULT_KIND,
+ "server": "docs",
+ "tool": "search",
+ "ok": true,
+ },
+ })
+ );
+}
+
+#[test]
+fn an_empty_envelope_omits_every_optional_member() {
+ let envelope = McpResultEnvelope::new("docs", "search", &McpToolResult::success("ok"));
+
+ assert_eq!(
+ envelope.to_metadata(),
+ json!({ "kind": MCP_RESULT_KIND, "server": "docs", "tool": "search" })
+ );
+}
+
+#[test]
+fn the_envelope_round_trips() {
+ let envelope = McpResultEnvelope::new("docs", "search", &rich_result());
+
+ assert_eq!(
+ McpResultEnvelope::from_metadata(&envelope.to_metadata()),
+ Some(envelope)
+ );
+}
+
+#[test]
+fn an_envelope_with_another_kind_is_rejected() {
+ let mut wire = McpResultEnvelope::new("docs", "search", &rich_result()).to_metadata();
+ wire["kind"] = json!(MCP_CALL_RESULT_KIND);
+
+ assert_eq!(McpResultEnvelope::from_metadata(&wire), None);
+ assert_eq!(McpResultEnvelope::from_metadata(&json!("text")), None);
+}
+
+#[test]
+fn an_outcome_is_read_from_an_envelope() {
+ let failed = McpCallOutcome::failed("docs", "search", McpCallError::new("code"));
+ let envelope =
+ McpResultEnvelope::new("docs", "search", &rich_result()).with_outcome(failed.clone());
+
+ assert_eq!(
+ McpCallOutcome::from_metadata(&envelope.to_metadata()),
+ Some(failed)
+ );
+}
+
+#[test]
+fn an_outcome_is_still_read_from_the_bare_shape() {
+ let outcome = McpCallOutcome::answered("docs", "search");
+ let wire = serde_json::to_value(&outcome).unwrap();
+
+ assert_eq!(McpCallOutcome::from_metadata(&wire), Some(outcome));
+}
+
+#[test]
+fn an_envelope_without_an_outcome_yields_none() {
+ let envelope = McpResultEnvelope::new("docs", "search", &rich_result());
+
+ assert_eq!(McpCallOutcome::from_metadata(&envelope.to_metadata()), None);
+}
+
+#[test]
+fn an_envelope_carrying_an_invalid_outcome_yields_none() {
+ let wire = json!({
+ "kind": MCP_RESULT_KIND,
+ "server": "docs",
+ "tool": "search",
+ "outcome": { "kind": MCP_CALL_RESULT_KIND, "server": "docs", "tool": "search", "ok": false },
+ });
+
+ assert_eq!(McpCallOutcome::from_metadata(&wire), None);
+}
diff --git a/crates/tinymcp-bus/src/agent_tools/types.rs b/crates/tinymcp-bus/src/agent_tools/types.rs
index 6681dae1..236d7d26 100644
--- a/crates/tinymcp-bus/src/agent_tools/types.rs
+++ b/crates/tinymcp-bus/src/agent_tools/types.rs
@@ -3,6 +3,8 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
+use crate::{McpResourceContents, McpToolResult};
+
/// Why a tool call's `arguments` could not be read as an object.
///
/// Each variant names what actually arrived, because the message is read by
@@ -141,10 +143,30 @@ impl McpCallOutcome {
/// Reads an outcome out of a tool result's metadata.
///
- /// `None` when `metadata` is not an object whose `kind` is
- /// [`MCP_CALL_RESULT_KIND`], or does not decode as an outcome.
+ /// Accepts both shapes a call's metadata takes: a bare outcome whose
+ /// `kind` is [`MCP_CALL_RESULT_KIND`], and an [`McpResultEnvelope`]
+ /// (`kind` [`MCP_RESULT_KIND`]) carrying one under `outcome`.
+ ///
+ /// `None` when `metadata` is neither, or does not decode as an outcome.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use tinymcp_bus::{McpCallOutcome, McpResultEnvelope, McpToolResult};
+ /// let outcome = McpCallOutcome::answered("docs", "search");
+ /// let envelope = McpResultEnvelope::new("docs", "search", &McpToolResult::success("ok"))
+ /// .with_outcome(outcome.clone());
+ /// assert_eq!(McpCallOutcome::from_metadata(&envelope.to_metadata()), Some(outcome.clone()));
+ /// let bare = serde_json::to_value(&outcome).unwrap();
+ /// assert_eq!(McpCallOutcome::from_metadata(&bare), Some(outcome));
+ /// ```
#[must_use]
pub fn from_metadata(metadata: &Value) -> Option {
+ if metadata.get("kind").and_then(Value::as_str) == Some(MCP_RESULT_KIND) {
+ return metadata
+ .get("outcome")
+ .and_then(|outcome| serde_json::from_value(outcome.clone()).ok());
+ }
serde_json::from_value(metadata.clone()).ok()
}
}
@@ -234,3 +256,113 @@ impl McpCallError {
}
}
}
+
+/// The `kind` discriminator an [`McpResultEnvelope`] carries.
+pub const MCP_RESULT_KIND: &str = "mcp_result";
+
+/// The host-only metadata attached to an answered MCP tool call.
+///
+/// Carries what the model-facing rendering drops — the reply's
+/// `structuredContent`, its `_meta`, and any embedded resources — so a host
+/// can render tool UI without re-calling the server. `outcome` is present
+/// when the call path classifies outcomes (`mcp_call_tool`), and is what
+/// [`McpCallOutcome::from_metadata`] reads.
+///
+/// Decoding rejects a payload whose `kind` is not [`MCP_RESULT_KIND`].
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(try_from = "McpResultEnvelopeWire")]
+pub struct McpResultEnvelope {
+ /// Always [`MCP_RESULT_KIND`].
+ pub kind: String,
+ /// The server the call went to, as the caller addressed it.
+ pub server: String,
+ /// The tool's name on that server.
+ pub tool: String,
+ /// The reply's `structuredContent`, verbatim.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub structured_content: Option,
+ /// The reply's `_meta`, verbatim.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
+ /// Resources the reply embedded.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub resources: Vec,
+ /// The call's classified outcome, when the call path reports one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub outcome: Option,
+}
+
+impl McpResultEnvelope {
+ /// The envelope for `result`, returned by `tool` on `server`.
+ #[must_use]
+ pub fn new(server: impl Into, tool: impl Into, result: &McpToolResult) -> Self {
+ Self {
+ kind: MCP_RESULT_KIND.to_string(),
+ server: server.into(),
+ tool: tool.into(),
+ structured_content: result.structured_content.clone(),
+ meta: result.meta.clone(),
+ resources: result.resources.clone(),
+ outcome: None,
+ }
+ }
+
+ /// The same envelope carrying `outcome`.
+ #[must_use]
+ pub fn with_outcome(mut self, outcome: McpCallOutcome) -> Self {
+ self.outcome = Some(outcome);
+ self
+ }
+
+ /// The envelope as a tool result's metadata value.
+ #[must_use]
+ pub fn to_metadata(&self) -> Value {
+ serde_json::to_value(self).unwrap_or(Value::Null)
+ }
+
+ /// Reads an envelope out of a tool result's metadata.
+ ///
+ /// `None` when `metadata` is not an object whose `kind` is
+ /// [`MCP_RESULT_KIND`], or does not decode as an envelope.
+ #[must_use]
+ pub fn from_metadata(metadata: &Value) -> Option {
+ serde_json::from_value(metadata.clone()).ok()
+ }
+}
+
+#[derive(Deserialize)]
+struct McpResultEnvelopeWire {
+ kind: String,
+ server: String,
+ tool: String,
+ #[serde(default)]
+ structured_content: Option,
+ #[serde(default)]
+ meta: Option,
+ #[serde(default)]
+ resources: Vec,
+ #[serde(default)]
+ outcome: Option,
+}
+
+impl TryFrom for McpResultEnvelope {
+ type Error = String;
+
+ fn try_from(wire: McpResultEnvelopeWire) -> Result {
+ if wire.kind != MCP_RESULT_KIND {
+ return Err(format!(
+ "expected kind `{MCP_RESULT_KIND}`, got `{}`",
+ wire.kind
+ ));
+ }
+ Ok(Self {
+ kind: wire.kind,
+ server: wire.server,
+ tool: wire.tool,
+ structured_content: wire.structured_content,
+ meta: wire.meta,
+ resources: wire.resources,
+ outcome: wire.outcome,
+ })
+ }
+}
diff --git a/crates/tinymcp-bus/src/config/mod_tests.rs b/crates/tinymcp-bus/src/config/mod_tests.rs
index 1c03be7e..c413ae76 100644
--- a/crates/tinymcp-bus/src/config/mod_tests.rs
+++ b/crates/tinymcp-bus/src/config/mod_tests.rs
@@ -324,3 +324,37 @@ fn a_proxy_round_trips_every_field() {
let decoded: McpProxyConfig = serde_json::from_value(encoded).expect("proxy decodes");
assert_eq!(decoded, proxy);
}
+
+// ---------------------------------------------------------------------------
+// Client capabilities
+// ---------------------------------------------------------------------------
+
+#[test]
+fn client_capabilities_default_to_an_empty_object_left_off_the_wire() {
+ let identity = McpClientIdentityConfig::default();
+ assert_eq!(identity.capabilities, json!({}));
+
+ let wire = serde_json::to_value(&identity).unwrap();
+ assert!(wire.get("capabilities").is_none(), "{wire}");
+
+ let decoded: McpClientIdentityConfig = serde_json::from_value(json!({})).unwrap();
+ assert_eq!(decoded.capabilities, json!({}));
+}
+
+#[test]
+fn configured_client_capabilities_round_trip() {
+ let capabilities = json!({
+ "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } }
+ });
+ let identity = McpClientIdentityConfig {
+ capabilities: capabilities.clone(),
+ ..McpClientIdentityConfig::default()
+ };
+
+ let wire = serde_json::to_value(&identity).unwrap();
+ assert_eq!(wire["capabilities"], capabilities);
+ assert_eq!(
+ serde_json::from_value::(wire).unwrap(),
+ identity
+ );
+}
diff --git a/crates/tinymcp-bus/src/config/types.rs b/crates/tinymcp-bus/src/config/types.rs
index 82226c82..a014e01d 100644
--- a/crates/tinymcp-bus/src/config/types.rs
+++ b/crates/tinymcp-bus/src/config/types.rs
@@ -7,6 +7,7 @@
//! implementation.
use serde::{Deserialize, Serialize};
+use serde_json::Value;
use std::collections::BTreeMap;
/// The serde default for a flag that is on unless the user turns it off.
@@ -238,6 +239,24 @@ pub struct McpClientIdentityConfig {
/// Sent as `initialize.clientInfo.version`.
#[serde(default = "default_client_version")]
pub version: String,
+ /// Sent as `initialize.capabilities`, on every transport.
+ ///
+ /// An empty object by default. A host that supports a protocol extension
+ /// declares it here, for example
+ /// `{"extensions": {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}}`.
+ #[serde(
+ default = "default_client_capabilities",
+ skip_serializing_if = "is_empty_object"
+ )]
+ pub capabilities: Value,
+}
+
+fn default_client_capabilities() -> Value {
+ Value::Object(serde_json::Map::new())
+}
+
+fn is_empty_object(value: &Value) -> bool {
+ value.as_object().is_some_and(serde_json::Map::is_empty)
}
fn default_client_name() -> String {
@@ -264,6 +283,7 @@ impl Default for McpClientIdentityConfig {
name: default_client_name(),
title: default_client_title(),
version: default_client_version(),
+ capabilities: default_client_capabilities(),
}
}
}
diff --git a/crates/tinymcp-bus/src/errors/mod.rs b/crates/tinymcp-bus/src/errors/mod.rs
index 4e33717c..301c6de3 100644
--- a/crates/tinymcp-bus/src/errors/mod.rs
+++ b/crates/tinymcp-bus/src/errors/mod.rs
@@ -57,6 +57,8 @@ pub const AUTH_DISCOVERY: &str = "ai.tinyhumans.tinymcp.Error.AuthDiscovery";
pub const TOOL_NOT_ALLOWED: &str = "ai.tinyhumans.tinymcp.Error.ToolNotAllowed";
/// A server is installed but has no live connection.
pub const NOT_CONNECTED: &str = "ai.tinyhumans.tinymcp.Error.NotConnected";
+/// A resource's contents exceeded the size the client will hold.
+pub const RESOURCE_TOO_LARGE: &str = "ai.tinyhumans.tinymcp.Error.ResourceTooLarge";
/// A server is installed but turned off by the user.
pub const SERVER_DISABLED: &str = "ai.tinyhumans.tinymcp.Error.ServerDisabled";
/// A named server is not configured or not installed.
@@ -119,6 +121,7 @@ pub const ALL: &[&str] = &[
CONFIG_DOC,
INVALID_ARGUMENTS,
REGISTRY_TIMEOUT,
+ RESOURCE_TOO_LARGE,
];
#[cfg(test)]
diff --git a/crates/tinymcp-bus/src/lib.rs b/crates/tinymcp-bus/src/lib.rs
index 33cc56ea..9d06cd3b 100644
--- a/crates/tinymcp-bus/src/lib.rs
+++ b/crates/tinymcp-bus/src/lib.rs
@@ -28,6 +28,8 @@
//! - [`agent_tools`] — the tool specs a host exposes to a model, the
//! normalization every forwarded call's `arguments` goes through, and the
//! structured outcome a forwarded call reports to the host.
+//! - [`ui`] — tool-provided UI: the presentation a host renders for one
+//! tool call, its widget document, and classified links.
//! - [`version`] — [`CONTRACT_VERSION`] and the [`is_compatible`] bind rule.
//!
//! # What is deliberately not here
@@ -127,11 +129,12 @@ pub mod names;
pub mod registry;
pub mod sanitize;
pub mod transport;
+pub mod ui;
pub mod version;
pub use agent_tools::{
- AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, McpCallError, McpCallOutcome,
- RegistryTool, normalize_tool_arguments, registry_tool_specs,
+ AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, McpCallError,
+ McpCallOutcome, McpResultEnvelope, RegistryTool, normalize_tool_arguments, registry_tool_specs,
};
pub use audit::{
DEFAULT_LIST_LIMIT, ERROR_MESSAGE_MAX_BYTES, MAX_LIST_LIMIT, McpWriteListQuery, McpWriteRecord,
@@ -158,8 +161,13 @@ pub use sanitize::{
};
pub use transport::{
AuthorizationServerMetadata, HEADER_PROTOCOL_VERSION, HEADER_SESSION_ID,
- LATEST_PROTOCOL_VERSION, McpAuthChallenge, McpAuthorizationContext, McpClientInfo,
- McpInitializeResult, McpRemoteTool, McpServerToolResult, McpSseEvent, McpToolContent,
- McpToolResult, ProtectedResourceMetadata, SUPPORTED_PROTOCOL_VERSIONS,
+ LATEST_PROTOCOL_VERSION, MAX_RESOURCE_BYTES, McpAuthChallenge, McpAuthorizationContext,
+ McpClientInfo, McpInitializeResult, McpRemoteTool, McpResource, McpResourceContents,
+ McpServerToolResult, McpSseEvent, McpToolContent, McpToolResult, ProtectedResourceMetadata,
+ SUPPORTED_PROTOCOL_VERSIONS,
+};
+pub use ui::{
+ LinkClass, MCP_APP_MIME, MCP_APPS_EXTENSION, MCP_UI_KIND, McpUiPresentation, UiCsp, UiFlavor,
+ UiLink, UiLinkKind, UiRendering, UiResource, WidgetCallPolicy,
};
pub use version::{CONTRACT_VERSION, is_compatible};
diff --git a/crates/tinymcp-bus/src/transport/mod.rs b/crates/tinymcp-bus/src/transport/mod.rs
index 0f3d47c6..515f7130 100644
--- a/crates/tinymcp-bus/src/transport/mod.rs
+++ b/crates/tinymcp-bus/src/transport/mod.rs
@@ -31,9 +31,10 @@ mod types;
pub use types::{
AuthorizationServerMetadata, HEADER_PROTOCOL_VERSION, HEADER_SESSION_ID,
- LATEST_PROTOCOL_VERSION, McpAuthChallenge, McpAuthorizationContext, McpClientInfo,
- McpInitializeResult, McpRemoteTool, McpServerToolResult, McpSseEvent, McpToolContent,
- McpToolResult, ProtectedResourceMetadata, SUPPORTED_PROTOCOL_VERSIONS,
+ LATEST_PROTOCOL_VERSION, MAX_RESOURCE_BYTES, McpAuthChallenge, McpAuthorizationContext,
+ McpClientInfo, McpInitializeResult, McpRemoteTool, McpResource, McpResourceContents,
+ McpServerToolResult, McpSseEvent, McpToolContent, McpToolResult, ProtectedResourceMetadata,
+ SUPPORTED_PROTOCOL_VERSIONS,
};
#[cfg(test)]
diff --git a/crates/tinymcp-bus/src/transport/mod_tests.rs b/crates/tinymcp-bus/src/transport/mod_tests.rs
index 32650da5..532f4168 100644
--- a/crates/tinymcp-bus/src/transport/mod_tests.rs
+++ b/crates/tinymcp-bus/src/transport/mod_tests.rs
@@ -290,6 +290,7 @@ fn mixed_content_joins_with_newlines_in_order() {
],
is_error: false,
markdown_formatted: None,
+ ..McpToolResult::default()
};
assert_eq!(result.text(), "line1\nline3");
assert_eq!(result.output(), "line1\n42\nline3");
@@ -486,3 +487,95 @@ fn the_streamable_http_header_names_are_pinned() {
assert_eq!(HEADER_SESSION_ID, "Mcp-Session-Id");
assert_eq!(crate::HEADER_SESSION_ID, HEADER_SESSION_ID);
}
+
+// ---------------------------------------------------------------------------
+// Tool `_meta`, resources, and a result's host-facing parts
+// ---------------------------------------------------------------------------
+
+#[test]
+fn a_tools_meta_is_decoded_from_its_wire_name() {
+ let tool: McpRemoteTool = serde_json::from_value(json!({
+ "name": "show_card",
+ "_meta": { "ui": { "resourceUri": "ui://card" } },
+ }))
+ .unwrap();
+
+ assert_eq!(
+ tool.meta,
+ Some(json!({ "ui": { "resourceUri": "ui://card" } }))
+ );
+ assert_eq!(
+ serde_json::to_value(&tool).unwrap()["_meta"],
+ tool.meta.unwrap()
+ );
+}
+
+#[test]
+fn a_tool_without_meta_serializes_without_the_member() {
+ let wire = serde_json::to_value(McpRemoteTool::new("plain")).unwrap();
+
+ assert!(wire.get("_meta").is_none(), "{wire}");
+}
+
+#[test]
+fn a_resource_listing_entry_keeps_its_wire_names() {
+ let resource: super::McpResource = serde_json::from_value(json!({
+ "uri": "ui://card",
+ "name": "card",
+ "mimeType": "text/html;profile=mcp-app",
+ "_meta": { "ui": { "prefersBorder": true } },
+ }))
+ .unwrap();
+
+ assert_eq!(
+ resource.mime_type.as_deref(),
+ Some("text/html;profile=mcp-app")
+ );
+ assert_eq!(resource.title, None);
+ let wire = serde_json::to_value(&resource).unwrap();
+ assert_eq!(wire["mimeType"], "text/html;profile=mcp-app");
+ assert!(wire.get("title").is_none());
+}
+
+#[test]
+fn resource_contents_count_text_and_blob_bytes() {
+ let contents: super::McpResourceContents = serde_json::from_value(json!({
+ "uri": "ui://card", "text": "abc", "blob": "AAAA",
+ }))
+ .unwrap();
+ assert_eq!(contents.byte_len(), 7);
+
+ let empty: super::McpResourceContents =
+ serde_json::from_value(json!({ "uri": "ui://card" })).unwrap();
+ assert_eq!(empty.byte_len(), 0);
+}
+
+#[test]
+fn a_results_host_facing_parts_use_the_protocols_names_and_never_render() {
+ let mut result = McpToolResult::success("shown");
+ result.structured_content = Some(json!({ "n": 1 }));
+ result.meta = Some(json!({ "k": "v" }));
+ result.resources =
+ vec![serde_json::from_value(json!({ "uri": "ui://a", "text": "" })).unwrap()];
+
+ let wire = serde_json::to_value(&result).unwrap();
+ assert_eq!(wire["structuredContent"], json!({ "n": 1 }));
+ assert_eq!(wire["_meta"], json!({ "k": "v" }));
+ assert_eq!(wire["resources"][0]["uri"], "ui://a");
+ assert_eq!(
+ serde_json::from_value::(wire).unwrap(),
+ result
+ );
+
+ assert_eq!(result.text(), "shown");
+ assert_eq!(result.output(), "shown");
+ assert_eq!(result.output_for_llm(true), "shown");
+}
+
+#[test]
+fn a_result_without_host_facing_parts_keeps_its_old_wire_form() {
+ assert_eq!(
+ serde_json::to_value(McpToolResult::success("x")).unwrap(),
+ json!({ "content": [{ "type": "text", "text": "x" }], "is_error": false })
+ );
+}
diff --git a/crates/tinymcp-bus/src/transport/types.rs b/crates/tinymcp-bus/src/transport/types.rs
index c7b0e353..80d30373 100644
--- a/crates/tinymcp-bus/src/transport/types.rs
+++ b/crates/tinymcp-bus/src/transport/types.rs
@@ -62,6 +62,12 @@ pub struct McpRemoteTool {
/// The JSON Schema describing the tool's arguments.
#[serde(default, rename = "inputSchema")]
pub input_schema: Value,
+ /// The tool's `_meta` object, verbatim.
+ ///
+ /// Carries extension data such as an MCP Apps `ui` resource reference.
+ /// Untrusted remote data; never place it in a model's context.
+ #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
}
impl McpRemoteTool {
@@ -82,6 +88,7 @@ impl McpRemoteTool {
title: None,
description: None,
input_schema: Value::Null,
+ meta: None,
}
}
@@ -197,6 +204,79 @@ pub enum McpToolContent {
},
}
+/// The most bytes a single resource's contents may carry.
+///
+/// Applied to `resources/read` replies and to resources embedded in a tool
+/// result. A resource above it is refused (on read) or dropped (when
+/// embedded), never truncated: a cut-off HTML document renders as something
+/// other than what the server sent.
+pub const MAX_RESOURCE_BYTES: usize = 2 * 1024 * 1024;
+
+/// A resource a server advertises in `resources/list`.
+///
+/// Every text field is untrusted remote data.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct McpResource {
+ /// The resource's URI, for example `ui://weather/card.html`.
+ pub uri: String,
+ /// The resource's programmatic name.
+ #[serde(default)]
+ pub name: String,
+ /// A human-readable label.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option,
+ /// A human-readable summary.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub description: Option,
+ /// The MIME type the server declares for it.
+ #[serde(default, rename = "mimeType", skip_serializing_if = "Option::is_none")]
+ pub mime_type: Option,
+ /// The resource's `_meta` object, verbatim.
+ #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
+}
+
+/// The contents of one resource, as `resources/read` returns them or as a
+/// tool result embeds them.
+///
+/// Exactly one of [`Self::text`] and [`Self::blob`] is normally present;
+/// both are kept verbatim, the blob still base64-encoded.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct McpResourceContents {
+ /// The resource's URI.
+ pub uri: String,
+ /// The MIME type of these contents.
+ #[serde(default, rename = "mimeType", skip_serializing_if = "Option::is_none")]
+ pub mime_type: Option,
+ /// Text contents.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub text: Option,
+ /// Binary contents, base64-encoded.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub blob: Option,
+ /// The contents' `_meta` object, verbatim.
+ #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
+}
+
+impl McpResourceContents {
+ /// How many bytes of payload these contents carry: text plus blob.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// # use tinymcp_bus::McpResourceContents;
+ /// let contents: McpResourceContents = serde_json::from_value(serde_json::json!({
+ /// "uri": "ui://card", "text": "hi
",
+ /// })).unwrap();
+ /// assert_eq!(contents.byte_len(), 9);
+ /// ```
+ #[must_use]
+ pub fn byte_len(&self) -> usize {
+ self.text.as_ref().map_or(0, String::len) + self.blob.as_ref().map_or(0, String::len)
+ }
+}
+
/// A tool result rendered into the shape a caller consumes.
///
/// This is the vocabulary a host's own tool layer speaks, produced from a
@@ -224,6 +304,23 @@ pub struct McpToolResult {
skip_serializing_if = "Option::is_none"
)]
pub markdown_formatted: Option,
+ /// The reply's `structuredContent`, verbatim.
+ ///
+ /// Host-facing only: no rendering method reads it, so it never changes
+ /// what a model sees.
+ #[serde(
+ default,
+ rename = "structuredContent",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub structured_content: Option,
+ /// The reply's `_meta` object, verbatim. Host-facing only.
+ #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")]
+ pub meta: Option,
+ /// Resources the reply embedded as `resource` content blocks, each at
+ /// most [`MAX_RESOURCE_BYTES`]. Host-facing only.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub resources: Vec,
}
impl McpToolResult {
@@ -242,7 +339,7 @@ impl McpToolResult {
Self {
content: vec![McpToolContent::Text { text: text.into() }],
is_error: false,
- markdown_formatted: None,
+ ..Self::default()
}
}
@@ -261,7 +358,7 @@ impl McpToolResult {
text: message.into(),
}],
is_error: true,
- markdown_formatted: None,
+ ..Self::default()
}
}
@@ -271,7 +368,7 @@ impl McpToolResult {
Self {
content: vec![McpToolContent::Json { data }],
is_error: false,
- markdown_formatted: None,
+ ..Self::default()
}
}
diff --git a/crates/tinymcp-bus/src/ui/mod.rs b/crates/tinymcp-bus/src/ui/mod.rs
new file mode 100644
index 00000000..db1412c1
--- /dev/null
+++ b/crates/tinymcp-bus/src/ui/mod.rs
@@ -0,0 +1,22 @@
+//! The vocabulary for tool-provided UI.
+//!
+//! A server can attach a widget to a tool (MCP Apps `_meta.ui.resourceUri`, or
+//! the Apps SDK `openai/outputTemplate`) and put links in its results. A host
+//! reduces one answered call to a [`McpUiPresentation`]: which widget, which
+//! data, which links. These are the shapes that presentation is made of, so
+//! every host that renders tool UI reads and writes the same metadata.
+//!
+//! The rules that produce a presentation (template resolution, link
+//! classification, size caps, the widget tool-call policy) live in
+//! `tinymcp::ui`.
+
+mod types;
+
+pub use types::{
+ LinkClass, MCP_APP_MIME, MCP_APPS_EXTENSION, MCP_UI_KIND, McpUiPresentation, UiCsp, UiFlavor,
+ UiLink, UiLinkKind, UiRendering, UiResource, WidgetCallPolicy,
+};
+
+#[cfg(test)]
+#[path = "mod_tests.rs"]
+mod tests;
diff --git a/crates/tinymcp-bus/src/ui/mod_tests.rs b/crates/tinymcp-bus/src/ui/mod_tests.rs
new file mode 100644
index 00000000..2b16bb8b
--- /dev/null
+++ b/crates/tinymcp-bus/src/ui/mod_tests.rs
@@ -0,0 +1,60 @@
+//! Wire-shape tests for the tool UI vocabulary.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use super::*;
+use serde_json::json;
+
+#[test]
+fn presentation_wire_shape_is_stable() {
+ let presentation = McpUiPresentation {
+ kind: MCP_UI_KIND.to_string(),
+ flavor: UiFlavor::AppsSdk,
+ server_id: Some("srv".into()),
+ tool: "search".into(),
+ resource_uri: Some("ui://widget/search.html".into()),
+ inline_id: None,
+ title: None,
+ tool_input: json!({"q": "bars"}),
+ structured_content: Some(json!({"items": []})),
+ result_meta: None,
+ links: vec![UiLink {
+ url: "upi://pay?pa=a@b".into(),
+ kind: UiLinkKind::Handoff,
+ }],
+ };
+ assert_eq!(
+ presentation.to_metadata(),
+ json!({
+ "kind": "mcp_ui",
+ "flavor": "apps_sdk",
+ "server_id": "srv",
+ "tool": "search",
+ "resource_uri": "ui://widget/search.html",
+ "tool_input": {"q": "bars"},
+ "structured_content": {"items": []},
+ "links": [{"url": "upi://pay?pa=a@b", "kind": "handoff"}]
+ })
+ );
+ assert!(presentation.has_frame());
+}
+
+#[test]
+fn minimal_presentation_decodes_with_defaults() {
+ let presentation: McpUiPresentation = serde_json::from_value(
+ json!({"kind": "mcp_ui", "flavor": "host_inline", "tool": "show_ui"}),
+ )
+ .expect("decodes");
+ assert!(!presentation.has_frame());
+ assert_eq!(presentation.links, Vec::new());
+ assert_eq!(presentation.tool_input, serde_json::Value::Null);
+}
+
+#[test]
+fn enums_use_snake_case() {
+ assert_eq!(json!(LinkClass::Image), json!("image"));
+ assert_eq!(json!(UiLinkKind::External), json!("external"));
+ assert_eq!(json!(WidgetCallPolicy::Confirm), json!("confirm"));
+ assert_eq!(json!(UiRendering::Links), json!("links"));
+ assert_eq!(json!(UiFlavor::McpApps), json!("mcp_apps"));
+}
diff --git a/crates/tinymcp-bus/src/ui/types.rs b/crates/tinymcp-bus/src/ui/types.rs
new file mode 100644
index 00000000..dd1f7619
--- /dev/null
+++ b/crates/tinymcp-bus/src/ui/types.rs
@@ -0,0 +1,161 @@
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+/// The `kind` a [`McpUiPresentation`] carries in a tool result's metadata.
+pub const MCP_UI_KIND: &str = "mcp_ui";
+
+/// The MIME type an MCP Apps resource declares.
+pub const MCP_APP_MIME: &str = "text/html;profile=mcp-app";
+
+/// The client capability extension that advertises MCP Apps support.
+pub const MCP_APPS_EXTENSION: &str = "io.modelcontextprotocol/ui";
+
+/// Which widget protocol a frame speaks to the page.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum UiFlavor {
+ /// MCP Apps (`ui/*` JSON-RPC over `postMessage`).
+ McpApps,
+ /// `OpenAI` Apps SDK (`window.openai`), bridged onto the same channel.
+ AppsSdk,
+ /// HTML the host supplies itself; the page may not call tools.
+ HostInline,
+}
+
+/// How a URL found in a tool result is classified.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LinkClass {
+ /// An `http(s)` page.
+ Web,
+ /// An app scheme (`upi://`, `phonepe://`, `intent://`, ...) meant for a
+ /// phone, never opened on the host machine.
+ Handoff,
+ /// An `http(s)` image asset; not an action.
+ Image,
+ /// A scheme that could run script or reach the host, or a malformed URL.
+ Blocked,
+}
+
+/// How a surfaced link may be opened.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum UiLinkKind {
+ /// `http(s)`: opened in a browser.
+ External,
+ /// Any other allowed scheme: handed to a phone.
+ Handoff,
+}
+
+/// A link a tool result offered, surfaced as an action.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct UiLink {
+ /// The URL, as found.
+ pub url: String,
+ /// How it may be opened.
+ pub kind: UiLinkKind,
+}
+
+/// What a host renders for one tool call that offered UI.
+///
+/// Carried as the tool result's metadata. Never carries the widget's HTML: a
+/// host reads that separately by `resource_uri` or `inline_id`.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct McpUiPresentation {
+ /// Always [`MCP_UI_KIND`].
+ pub kind: String,
+ /// The widget protocol.
+ pub flavor: UiFlavor,
+ /// The server the tool belongs to; `None` for host-supplied UI.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub server_id: Option,
+ /// The tool's name on its server.
+ pub tool: String,
+ /// The `ui://` resource the widget loads.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub resource_uri: Option,
+ /// A host-held document the widget loads instead.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub inline_id: Option,
+ /// A display title.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub title: Option,
+ /// The call's arguments.
+ #[serde(default)]
+ pub tool_input: Value,
+ /// The result's `structuredContent`, when small enough to carry.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub structured_content: Option,
+ /// The result's `_meta`, when small enough to carry.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub result_meta: Option,
+ /// Links the result offered.
+ #[serde(default)]
+ pub links: Vec,
+}
+
+impl McpUiPresentation {
+ /// Whether the presentation has a widget document to show.
+ #[must_use]
+ pub fn has_frame(&self) -> bool {
+ self.resource_uri.is_some() || self.inline_id.is_some()
+ }
+
+ /// The presentation as tool-result metadata.
+ #[must_use]
+ pub fn to_metadata(&self) -> Value {
+ serde_json::to_value(self).unwrap_or(Value::Null)
+ }
+}
+
+/// The `https` origins a widget may reach.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct UiCsp {
+ /// Origins the widget may fetch from.
+ #[serde(default)]
+ pub connect_domains: Vec,
+ /// Origins the widget may load scripts, styles, images and fonts from.
+ #[serde(default)]
+ pub resource_domains: Vec,
+}
+
+/// A widget document, validated and ready to sandbox.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct UiResource {
+ /// The document.
+ pub html: String,
+ /// The MIME type the server declared.
+ pub mime_type: String,
+ /// The declared origins, reduced to `https`.
+ pub csp: UiCsp,
+ /// Declared permissions, verbatim.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub permissions: Option,
+ /// Whether the widget asks for a border.
+ #[serde(default)]
+ pub prefers_border: bool,
+}
+
+/// What a host does with a tool call a widget asks for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum WidgetCallPolicy {
+ /// Call it: the tool declares it changes nothing.
+ Allow,
+ /// Ask the user first.
+ Confirm,
+ /// Refuse: the widget may not call tools, or the tool is hidden from it.
+ Deny,
+}
+
+/// How a host shows one presentation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum UiRendering {
+ /// Sandbox and show the widget.
+ Widget,
+ /// Show the classified links only.
+ Links,
+ /// Show nothing beyond the text.
+ Nothing,
+}
diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs
index b2c35c02..70ea7d52 100644
--- a/crates/tinymcp-bus/src/version/mod.rs
+++ b/crates/tinymcp-bus/src/version/mod.rs
@@ -12,8 +12,11 @@
/// 1.1 added agent tools; 1.2 added registry and directory members; 1.3 added
/// the structured call outcome and the credential-store error name; 1.4 added
/// the search page's freshness, including answers from the local catalog index,
-/// and the registry-timeout error name.
-pub const CONTRACT_VERSION: (u32, u32) = (1, 4);
+/// and the registry-timeout error name; 1.5 added tool `_meta`, client
+/// capabilities, resource payloads, the result envelope, and the
+/// resource-too-large error name; 1.6 added the tool UI presentation
+/// vocabulary.
+pub const CONTRACT_VERSION: (u32, u32) = (1, 6);
/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module
/// reporting `module`.
@@ -27,7 +30,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (1, 4);
/// ```
/// # use tinymcp_bus::{is_compatible, CONTRACT_VERSION};
/// assert!(is_compatible(CONTRACT_VERSION));
-/// assert!(is_compatible((1, 4)));
+/// assert!(is_compatible((1, 7)));
/// assert!(!is_compatible((2, 0)));
/// ```
#[must_use]
diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs
index 58a9a348..904e3f10 100644
--- a/crates/tinymcp-bus/src/version/mod_tests.rs
+++ b/crates/tinymcp-bus/src/version/mod_tests.rs
@@ -4,14 +4,14 @@ use super::{CONTRACT_VERSION, binds, is_compatible};
#[test]
fn the_shipped_contract_version_is_pinned() {
- assert_eq!(CONTRACT_VERSION, (1, 4));
+ assert_eq!(CONTRACT_VERSION, (1, 6));
}
#[test]
fn a_host_on_this_contract_refuses_a_module_from_before_it() {
- // 1.4 added the search page's freshness; a 1.3 module does not report it.
+ // 1.6 added the tool UI presentation vocabulary; a 1.5 module predates it.
assert!(!is_compatible((1, 0)));
- assert!(!is_compatible((1, 3)));
+ assert!(!is_compatible((1, 5)));
}
#[test]
@@ -21,7 +21,7 @@ fn the_contract_binds_to_itself() {
#[test]
fn a_newer_minor_on_the_module_side_binds() {
- assert!(is_compatible((1, 4)));
+ assert!(is_compatible((1, 7)));
assert!(is_compatible((1, 97)));
}
diff --git a/crates/tinymcp/Cargo.toml b/crates/tinymcp/Cargo.toml
index ae984d1c..f7dde8c4 100644
--- a/crates/tinymcp/Cargo.toml
+++ b/crates/tinymcp/Cargo.toml
@@ -54,6 +54,8 @@ anyhow = { workspace = true, optional = true }
# credential-looking query values, and matches their percent-encoded spellings.
url = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
+# Finding URLs in tool results, for the `ui` feature's link extraction.
+regex = { workspace = true, optional = true }
[[example]]
name = "verify_module"
@@ -97,6 +99,10 @@ server-http = ["dep:axum", "dep:tokio-stream", "tokio/net"]
# deferred exposure, and an invoker seam a host wraps with its own policy.
# Off by default so the loadable module does not link the tool vocabulary.
tools = ["dep:tinytools", "dep:async-trait", "dep:anyhow", "dep:url", "dep:urlencoding"]
+# Tool-provided UI for hosts (`tinymcp::ui`): widget template resolution,
+# widget document checks, link classification, and the widget tool-call and
+# rendering policy. Off by default; a host that renders tool UI turns it on.
+ui = ["dep:url", "dep:regex"]
[lints]
workspace = true
diff --git a/crates/tinymcp/src/config_servers/mod.rs b/crates/tinymcp/src/config_servers/mod.rs
index e415d255..ee546d79 100644
--- a/crates/tinymcp/src/config_servers/mod.rs
+++ b/crates/tinymcp/src/config_servers/mod.rs
@@ -35,3 +35,7 @@ pub use types::{McpRegistrySource, McpServerDefinition, McpServerRegistry, McpTr
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
+
+#[cfg(test)]
+#[path = "mod_ui_tests.rs"]
+mod ui_test;
diff --git a/crates/tinymcp/src/config_servers/mod_ui_tests.rs b/crates/tinymcp/src/config_servers/mod_ui_tests.rs
new file mode 100644
index 00000000..a70c6c4a
--- /dev/null
+++ b/crates/tinymcp/src/config_servers/mod_ui_tests.rs
@@ -0,0 +1,156 @@
+//! Tests for the static registry's MCP Apps surface: tool `_meta` lookups and
+//! resources, over both transports.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::json;
+use tinymcp_bus::{McpClientConfig, McpClientIdentityConfig, McpServerConfig};
+
+use super::McpServerRegistry;
+use crate::Error;
+use crate::transport::ui_fixture::{self, APP_MIME, CARD_HTML, CARD_URI, show_card_meta};
+
+fn registry_for(server: McpServerConfig, identity: McpClientIdentityConfig) -> McpServerRegistry {
+ McpServerRegistry::from_config(&McpClientConfig {
+ servers: vec![server],
+ client_identity: identity,
+ ..McpClientConfig::default()
+ })
+ .unwrap()
+}
+
+fn http(endpoint: &str, disallowed: &[&str]) -> McpServerConfig {
+ McpServerConfig {
+ name: "ui".into(),
+ endpoint: endpoint.into(),
+ disallowed_tools: disallowed.iter().map(ToString::to_string).collect(),
+ ..McpServerConfig::default()
+ }
+}
+
+#[tokio::test]
+async fn tool_meta_is_answered_from_the_last_listing() {
+ let fixture = ui_fixture::spawn().await;
+ let registry = registry_for(
+ http(&fixture.endpoint, &[]),
+ McpClientIdentityConfig::default(),
+ );
+
+ assert_eq!(registry.tool_meta("ui", "show_card"), None);
+ let tools = registry.list_tools("ui").await.unwrap();
+ assert_eq!(
+ tools.iter().find(|t| t.name == "show_card").unwrap().meta,
+ Some(show_card_meta())
+ );
+
+ assert_eq!(
+ registry.tool_meta("ui", "show_card"),
+ Some(show_card_meta())
+ );
+ assert_eq!(registry.tool_meta("ui", "plain"), None);
+ assert_eq!(registry.tool_meta("nope", "show_card"), None);
+}
+
+#[tokio::test]
+async fn tool_meta_is_withheld_for_a_disallowed_tool() {
+ let fixture = ui_fixture::spawn().await;
+ let registry = registry_for(
+ http(&fixture.endpoint, &["show_card"]),
+ McpClientIdentityConfig::default(),
+ );
+ registry.initialize("ui").await.unwrap();
+ let _ = registry.list_tools("ui").await.unwrap();
+
+ assert_eq!(registry.tool_meta("ui", "show_card"), None);
+}
+
+#[tokio::test]
+async fn the_configured_capabilities_reach_the_server() {
+ let fixture = ui_fixture::spawn().await;
+ let capabilities = json!({
+ "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": [APP_MIME] } }
+ });
+ let registry = registry_for(
+ http(&fixture.endpoint, &[]),
+ McpClientIdentityConfig {
+ capabilities: capabilities.clone(),
+ ..McpClientIdentityConfig::default()
+ },
+ );
+
+ registry.initialize("ui").await.unwrap();
+
+ assert_eq!(
+ fixture.initialize_params.lock().unwrap()[0]["capabilities"],
+ capabilities
+ );
+}
+
+#[tokio::test]
+async fn resources_are_listed_and_read_by_server_name() {
+ let fixture = ui_fixture::spawn().await;
+ let registry = registry_for(
+ http(&fixture.endpoint, &[]),
+ McpClientIdentityConfig::default(),
+ );
+
+ let listed = registry.list_resources("ui").await.unwrap();
+ assert_eq!(listed.len(), 2);
+ let contents = registry.read_resource("ui", CARD_URI).await.unwrap();
+ assert_eq!(contents[0].text.as_deref(), Some(CARD_HTML));
+}
+
+#[tokio::test]
+async fn resources_of_an_unknown_server_are_refused() {
+ let registry = McpServerRegistry::default();
+
+ let error = registry.read_resource("nope", CARD_URI).await.unwrap_err();
+ assert!(matches!(error, Error::UnknownServer { .. }), "{error:?}");
+ let error = registry.list_resources("nope").await.unwrap_err();
+ assert!(matches!(error, Error::UnknownServer { .. }), "{error:?}");
+}
+
+#[cfg(unix)]
+#[tokio::test]
+async fn a_stdio_server_answers_meta_and_resources_too() {
+ use std::fmt::Write as _;
+
+ let initialize = format!(
+ r#"{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"{}","capabilities":{{}},"serverInfo":{{}}}}}}"#,
+ tinymcp_bus::LATEST_PROTOCOL_VERSION
+ );
+ let replies = [
+ initialize.as_str(),
+ "",
+ r#"{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"show_card","_meta":{"ui":{"resourceUri":"ui://card"}}}]}}"#,
+ r#"{"jsonrpc":"2.0","id":4,"result":{"resources":[{"uri":"ui://card","name":"card"}]}}"#,
+ r#"{"jsonrpc":"2.0","id":5,"result":{"contents":[{"uri":"ui://card","text":"stdio
"}]}}"#,
+ ];
+ let mut script = String::new();
+ for reply in replies {
+ script.push_str("read -r _line\n");
+ let _ = writeln!(script, "printf '%s\\n' '{reply}'");
+ }
+ script.push_str("cat > /dev/null\n");
+ let registry = registry_for(
+ McpServerConfig {
+ name: "ui".into(),
+ command: "/bin/sh".into(),
+ args: vec!["-c".into(), script],
+ ..McpServerConfig::default()
+ },
+ McpClientIdentityConfig::default(),
+ );
+
+ let _ = registry.list_tools("ui").await.unwrap();
+ assert_eq!(
+ registry.tool_meta("ui", "show_card"),
+ Some(show_card_meta())
+ );
+ assert_eq!(
+ registry.list_resources("ui").await.unwrap()[0].uri,
+ CARD_URI
+ );
+ let contents = registry.read_resource("ui", CARD_URI).await.unwrap();
+ assert_eq!(contents[0].text.as_deref(), Some("stdio
"));
+}
diff --git a/crates/tinymcp/src/config_servers/types.rs b/crates/tinymcp/src/config_servers/types.rs
index a1148d4d..7d74da9a 100644
--- a/crates/tinymcp/src/config_servers/types.rs
+++ b/crates/tinymcp/src/config_servers/types.rs
@@ -12,7 +12,8 @@ use crate::transport::http::McpHttpClient;
use crate::transport::stdio::McpStdioClient;
use tinymcp_bus::{
McpAuthConfig, McpAuthorizationContext, McpClientConfig, McpClientIdentityConfig,
- McpInitializeResult, McpProxyConfig, McpRemoteTool, McpServerConfig, McpServerToolResult,
+ McpInitializeResult, McpProxyConfig, McpRemoteTool, McpResource, McpResourceContents,
+ McpServerConfig, McpServerToolResult,
};
/// Where a server in the static set came from.
@@ -184,6 +185,39 @@ impl McpTransportClient {
}
}
+ /// A tool from the transport's last listing, without a round trip.
+ #[must_use]
+ pub fn cached_tool(&self, tool: &str) -> Option {
+ match self {
+ Self::Http(client) => client.cached_tool(tool),
+ Self::Stdio(client) => client.cached_tool(tool),
+ }
+ }
+
+ /// Lists the resources the server advertises.
+ ///
+ /// # Errors
+ ///
+ /// Returns whatever the underlying transport returns.
+ pub async fn list_resources(&self) -> Result> {
+ match self {
+ Self::Http(client) => client.list_resources().await,
+ Self::Stdio(client) => client.list_resources().await,
+ }
+ }
+
+ /// Reads one resource's contents.
+ ///
+ /// # Errors
+ ///
+ /// Returns whatever the underlying transport returns.
+ pub async fn read_resource(&self, uri: &str) -> Result> {
+ match self {
+ Self::Http(client) => client.read_resource(uri).await,
+ Self::Stdio(client) => client.read_resource(uri).await,
+ }
+ }
+
/// Discovers how to authorize, when the transport has a notion of it.
///
/// A subprocess server always reports `None`: there is no 401 and no
@@ -450,6 +484,41 @@ impl McpServerRegistry {
definition.client.call_tool(tool, arguments).await
}
+ /// A listed tool's `_meta`, from the transport's last listing.
+ ///
+ /// No round trip. `None` when the server or tool is unknown, the tool is
+ /// not permitted, nothing has been listed yet, or the tool has no `_meta`.
+ #[must_use]
+ pub fn tool_meta(&self, server: &str, tool: &str) -> Option {
+ let definition = self.get(server)?;
+ if !definition.is_tool_allowed(tool) {
+ return None;
+ }
+ definition.client.cached_tool(tool)?.meta
+ }
+
+ /// Lists a server's resources.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::UnknownServer`] when `server` is not registered, plus
+ /// whatever the transport returns.
+ pub async fn list_resources(&self, server: &str) -> Result> {
+ self.require(server)?.client.list_resources().await
+ }
+
+ /// Reads one of a server's resources.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::UnknownServer`] when `server` is not registered,
+ /// [`Error::ResourceTooLarge`] when the contents exceed
+ /// [`tinymcp_bus::MAX_RESOURCE_BYTES`], plus whatever the transport
+ /// returns.
+ pub async fn read_resource(&self, server: &str, uri: &str) -> Result> {
+ self.require(server)?.client.read_resource(uri).await
+ }
+
/// Performs a server's handshake.
///
/// # Errors
diff --git a/crates/tinymcp/src/error/mod.rs b/crates/tinymcp/src/error/mod.rs
index 10e644a9..e244fae7 100644
--- a/crates/tinymcp/src/error/mod.rs
+++ b/crates/tinymcp/src/error/mod.rs
@@ -204,6 +204,20 @@ pub enum Error {
server: String,
},
+ /// A resource's contents exceeded the size this client will hold.
+ ///
+ /// Refused rather than truncated: a cut-off document renders as something
+ /// other than what the server sent.
+ #[error("mcp resource `{uri}` is {bytes} bytes, above the {limit}-byte limit")]
+ ResourceTooLarge {
+ /// The resource that was read.
+ uri: String,
+ /// How many bytes its contents carried.
+ bytes: usize,
+ /// The cap it exceeded.
+ limit: usize,
+ },
+
/// A server is installed but turned off.
///
/// Being off is a setting the user chose, not a failure, so it is neither a
@@ -426,6 +440,7 @@ impl Error {
Self::AuthDiscovery { .. } => errors::AUTH_DISCOVERY,
Self::ToolNotAllowed { .. } => errors::TOOL_NOT_ALLOWED,
Self::NotConnected { .. } => errors::NOT_CONNECTED,
+ Self::ResourceTooLarge { .. } => errors::RESOURCE_TOO_LARGE,
Self::ServerDisabled { .. } => errors::SERVER_DISABLED,
Self::UnknownServer { .. } => errors::UNKNOWN_SERVER,
Self::ClientBuild { .. } => errors::CLIENT_BUILD,
diff --git a/crates/tinymcp/src/error/mod_tests.rs b/crates/tinymcp/src/error/mod_tests.rs
index 616ddb7a..251ce8e9 100644
--- a/crates/tinymcp/src/error/mod_tests.rs
+++ b/crates/tinymcp/src/error/mod_tests.rs
@@ -339,6 +339,11 @@ fn one_of_every_variant() -> Vec {
errors.push(Error::ServerDisabled {
server: "srv".into(),
});
+ errors.push(Error::ResourceTooLarge {
+ uri: "ui://card".into(),
+ bytes: 3,
+ limit: 2,
+ });
errors.push(Error::ClientBuild {
source: Box::new(a_reqwest_error()),
});
diff --git a/crates/tinymcp/src/lib.rs b/crates/tinymcp/src/lib.rs
index ca8448b8..52982825 100644
--- a/crates/tinymcp/src/lib.rs
+++ b/crates/tinymcp/src/lib.rs
@@ -83,6 +83,8 @@ pub mod tinybus_module;
#[cfg(feature = "tools")]
pub mod tools;
pub mod transport;
+#[cfg(feature = "ui")]
+pub mod ui;
pub use audit::AuditStore;
pub use config_servers::{
@@ -115,14 +117,14 @@ pub use tinymcp_bus::{
AuthDetection, AuthKind, AuthorizationServerMetadata, CONTRACT_VERSION, ChatTurn, CommandKind,
ConnStatus, ConnectedServerOverview, DEFAULT_LIST_LIMIT, ERROR_MESSAGE_MAX_BYTES, ExtraFields,
HEADER_PROTOCOL_VERSION, HEADER_SESSION_ID, HttpHeader, INTERFACE, InstalledServer,
- LATEST_PROTOCOL_VERSION, MAX_DESCRIPTION_BYTES, MAX_LIST_LIMIT, MAX_TITLE_BYTES,
- MCP_CALL_RESULT_KIND, METHODS, McpAuthChallenge, McpAuthConfig, McpAuthHint,
- McpAuthorizationContext, McpCallError, McpCallOutcome, McpClientConfig,
- McpClientIdentityConfig, McpClientInfo, McpInitializeResult, McpProxyConfig,
- McpRegistryAuthConfig, McpRemoteTool, McpServerConfig, McpServerToolResult, McpSseEvent,
- McpTool, McpToolContent, McpToolResult, McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord,
- OBJECT_PATH, ProtectedResourceMetadata, RegistryConnection, RegistryFreshness,
- RegistryListResponse, RegistryPagination, RegistryServerDetail, RegistryServerSummary,
- SUPPORTED_PROTOCOL_VERSIONS, SearchCuration, ServerDetail, ServerStatus, Transport, config,
- is_compatible, names, sanitize, version,
+ LATEST_PROTOCOL_VERSION, MAX_DESCRIPTION_BYTES, MAX_LIST_LIMIT, MAX_RESOURCE_BYTES,
+ MAX_TITLE_BYTES, MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, METHODS, McpAuthChallenge,
+ McpAuthConfig, McpAuthHint, McpAuthorizationContext, McpCallError, McpCallOutcome,
+ McpClientConfig, McpClientIdentityConfig, McpClientInfo, McpInitializeResult, McpProxyConfig,
+ McpRegistryAuthConfig, McpRemoteTool, McpResource, McpResourceContents, McpResultEnvelope,
+ McpServerConfig, McpServerToolResult, McpSseEvent, McpTool, McpToolContent, McpToolResult,
+ McpWriteListQuery, McpWriteRecord, NewMcpWriteRecord, OBJECT_PATH, ProtectedResourceMetadata,
+ RegistryConnection, RegistryFreshness, RegistryListResponse, RegistryPagination,
+ RegistryServerDetail, RegistryServerSummary, SUPPORTED_PROTOCOL_VERSIONS, SearchCuration,
+ ServerDetail, ServerStatus, Transport, config, is_compatible, names, sanitize, version,
};
diff --git a/crates/tinymcp/src/registry/connections/mod.rs b/crates/tinymcp/src/registry/connections/mod.rs
index c30d4a3f..77cf220f 100644
--- a/crates/tinymcp/src/registry/connections/mod.rs
+++ b/crates/tinymcp/src/registry/connections/mod.rs
@@ -47,3 +47,7 @@ pub use types::{Connections, ProbeOutcome, REMOTE_REQUEST_TIMEOUT};
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
+
+#[cfg(test)]
+#[path = "mod_ui_tests.rs"]
+mod ui_test;
diff --git a/crates/tinymcp/src/registry/connections/mod_ui_tests.rs b/crates/tinymcp/src/registry/connections/mod_ui_tests.rs
new file mode 100644
index 00000000..60881e9d
--- /dev/null
+++ b/crates/tinymcp/src/registry/connections/mod_ui_tests.rs
@@ -0,0 +1,109 @@
+//! Tests for the connection map's MCP Apps surface: tool `_meta` captured at
+//! connect, and resources read through a connected server.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::json;
+use tinymcp_bus::{CommandKind, InstalledServer, McpClientIdentityConfig, Transport};
+
+use super::types::Connections;
+use crate::Error;
+use crate::registry::Store;
+use crate::registry::oauth::OAuthFlow;
+use crate::transport::ui_fixture::{self, APP_MIME, CARD_HTML, CARD_URI, show_card_meta};
+
+fn install(url: &str) -> InstalledServer {
+ InstalledServer {
+ server_id: "ui".into(),
+ qualified_name: "@test/ui".into(),
+ display_name: "ui".into(),
+ description: None,
+ icon_url: None,
+ command_kind: CommandKind::Node,
+ command: "npx".into(),
+ args: Vec::new(),
+ env_keys: Vec::new(),
+ config: None,
+ installed_at: 1_000,
+ last_connected_at: None,
+ transport: Transport::HttpRemote {
+ url: url.to_string(),
+ },
+ enabled: true,
+ }
+}
+
+async fn connected(identity: &McpClientIdentityConfig) -> (Connections, ui_fixture::UiFixture) {
+ let fixture = ui_fixture::spawn().await;
+ let server = install(&fixture.endpoint);
+ let store = Store::open_in_memory().unwrap();
+ store.insert_server(&server).unwrap();
+ let connections = Connections::new();
+ connections
+ .connect(
+ &store,
+ &OAuthFlow::new(None).unwrap(),
+ identity,
+ None,
+ &server,
+ )
+ .await
+ .unwrap();
+ (connections, fixture)
+}
+
+#[tokio::test]
+async fn tool_meta_is_captured_at_connect() {
+ let (connections, _fixture) = connected(&McpClientIdentityConfig::default()).await;
+
+ assert_eq!(
+ connections.tool_meta("ui", "show_card").await,
+ Some(show_card_meta())
+ );
+ assert_eq!(connections.tool_meta("ui", "plain").await, None);
+ assert_eq!(connections.tool_meta("ui", "missing").await, None);
+ assert_eq!(connections.tool_meta("other", "show_card").await, None);
+
+ let metas = connections.tool_metas("ui").await.unwrap();
+ assert_eq!(metas.len(), 1);
+ assert_eq!(metas.get("show_card"), Some(&show_card_meta()));
+ assert!(connections.tool_metas("other").await.is_none());
+}
+
+#[tokio::test]
+async fn the_identity_capabilities_reach_the_server_on_connect() {
+ let capabilities = json!({
+ "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": [APP_MIME] } }
+ });
+ let identity = McpClientIdentityConfig {
+ capabilities: capabilities.clone(),
+ ..McpClientIdentityConfig::default()
+ };
+ let (_connections, fixture) = connected(&identity).await;
+
+ assert_eq!(
+ fixture.initialize_params.lock().unwrap()[0]["capabilities"],
+ capabilities
+ );
+}
+
+#[tokio::test]
+async fn a_connected_servers_resources_are_listed_and_read() {
+ let (connections, _fixture) = connected(&McpClientIdentityConfig::default()).await;
+
+ let listed = connections.list_resources("ui").await.unwrap();
+ assert_eq!(listed[0].uri, CARD_URI);
+
+ let contents = connections.read_resource("ui", CARD_URI).await.unwrap();
+ assert_eq!(contents[0].text.as_deref(), Some(CARD_HTML));
+}
+
+#[tokio::test]
+async fn resources_of_an_unconnected_server_are_refused() {
+ let connections = Connections::new();
+
+ let error = connections.read_resource("ui", CARD_URI).await.unwrap_err();
+ assert!(matches!(error, Error::NotConnected { .. }), "{error:?}");
+ let error = connections.list_resources("ui").await.unwrap_err();
+ assert!(matches!(error, Error::NotConnected { .. }), "{error:?}");
+}
diff --git a/crates/tinymcp/src/registry/connections/types.rs b/crates/tinymcp/src/registry/connections/types.rs
index 441ee6e9..53063a3b 100644
--- a/crates/tinymcp/src/registry/connections/types.rs
+++ b/crates/tinymcp/src/registry/connections/types.rs
@@ -16,7 +16,7 @@ use crate::transport::http::McpHttpClient;
use crate::transport::stdio::McpStdioClient;
use tinymcp_bus::{
ConnStatus, ConnectedServerOverview, InstalledServer, McpAuthConfig, McpClientIdentityConfig,
- McpProxyConfig, McpServerToolResult, McpTool, Transport,
+ McpProxyConfig, McpResource, McpResourceContents, McpServerToolResult, McpTool, Transport,
};
/// The per-request timeout for a connected HTTP-remote server.
@@ -133,6 +133,22 @@ impl ActiveClient {
}
}
+ /// Lists the server's resources.
+ async fn list_resources(&self) -> Result> {
+ match self {
+ Self::Stdio(client) => client.list_resources().await,
+ Self::Http(client) => client.list_resources().await,
+ }
+ }
+
+ /// Reads one of the server's resources.
+ async fn read_resource(&self, uri: &str) -> Result> {
+ match self {
+ Self::Stdio(client) => client.read_resource(uri).await,
+ Self::Http(client) => client.read_resource(uri).await,
+ }
+ }
+
/// Ends the session.
async fn close_session(&self) -> Result<()> {
match self {
@@ -148,6 +164,8 @@ impl ActiveClient {
struct Connection {
client: ActiveClient,
tools: RwLock>,
+ /// Each listed tool's `_meta`, by tool name, for tools that sent one.
+ tool_meta: HashMap,
qualified_name: String,
display_name: String,
description: Option,
@@ -287,9 +305,12 @@ impl Connections {
}
};
- let tools: Vec = client
- .list_tools()
- .await?
+ let remote_tools = client.list_tools().await?;
+ let tool_meta: HashMap = remote_tools
+ .iter()
+ .filter_map(|remote| Some((remote.name.clone(), remote.meta.clone()?)))
+ .collect();
+ let tools: Vec = remote_tools
.into_iter()
.map(|remote| McpTool {
name: remote.name,
@@ -306,6 +327,7 @@ impl Connections {
let connection = Arc::new(Connection {
client,
tools: RwLock::new(tools.clone()),
+ tool_meta,
qualified_name: server.qualified_name.clone(),
display_name: server.display_name.clone(),
description: server.description.clone(),
@@ -587,6 +609,53 @@ impl Connections {
Some(self.get(server_id).await?.tools_snapshot().await)
}
+ /// One connected tool's `_meta`, as the server listed it at connect.
+ ///
+ /// No round trip. `None` when the server is not connected, the tool is
+ /// unknown, or it sent no `_meta`.
+ pub async fn tool_meta(&self, server_id: &str, tool: &str) -> Option {
+ self.get(server_id).await?.tool_meta.get(tool).cloned()
+ }
+
+ /// Every connected tool's `_meta` on one server, by tool name.
+ ///
+ /// `None` when the server is not connected. Tools that sent no `_meta`
+ /// are absent from the map.
+ pub async fn tool_metas(&self, server_id: &str) -> Option> {
+ Some(self.get(server_id).await?.tool_meta.clone())
+ }
+
+ /// Lists a connected server's resources.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::NotConnected`] when the server has no live connection,
+ /// plus whatever the transport returns.
+ pub async fn list_resources(&self, server_id: &str) -> Result> {
+ self.require(server_id).await?.client.list_resources().await
+ }
+
+ /// Reads one resource of a connected server, verbatim.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::NotConnected`] when the server has no live connection,
+ /// [`Error::ResourceTooLarge`] when the contents exceed
+ /// [`tinymcp_bus::MAX_RESOURCE_BYTES`], plus whatever the transport
+ /// returns.
+ pub async fn read_resource(
+ &self,
+ server_id: &str,
+ uri: &str,
+ ) -> Result> {
+ tracing::debug!(server_id, "reading a resource");
+ self.require(server_id)
+ .await?
+ .client
+ .read_resource(uri)
+ .await
+ }
+
/// Every connected server's identity and tools.
///
/// Sorted by qualified name. A caller rendering this into a prompt would
@@ -671,6 +740,15 @@ impl Connections {
self.live.read().await.len()
}
+ /// One connection, or [`Error::NotConnected`].
+ async fn require(&self, server_id: &str) -> Result> {
+ self.get(server_id)
+ .await
+ .ok_or_else(|| Error::NotConnected {
+ server: server_id.to_string(),
+ })
+ }
+
/// One connection, cloned out from under the lock.
async fn get(&self, server_id: &str) -> Option> {
self.live.read().await.get(server_id).cloned()
diff --git a/crates/tinymcp/src/tools/bridge.rs b/crates/tinymcp/src/tools/bridge.rs
index 61610334..129761b6 100644
--- a/crates/tinymcp/src/tools/bridge.rs
+++ b/crates/tinymcp/src/tools/bridge.rs
@@ -12,11 +12,13 @@
//!
//! Every result [`McpCallTool`] returns once the act gate has allowed the call
//! and it has a server, a tool and an `arguments` value carries a
-//! [`McpCallOutcome`] as its metadata. A call missing one of those, or refused
-//! by the gate, fails before any result exists and carries none. The outcome
-//! says whether the server answered and, when it did not, the error's wire
-//! name and whether it was a 401 that advertised OAuth. The model never sees it; a host reads it to meter calls
-//! and to surface failures without parsing the result text.
+//! [`McpCallOutcome`] in its metadata: inside an [`McpResultEnvelope`] when the
+//! server answered, bare when it did not. [`McpCallOutcome::from_metadata`]
+//! reads both. A call missing one of those, or refused by the gate, fails
+//! before any result exists and carries none. The outcome says whether the
+//! server answered and, when it did not, the error's wire name and whether it
+//! was a 401 that advertised OAuth. The model never sees it; a host reads it
+//! to meter calls and to surface failures without parsing the result text.
// The tool names and descriptions are fixed strings, and the rendered Markdown
// is built a line at a time; both are clearer as written.
@@ -26,7 +28,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
-use tinymcp_bus::{McpAuthConfig, McpCallError, McpCallOutcome};
+use tinymcp_bus::{McpAuthConfig, McpCallError, McpCallOutcome, McpResultEnvelope};
use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
use super::naming::disambiguated_tool_name;
@@ -379,10 +381,13 @@ impl Tool for McpCallTool {
if options.prefer_markdown && result.markdown_formatted.is_none() {
result.markdown_formatted = Some(result.output());
}
- Ok(with_outcome(
- scrubber.scrub_result(super::tool_result(result)),
- &outcome,
- ))
+ let envelope = scrubbed_envelope(
+ &scrubber,
+ McpResultEnvelope::new(&server, &tool, &result).with_outcome(outcome),
+ );
+ let mut mapped = scrubber.scrub_result(super::tool_result(result));
+ mapped.metadata = Some(envelope.to_metadata());
+ Ok(mapped)
}
async fn execute(&self, args: Value) -> anyhow::Result {
@@ -397,6 +402,28 @@ fn with_outcome(mut result: ToolResult, outcome: &McpCallOutcome) -> ToolResult
result
}
+/// `envelope` with every known secret replaced in the server-supplied parts.
+pub(super) fn scrubbed_envelope(
+ scrubber: &SecretScrubber,
+ mut envelope: McpResultEnvelope,
+) -> McpResultEnvelope {
+ if let Some(structured) = envelope.structured_content.as_mut() {
+ scrubber.scrub_value(structured);
+ }
+ if let Some(meta) = envelope.meta.as_mut() {
+ scrubber.scrub_value(meta);
+ }
+ for resource in &mut envelope.resources {
+ if let Some(text) = resource.text.as_mut() {
+ *text = scrubber.scrub(text);
+ }
+ if let Some(meta) = resource.meta.as_mut() {
+ scrubber.scrub_value(meta);
+ }
+ }
+ envelope
+}
+
/// The host-facing classification of a failed call.
fn call_error(error: &crate::Error) -> McpCallError {
McpCallError {
diff --git a/crates/tinymcp/src/tools/bridge_outcome_tests.rs b/crates/tinymcp/src/tools/bridge_outcome_tests.rs
index d228b3eb..ee5e7eb7 100644
--- a/crates/tinymcp/src/tools/bridge_outcome_tests.rs
+++ b/crates/tinymcp/src/tools/bridge_outcome_tests.rs
@@ -12,7 +12,8 @@ use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{Value, json};
use tinymcp_bus::{
- MCP_CALL_RESULT_KIND, McpAuthConfig, McpCallOutcome, McpClientConfig, McpServerConfig, errors,
+ MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, McpAuthConfig, McpCallOutcome, McpClientConfig,
+ McpServerConfig, errors,
};
use tinytools::{Tool, ToolResult};
@@ -148,6 +149,10 @@ async fn an_answered_call_reports_ok_with_no_error() {
);
assert_eq!(
result.metadata.as_ref().unwrap()["kind"],
+ json!(MCP_RESULT_KIND)
+ );
+ assert_eq!(
+ result.metadata.as_ref().unwrap()["outcome"]["kind"],
json!(MCP_CALL_RESULT_KIND)
);
}
diff --git a/crates/tinymcp/src/tools/envelope_tests.rs b/crates/tinymcp/src/tools/envelope_tests.rs
new file mode 100644
index 00000000..7dfa8c0c
--- /dev/null
+++ b/crates/tinymcp/src/tools/envelope_tests.rs
@@ -0,0 +1,189 @@
+//! Tests for the `mcp_result` envelope tool results carry as host-only
+//! metadata: what it holds, that the model-facing content is unchanged by it,
+//! that secrets are scrubbed from it, and that a host reading call outcomes
+//! with [`McpCallOutcome::from_metadata`] still finds them.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::sync::Arc;
+
+use serde_json::{Value, json};
+use tinymcp_bus::{
+ ConnectedServerOverview, MCP_RESULT_KIND, McpAuthConfig, McpCallOutcome, McpClientConfig,
+ McpResultEnvelope, McpServerConfig, McpTool, McpToolResult,
+};
+use tinytools::{Tool, ToolResult};
+
+use super::{
+ ActGate, McpCallTool, McpToolInvoker, McpToolSource, tool_result, tool_result_for, tools_for,
+};
+use crate::McpServerRegistry;
+use crate::transport::render_tool_result;
+use crate::transport::ui_fixture::{self, CALL_TEXT, CARD_HTML, CARD_URI};
+
+fn envelope_of(result: &ToolResult) -> McpResultEnvelope {
+ McpResultEnvelope::from_metadata(result.metadata.as_ref().expect("metadata is attached"))
+ .expect("metadata decodes as an envelope")
+}
+
+#[test]
+fn tool_result_for_attaches_the_envelope_and_leaves_content_alone() {
+ let rendered = render_tool_result(&ui_fixture::call_reply());
+
+ let plain = tool_result(rendered.clone());
+ let enveloped = tool_result_for("srv", "show_card", rendered);
+
+ assert_eq!(
+ serde_json::to_value(&plain.content).unwrap(),
+ serde_json::to_value(&enveloped.content).unwrap()
+ );
+ assert_eq!(plain.output(), enveloped.output());
+ assert_eq!(plain.markdown_formatted, enveloped.markdown_formatted);
+ assert!(plain.metadata.is_none());
+
+ let envelope = envelope_of(&enveloped);
+ assert_eq!(envelope.kind, MCP_RESULT_KIND);
+ assert_eq!(envelope.server, "srv");
+ assert_eq!(envelope.tool, "show_card");
+ assert_eq!(
+ envelope.structured_content,
+ Some(json!({ "temperature": 21 }))
+ );
+ assert_eq!(envelope.resources[0].text.as_deref(), Some(CARD_HTML));
+ assert_eq!(envelope.outcome, None);
+ assert_eq!(
+ McpCallOutcome::from_metadata(enveloped.metadata.as_ref().unwrap()),
+ None
+ );
+}
+
+#[test]
+fn a_plain_result_carries_a_bare_envelope() {
+ let result = tool_result_for("srv", "t", McpToolResult::success("hi"));
+
+ assert_eq!(
+ result.metadata,
+ Some(json!({ "kind": MCP_RESULT_KIND, "server": "srv", "tool": "t" }))
+ );
+}
+
+#[derive(Debug)]
+struct Fixed;
+
+#[async_trait::async_trait]
+impl McpToolInvoker for Fixed {
+ async fn invoke(&self, _: &str, _: &str, _: Value) -> crate::Result {
+ Ok(render_tool_result(&ui_fixture::call_reply()))
+ }
+}
+
+#[tokio::test]
+async fn a_server_tool_returns_the_envelope_for_its_server_and_remote_name() {
+ let invoker: Arc = Arc::new(Fixed);
+ let overview = ConnectedServerOverview {
+ server_id: "srv-1".into(),
+ qualified_name: "@acme/cards".into(),
+ display_name: "Cards".into(),
+ description: None,
+ instructions: None,
+ tools: vec![McpTool::new("show_card")],
+ };
+ let tool = tools_for(&[McpToolSource::from_overview(&overview)], &invoker).remove(0);
+
+ let result = tool.execute(json!({})).await.unwrap();
+
+ assert_eq!(result.output(), CALL_TEXT);
+ let envelope = envelope_of(&result);
+ assert_eq!(envelope.server, "srv-1");
+ assert_eq!(envelope.tool, "show_card");
+ assert_eq!(
+ envelope.meta,
+ Some(json!({ "openai/outputTemplate": CARD_URI }))
+ );
+}
+
+fn bridge_for(endpoint: &str, auth: McpAuthConfig) -> McpCallTool {
+ let registry = McpServerRegistry::from_config(&McpClientConfig {
+ servers: vec![McpServerConfig {
+ name: "ui".into(),
+ endpoint: endpoint.into(),
+ auth,
+ ..McpServerConfig::default()
+ }],
+ ..McpClientConfig::default()
+ })
+ .unwrap();
+ let gate: ActGate = Arc::new(|_| Ok(()));
+ McpCallTool::new(Arc::new(registry), gate)
+}
+
+#[tokio::test]
+async fn an_answered_bridge_call_carries_the_envelope_with_its_outcome() {
+ let fixture = ui_fixture::spawn().await;
+ let result = bridge_for(&fixture.endpoint, McpAuthConfig::None)
+ .execute(json!({ "server": "ui", "tool": "show_card", "arguments": {} }))
+ .await
+ .unwrap();
+
+ let envelope = envelope_of(&result);
+ assert_eq!(envelope.server, "ui");
+ assert_eq!(envelope.tool, "show_card");
+ assert_eq!(
+ envelope.structured_content,
+ Some(json!({ "temperature": 21 }))
+ );
+ assert_eq!(envelope.resources[0].uri, CARD_URI);
+ assert_eq!(
+ envelope.outcome,
+ Some(McpCallOutcome::answered("ui", "show_card"))
+ );
+ assert_eq!(
+ McpCallOutcome::from_metadata(result.metadata.as_ref().unwrap()),
+ Some(McpCallOutcome::answered("ui", "show_card"))
+ );
+ assert_eq!(result.output(), CALL_TEXT);
+ assert!(!result.output().contains("temperature"));
+}
+
+#[tokio::test]
+async fn secrets_are_scrubbed_from_the_envelope() {
+ const SECRET: &str = "envelope-Secret_42";
+ let fixture = ui_fixture::spawn().await;
+ let tool = bridge_for(
+ &fixture.endpoint,
+ McpAuthConfig::BearerToken {
+ token: SECRET.into(),
+ },
+ );
+
+ let result = tool
+ .execute(json!({ "server": "ui", "tool": "show_card", "arguments": {} }))
+ .await
+ .unwrap();
+ let mut envelope = envelope_of(&result);
+ envelope.structured_content = Some(json!({ "echo": SECRET }));
+ envelope.meta = Some(json!({ "echo": SECRET }));
+ envelope.resources[0].text = Some(format!("{SECRET}
"));
+ envelope.resources[0].meta = Some(json!({ "echo": SECRET }));
+
+ let scrubber = super::SecretScrubber::for_server(
+ &McpServerRegistry::from_config(&McpClientConfig {
+ servers: vec![McpServerConfig {
+ name: "ui".into(),
+ endpoint: fixture.endpoint.clone(),
+ auth: McpAuthConfig::BearerToken {
+ token: SECRET.into(),
+ },
+ ..McpServerConfig::default()
+ }],
+ ..McpClientConfig::default()
+ })
+ .unwrap(),
+ "ui",
+ );
+ let cleaned = super::bridge::scrubbed_envelope(&scrubber, envelope).to_metadata();
+
+ assert!(!cleaned.to_string().contains(SECRET), "{cleaned}");
+ assert_eq!(cleaned["kind"], MCP_RESULT_KIND);
+ assert_eq!(cleaned["outcome"]["ok"], true);
+}
diff --git a/crates/tinymcp/src/tools/mod.rs b/crates/tinymcp/src/tools/mod.rs
index 11b63643..8ea4f7ef 100644
--- a/crates/tinymcp/src/tools/mod.rs
+++ b/crates/tinymcp/src/tools/mod.rs
@@ -44,7 +44,7 @@ use std::sync::Arc;
pub use bridge::{ActGate, McpCallTool, McpListServersTool, McpListToolsTool};
pub use invoker::McpToolInvoker;
-pub use result::{MAX_LLM_BLOCK_BYTES, tool_result};
+pub use result::{MAX_LLM_BLOCK_BYTES, tool_result, tool_result_for};
pub use schema::tool_parameters;
pub use scrub::{REDACTED, SecretScrubber};
pub use source::{McpExposure, McpToolSource};
@@ -109,5 +109,8 @@ mod bridge_outcome_test;
#[path = "bridge_tests.rs"]
mod bridge_test;
#[cfg(test)]
+#[path = "envelope_tests.rs"]
+mod envelope_test;
+#[cfg(test)]
#[path = "scrub_tests.rs"]
mod scrub_test;
diff --git a/crates/tinymcp/src/tools/mod_tests.rs b/crates/tinymcp/src/tools/mod_tests.rs
index adc9a8ce..e5e79eb3 100644
--- a/crates/tinymcp/src/tools/mod_tests.rs
+++ b/crates/tinymcp/src/tools/mod_tests.rs
@@ -387,6 +387,7 @@ fn a_rendered_result_keeps_its_error_flag_and_markdown() {
],
is_error: true,
markdown_formatted: Some("**t**".into()),
+ ..McpToolResult::default()
});
assert!(result.is_error);
assert!(result.text().starts_with('t'));
@@ -633,6 +634,7 @@ fn oversized_text_json_and_markdown_results_are_elided() {
],
is_error: false,
markdown_formatted: Some("m".repeat(super::MAX_LLM_BLOCK_BYTES + 1)),
+ ..McpToolResult::default()
});
let tinytools::ToolContent::Text { text } = &result.content[0] else {
@@ -672,6 +674,7 @@ impl McpToolInvoker for Recording {
content: vec![McpToolContent::Text { text: "ok".into() }],
is_error: false,
markdown_formatted: None,
+ ..McpToolResult::default()
})
}
}
diff --git a/crates/tinymcp/src/tools/result.rs b/crates/tinymcp/src/tools/result.rs
index 6d4b38de..a034c797 100644
--- a/crates/tinymcp/src/tools/result.rs
+++ b/crates/tinymcp/src/tools/result.rs
@@ -1,7 +1,7 @@
//! Converting a rendered MCP result into a `tinytools` result.
use serde_json::{Value, json};
-use tinymcp_bus::{McpToolContent, McpToolResult};
+use tinymcp_bus::{McpResultEnvelope, McpToolContent, McpToolResult};
use tinytools::{ToolContent, ToolResult};
/// The most a content block may serialize to before its payload is elided.
@@ -39,6 +39,21 @@ pub fn tool_result(result: McpToolResult) -> ToolResult {
}
}
+/// Maps a rendered MCP result onto [`ToolResult`], as [`tool_result`] does,
+/// and attaches an [`McpResultEnvelope`] for `tool` on `server` as its
+/// host-only metadata.
+///
+/// The model-facing content is exactly what [`tool_result`] produces; the
+/// envelope carries the reply's `structuredContent`, `_meta` and embedded
+/// resources for a host that renders tool UI.
+#[must_use]
+pub fn tool_result_for(server: &str, tool: &str, result: McpToolResult) -> ToolResult {
+ let envelope = McpResultEnvelope::new(server, tool, &result);
+ let mut mapped = tool_result(result);
+ mapped.metadata = Some(envelope.to_metadata());
+ mapped
+}
+
/// An unrecognized block, carried as bounded JSON.
pub(crate) fn passthrough(block: &McpToolContent) -> ToolContent {
ToolContent::Json {
diff --git a/crates/tinymcp/src/tools/tool.rs b/crates/tinymcp/src/tools/tool.rs
index c251bfad..37babf4a 100644
--- a/crates/tinymcp/src/tools/tool.rs
+++ b/crates/tinymcp/src/tools/tool.rs
@@ -10,7 +10,7 @@ use tinytools::{PermissionLevel, Tool, ToolCategory, ToolExposure, ToolResult};
use super::invoker::McpToolInvoker;
use super::naming::legacy_tool_name;
-use super::result::tool_result;
+use super::result::tool_result_for;
use super::schema::tool_parameters;
use super::source::McpToolSource;
@@ -184,7 +184,7 @@ impl Tool for McpServerTool {
.invoke(&self.server_id, &self.remote_name, arguments)
.await
{
- Ok(result) => Ok(tool_result(result)),
+ Ok(result) => Ok(tool_result_for(&self.server_id, &self.remote_name, result)),
// A server that is gone or refuses is an answer the model can act
// on, not a crash of the turn.
Err(error) => {
diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs
index f8c88b4b..d02b1378 100644
--- a/crates/tinymcp/src/transport/http/mod.rs
+++ b/crates/tinymcp/src/transport/http/mod.rs
@@ -1,7 +1,8 @@
//! The Streamable HTTP transport.
//!
//! [`McpHttpClient`] speaks MCP over HTTP: the `initialize` handshake and
-//! protocol-version negotiation, `tools/list` and `tools/call`, server-sent
+//! protocol-version negotiation, `tools/list` and `tools/call`,
+//! `resources/list` and `resources/read`, server-sent
//! event draining, session lifecycle through `Mcp-Session-Id`, OAuth discovery
//! from a `WWW-Authenticate` challenge or the server's well-known metadata, and
//! a graceful `DELETE` on close.
@@ -48,6 +49,9 @@ use serde_json::{Value, json};
use std::collections::HashMap;
use crate::error::{Error, Result};
+use crate::transport::resources::{
+ MAX_RESOURCE_PAGES, list_params, parse_read_result, parse_resource_page,
+};
use crate::transport::{redact_endpoint, render_tool_result, validate_protocol_version};
use discovery::{DISCOVERY_BUDGET, WellKnownOutcome};
use headers::{
@@ -57,8 +61,8 @@ use sse::{first_complete_sse_data, parse_sse_events, parse_sse_message};
use tinymcp_bus::{
AuthorizationServerMetadata, HEADER_PROTOCOL_VERSION, HEADER_SESSION_ID,
LATEST_PROTOCOL_VERSION, McpAuthConfig, McpAuthorizationContext, McpClientIdentityConfig,
- McpClientInfo, McpInitializeResult, McpProxyConfig, McpRemoteTool, McpServerToolResult,
- McpSseEvent, ProtectedResourceMetadata,
+ McpClientInfo, McpInitializeResult, McpProxyConfig, McpRemoteTool, McpResource,
+ McpResourceContents, McpServerToolResult, McpSseEvent, ProtectedResourceMetadata,
};
/// The `Mcp-Method` request header, which some servers route on.
@@ -82,6 +86,7 @@ pub struct McpHttpClient {
http: reqwest::Client,
next_id: AtomicI64,
client_info: McpClientInfo,
+ capabilities: Value,
auth: McpAuthConfig,
state: Mutex,
discovery_http: reqwest::Client,
@@ -157,6 +162,14 @@ impl McpHttpClientBuilder {
self
}
+ /// Sets the capabilities sent in `initialize`, replacing those the
+ /// identity carries.
+ #[must_use]
+ pub fn capabilities(mut self, capabilities: Value) -> Self {
+ self.identity.capabilities = capabilities;
+ self
+ }
+
/// Routes outbound requests through a proxy the host already resolved.
#[must_use]
pub fn proxy(mut self, proxy: Option) -> Self {
@@ -209,6 +222,7 @@ impl McpHttpClientBuilder {
http,
next_id: AtomicI64::new(1),
client_info: McpClientInfo::from(&self.identity),
+ capabilities: self.identity.capabilities,
auth: self.auth,
state: Mutex::new(SessionState::default()),
discovery_http,
@@ -381,7 +395,7 @@ impl McpHttpClient {
"method": "initialize",
"params": {
"protocolVersion": LATEST_PROTOCOL_VERSION,
- "capabilities": {},
+ "capabilities": self.capabilities,
"clientInfo": self.client_info,
},
});
@@ -517,6 +531,80 @@ impl McpHttpClient {
Ok(McpServerToolResult::new(result, rendered))
}
+ /// A tool from the last listing, without a round trip.
+ ///
+ /// `None` when the tool was not in it or nothing has been listed yet.
+ #[must_use]
+ pub fn cached_tool(&self, name: &str) -> Option {
+ self.state.lock().cached_tools.get(name).cloned()
+ }
+
+ /// Lists the resources the server advertises, following pagination.
+ ///
+ /// Stops after a bounded number of pages, so a server that never stops
+ /// handing out cursors cannot hold the caller forever.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::MalformedResponse`] when a page has no `resources`
+ /// member, plus anything [`Self::initialize`] can return.
+ pub async fn list_resources(&self) -> Result> {
+ self.initialize().await?;
+
+ let mut resources = Vec::new();
+ let mut cursor: Option = None;
+ for _ in 0..MAX_RESOURCE_PAGES {
+ let result = self
+ .send_jsonrpc(
+ "resources/list",
+ list_params(cursor.as_deref()),
+ RequestOptions::standard("resources/list", None, Vec::new()),
+ )
+ .await?
+ .result;
+ let (page, next) = parse_resource_page(&result)?;
+ resources.extend(page);
+ match next {
+ Some(next) => cursor = Some(next),
+ None => break,
+ }
+ }
+ tracing::debug!(
+ endpoint = %redact_endpoint(&self.endpoint),
+ resources = resources.len(),
+ "listed resources"
+ );
+ Ok(resources)
+ }
+
+ /// Reads one resource's contents, verbatim.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::ResourceTooLarge`] when the contents exceed
+ /// [`tinymcp_bus::MAX_RESOURCE_BYTES`], [`Error::MalformedResponse`] when
+ /// the reply has no `contents`, plus anything [`Self::initialize`] can
+ /// return.
+ pub async fn read_resource(&self, uri: &str) -> Result> {
+ self.initialize().await?;
+
+ let result = self
+ .send_jsonrpc(
+ "resources/read",
+ json!({ "uri": uri }),
+ RequestOptions::standard("resources/read", None, Vec::new()),
+ )
+ .await?
+ .result;
+ let contents = parse_read_result(uri, &result)?;
+ tracing::debug!(
+ endpoint = %redact_endpoint(&self.endpoint),
+ parts = contents.len(),
+ "read a resource"
+ );
+ Ok(contents)
+ }
+
/// Discovers how to authorize to this server, if it demands authorization.
///
/// Sends an unauthenticated `initialize` and reads the `WWW-Authenticate`
@@ -557,7 +645,7 @@ impl McpHttpClient {
"method": "initialize",
"params": {
"protocolVersion": LATEST_PROTOCOL_VERSION,
- "capabilities": {},
+ "capabilities": self.capabilities,
"clientInfo": self.client_info,
},
});
@@ -1157,3 +1245,7 @@ fn fill_missing_metadata(
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
+
+#[cfg(test)]
+#[path = "mod_ui_tests.rs"]
+mod ui_test;
diff --git a/crates/tinymcp/src/transport/http/mod_ui_tests.rs b/crates/tinymcp/src/transport/http/mod_ui_tests.rs
new file mode 100644
index 00000000..90a0b4c4
--- /dev/null
+++ b/crates/tinymcp/src/transport/http/mod_ui_tests.rs
@@ -0,0 +1,174 @@
+//! Tests for the HTTP transport's MCP Apps surface: client capabilities at
+//! `initialize`, tool `_meta` kept from `tools/list`, and resources.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::json;
+use tinymcp_bus::{MAX_RESOURCE_BYTES, McpClientIdentityConfig};
+
+use super::McpHttpClient;
+use crate::Error;
+use crate::transport::ui_fixture::{
+ self, APP_MIME, BIG_URI, CALL_TEXT, CARD_HTML, CARD_URI, show_card_meta,
+};
+
+#[tokio::test]
+async fn initialize_sends_empty_capabilities_by_default() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ client.initialize().await.unwrap();
+
+ let params = fixture.initialize_params.lock().unwrap().clone();
+ assert_eq!(params.len(), 1);
+ assert_eq!(params[0]["capabilities"], json!({}));
+}
+
+#[tokio::test]
+async fn initialize_sends_the_capabilities_the_identity_carries() {
+ let fixture = ui_fixture::spawn().await;
+ let capabilities = json!({
+ "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": [APP_MIME] } }
+ });
+ let identity = McpClientIdentityConfig {
+ capabilities: capabilities.clone(),
+ ..McpClientIdentityConfig::default()
+ };
+ let client = McpHttpClient::builder(&fixture.endpoint)
+ .identity(identity)
+ .build()
+ .unwrap();
+
+ client.initialize().await.unwrap();
+
+ assert_eq!(
+ fixture.initialize_params.lock().unwrap()[0]["capabilities"],
+ capabilities
+ );
+}
+
+#[tokio::test]
+async fn the_builder_setter_overrides_the_identity_capabilities() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::builder(&fixture.endpoint)
+ .capabilities(json!({ "extensions": { "x": {} } }))
+ .build()
+ .unwrap();
+
+ client.initialize().await.unwrap();
+
+ assert_eq!(
+ fixture.initialize_params.lock().unwrap()[0]["capabilities"],
+ json!({ "extensions": { "x": {} } })
+ );
+}
+
+#[tokio::test]
+async fn discovery_sends_the_configured_capabilities_too() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::builder(&fixture.endpoint)
+ .capabilities(json!({ "extensions": { "x": {} } }))
+ .build()
+ .unwrap();
+
+ assert!(client.discover_authorization().await.unwrap().is_none());
+
+ assert_eq!(
+ fixture.initialize_params.lock().unwrap()[0]["capabilities"],
+ json!({ "extensions": { "x": {} } })
+ );
+}
+
+#[tokio::test]
+async fn tools_list_keeps_each_tools_meta_and_caches_it() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ assert!(client.cached_tool("show_card").is_none());
+ let tools = client.list_tools().await.unwrap();
+
+ let card = tools.iter().find(|tool| tool.name == "show_card").unwrap();
+ assert_eq!(card.meta, Some(show_card_meta()));
+ let plain = tools.iter().find(|tool| tool.name == "plain").unwrap();
+ assert_eq!(plain.meta, None);
+ assert_eq!(
+ client.cached_tool("show_card").unwrap().meta,
+ Some(show_card_meta())
+ );
+ assert!(client.cached_tool("missing").is_none());
+}
+
+#[tokio::test]
+async fn resources_are_listed_across_pages() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ let resources = client.list_resources().await.unwrap();
+
+ let uris: Vec<&str> = resources.iter().map(|r| r.uri.as_str()).collect();
+ assert_eq!(uris, [CARD_URI, "ui://other"]);
+ assert_eq!(resources[0].mime_type.as_deref(), Some(APP_MIME));
+}
+
+#[tokio::test]
+async fn a_resource_is_read_verbatim() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ let contents = client.read_resource(CARD_URI).await.unwrap();
+
+ assert_eq!(contents.len(), 1);
+ assert_eq!(contents[0].uri, CARD_URI);
+ assert_eq!(contents[0].text.as_deref(), Some(CARD_HTML));
+ assert_eq!(contents[0].mime_type.as_deref(), Some(APP_MIME));
+}
+
+#[tokio::test]
+async fn an_oversized_resource_is_refused() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ let error = client.read_resource(BIG_URI).await.unwrap_err();
+
+ match error {
+ Error::ResourceTooLarge { uri, bytes, limit } => {
+ assert_eq!(uri, BIG_URI);
+ assert_eq!(bytes, MAX_RESOURCE_BYTES + 1);
+ assert_eq!(limit, MAX_RESOURCE_BYTES);
+ }
+ other => panic!("expected ResourceTooLarge, got {other:?}"),
+ }
+}
+
+#[tokio::test]
+async fn an_unknown_resource_is_an_rpc_error() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ let error = client.read_resource("ui://nope").await.unwrap_err();
+
+ assert!(matches!(error, Error::Rpc { .. }), "{error:?}");
+}
+
+#[tokio::test]
+async fn a_tool_call_keeps_structured_content_meta_and_resources() {
+ let fixture = ui_fixture::spawn().await;
+ let client = McpHttpClient::new(&fixture.endpoint, 5).unwrap();
+
+ let result = client.call_tool("show_card", json!({})).await.unwrap();
+
+ assert_eq!(result.rendered.text(), CALL_TEXT);
+ assert_eq!(
+ result.rendered.structured_content,
+ Some(json!({ "temperature": 21 }))
+ );
+ assert_eq!(
+ result.rendered.meta,
+ Some(json!({ "openai/outputTemplate": CARD_URI }))
+ );
+ assert_eq!(result.rendered.resources.len(), 1);
+ assert_eq!(
+ result.rendered.resources[0].text.as_deref(),
+ Some(CARD_HTML)
+ );
+}
diff --git a/crates/tinymcp/src/transport/mod.rs b/crates/tinymcp/src/transport/mod.rs
index 0cdebfb2..d5d4cd18 100644
--- a/crates/tinymcp/src/transport/mod.rs
+++ b/crates/tinymcp/src/transport/mod.rs
@@ -18,6 +18,9 @@
//! logs, telemetry, and user interfaces alike.
mod render;
+mod resources;
+#[cfg(test)]
+pub(crate) mod ui_fixture;
pub use render::{redact_endpoint, render_tool_result};
pub mod http;
diff --git a/crates/tinymcp/src/transport/render.rs b/crates/tinymcp/src/transport/render.rs
index 515c02d6..5e82b4e8 100644
--- a/crates/tinymcp/src/transport/render.rs
+++ b/crates/tinymcp/src/transport/render.rs
@@ -7,7 +7,7 @@
use serde_json::Value;
-use tinymcp_bus::McpToolResult;
+use tinymcp_bus::{MAX_RESOURCE_BYTES, McpResourceContents, McpToolResult};
/// Reduces an endpoint to scheme and authority, or ``.
///
@@ -60,6 +60,11 @@ pub fn redact_endpoint(raw: &str) -> String {
/// call succeeded and the tool said no, and a caller that conflates the two
/// reports a network problem for a bad argument.
///
+/// `structuredContent`, `_meta`, and embedded `resource` blocks are copied
+/// into the host-facing fields of [`McpToolResult`]. They never change the
+/// rendered text. An embedded resource above [`MAX_RESOURCE_BYTES`] is
+/// dropped.
+///
/// # Examples
///
/// ```
@@ -92,9 +97,47 @@ pub fn render_tool_result(result: &Value) -> McpToolResult {
rendered = result.to_string();
}
- if is_error {
+ let mut rendered = if is_error {
McpToolResult::error(rendered)
} else {
McpToolResult::success(rendered)
- }
+ };
+ rendered.structured_content = result
+ .get("structuredContent")
+ .filter(|value| !value.is_null())
+ .cloned();
+ rendered.meta = result
+ .get("_meta")
+ .filter(|value| !value.is_null())
+ .cloned();
+ rendered.resources = embedded_resources(result);
+ rendered
+}
+
+/// The `resource` content blocks of a reply, each within
+/// [`MAX_RESOURCE_BYTES`].
+fn embedded_resources(result: &Value) -> Vec {
+ let Some(content) = result.get("content").and_then(Value::as_array) else {
+ return Vec::new();
+ };
+ content
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("resource"))
+ .filter_map(|block| block.get("resource"))
+ .filter_map(|resource| serde_json::from_value::(resource.clone()).ok())
+ .filter(|resource| {
+ let fits = resource.byte_len() <= MAX_RESOURCE_BYTES;
+ if !fits {
+ tracing::debug!(
+ bytes = resource.byte_len(),
+ "dropped an embedded resource above the size cap"
+ );
+ }
+ fits
+ })
+ .collect()
}
+
+#[cfg(test)]
+#[path = "render_tests.rs"]
+mod test;
diff --git a/crates/tinymcp/src/transport/render_tests.rs b/crates/tinymcp/src/transport/render_tests.rs
new file mode 100644
index 00000000..4cd17fa1
--- /dev/null
+++ b/crates/tinymcp/src/transport/render_tests.rs
@@ -0,0 +1,91 @@
+//! Unit tests for rendering a tool reply's host-facing parts.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::{Value, json};
+use tinymcp_bus::MAX_RESOURCE_BYTES;
+
+use super::render_tool_result;
+
+/// The same reply with every host-facing part stripped.
+fn without_host_parts(reply: &Value) -> Value {
+ let mut stripped = reply.clone();
+ let object = stripped.as_object_mut().unwrap();
+ object.remove("structuredContent");
+ object.remove("_meta");
+ stripped
+}
+
+#[test]
+fn structured_content_meta_and_resources_are_kept() {
+ let reply = crate::transport::ui_fixture::call_reply();
+
+ let rendered = render_tool_result(&reply);
+
+ assert_eq!(
+ rendered.structured_content,
+ Some(json!({ "temperature": 21 }))
+ );
+ assert_eq!(rendered.meta, reply.get("_meta").cloned());
+ assert_eq!(rendered.resources.len(), 1);
+ assert_eq!(rendered.resources[0].uri, "ui://card");
+}
+
+#[test]
+fn the_rendered_text_is_byte_identical_with_or_without_host_parts() {
+ let reply = crate::transport::ui_fixture::call_reply();
+
+ let rich = render_tool_result(&reply);
+ let plain = render_tool_result(&without_host_parts(&reply));
+
+ assert_eq!(rich.content, plain.content);
+ assert_eq!(rich.text(), plain.text());
+ assert_eq!(rich.output(), plain.output());
+ assert_eq!(rich.output_for_llm(true), plain.output_for_llm(true));
+ assert_eq!(rich.text(), crate::transport::ui_fixture::CALL_TEXT);
+}
+
+#[test]
+fn a_reply_without_host_parts_leaves_them_empty() {
+ let rendered = render_tool_result(&json!({
+ "content": [{ "type": "text", "text": "hi" }],
+ "structuredContent": null,
+ }));
+
+ assert_eq!(rendered.structured_content, None);
+ assert_eq!(rendered.meta, None);
+ assert_eq!(rendered.resources.len(), 0);
+ assert_eq!(
+ serde_json::to_value(&rendered).unwrap(),
+ json!({ "content": [{ "type": "text", "text": "hi" }], "is_error": false })
+ );
+}
+
+#[test]
+fn an_oversized_or_undecodable_embedded_resource_is_dropped() {
+ let rendered = render_tool_result(&json!({
+ "content": [
+ { "type": "text", "text": "hi" },
+ { "type": "resource", "resource": { "uri": "ui://big", "text": "a".repeat(MAX_RESOURCE_BYTES + 1) } },
+ { "type": "resource", "resource": { "text": "no uri" } },
+ { "type": "resource" },
+ { "type": "resource", "resource": { "uri": "ui://ok", "blob": "AA==" } },
+ ],
+ }));
+
+ assert_eq!(rendered.text(), "hi");
+ assert_eq!(rendered.resources.len(), 1);
+ assert_eq!(rendered.resources[0].uri, "ui://ok");
+}
+
+#[test]
+fn an_error_reply_keeps_its_structured_content() {
+ let rendered = render_tool_result(&json!({
+ "content": [{ "type": "text", "text": "nope" }],
+ "isError": true,
+ "structuredContent": { "code": 7 },
+ }));
+
+ assert!(rendered.is_error);
+ assert_eq!(rendered.structured_content, Some(json!({ "code": 7 })));
+}
diff --git a/crates/tinymcp/src/transport/resources.rs b/crates/tinymcp/src/transport/resources.rs
new file mode 100644
index 00000000..369f4cea
--- /dev/null
+++ b/crates/tinymcp/src/transport/resources.rs
@@ -0,0 +1,63 @@
+//! Decoding `resources/list` and `resources/read` replies.
+//!
+//! Shared by both transports so a server reached over HTTP and one reached
+//! over stdio are read by the same rules, including the size cap.
+
+use serde_json::Value;
+use tinymcp_bus::{MAX_RESOURCE_BYTES, McpResource, McpResourceContents};
+
+use crate::error::{Error, Result};
+
+/// The most `resources/list` pages a listing follows before stopping.
+pub(crate) const MAX_RESOURCE_PAGES: usize = 32;
+
+/// One `resources/list` page: its resources and the cursor for the next.
+pub(crate) fn parse_resource_page(result: &Value) -> Result<(Vec, Option)> {
+ let resources = result
+ .get("resources")
+ .ok_or_else(|| Error::malformed("resources/list reply has no `resources` member"))?;
+ let resources: Vec = serde_json::from_value(resources.clone())
+ .map_err(|error| Error::malformed(format!("resources/list entries: {error}")))?;
+ let next = result
+ .get("nextCursor")
+ .and_then(Value::as_str)
+ .filter(|cursor| !cursor.is_empty())
+ .map(ToString::to_string);
+ Ok((resources, next))
+}
+
+/// The contents of a `resources/read` reply for `uri`.
+///
+/// # Errors
+///
+/// [`Error::MalformedResponse`] when the reply has no `contents` array, and
+/// [`Error::ResourceTooLarge`] when the contents together exceed
+/// [`MAX_RESOURCE_BYTES`].
+pub(crate) fn parse_read_result(uri: &str, result: &Value) -> Result> {
+ let contents = result
+ .get("contents")
+ .ok_or_else(|| Error::malformed("resources/read reply has no `contents` member"))?;
+ let contents: Vec = serde_json::from_value(contents.clone())
+ .map_err(|error| Error::malformed(format!("resources/read contents: {error}")))?;
+ let bytes: usize = contents.iter().map(McpResourceContents::byte_len).sum();
+ if bytes > MAX_RESOURCE_BYTES {
+ return Err(Error::ResourceTooLarge {
+ uri: uri.to_string(),
+ bytes,
+ limit: MAX_RESOURCE_BYTES,
+ });
+ }
+ Ok(contents)
+}
+
+/// The params of a `resources/list` request for `cursor`.
+pub(crate) fn list_params(cursor: Option<&str>) -> Value {
+ cursor.map_or_else(
+ || serde_json::json!({}),
+ |cursor| serde_json::json!({ "cursor": cursor }),
+ )
+}
+
+#[cfg(test)]
+#[path = "resources_tests.rs"]
+mod test;
diff --git a/crates/tinymcp/src/transport/resources_tests.rs b/crates/tinymcp/src/transport/resources_tests.rs
new file mode 100644
index 00000000..6e39510e
--- /dev/null
+++ b/crates/tinymcp/src/transport/resources_tests.rs
@@ -0,0 +1,107 @@
+//! Unit tests for decoding resource replies.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use serde_json::json;
+use tinymcp_bus::MAX_RESOURCE_BYTES;
+
+use super::{list_params, parse_read_result, parse_resource_page};
+use crate::Error;
+
+#[test]
+fn a_page_yields_its_resources_and_cursor() {
+ let (resources, next) = parse_resource_page(&json!({
+ "resources": [{ "uri": "ui://a", "name": "a", "_meta": { "k": 1 } }],
+ "nextCursor": "c2",
+ }))
+ .unwrap();
+
+ assert_eq!(resources.len(), 1);
+ assert_eq!(resources[0].meta, Some(json!({ "k": 1 })));
+ assert_eq!(next.as_deref(), Some("c2"));
+}
+
+#[test]
+fn an_empty_cursor_ends_the_listing() {
+ let (_, next) = parse_resource_page(&json!({ "resources": [], "nextCursor": "" })).unwrap();
+ assert_eq!(next, None);
+}
+
+#[test]
+fn a_page_without_resources_is_malformed() {
+ let error = parse_resource_page(&json!({})).unwrap_err();
+ assert!(
+ matches!(error, Error::MalformedResponse { .. }),
+ "{error:?}"
+ );
+}
+
+#[test]
+fn a_page_with_undecodable_entries_is_malformed() {
+ let error = parse_resource_page(&json!({ "resources": [{ "name": 1 }] })).unwrap_err();
+ assert!(
+ matches!(error, Error::MalformedResponse { .. }),
+ "{error:?}"
+ );
+}
+
+#[test]
+fn read_contents_are_kept_verbatim() {
+ let contents = parse_read_result(
+ "ui://a",
+ &json!({ "contents": [
+ { "uri": "ui://a", "mimeType": "text/html", "text": "" },
+ { "uri": "ui://a", "blob": "AAEC" },
+ ] }),
+ )
+ .unwrap();
+
+ assert_eq!(contents[0].text.as_deref(), Some("
"));
+ assert_eq!(contents[1].blob.as_deref(), Some("AAEC"));
+}
+
+#[test]
+fn read_contents_at_the_cap_are_accepted() {
+ let text = "a".repeat(MAX_RESOURCE_BYTES);
+ let contents = parse_read_result(
+ "ui://a",
+ &json!({ "contents": [{ "uri": "ui://a", "text": text }] }),
+ )
+ .unwrap();
+ assert_eq!(contents[0].byte_len(), MAX_RESOURCE_BYTES);
+}
+
+#[test]
+fn read_contents_above_the_cap_together_are_refused() {
+ let half = "a".repeat(MAX_RESOURCE_BYTES / 2 + 1);
+ let error = parse_read_result(
+ "ui://a",
+ &json!({ "contents": [
+ { "uri": "ui://a", "text": half },
+ { "uri": "ui://a", "blob": half },
+ ] }),
+ )
+ .unwrap_err();
+
+ assert!(matches!(error, Error::ResourceTooLarge { .. }), "{error:?}");
+}
+
+#[test]
+fn a_read_reply_without_contents_is_malformed() {
+ let error = parse_read_result("ui://a", &json!({})).unwrap_err();
+ assert!(
+ matches!(error, Error::MalformedResponse { .. }),
+ "{error:?}"
+ );
+ let error = parse_read_result("ui://a", &json!({ "contents": 3 })).unwrap_err();
+ assert!(
+ matches!(error, Error::MalformedResponse { .. }),
+ "{error:?}"
+ );
+}
+
+#[test]
+fn list_params_carry_the_cursor_only_when_there_is_one() {
+ assert_eq!(list_params(None), json!({}));
+ assert_eq!(list_params(Some("c")), json!({ "cursor": "c" }));
+}
diff --git a/crates/tinymcp/src/transport/stdio/mod.rs b/crates/tinymcp/src/transport/stdio/mod.rs
index 4c4986c1..66adcc0a 100644
--- a/crates/tinymcp/src/transport/stdio/mod.rs
+++ b/crates/tinymcp/src/transport/stdio/mod.rs
@@ -2,7 +2,8 @@
//!
//! [`McpStdioClient`] spawns a server as a child process and speaks
//! newline-delimited JSON-RPC over its standard input and output, per the MCP
-//! stdio transport.
+//! stdio transport: `initialize`, `tools/list`, `tools/call`,
+//! `resources/list` and `resources/read`.
//!
//! # One session, one child
//!
@@ -27,6 +28,7 @@
//! that is not JSON is skipped with a debug log rather than treated as a
//! failure, because servers print to it anyway.
+use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicI64, Ordering};
@@ -38,10 +40,13 @@ use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
use crate::error::{Error, Result};
+use crate::transport::resources::{
+ MAX_RESOURCE_PAGES, list_params, parse_read_result, parse_resource_page,
+};
use crate::transport::{render_tool_result, validate_protocol_version};
use tinymcp_bus::{
LATEST_PROTOCOL_VERSION, McpClientIdentityConfig, McpClientInfo, McpInitializeResult,
- McpRemoteTool, McpServerToolResult,
+ McpRemoteTool, McpResource, McpResourceContents, McpServerToolResult,
};
pub mod spawn_env;
@@ -55,7 +60,9 @@ pub struct McpStdioClient {
cwd: Option,
next_id: AtomicI64,
client_info: McpClientInfo,
+ capabilities: Value,
state: Mutex>,
+ cached_tools: parking_lot::Mutex>,
}
/// A running child and the pipes to it.
@@ -86,10 +93,20 @@ impl McpStdioClient {
cwd,
next_id: AtomicI64::new(1),
client_info: McpClientInfo::from(identity),
+ capabilities: identity.capabilities.clone(),
state: Mutex::new(None),
+ cached_tools: parking_lot::Mutex::new(HashMap::new()),
}
}
+ /// The same client sending `capabilities` in `initialize`, replacing
+ /// those the identity carried.
+ #[must_use]
+ pub fn with_capabilities(mut self, capabilities: Value) -> Self {
+ self.capabilities = capabilities;
+ self
+ }
+
/// The command this client spawns.
#[must_use]
pub fn command(&self) -> &str {
@@ -206,7 +223,7 @@ impl McpStdioClient {
"initialize",
json!({
"protocolVersion": LATEST_PROTOCOL_VERSION,
- "capabilities": {},
+ "capabilities": self.capabilities,
"clientInfo": self.client_info,
}),
)
@@ -255,8 +272,75 @@ impl McpStdioClient {
.get("tools")
.ok_or_else(|| Error::malformed("stdio tools/list reply has no `tools` member"))?;
- serde_json::from_value(tools.clone())
- .map_err(|error| Error::malformed(format!("stdio tools/list entries: {error}")))
+ let tools: Vec = serde_json::from_value(tools.clone())
+ .map_err(|error| Error::malformed(format!("stdio tools/list entries: {error}")))?;
+ *self.cached_tools.lock() = tools
+ .iter()
+ .map(|tool| (tool.name.clone(), tool.clone()))
+ .collect();
+ Ok(tools)
+ }
+
+ /// A tool from the last listing, without a round trip.
+ ///
+ /// `None` when the tool was not in it or nothing has been listed yet.
+ #[must_use]
+ pub fn cached_tool(&self, name: &str) -> Option {
+ self.cached_tools.lock().get(name).cloned()
+ }
+
+ /// Lists the resources the server advertises, following pagination up to
+ /// a bounded number of pages.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::MalformedResponse`] when a page has no `resources`
+ /// member, plus anything [`Self::initialize`] can return.
+ pub async fn list_resources(&self) -> Result> {
+ self.initialize().await?;
+
+ let mut state = self.state.lock().await;
+ let session = state
+ .as_mut()
+ .ok_or_else(|| Error::malformed("the stdio session is not initialized"))?;
+
+ let mut resources = Vec::new();
+ let mut cursor: Option = None;
+ for _ in 0..MAX_RESOURCE_PAGES {
+ let response = self
+ .request(session, "resources/list", list_params(cursor.as_deref()))
+ .await?;
+ let (page, next) = parse_resource_page(&response)?;
+ resources.extend(page);
+ match next {
+ Some(next) => cursor = Some(next),
+ None => break,
+ }
+ }
+ tracing::debug!(command = %self.command, resources = resources.len(), "listed resources");
+ Ok(resources)
+ }
+
+ /// Reads one resource's contents, verbatim.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::ResourceTooLarge`] when the contents exceed
+ /// [`tinymcp_bus::MAX_RESOURCE_BYTES`], [`Error::MalformedResponse`] when
+ /// the reply has no `contents`, plus anything [`Self::initialize`] can
+ /// return.
+ pub async fn read_resource(&self, uri: &str) -> Result> {
+ self.initialize().await?;
+
+ let mut state = self.state.lock().await;
+ let session = state
+ .as_mut()
+ .ok_or_else(|| Error::malformed("the stdio session is not initialized"))?;
+
+ let response = self
+ .request(session, "resources/read", json!({ "uri": uri }))
+ .await?;
+ parse_read_result(uri, &response)
}
/// Calls `name` with `arguments`.
@@ -428,3 +512,7 @@ impl McpStdioClient {
#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
+
+#[cfg(test)]
+#[path = "mod_ui_tests.rs"]
+mod ui_test;
diff --git a/crates/tinymcp/src/transport/stdio/mod_ui_tests.rs b/crates/tinymcp/src/transport/stdio/mod_ui_tests.rs
new file mode 100644
index 00000000..5f5bec42
--- /dev/null
+++ b/crates/tinymcp/src/transport/stdio/mod_ui_tests.rs
@@ -0,0 +1,173 @@
+//! Tests for the subprocess transport's MCP Apps surface: client capabilities
+//! at `initialize`, tool `_meta` kept from `tools/list`, and resources.
+//!
+//! Unix-only for the reason the main suite gives: the fake server is a shell
+//! script.
+
+#![cfg(unix)]
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use std::fmt::Write as _;
+
+use serde_json::json;
+use tinymcp_bus::{LATEST_PROTOCOL_VERSION, McpClientIdentityConfig};
+
+use super::McpStdioClient;
+use crate::Error;
+
+fn initialize_reply() -> String {
+ format!(
+ r#"{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"{LATEST_PROTOCOL_VERSION}","capabilities":{{}},"serverInfo":{{"name":"fake","version":"1"}}}}}}"#
+ )
+}
+
+/// A server answering each request in order, one JSON line per reply.
+fn responder(replies: &[&str]) -> String {
+ let mut body = String::new();
+ for reply in replies {
+ body.push_str("read -r _line\n");
+ let _ = writeln!(body, "printf '%s\\n' '{reply}'");
+ }
+ body.push_str("cat > /dev/null\n");
+ body
+}
+
+/// A server that completes the handshake only when the `initialize` line
+/// contains `expected`, and answers an RPC error otherwise.
+fn capability_checker(expected: &str) -> String {
+ let error =
+ r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"unexpected capabilities"}}"#;
+ format!(
+ "read -r line\ncase \"$line\" in *'{expected}'*) printf '%s\\n' '{}' ;; *) printf '%s\\n' '{error}' ;; esac\ncat > /dev/null\n",
+ initialize_reply()
+ )
+}
+
+fn client_with(body: String, identity: &McpClientIdentityConfig) -> McpStdioClient {
+ McpStdioClient::new(
+ "/bin/sh",
+ vec!["-c".to_string(), body],
+ Vec::new(),
+ None,
+ identity,
+ )
+}
+
+fn client_for(body: String) -> McpStdioClient {
+ client_with(body, &McpClientIdentityConfig::default())
+}
+
+#[tokio::test]
+async fn initialize_sends_empty_capabilities_by_default() {
+ let client = client_for(capability_checker(r#""capabilities":{}"#));
+
+ client.initialize().await.expect("the default capabilities");
+}
+
+#[tokio::test]
+async fn initialize_sends_the_capabilities_the_identity_carries() {
+ let identity = McpClientIdentityConfig {
+ capabilities: json!({ "extensions": { "x": {} } }),
+ ..McpClientIdentityConfig::default()
+ };
+ let client = client_with(
+ capability_checker(r#""capabilities":{"extensions":{"x":{}}}"#),
+ &identity,
+ );
+
+ client
+ .initialize()
+ .await
+ .expect("the configured capabilities");
+}
+
+#[tokio::test]
+async fn with_capabilities_overrides_the_identity() {
+ let client = client_for(capability_checker(
+ r#""capabilities":{"extensions":{"y":{}}}"#,
+ ))
+ .with_capabilities(json!({ "extensions": { "y": {} } }));
+
+ client
+ .initialize()
+ .await
+ .expect("the overriding capabilities");
+}
+
+#[tokio::test]
+async fn capabilities_the_server_did_not_expect_fail_the_check() {
+ let client = client_for(capability_checker(r#""capabilities":{"extensions""#));
+
+ let error = client.initialize().await.unwrap_err();
+
+ assert!(matches!(error, Error::Rpc { .. }), "{error:?}");
+}
+
+#[tokio::test]
+async fn tools_list_keeps_meta_and_caches_it() {
+ let tools = r#"{"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"show_card","_meta":{"ui":{"resourceUri":"ui://card"}}},{"name":"plain"}]}}"#;
+ let client = client_for(responder(&[&initialize_reply(), "", tools]));
+
+ assert!(client.cached_tool("show_card").is_none());
+ let listed = client.list_tools().await.unwrap();
+
+ assert_eq!(
+ listed[0].meta,
+ Some(json!({ "ui": { "resourceUri": "ui://card" } }))
+ );
+ assert_eq!(
+ client.cached_tool("show_card").unwrap().meta,
+ Some(json!({ "ui": { "resourceUri": "ui://card" } }))
+ );
+ assert_eq!(client.cached_tool("plain").unwrap().meta, None);
+}
+
+#[tokio::test]
+async fn resources_are_listed_across_pages() {
+ let first = r#"{"jsonrpc":"2.0","id":3,"result":{"resources":[{"uri":"ui://a","name":"a"}],"nextCursor":"c2"}}"#;
+ let second = r#"{"jsonrpc":"2.0","id":4,"result":{"resources":[{"uri":"ui://b","name":"b"}]}}"#;
+ let client = client_for(responder(&[&initialize_reply(), "", first, second]));
+
+ let resources = client.list_resources().await.unwrap();
+
+ let uris: Vec<&str> = resources.iter().map(|r| r.uri.as_str()).collect();
+ assert_eq!(uris, ["ui://a", "ui://b"]);
+}
+
+#[tokio::test]
+async fn a_resource_is_read_verbatim() {
+ let read = r#"{"jsonrpc":"2.0","id":3,"result":{"contents":[{"uri":"ui://a","mimeType":"text/html;profile=mcp-app","text":"hi
"}]}}"#;
+ let client = client_for(responder(&[&initialize_reply(), "", read]));
+
+ let contents = client.read_resource("ui://a").await.unwrap();
+
+ assert_eq!(contents[0].text.as_deref(), Some("hi
"));
+ assert_eq!(
+ contents[0].mime_type.as_deref(),
+ Some("text/html;profile=mcp-app")
+ );
+}
+
+#[tokio::test]
+async fn a_read_reply_without_contents_is_malformed() {
+ let read = r#"{"jsonrpc":"2.0","id":3,"result":{}}"#;
+ let client = client_for(responder(&[&initialize_reply(), "", read]));
+
+ let error = client.read_resource("ui://a").await.unwrap_err();
+
+ assert!(
+ matches!(error, Error::MalformedResponse { .. }),
+ "{error:?}"
+ );
+}
+
+#[tokio::test]
+async fn a_tool_call_keeps_structured_content() {
+ let call = r#"{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"ok"}],"structuredContent":{"n":1}}}"#;
+ let client = client_for(responder(&[&initialize_reply(), "", call]));
+
+ let result = client.call_tool("show_card", json!({})).await.unwrap();
+
+ assert_eq!(result.rendered.text(), "ok");
+ assert_eq!(result.rendered.structured_content, Some(json!({ "n": 1 })));
+}
diff --git a/crates/tinymcp/src/transport/ui_fixture.rs b/crates/tinymcp/src/transport/ui_fixture.rs
new file mode 100644
index 00000000..6b53bee8
--- /dev/null
+++ b/crates/tinymcp/src/transport/ui_fixture.rs
@@ -0,0 +1,117 @@
+//! A loopback MCP server exercising tool `_meta`, resources, and rich tool
+//! results, shared by the suites that cover those paths.
+
+#![allow(clippy::unwrap_used, clippy::expect_used)]
+
+use std::sync::{Arc, Mutex};
+
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use axum::routing::post;
+use axum::{Json, Router};
+use serde_json::{Value, json};
+use tinymcp_bus::MAX_RESOURCE_BYTES;
+
+/// The HTML the fixture serves at [`CARD_URI`].
+pub(crate) const CARD_HTML: &str = "card
";
+/// A resource the fixture serves.
+pub(crate) const CARD_URI: &str = "ui://card";
+/// A resource above [`MAX_RESOURCE_BYTES`].
+pub(crate) const BIG_URI: &str = "ui://big";
+/// The MIME type the fixture declares for [`CARD_URI`].
+pub(crate) const APP_MIME: &str = "text/html;profile=mcp-app";
+/// The text block the fixture's tool call answers with.
+pub(crate) const CALL_TEXT: &str = "card shown";
+
+/// A running fixture.
+pub(crate) struct UiFixture {
+ /// The endpoint to dial.
+ pub endpoint: String,
+ /// The `params` of every `initialize` the fixture received.
+ pub initialize_params: Arc>>,
+}
+
+/// The `tools/call` reply the fixture sends.
+pub(crate) fn call_reply() -> Value {
+ json!({
+ "content": [
+ { "type": "text", "text": CALL_TEXT },
+ { "type": "resource", "resource": {
+ "uri": CARD_URI, "mimeType": APP_MIME, "text": CARD_HTML,
+ } },
+ ],
+ "structuredContent": { "temperature": 21 },
+ "_meta": { "openai/outputTemplate": CARD_URI },
+ })
+}
+
+/// The `_meta` the fixture lists for its `show_card` tool.
+pub(crate) fn show_card_meta() -> Value {
+ json!({ "ui": { "resourceUri": CARD_URI } })
+}
+
+/// Starts the fixture on a loopback port.
+pub(crate) async fn spawn() -> UiFixture {
+ let initialize_params = Arc::new(Mutex::new(Vec::new()));
+ let app = Router::new()
+ .route("/", post(handle))
+ .with_state(Arc::clone(&initialize_params));
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ tokio::spawn(async move {
+ axum::serve(listener, app).await.unwrap();
+ });
+ UiFixture {
+ endpoint: format!("http://{addr}/"),
+ initialize_params,
+ }
+}
+
+async fn handle(
+ State(initialize_params): State>>>,
+ Json(body): Json,
+) -> Response {
+ let params = body.get("params").cloned().unwrap_or(Value::Null);
+ let result = match body["method"].as_str().unwrap_or_default() {
+ "initialize" => {
+ initialize_params.lock().unwrap().push(params);
+ json!({
+ "protocolVersion": tinymcp_bus::LATEST_PROTOCOL_VERSION,
+ "capabilities": { "tools": {}, "resources": {} },
+ "serverInfo": { "name": "ui-fixture", "version": "1" },
+ })
+ }
+ "notifications/initialized" => return StatusCode::ACCEPTED.into_response(),
+ "tools/list" => json!({ "tools": [
+ { "name": "show_card", "inputSchema": { "type": "object" }, "_meta": show_card_meta() },
+ { "name": "plain", "inputSchema": { "type": "object" } },
+ ] }),
+ "tools/call" => call_reply(),
+ "resources/list" => match params.get("cursor").and_then(Value::as_str) {
+ None => json!({
+ "resources": [{ "uri": CARD_URI, "name": "card", "mimeType": APP_MIME }],
+ "nextCursor": "page-2",
+ }),
+ Some(_) => json!({ "resources": [{ "uri": "ui://other", "name": "other" }] }),
+ },
+ "resources/read" => match params.get("uri").and_then(Value::as_str) {
+ Some(CARD_URI) => json!({
+ "contents": [{ "uri": CARD_URI, "mimeType": APP_MIME, "text": CARD_HTML }],
+ }),
+ Some(BIG_URI) => json!({
+ "contents": [{ "uri": BIG_URI, "text": "a".repeat(MAX_RESOURCE_BYTES + 1) }],
+ }),
+ _ => {
+ return Json(json!({
+ "jsonrpc": "2.0",
+ "id": body["id"].clone(),
+ "error": { "code": -32002, "message": "resource not found" },
+ }))
+ .into_response();
+ }
+ },
+ _ => json!({}),
+ };
+ Json(json!({ "jsonrpc": "2.0", "id": body["id"].clone(), "result": result })).into_response()
+}
diff --git a/crates/tinymcp/src/ui/links.rs b/crates/tinymcp/src/ui/links.rs
new file mode 100644
index 00000000..5d4ebee4
--- /dev/null
+++ b/crates/tinymcp/src/ui/links.rs
@@ -0,0 +1,180 @@
+//! Links a tool result offers: payment pages, deep links, sign-in URLs.
+//!
+//! Found in the structured content and the text, classified, and capped. A
+//! scheme that could run script or reach the host is blocked, and an image
+//! asset is not an action.
+
+use std::sync::LazyLock;
+
+use regex::Regex;
+use serde_json::Value;
+
+use super::{LinkClass, UiLink, UiLinkKind};
+
+/// The most links one result surfaces.
+pub const MAX_LINKS: usize = 5;
+
+const MAX_URL_LEN: usize = 2048;
+const MAX_WALK_DEPTH: usize = 8;
+const MAX_WALK_STRINGS: usize = 512;
+
+const BLOCKED_SCHEMES: &[&str] = &[
+ "javascript",
+ "data",
+ "vbscript",
+ "file",
+ "blob",
+ "about",
+ "tauri",
+ "ipc",
+ "asset",
+ "ohwidget",
+ "filesystem",
+ "chrome",
+ "chrome-extension",
+ "view-source",
+ "ws",
+ "wss",
+ "ftp",
+ "openhuman",
+];
+
+const IMAGE_EXTENSIONS: &[&str] = &[
+ "jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "bmp", "ico", "heic", "heif", "tif", "tiff",
+];
+
+static URL_RE: LazyLock> = LazyLock::new(|| {
+ Regex::new(r#"(?i)\b([a-z][a-z0-9+.\-]{1,31})://[^\s"'<>\x60{}|\\^\[\]]+"#).ok()
+});
+
+/// How `url` is classified, by scheme and, for `http(s)`, by path.
+#[must_use]
+pub fn classify_link(url: &str) -> LinkClass {
+ let Some((scheme, rest)) = url.split_once(':') else {
+ return LinkClass::Blocked;
+ };
+ let scheme = scheme.to_ascii_lowercase();
+ if scheme.is_empty()
+ || !scheme
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
+ || !scheme.starts_with(|c: char| c.is_ascii_alphabetic())
+ || BLOCKED_SCHEMES.contains(&scheme.as_str())
+ || rest.trim().is_empty()
+ || url.chars().any(char::is_control)
+ || url.len() > MAX_URL_LEN
+ {
+ return LinkClass::Blocked;
+ }
+ match scheme.as_str() {
+ "http" | "https" => match url::Url::parse(url) {
+ Ok(parsed) if parsed.host_str().is_some_and(|host| !host.is_empty()) => {
+ if image_path(&parsed) {
+ LinkClass::Image
+ } else {
+ LinkClass::Web
+ }
+ }
+ _ => LinkClass::Blocked,
+ },
+ _ => LinkClass::Handoff,
+ }
+}
+
+/// How a link of class `class` may be opened, or `None` when it is not an
+/// action.
+#[must_use]
+pub fn link_kind(class: LinkClass) -> Option {
+ match class {
+ LinkClass::Web => Some(UiLinkKind::External),
+ LinkClass::Handoff => Some(UiLinkKind::Handoff),
+ LinkClass::Image | LinkClass::Blocked => None,
+ }
+}
+
+/// Whether an `http(s)` URL points at an image asset rather than a page.
+#[must_use]
+pub fn is_image_url(url: &str) -> bool {
+ url::Url::parse(url).is_ok_and(|parsed| image_path(&parsed))
+}
+
+fn image_path(parsed: &url::Url) -> bool {
+ let path = parsed.path().to_ascii_lowercase();
+ if path.contains("/image/upload/") {
+ return true;
+ }
+ path.rsplit_once('.')
+ .is_some_and(|(_, ext)| !ext.contains('/') && IMAGE_EXTENSIONS.contains(&ext))
+}
+
+/// Every actionable link in `structured` and `text`, deduplicated, at most
+/// [`MAX_LINKS`], structured content first.
+#[must_use]
+pub fn extract_links(structured: Option<&Value>, text: &str) -> Vec {
+ let mut out: Vec = Vec::new();
+ let Some(pattern) = URL_RE.as_ref() else {
+ return out;
+ };
+ let mut strings = Vec::new();
+ if let Some(value) = structured {
+ collect_strings(value, 0, &mut strings);
+ }
+ strings.push(text);
+ for haystack in strings {
+ for found in pattern.find_iter(haystack) {
+ if out.len() >= MAX_LINKS {
+ return out;
+ }
+ let url = trim_trailing(found.as_str());
+ let Some(kind) = link_kind(classify_link(url)) else {
+ tracing::trace!("[mcp_ui] skipped a link that is not an action");
+ continue;
+ };
+ if out.iter().any(|link| link.url == url) {
+ continue;
+ }
+ out.push(UiLink {
+ url: url.to_string(),
+ kind,
+ });
+ }
+ }
+ out
+}
+
+fn collect_strings<'a>(value: &'a Value, depth: usize, out: &mut Vec<&'a str>) {
+ if depth > MAX_WALK_DEPTH || out.len() >= MAX_WALK_STRINGS {
+ return;
+ }
+ match value {
+ Value::String(text) => out.push(text),
+ Value::Array(items) => items
+ .iter()
+ .for_each(|item| collect_strings(item, depth + 1, out)),
+ Value::Object(map) => map
+ .values()
+ .for_each(|item| collect_strings(item, depth + 1, out)),
+ _ => {}
+ }
+}
+
+fn trim_trailing(url: &str) -> &str {
+ let mut end = url.len();
+ loop {
+ let trimmed = &url[..end];
+ let Some(last) = trimmed.chars().last() else {
+ return trimmed;
+ };
+ let unbalanced_paren =
+ last == ')' && trimmed.matches('(').count() < trimmed.matches(')').count();
+ if matches!(last, '.' | ',' | ';' | ':' | '!' | '?' | '*') || unbalanced_paren {
+ end -= last.len_utf8();
+ } else {
+ return trimmed;
+ }
+ }
+}
+
+#[cfg(test)]
+#[path = "links_tests.rs"]
+mod tests;
diff --git a/crates/tinymcp/src/ui/links_tests.rs b/crates/tinymcp/src/ui/links_tests.rs
new file mode 100644
index 00000000..6df19afd
--- /dev/null
+++ b/crates/tinymcp/src/ui/links_tests.rs
@@ -0,0 +1,128 @@
+//! Unit tests for link extraction and classification.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use super::*;
+use serde_json::json;
+
+fn urls(links: &[UiLink]) -> Vec<&str> {
+ links.iter().map(|link| link.url.as_str()).collect()
+}
+
+#[test]
+fn keeps_https_and_handoff_schemes() {
+ let text =
+ "Pay at https://pay.example.com/o/1 or phonepe://pay?id=1 or upi://pay?pa=a@b&am=10.";
+ let links = extract_links(None, text);
+ assert_eq!(
+ urls(&links),
+ vec![
+ "https://pay.example.com/o/1",
+ "phonepe://pay?id=1",
+ "upi://pay?pa=a@b&am=10"
+ ]
+ );
+ assert_eq!(links[0].kind, UiLinkKind::External);
+ assert_eq!(links[1].kind, UiLinkKind::Handoff);
+ assert_eq!(links[2].kind, UiLinkKind::Handoff);
+}
+
+#[test]
+fn drops_dangerous_schemes() {
+ let text = "javascript://x%0Aalert(1) data://text/html,hi file:///etc/passwd tauri://localhost ohwidget://x blob://y";
+ assert_eq!(extract_links(None, text), Vec::new());
+}
+
+#[test]
+fn classify_table() {
+ assert_eq!(classify_link("https://a.com"), LinkClass::Web);
+ assert_eq!(classify_link("http://a.com/x"), LinkClass::Web);
+ assert_eq!(classify_link("tez://upi/pay"), LinkClass::Handoff);
+ assert_eq!(
+ classify_link("intent://pay#Intent;scheme=upi;end"),
+ LinkClass::Handoff
+ );
+ assert_eq!(classify_link("https://a.com/p/photo.PNG"), LinkClass::Image);
+ assert_eq!(classify_link("JAVASCRIPT:alert(1)"), LinkClass::Blocked);
+ assert_eq!(classify_link("data:text/html,x"), LinkClass::Blocked);
+ assert_eq!(classify_link("https://"), LinkClass::Blocked);
+ assert_eq!(classify_link("nonsense"), LinkClass::Blocked);
+ assert_eq!(classify_link("1http://a.com"), LinkClass::Blocked);
+}
+
+#[test]
+fn only_web_and_handoff_are_actions() {
+ assert_eq!(link_kind(LinkClass::Web), Some(UiLinkKind::External));
+ assert_eq!(link_kind(LinkClass::Handoff), Some(UiLinkKind::Handoff));
+ assert_eq!(link_kind(LinkClass::Image), None);
+ assert_eq!(link_kind(LinkClass::Blocked), None);
+}
+
+#[test]
+fn structured_content_comes_first_and_dedupes() {
+ let structured = json!({
+ "order": {"a_payment_url": "https://pay.example.com/o/1", "b_deep": ["phonepe://pay?id=1"]}
+ });
+ let links = extract_links(
+ Some(&structured),
+ "See https://pay.example.com/o/1 and https://other.example.com.",
+ );
+ assert_eq!(
+ urls(&links),
+ vec![
+ "https://pay.example.com/o/1",
+ "phonepe://pay?id=1",
+ "https://other.example.com"
+ ]
+ );
+}
+
+#[test]
+fn caps_at_max_links() {
+ let text = (0..10)
+ .map(|i| format!("https://e{i}.example.com"))
+ .collect::>()
+ .join(" ");
+ assert_eq!(extract_links(None, &text).len(), MAX_LINKS);
+}
+
+#[test]
+fn trims_markdown_punctuation_and_unbalanced_parens() {
+ let links = extract_links(
+ None,
+ "[pay](https://pay.example.com/x) (see https://a.example.com/p_(1)).",
+ );
+ assert_eq!(
+ urls(&links),
+ vec!["https://pay.example.com/x", "https://a.example.com/p_(1)"]
+ );
+}
+
+#[test]
+fn drops_image_assets() {
+ let structured = json!({
+ "a_image": "https://media.example.com/shop/image/upload/fl_lossy,f_auto/CATALOG/2026/6/4/x_1",
+ "b_photo": "https://cdn.example.com/products/bar_1.JPG",
+ "c_page": "https://www.example.com/shop/item/123",
+ });
+ let text = "Logo https://cdn.example.com/logo.svg?v=2 and pay at upi://pay?pa=a@b.png";
+ let links = extract_links(Some(&structured), text);
+ assert_eq!(
+ urls(&links),
+ vec![
+ "https://www.example.com/shop/item/123",
+ "upi://pay?pa=a@b.png"
+ ]
+ );
+}
+
+#[test]
+fn image_url_table() {
+ assert!(is_image_url(
+ "https://res.cloudinary.com/demo/image/upload/sample"
+ ));
+ assert!(is_image_url("https://cdn.example.com/a/b.webp"));
+ assert!(!is_image_url("https://example.com/pngs/list"));
+ assert!(!is_image_url("https://example.com/checkout"));
+ assert!(!is_image_url("not a url"));
+}
diff --git a/crates/tinymcp/src/ui/mod.rs b/crates/tinymcp/src/ui/mod.rs
new file mode 100644
index 00000000..f0260a05
--- /dev/null
+++ b/crates/tinymcp/src/ui/mod.rs
@@ -0,0 +1,38 @@
+//! Tool-provided UI for hosts: the rules that turn one answered MCP tool call
+//! into what a host shows for it.
+//!
+//! - [`resolve_presentation`] reads the tool's `_meta` template
+//! (`ui.resourceUri`, or the Apps SDK `openai/outputTemplate`), the result's
+//! structured content and embedded resources, and its text, and returns a
+//! [`McpUiPresentation`] with size-capped data and classified links.
+//! - [`resource_from_contents`] validates a widget document: HTML only, at
+//! most [`MAX_WIDGET_BYTES`], CSP origins reduced to `https`.
+//! - [`classify_link`] sorts a URL into web, phone handoff, image, or blocked;
+//! [`extract_links`] surfaces only the first two.
+//! - [`widget_call_policy`] decides whether a widget's tool call runs, waits
+//! for the user, or is refused; [`rendering`] decides whether a host shows
+//! the widget, the links, or nothing.
+//!
+//! A host that cannot sandbox HTML (a messaging or email channel) does not
+//! advertise [`client_capabilities`], so servers that follow the MCP Apps spec
+//! answer with text, and renders [`UiRendering::Links`] from the classified
+//! links. The vocabulary lives in `tinymcp_bus::ui`.
+
+mod links;
+mod policy;
+mod resolve;
+
+pub use links::{MAX_LINKS, classify_link, extract_links, is_image_url, link_kind};
+pub use policy::{
+ client_capabilities, is_read_only, rendering, visible_to_app, widget_call_policy,
+};
+pub use resolve::{
+ MAX_INPUT_BYTES, MAX_META_BYTES, MAX_STRUCTURED_BYTES, MAX_WIDGET_BYTES, ResolvedUi,
+ UiCallView, UiSource, bounded, https_origins, is_html_mime, is_ui_uri, resolve_presentation,
+ resource_from_contents, select_source, template_from_meta, view_from_envelope,
+ view_from_raw_result,
+};
+pub use tinymcp_bus::ui::{
+ LinkClass, MCP_APP_MIME, MCP_APPS_EXTENSION, MCP_UI_KIND, McpUiPresentation, UiCsp, UiFlavor,
+ UiLink, UiLinkKind, UiRendering, UiResource, WidgetCallPolicy,
+};
diff --git a/crates/tinymcp/src/ui/policy.rs b/crates/tinymcp/src/ui/policy.rs
new file mode 100644
index 00000000..a463e749
--- /dev/null
+++ b/crates/tinymcp/src/ui/policy.rs
@@ -0,0 +1,77 @@
+//! What a host does with tool UI: whether a widget may call a tool, how a
+//! presentation is shown, and what the host advertises to servers.
+
+use serde_json::{Map, Value, json};
+
+use super::{
+ MCP_APP_MIME, MCP_APPS_EXTENSION, McpUiPresentation, UiFlavor, UiRendering, WidgetCallPolicy,
+};
+
+/// Whether a tool may be called from a widget: its `_meta.ui.visibility`
+/// is absent or lists `"app"`.
+#[must_use]
+pub fn visible_to_app(tool_meta: Option<&Value>) -> bool {
+ match tool_meta.and_then(|meta| meta.pointer("/ui/visibility")) {
+ None | Some(Value::Null) => true,
+ Some(Value::Array(items)) => items.iter().any(|item| item.as_str() == Some("app")),
+ Some(_) => false,
+ }
+}
+
+/// Whether a tool declares it changes nothing (`annotations.readOnlyHint`).
+#[must_use]
+pub fn is_read_only(annotations: Option<&Value>) -> bool {
+ annotations
+ .and_then(|annotations| annotations.get("readOnlyHint"))
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+}
+
+/// What a host does with a tool call a widget of `flavor` asks for.
+///
+/// Host-supplied pages may not call tools, nor may a widget call a tool
+/// hidden from apps. A read-only tool is called; anything else waits for the
+/// user.
+#[must_use]
+pub fn widget_call_policy(
+ flavor: UiFlavor,
+ tool_meta: Option<&Value>,
+ annotations: Option<&Value>,
+) -> WidgetCallPolicy {
+ if flavor == UiFlavor::HostInline || !visible_to_app(tool_meta) {
+ WidgetCallPolicy::Deny
+ } else if is_read_only(annotations) {
+ WidgetCallPolicy::Allow
+ } else {
+ WidgetCallPolicy::Confirm
+ }
+}
+
+/// How a host shows `presentation`. A host that cannot sandbox widgets
+/// (`renders_widgets` false) falls back to the links.
+#[must_use]
+pub fn rendering(renders_widgets: bool, presentation: &McpUiPresentation) -> UiRendering {
+ if renders_widgets && presentation.has_frame() {
+ UiRendering::Widget
+ } else if !presentation.links.is_empty() {
+ UiRendering::Links
+ } else {
+ UiRendering::Nothing
+ }
+}
+
+/// The client capabilities a host that renders widgets advertises. A host
+/// that does not leaves this out, so servers answer with text alone.
+#[must_use]
+pub fn client_capabilities() -> Value {
+ let mut extensions = Map::new();
+ extensions.insert(
+ MCP_APPS_EXTENSION.to_string(),
+ json!({ "mimeTypes": [MCP_APP_MIME] }),
+ );
+ json!({ "extensions": extensions })
+}
+
+#[cfg(test)]
+#[path = "policy_tests.rs"]
+mod tests;
diff --git a/crates/tinymcp/src/ui/policy_tests.rs b/crates/tinymcp/src/ui/policy_tests.rs
new file mode 100644
index 00000000..7689ce3d
--- /dev/null
+++ b/crates/tinymcp/src/ui/policy_tests.rs
@@ -0,0 +1,92 @@
+//! Unit tests for the widget tool-call policy and the rendering decision.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use super::*;
+use crate::ui::{MCP_UI_KIND, UiLink, UiLinkKind};
+
+fn presentation(resource_uri: Option<&str>, links: Vec) -> McpUiPresentation {
+ McpUiPresentation {
+ kind: MCP_UI_KIND.to_string(),
+ flavor: UiFlavor::McpApps,
+ server_id: Some("srv".into()),
+ tool: "t".into(),
+ resource_uri: resource_uri.map(str::to_string),
+ inline_id: None,
+ title: None,
+ tool_input: Value::Null,
+ structured_content: None,
+ result_meta: None,
+ links,
+ }
+}
+
+#[test]
+fn visibility_defaults_to_app_and_honours_the_list() {
+ assert!(visible_to_app(None));
+ assert!(visible_to_app(Some(&json!({"ui": {"visibility": null}}))));
+ assert!(visible_to_app(Some(
+ &json!({"ui": {"visibility": ["model", "app"]}})
+ )));
+ assert!(!visible_to_app(Some(
+ &json!({"ui": {"visibility": ["model"]}})
+ )));
+ assert!(!visible_to_app(Some(&json!({"ui": {"visibility": "app"}}))));
+}
+
+#[test]
+fn read_only_needs_an_explicit_hint() {
+ assert!(is_read_only(Some(&json!({"readOnlyHint": true}))));
+ assert!(!is_read_only(Some(&json!({"readOnlyHint": false}))));
+ assert!(!is_read_only(Some(&json!({"readOnlyHint": "true"}))));
+ assert!(!is_read_only(None));
+}
+
+#[test]
+fn widget_calls_confirm_unless_read_only_and_never_from_host_pages() {
+ let read_only = json!({"readOnlyHint": true});
+ assert_eq!(
+ widget_call_policy(UiFlavor::McpApps, None, Some(&read_only)),
+ WidgetCallPolicy::Allow
+ );
+ assert_eq!(
+ widget_call_policy(UiFlavor::AppsSdk, None, None),
+ WidgetCallPolicy::Confirm
+ );
+ assert_eq!(
+ widget_call_policy(UiFlavor::HostInline, None, Some(&read_only)),
+ WidgetCallPolicy::Deny
+ );
+ let hidden = json!({"ui": {"visibility": ["model"]}});
+ assert_eq!(
+ widget_call_policy(UiFlavor::McpApps, Some(&hidden), Some(&read_only)),
+ WidgetCallPolicy::Deny
+ );
+}
+
+#[test]
+fn rendering_falls_back_to_links_without_widget_support() {
+ let link = UiLink {
+ url: "upi://pay".into(),
+ kind: UiLinkKind::Handoff,
+ };
+ let both = presentation(Some("ui://w"), vec![link.clone()]);
+ assert_eq!(rendering(true, &both), UiRendering::Widget);
+ assert_eq!(rendering(false, &both), UiRendering::Links);
+ assert_eq!(
+ rendering(true, &presentation(None, vec![link])),
+ UiRendering::Links
+ );
+ assert_eq!(
+ rendering(false, &presentation(Some("ui://w"), Vec::new())),
+ UiRendering::Nothing
+ );
+}
+
+#[test]
+fn capabilities_advertise_the_mcp_apps_extension() {
+ assert_eq!(
+ client_capabilities(),
+ json!({"extensions": {"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}}})
+ );
+}
diff --git a/crates/tinymcp/src/ui/resolve.rs b/crates/tinymcp/src/ui/resolve.rs
new file mode 100644
index 00000000..6bd88e65
--- /dev/null
+++ b/crates/tinymcp/src/ui/resolve.rs
@@ -0,0 +1,456 @@
+//! Turning one answered tool call into its presentation.
+//!
+//! Pure over the call's JSON: the tool descriptor's `_meta`, the result's
+//! structured content, `_meta` and embedded resources, and its text. Nothing is
+//! cached or fetched here; a document the result carried is handed back for the
+//! host to keep.
+
+use base64::Engine as _;
+use serde_json::Value;
+
+use super::links::extract_links;
+use super::{MCP_UI_KIND, McpUiPresentation, UiCsp, UiFlavor, UiResource};
+use crate::MCP_RESULT_KIND;
+
+/// The largest widget document a host serves to a frame.
+pub const MAX_WIDGET_BYTES: usize = 2 * 1024 * 1024;
+/// The largest structured content carried with a presentation.
+pub const MAX_STRUCTURED_BYTES: usize = 64 * 1024;
+/// The largest result `_meta` carried with a presentation.
+pub const MAX_META_BYTES: usize = 16 * 1024;
+/// The largest tool input carried with a presentation.
+pub const MAX_INPUT_BYTES: usize = 16 * 1024;
+const MAX_CSP_DOMAINS: usize = 32;
+
+/// One answered tool call, reduced to what UI resolution reads.
+#[derive(Debug, Clone, Default)]
+pub struct UiCallView {
+ /// The server the tool belongs to.
+ pub server_id: String,
+ /// The tool's name on that server.
+ pub tool: String,
+ /// The tool descriptor's `_meta`.
+ pub tool_meta: Option,
+ /// The call's arguments.
+ pub tool_input: Value,
+ /// The result's `structuredContent`.
+ pub structured_content: Option,
+ /// The result's `_meta`.
+ pub result_meta: Option,
+ /// Embedded resources, as `{uri, mimeType, text?, blob?, _meta?}` objects.
+ pub resources: Vec,
+ /// The result's text, as the model saw it.
+ pub text: String,
+}
+
+/// Where a widget's document comes from.
+#[derive(Debug, Clone, PartialEq)]
+pub enum UiSource {
+ /// A `ui://` resource on the tool's server.
+ Uri {
+ /// The resource URI.
+ uri: String,
+ /// The protocol the widget speaks.
+ flavor: UiFlavor,
+ },
+ /// A document the result embedded.
+ Embedded {
+ /// The embedded resource entry.
+ resource: Value,
+ },
+}
+
+/// A presentation, and any widget document the result itself carried.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ResolvedUi {
+ /// The presentation. `inline_id` is unset; a host that keeps
+ /// [`ResolvedUi::inline_document`] assigns it.
+ pub presentation: McpUiPresentation,
+ /// The document for `presentation.resource_uri`, when the result embedded
+ /// it, keyed by that URI.
+ pub prefetched: Option<(String, UiResource)>,
+ /// A widget document the result embedded without a `ui://` template.
+ pub inline_document: Option,
+}
+
+/// Whether `uri` is a widget resource URI.
+#[must_use]
+pub fn is_ui_uri(uri: &str) -> bool {
+ uri.len() > "ui://".len()
+ && uri
+ .get(..5)
+ .is_some_and(|scheme| scheme.eq_ignore_ascii_case("ui://"))
+}
+
+/// Whether `mime` is an HTML document type a widget may be served as.
+#[must_use]
+pub fn is_html_mime(mime: &str) -> bool {
+ mime.trim().to_ascii_lowercase().starts_with("text/html")
+}
+
+fn meta_str<'a>(meta: Option<&'a Value>, path: &[&str]) -> Option<&'a str> {
+ let mut cursor = meta?;
+ for key in path {
+ cursor = cursor.get(*key)?;
+ }
+ cursor.as_str()
+}
+
+const TEMPLATE_KEYS: [(&[&str], UiFlavor); 3] = [
+ (&["ui", "resourceUri"], UiFlavor::McpApps),
+ (&["ui/resourceUri"], UiFlavor::McpApps),
+ (&["openai/outputTemplate"], UiFlavor::AppsSdk),
+];
+
+/// The widget template a tool's `_meta` declares, by precedence:
+/// `ui.resourceUri`, the legacy `ui/resourceUri`, then the Apps SDK
+/// `openai/outputTemplate`. Only `ui://` URIs count.
+#[must_use]
+pub fn template_from_meta(meta: &Value) -> Option<(String, UiFlavor)> {
+ TEMPLATE_KEYS.iter().find_map(|(path, flavor)| {
+ meta_str(Some(meta), path)
+ .map(str::trim)
+ .filter(|uri| is_ui_uri(uri))
+ .map(|uri| (uri.to_string(), *flavor))
+ })
+}
+
+/// The source a call's widget loads from: a template declared on the tool or
+/// the result (tool first, at each precedence level), then an embedded `ui://`
+/// HTML resource.
+#[must_use]
+pub fn select_source(view: &UiCallView) -> Option {
+ let metas = [view.tool_meta.as_ref(), view.result_meta.as_ref()];
+ for (path, flavor) in TEMPLATE_KEYS {
+ for meta in metas {
+ if let Some(uri) = meta_str(meta, path).map(str::trim) {
+ if is_ui_uri(uri) {
+ return Some(UiSource::Uri {
+ uri: uri.to_string(),
+ flavor,
+ });
+ }
+ tracing::debug!(tool = %view.tool, "[mcp_ui] ignored a non-ui:// resource URI");
+ }
+ }
+ }
+ view.resources
+ .iter()
+ .find(|resource| {
+ let uri = resource.get("uri").and_then(Value::as_str).unwrap_or("");
+ let mime = resource
+ .get("mimeType")
+ .and_then(Value::as_str)
+ .unwrap_or("");
+ is_ui_uri(uri) && is_html_mime(mime)
+ })
+ .map(|resource| UiSource::Embedded {
+ resource: resource.clone(),
+ })
+}
+
+/// `value` when it is non-null and serializes to at most `limit` bytes.
+#[must_use]
+pub fn bounded(value: Option<&Value>, limit: usize) -> Option {
+ let value = value.filter(|value| !value.is_null())?;
+ let size = serde_json::to_vec(value).map(|bytes| bytes.len()).ok()?;
+ if size > limit {
+ tracing::debug!(size, limit, "[mcp_ui] dropped an oversized field");
+ return None;
+ }
+ Some(value.clone())
+}
+
+/// The presentation for one call, or `None` when it offered neither a widget
+/// nor a link.
+#[must_use]
+pub fn resolve_presentation(view: &UiCallView) -> Option {
+ let links = extract_links(view.structured_content.as_ref(), &view.text);
+ let mut prefetched = None;
+ let mut inline_document = None;
+ let (resource_uri, flavor) = match select_source(view) {
+ Some(UiSource::Uri { uri, flavor }) => {
+ prefetched = view
+ .resources
+ .iter()
+ .find(|resource| resource.get("uri").and_then(Value::as_str) == Some(uri.as_str()))
+ .and_then(|embedded| {
+ resource_from_contents(std::slice::from_ref(embedded), &uri).ok()
+ })
+ .map(|document| (uri.clone(), document));
+ (Some(uri), flavor)
+ }
+ Some(UiSource::Embedded { resource }) => {
+ let uri = resource
+ .get("uri")
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_string();
+ match resource_from_contents(std::slice::from_ref(&resource), &uri) {
+ Ok(document) => inline_document = Some(document),
+ Err(error) => {
+ tracing::debug!(tool = %view.tool, %error, "[mcp_ui] embedded widget rejected");
+ }
+ }
+ (None, UiFlavor::McpApps)
+ }
+ None => (None, UiFlavor::McpApps),
+ };
+ if resource_uri.is_none() && inline_document.is_none() && links.is_empty() {
+ return None;
+ }
+ tracing::debug!(
+ server_id = %view.server_id,
+ tool = %view.tool,
+ frame = resource_uri.is_some() || inline_document.is_some(),
+ links = links.len(),
+ "[mcp_ui] tool call offered UI"
+ );
+ Some(ResolvedUi {
+ presentation: McpUiPresentation {
+ kind: MCP_UI_KIND.to_string(),
+ flavor,
+ server_id: Some(view.server_id.clone()),
+ tool: view.tool.clone(),
+ resource_uri,
+ inline_id: None,
+ title: None,
+ tool_input: bounded(Some(&view.tool_input), MAX_INPUT_BYTES).unwrap_or(Value::Null),
+ structured_content: bounded(view.structured_content.as_ref(), MAX_STRUCTURED_BYTES),
+ result_meta: bounded(view.result_meta.as_ref(), MAX_META_BYTES),
+ links,
+ },
+ prefetched,
+ inline_document,
+ })
+}
+
+fn normalize_resource_entry(entry: &Value) -> Value {
+ match entry.get("type").and_then(Value::as_str) {
+ Some("resource") => entry.get("resource").cloned().unwrap_or(Value::Null),
+ _ => entry.clone(),
+ }
+}
+
+fn resources_from_content(content: Option<&Value>) -> Vec {
+ content
+ .and_then(Value::as_array)
+ .map(|blocks| {
+ blocks
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("resource"))
+ .map(normalize_resource_entry)
+ .collect()
+ })
+ .unwrap_or_default()
+}
+
+fn text_from_content(content: Option<&Value>) -> String {
+ content
+ .and_then(Value::as_array)
+ .map(|blocks| {
+ blocks
+ .iter()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
+ .filter_map(|block| block.get("text").and_then(Value::as_str))
+ .collect::>()
+ .join("\n")
+ })
+ .unwrap_or_default()
+}
+
+/// A view over a server's raw `tools/call` reply.
+#[must_use]
+pub fn view_from_raw_result(
+ server_id: &str,
+ tool: &str,
+ tool_meta: Option,
+ tool_input: Value,
+ raw: &Value,
+) -> UiCallView {
+ UiCallView {
+ server_id: server_id.to_string(),
+ tool: tool.to_string(),
+ tool_meta,
+ tool_input,
+ structured_content: raw.get("structuredContent").cloned(),
+ result_meta: raw.get("_meta").cloned(),
+ resources: resources_from_content(raw.get("content")),
+ text: text_from_content(raw.get("content")),
+ }
+}
+
+/// A view over the `mcp_result` envelope a tool result's metadata carries.
+///
+/// `None` when `metadata` is not that envelope.
+#[must_use]
+pub fn view_from_envelope(
+ metadata: &Value,
+ tool_meta: Option,
+ tool_input: Value,
+ text: &str,
+) -> Option {
+ if metadata.get("kind").and_then(Value::as_str) != Some(MCP_RESULT_KIND) {
+ return None;
+ }
+ let server_id = metadata.get("server").and_then(Value::as_str)?;
+ let tool = metadata.get("tool").and_then(Value::as_str)?;
+ let resources = metadata
+ .get("resources")
+ .and_then(Value::as_array)
+ .map(|entries| entries.iter().map(normalize_resource_entry).collect())
+ .unwrap_or_default();
+ Some(UiCallView {
+ server_id: server_id.to_string(),
+ tool: tool.to_string(),
+ tool_meta,
+ tool_input,
+ structured_content: metadata
+ .get("structured_content")
+ .filter(|value| !value.is_null())
+ .cloned(),
+ result_meta: metadata
+ .get("meta")
+ .filter(|value| !value.is_null())
+ .cloned(),
+ resources,
+ text: text.to_string(),
+ })
+}
+
+/// The widget document for `uri` among a `resources/read` reply's contents.
+///
+/// # Errors
+///
+/// When no entry matches, the entry is not HTML, has no text, or is larger
+/// than [`MAX_WIDGET_BYTES`].
+pub fn resource_from_contents(contents: &[Value], uri: &str) -> Result {
+ let entry = contents
+ .iter()
+ .find(|entry| entry.get("uri").and_then(Value::as_str) == Some(uri))
+ .or_else(|| (contents.len() == 1).then(|| &contents[0]))
+ .ok_or_else(|| "the server returned no document for this URI".to_string())?;
+ let mime_type = entry
+ .get("mimeType")
+ .and_then(Value::as_str)
+ .unwrap_or("")
+ .trim()
+ .to_string();
+ if !is_html_mime(&mime_type) {
+ return Err(format!("unsupported widget type `{mime_type}`"));
+ }
+ let html = match (
+ entry.get("text").and_then(Value::as_str),
+ entry.get("blob").and_then(Value::as_str),
+ ) {
+ (Some(text), _) => {
+ if text.len() > MAX_WIDGET_BYTES {
+ return Err("the widget document is too large".to_string());
+ }
+ text.to_string()
+ }
+ (None, Some(blob)) => {
+ if blob.len() > MAX_WIDGET_BYTES * 4 / 3 + 4 {
+ return Err("the widget document is too large".to_string());
+ }
+ let bytes = base64::engine::general_purpose::STANDARD
+ .decode(blob.trim())
+ .map_err(|_| "the widget document is not valid base64".to_string())?;
+ String::from_utf8(bytes).map_err(|_| "the widget document is not UTF-8".to_string())?
+ }
+ (None, None) => return Err("the widget document is empty".to_string()),
+ };
+ if html.len() > MAX_WIDGET_BYTES {
+ return Err("the widget document is too large".to_string());
+ }
+ let meta = entry.get("_meta");
+ Ok(UiResource {
+ html,
+ mime_type,
+ csp: csp_from_meta(meta),
+ permissions: meta
+ .and_then(|meta| meta.pointer("/ui/permissions"))
+ .filter(|value| value.is_object())
+ .cloned(),
+ prefers_border: meta
+ .and_then(|meta| {
+ meta.pointer("/ui/prefersBorder")
+ .or_else(|| meta.get("openai/widgetPrefersBorder"))
+ })
+ .and_then(Value::as_bool)
+ .unwrap_or(false),
+ })
+}
+
+fn csp_from_meta(meta: Option<&Value>) -> UiCsp {
+ let Some(meta) = meta else {
+ return UiCsp::default();
+ };
+ let (connect, resource) = if let Some(csp) = meta.pointer("/ui/csp") {
+ (csp.get("connectDomains"), csp.get("resourceDomains"))
+ } else if let Some(csp) = meta.get("openai/widgetCSP") {
+ (csp.get("connect_domains"), csp.get("resource_domains"))
+ } else {
+ (None, None)
+ };
+ UiCsp {
+ connect_domains: https_origins(connect),
+ resource_domains: https_origins(resource),
+ }
+}
+
+/// Each declared origin that is `https`, reduced to `https://host[:port]`;
+/// a leading `*.` wildcard label is kept.
+#[must_use]
+pub fn https_origins(value: Option<&Value>) -> Vec {
+ let mut out: Vec = Vec::new();
+ let Some(items) = value.and_then(Value::as_array) else {
+ return out;
+ };
+ for item in items.iter().filter_map(Value::as_str) {
+ if out.len() >= MAX_CSP_DOMAINS {
+ break;
+ }
+ if let Some(origin) = https_origin(item)
+ && !out.contains(&origin)
+ {
+ out.push(origin);
+ }
+ }
+ out
+}
+
+fn https_origin(raw: &str) -> Option {
+ let raw = raw.trim();
+ let rest = raw
+ .get(..8)
+ .filter(|prefix| prefix.eq_ignore_ascii_case("https://"))
+ .map(|_| &raw[8..])?;
+ let authority = rest.split(['/', '?', '#']).next()?.to_ascii_lowercase();
+ let (host, port) = match authority.rsplit_once(':') {
+ Some((host, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => {
+ (host.to_string(), Some(port.to_string()))
+ }
+ Some(_) => return None,
+ None => (authority.clone(), None),
+ };
+ let bare = host.strip_prefix("*.").unwrap_or(&host);
+ let labels_ok = !bare.is_empty()
+ && bare.contains('.')
+ && bare.split('.').all(|label| {
+ !label.is_empty()
+ && label.len() <= 63
+ && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
+ });
+ if !labels_ok {
+ return None;
+ }
+ Some(match port {
+ Some(port) => format!("https://{host}:{port}"),
+ None => format!("https://{host}"),
+ })
+}
+
+#[cfg(test)]
+#[path = "resolve_tests.rs"]
+mod tests;
diff --git a/crates/tinymcp/src/ui/resolve_tests.rs b/crates/tinymcp/src/ui/resolve_tests.rs
new file mode 100644
index 00000000..3a0e4d74
--- /dev/null
+++ b/crates/tinymcp/src/ui/resolve_tests.rs
@@ -0,0 +1,239 @@
+//! Unit tests for template resolution, call views and widget documents.
+
+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+use super::*;
+use serde_json::json;
+
+fn view() -> UiCallView {
+ UiCallView {
+ server_id: "srv".into(),
+ tool: "order".into(),
+ tool_input: json!({"item": "dosa"}),
+ ..UiCallView::default()
+ }
+}
+
+#[test]
+fn resource_uri_precedence() {
+ let mut v = view();
+ v.tool_meta = Some(json!({
+ "openai/outputTemplate": "ui://widget/sdk.html",
+ "ui/resourceUri": "ui://widget/legacy.html",
+ "ui": {"resourceUri": "ui://widget/app.html"}
+ }));
+ assert_eq!(
+ select_source(&v),
+ Some(UiSource::Uri {
+ uri: "ui://widget/app.html".into(),
+ flavor: UiFlavor::McpApps
+ })
+ );
+
+ v.tool_meta = Some(json!({
+ "openai/outputTemplate": "ui://widget/sdk.html",
+ "ui/resourceUri": "ui://widget/legacy.html"
+ }));
+ assert_eq!(
+ select_source(&v),
+ Some(UiSource::Uri {
+ uri: "ui://widget/legacy.html".into(),
+ flavor: UiFlavor::McpApps
+ })
+ );
+
+ v.tool_meta = Some(json!({"openai/outputTemplate": "ui://widget/sdk.html"}));
+ assert_eq!(
+ select_source(&v),
+ Some(UiSource::Uri {
+ uri: "ui://widget/sdk.html".into(),
+ flavor: UiFlavor::AppsSdk
+ })
+ );
+}
+
+#[test]
+fn only_ui_scheme_is_accepted() {
+ let mut v = view();
+ v.tool_meta = Some(json!({"ui": {"resourceUri": "https://evil.example.com/w.html"}}));
+ assert_eq!(select_source(&v), None);
+ v.tool_meta = Some(json!({"ui": {"resourceUri": "file:///etc/passwd"}}));
+ assert_eq!(select_source(&v), None);
+ assert!(!is_ui_uri("ui://"));
+ assert!(is_ui_uri("UI://x"));
+ assert!(!is_ui_uri("é://xx"));
+}
+
+#[test]
+fn embedded_html_resource_is_returned_as_an_inline_document() {
+ let mut v = view();
+ v.resources = vec![
+ json!({"uri": "ui://w/plain", "mimeType": "text/plain", "text": "x"}),
+ json!({"uri": "https://x/y", "mimeType": "text/html", "text": "no
"}),
+ json!({"uri": "ui://w/card", "mimeType": "text/html;profile=mcp-app", "text": "hi
"}),
+ ];
+ let resolved = resolve_presentation(&v).expect("presentation");
+ assert_eq!(resolved.presentation.kind, MCP_UI_KIND);
+ assert!(resolved.presentation.resource_uri.is_none());
+ assert!(resolved.presentation.inline_id.is_none());
+ assert_eq!(
+ resolved.inline_document.expect("document").html,
+ "hi
"
+ );
+ assert!(resolved.prefetched.is_none());
+}
+
+#[test]
+fn a_template_document_the_result_embedded_is_prefetched() {
+ let mut v = view();
+ v.tool_meta = Some(json!({"ui": {"resourceUri": "ui://w/card"}}));
+ v.resources =
+ vec![json!({"uri": "ui://w/card", "mimeType": "text/html", "text": "card
"})];
+ let resolved = resolve_presentation(&v).expect("presentation");
+ assert_eq!(
+ resolved.presentation.resource_uri.as_deref(),
+ Some("ui://w/card")
+ );
+ let (uri, document) = resolved.prefetched.expect("prefetched");
+ assert_eq!(uri, "ui://w/card");
+ assert_eq!(document.html, "card
");
+ assert!(resolved.inline_document.is_none());
+}
+
+#[test]
+fn template_from_meta_follows_precedence_and_scheme() {
+ assert_eq!(
+ template_from_meta(
+ &json!({"openai/outputTemplate": "ui://a", "ui": {"resourceUri": "ui://b"}})
+ ),
+ Some(("ui://b".to_string(), UiFlavor::McpApps))
+ );
+ assert_eq!(
+ template_from_meta(&json!({"openai/outputTemplate": " ui://a "})),
+ Some(("ui://a".to_string(), UiFlavor::AppsSdk))
+ );
+ assert_eq!(
+ template_from_meta(&json!({"ui": {"resourceUri": "https://x"}})),
+ None
+ );
+ assert_eq!(template_from_meta(&json!({})), None);
+}
+
+#[test]
+fn no_ui_and_no_links_yields_nothing() {
+ let mut v = view();
+ v.text = "Your order is placed.".into();
+ assert!(resolve_presentation(&v).is_none());
+}
+
+#[test]
+fn links_only_presentation() {
+ let mut v = view();
+ v.text = "Pay here: upi://pay?pa=shop@bank&am=120".into();
+ let presentation = resolve_presentation(&v).expect("presentation").presentation;
+ assert!(!presentation.has_frame());
+ assert_eq!(presentation.links.len(), 1);
+ assert_eq!(presentation.tool_input, json!({"item": "dosa"}));
+}
+
+#[test]
+fn oversized_structured_content_is_dropped_but_links_kept() {
+ let mut v = view();
+ let big = "x".repeat(MAX_STRUCTURED_BYTES + 10);
+ v.structured_content = Some(json!({"blob": big, "pay": "https://pay.example.com/1"}));
+ let presentation = resolve_presentation(&v).expect("presentation").presentation;
+ assert!(presentation.structured_content.is_none());
+ assert_eq!(presentation.links[0].url, "https://pay.example.com/1");
+}
+
+#[test]
+fn raw_result_view_reads_content_blocks() {
+ let raw = json!({
+ "content": [
+ {"type": "text", "text": "done"},
+ {"type": "resource", "resource": {"uri": "ui://a", "mimeType": "text/html", "text": " "}}
+ ],
+ "structuredContent": {"id": 1},
+ "_meta": {"k": "v"}
+ });
+ let v = view_from_raw_result("srv", "t", None, json!({}), &raw);
+ assert_eq!(v.text, "done");
+ assert_eq!(v.resources.len(), 1);
+ assert_eq!(v.structured_content, Some(json!({"id": 1})));
+ assert_eq!(v.result_meta, Some(json!({"k": "v"})));
+}
+
+#[test]
+fn envelope_view_requires_mcp_result_kind() {
+ assert!(view_from_envelope(&json!({"kind": "mcp_call"}), None, json!({}), "").is_none());
+ let envelope = json!({
+ "kind": "mcp_result",
+ "server": "srv",
+ "tool": "t",
+ "structured_content": {"a": 1},
+ "meta": null,
+ "resources": [{"uri": "ui://x", "mimeType": "text/html", "text": " "}]
+ });
+ let v = view_from_envelope(&envelope, None, json!({}), "txt").expect("view");
+ assert_eq!(v.server_id, "srv");
+ assert!(v.result_meta.is_none());
+ assert_eq!(v.resources.len(), 1);
+ assert_eq!(v.text, "txt");
+}
+
+#[test]
+fn resource_contents_validation() {
+ let ok = json!({
+ "uri": "ui://a",
+ "mimeType": "text/html;profile=mcp-app",
+ "text": "",
+ "_meta": {"ui": {
+ "csp": {"connectDomains": ["https://api.example.com/x", "http://insecure.example.com", "javascript:alert(1)"],
+ "resourceDomains": ["https://*.cdn.example.com", "https://cdn.example.com:8443"]},
+ "prefersBorder": true,
+ "permissions": {"clipboardWrite": {}}
+ }}
+ });
+ let resource = resource_from_contents(&[ok], "ui://a").expect("resource");
+ assert_eq!(
+ resource.csp.connect_domains,
+ vec!["https://api.example.com"]
+ );
+ assert_eq!(
+ resource.csp.resource_domains,
+ vec!["https://*.cdn.example.com", "https://cdn.example.com:8443"]
+ );
+ assert!(resource.prefers_border);
+ assert!(resource.permissions.is_some());
+
+ let not_html = json!({"uri": "ui://a", "mimeType": "application/javascript", "text": "x"});
+ assert!(resource_from_contents(&[not_html], "ui://a").is_err());
+
+ let huge =
+ json!({"uri": "ui://a", "mimeType": "text/html", "text": "x".repeat(MAX_WIDGET_BYTES + 1)});
+ assert!(resource_from_contents(&[huge], "ui://a").is_err());
+
+ let blob = json!({"uri": "ui://a", "mimeType": "text/html+skybridge", "blob": "PGI+aGk8L2I+",
+ "_meta": {"openai/widgetCSP": {"connect_domains": ["https://api.example.com"]},
+ "openai/widgetPrefersBorder": true}});
+ let resource = resource_from_contents(&[blob], "ui://a").expect("blob");
+ assert_eq!(resource.html, "hi ");
+ assert_eq!(
+ resource.csp.connect_domains,
+ vec!["https://api.example.com"]
+ );
+ assert!(resource.prefers_border);
+}
+
+#[test]
+fn https_origin_rejects_odd_hosts() {
+ let origins = https_origins(Some(&json!([
+ "https://localhost",
+ "https://a..b.com",
+ "https://ok.example.com",
+ "https://ok.example.com",
+ "https://x.example.com:abc",
+ "https://user@x.example.com"
+ ])));
+ assert_eq!(origins, vec!["https://ok.example.com"]);
+}