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"]); +}