diff --git a/Cargo.lock b/Cargo.lock index 0f8eb4c4..77e668bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1859,7 +1859,6 @@ dependencies = [ "dirs", "futures-util", "parking_lot", - "regex", "reqwest", "serde", "serde_json", @@ -1887,9 +1886,12 @@ dependencies = [ name = "tinymcp-bus" version = "0.18.5" dependencies = [ + "base64 0.23.1", + "regex", "schemars", "serde", "serde_json", + "url", ] [[package]] diff --git a/README.md b/README.md index 9fb68c2e..eca2954c 100644 --- a/README.md +++ b/README.md @@ -199,10 +199,12 @@ Enable the `tools` feature to expose each server tool as a ## 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. +`tinymcp_bus::ui` holds the rules that turn one answered tool call into what a +host shows for it, beside the vocabulary (`McpUiPresentation`, `UiLink`, +`LinkClass`, `UiResource`, `WidgetCallPolicy`, `UiRendering`), so every host +reads and writes the same metadata. The rules are pure functions of the call's +JSON, so a host applies them with the contract alone; the `ui` feature +re-exports the same items as `tinymcp::ui` for library consumers. - **Widget template.** `resolve_presentation` takes a `UiCallView` (build one with `view_from_raw_result` or `view_from_envelope`) and reads the tool's @@ -218,7 +220,10 @@ tool call into what a host shows for it. The vocabulary (`McpUiPresentation`, - **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. + schemes). `extract_links` surfaces at most five web and handoff links. A web + link is `Web` only when its host and path split cleanly; userinfo, + backslashes, IDN or percent-encoded hosts, IPv6 and non-canonical IPv4 + hosts, bad ports and dot segments are `Blocked`. - **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. diff --git a/crates/tinymcp-bus/Cargo.toml b/crates/tinymcp-bus/Cargo.toml index 890b3f9f..6603b4c8 100644 --- a/crates/tinymcp-bus/Cargo.toml +++ b/crates/tinymcp-bus/Cargo.toml @@ -23,6 +23,16 @@ serde_json = { workspace = true } # these definitions rather than a hand-kept copy. Off by default because a # host that only makes calls should not pay for it. Pure Rust, no transport. schemars = { workspace = true, optional = true } +# Decoding a widget document a tool result embedded as a base64 `blob`, for +# the pure tool-UI rules in `ui`. No dependencies of its own. +base64 = { workspace = true } + +[dev-dependencies] +# The WHATWG parser the `ui` link splitter must agree with: its differential +# tests check every web link the splitter accepts against `url`'s reading. +url = { workspace = true } +# The pattern the `ui` URL scanner replaced, kept as the scanner's oracle. +regex = { workspace = true } [features] default = [] diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index 8fdccbb4..1848e075 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -2,8 +2,8 @@ Shared serialized MCP vocabulary, fixed tool declarations, errors, method names and contract version. The implementation re-exports the same definitions. -Normal/build dependency closures contain only serialization/schema utilities, -including with all features enabled. No optional runtime, transport, native or +Normal/build dependency closures contain only serialization/schema utilities +and `base64`, including with all features enabled. No optional runtime, transport, native or TinyTools dependency belongs here. DTO constructors, fixed catalog/schema declarations and serde helpers are @@ -67,6 +67,16 @@ Contract 1.24 carries each tool's `_meta` on `McpTool`, so `ListTools`, with `ResourceTooLarge`, and redact the server's known credentials from the contents' text and `_meta`. +Contract 1.25 moves the pure tool-UI rules (template resolution, widget +document checks, link classification and extraction, the widget tool-call and +rendering policy) from `tinymcp::ui` into `ui`, so a host renders tool UI +without linking the module crate. `tinymcp::ui` re-exports them. Link +classification splits `http(s)` hosts by hand, fail-closed, instead of with a +URL parser. No member was added or removed. Behaviour change (fail-closed): +`http(s)` links with userinfo, IDN/punycode or %-encoded hosts, IPv6, +non-canonical IPv4, bad ports or dot segments are now `Blocked` (previously +`Web`). + Library consumers bring implementation extension traits into scope for moved DTO methods. Contract-only hosts call the compiled module. See [the relocation specification](../../docs/specs/pure-vocabulary.md) for public diff --git a/crates/tinymcp/src/ui/links.rs b/crates/tinymcp-bus/src/ui/links.rs similarity index 62% rename from crates/tinymcp/src/ui/links.rs rename to crates/tinymcp-bus/src/ui/links.rs index 5d4ebee4..6d0834e9 100644 --- a/crates/tinymcp/src/ui/links.rs +++ b/crates/tinymcp-bus/src/ui/links.rs @@ -4,11 +4,9 @@ //! 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::web_url::WebUrl; use super::{LinkClass, UiLink, UiLinkKind}; /// The most links one result surfaces. @@ -43,9 +41,10 @@ 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() -}); +/// The longest scheme the scanner recognizes, in bytes. +const MAX_SCHEME_LEN: usize = 32; +/// Characters that end a URL in free text, besides whitespace. +const URL_TERMINATORS: &str = "\"'<>`{}|\\^[]"; /// How `url` is classified, by scheme and, for `http(s)`, by path. #[must_use] @@ -67,15 +66,10 @@ pub fn classify_link(url: &str) -> LinkClass { 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, + "http" | "https" => match WebUrl::split(url) { + Some(web) if image_path(web.path) => LinkClass::Image, + Some(_) => LinkClass::Web, + None => LinkClass::Blocked, }, _ => LinkClass::Handoff, } @@ -95,11 +89,11 @@ pub fn link_kind(class: LinkClass) -> Option { /// 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)) + WebUrl::split(url).is_some_and(|web| image_path(web.path)) } -fn image_path(parsed: &url::Url) -> bool { - let path = parsed.path().to_ascii_lowercase(); +pub(super) fn image_path(path: &str) -> bool { + let path = path.to_ascii_lowercase(); if path.contains("/image/upload/") { return true; } @@ -112,22 +106,18 @@ fn image_path(parsed: &url::Url) -> bool { #[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) { + for found in url_spans(haystack) { if out.len() >= MAX_LINKS { return out; } - let url = trim_trailing(found.as_str()); + let url = trim_trailing(found); 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) { @@ -142,6 +132,59 @@ pub fn extract_links(structured: Option<&Value>, text: &str) -> Vec { out } +/// Every `scheme://rest` run in `haystack`, leftmost first and +/// non-overlapping: a scheme of 2 to [`MAX_SCHEME_LEN`] ASCII letters, digits, +/// `+`, `.` or `-` that starts with a letter and a word boundary, then `://`, +/// then everything up to whitespace or a [`URL_TERMINATORS`] character. +/// +/// Only finds candidates; [`classify_link`] decides what each one is. +fn url_spans(haystack: &str) -> Vec<&str> { + let mut spans = Vec::new(); + let mut at = 0; + while let Some(next) = haystack[at..].chars().next() { + let at_boundary = haystack[..at] + .chars() + .next_back() + .is_none_or(|c| !is_word(c)); + if at_boundary && let Some(len) = url_len(&haystack[at..]) { + spans.push(&haystack[at..at + len]); + at += len; + } else { + at += next.len_utf8(); + } + } + spans +} + +/// The length of the URL `text` starts with, if it starts with one. +fn url_len(text: &str) -> Option { + let scheme = text + .bytes() + .take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'.' | b'-')) + // Bounded, so text like `a.a.a.…` (a boundary every other byte) stays + // linear rather than rescanning the run from every boundary. + .take(MAX_SCHEME_LEN + 1) + .count(); + if !(2..=MAX_SCHEME_LEN).contains(&scheme) + || !text.as_bytes()[0].is_ascii_alphabetic() + || !text[scheme..].starts_with("://") + { + return None; + } + let start = scheme + "://".len(); + let body: usize = text[start..] + .chars() + .take_while(|c| !c.is_whitespace() && !URL_TERMINATORS.contains(*c)) + .map(char::len_utf8) + .sum(); + (body > 0).then_some(start + body) +} + +/// A word character, for the boundary before a scheme. +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + 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; @@ -178,3 +221,7 @@ fn trim_trailing(url: &str) -> &str { #[cfg(test)] #[path = "links_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "links_scan_tests.rs"] +mod scan_tests; diff --git a/crates/tinymcp-bus/src/ui/links_scan_tests.rs b/crates/tinymcp-bus/src/ui/links_scan_tests.rs new file mode 100644 index 00000000..25174fc6 --- /dev/null +++ b/crates/tinymcp-bus/src/ui/links_scan_tests.rs @@ -0,0 +1,81 @@ +//! Differential tests for the hand-written URL scanner, against the regex it +//! replaced: `(?i)\b([a-z][a-z0-9+.\-]{1,31})://[^\s"'<>\x60{}|\\^\[\]]+`. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +fn regex_spans(haystack: &str) -> Vec<&str> { + regex::Regex::new(r#"(?i)\b([a-z][a-z0-9+.\-]{1,31})://[^\s"'<>\x60{}|\\^\[\]]+"#) + .unwrap() + .find_iter(haystack) + .map(|found| found.as_str()) + .collect() +} + +const HAYSTACKS: &[&str] = &[ + "", + "no links here", + "Pay at https://pay.example.com/o/1 or phonepe://pay?id=1 or upi://pay?pa=a@b&am=10.", + "javascript://x%0Aalert(1) data://text/html,hi file:///etc/passwd tauri://localhost", + "[pay](https://pay.example.com/x) (see https://a.example.com/p_(1)).", + "\"https://q.com/a\"'https://r.com/b'`https://t.com/d`", + "{https://u.com}|https://v.com|^https://w.com^\\https://x.com\\", + "1http://a.com _http://b.com ahttp://c.com x.https://d.com x-https://e.com x+f://g", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.https://long.example.com", + "abcdefghijklmnopqrstuvwxyzabcdef://exactly-32 abcdefghijklmnopqrstuvwxyzabcdefg://33", + "a://one-letter ab://two-letters", + "https:// empty https://\tx https://\u{a0}nbsp https://\u{3000}ideographic", + "éhttps://after-accent 日本https://after-cjk ١https://after-arabic-digit", + "—https://after-dash «https://after-guillemet» (https://in-parens)", + "HTTPS://UPPER.EXAMPLE.COM/PATH mailto://x@y.com", + "https://a.com/ü/日本?q=ü#ü https://b.com/emoji/😀", + "https://a.com:https://b.com https://c.com//https://d.com", + "x://y://z http:/single http//no-colon", +]; + +#[test] +fn finds_the_same_spans_as_the_regex() { + for haystack in HAYSTACKS { + assert_eq!( + url_spans(haystack), + regex_spans(haystack), + "{haystack:?}: the scanner and the regex disagree" + ); + } +} + +#[test] +fn finds_spans_in_generated_text() { + let pieces = [ + "https://a.com", + "x", + " ", + ".", + "_", + "é", + "://", + "ab", + "\"", + "1", + "-", + "+", + "\n", + "upi://pay?pa=a@b", + "(", + ")", + "日", + ]; + for a in pieces { + for b in pieces { + for c in pieces { + let haystack = format!("{a}{b}{c}"); + assert_eq!( + url_spans(&haystack), + regex_spans(&haystack), + "{haystack:?}: the scanner and the regex disagree" + ); + } + } + } +} diff --git a/crates/tinymcp/src/ui/links_tests.rs b/crates/tinymcp-bus/src/ui/links_tests.rs similarity index 100% rename from crates/tinymcp/src/ui/links_tests.rs rename to crates/tinymcp-bus/src/ui/links_tests.rs diff --git a/crates/tinymcp-bus/src/ui/mod.rs b/crates/tinymcp-bus/src/ui/mod.rs index db1412c1..010742da 100644 --- a/crates/tinymcp-bus/src/ui/mod.rs +++ b/crates/tinymcp-bus/src/ui/mod.rs @@ -1,17 +1,50 @@ -//! The vocabulary for tool-provided UI. +//! Tool-provided UI: the vocabulary, and the pure rules that turn one answered +//! MCP tool call into what a host shows for it. //! //! 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. +//! data, which links. The shapes that presentation is made of are the shared +//! vocabulary, 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`. +//! The rules live here too because they are pure functions of the call's JSON +//! (no I/O, no runtime, nothing cached or fetched), so a host applies them +//! without linking the module: +//! +//! - [`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. +mod links; +mod policy; +mod resolve; mod types; +mod web_url; +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 types::{ 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-bus/src/ui/policy.rs similarity index 100% rename from crates/tinymcp/src/ui/policy.rs rename to crates/tinymcp-bus/src/ui/policy.rs diff --git a/crates/tinymcp/src/ui/policy_tests.rs b/crates/tinymcp-bus/src/ui/policy_tests.rs similarity index 100% rename from crates/tinymcp/src/ui/policy_tests.rs rename to crates/tinymcp-bus/src/ui/policy_tests.rs diff --git a/crates/tinymcp/src/ui/resolve.rs b/crates/tinymcp-bus/src/ui/resolve.rs similarity index 93% rename from crates/tinymcp/src/ui/resolve.rs rename to crates/tinymcp-bus/src/ui/resolve.rs index 6bd88e65..717b1b85 100644 --- a/crates/tinymcp/src/ui/resolve.rs +++ b/crates/tinymcp-bus/src/ui/resolve.rs @@ -123,14 +123,13 @@ 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"); + if let Some(uri) = meta_str(meta, path).map(str::trim) + && is_ui_uri(uri) + { + return Some(UiSource::Uri { + uri: uri.to_string(), + flavor, + }); } } } @@ -154,11 +153,7 @@ pub fn select_source(view: &UiCallView) -> Option { 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()) + (size <= limit).then(|| value.clone()) } /// The presentation for one call, or `None` when it offered neither a widget @@ -186,12 +181,7 @@ pub fn resolve_presentation(view: &UiCallView) -> Option { .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"); - } - } + inline_document = resource_from_contents(std::slice::from_ref(&resource), &uri).ok(); (None, UiFlavor::McpApps) } None => (None, UiFlavor::McpApps), @@ -199,13 +189,6 @@ pub fn resolve_presentation(view: &UiCallView) -> Option { 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(), diff --git a/crates/tinymcp/src/ui/resolve_tests.rs b/crates/tinymcp-bus/src/ui/resolve_tests.rs similarity index 100% rename from crates/tinymcp/src/ui/resolve_tests.rs rename to crates/tinymcp-bus/src/ui/resolve_tests.rs diff --git a/crates/tinymcp-bus/src/ui/web_url.rs b/crates/tinymcp-bus/src/ui/web_url.rs new file mode 100644 index 00000000..66d892f8 --- /dev/null +++ b/crates/tinymcp-bus/src/ui/web_url.rs @@ -0,0 +1,113 @@ +//! The host and path of an `http(s)` link, split by hand so the contract needs +//! no URL parser. +//! +//! Fail-closed: [`WebUrl::split`] accepts only plain shapes it splits exactly as +//! a WHATWG parser (the `url` crate) would, and refuses everything else, which +//! link classification treats as blocked. Refused even though a browser would +//! open them: +//! +//! - userinfo and backslashes; +//! - whitespace or control characters anywhere; +//! - hosts that are percent-encoded or non-ASCII (IDN), and punycode labels; +//! - bracketed IPv6, and IPv4 in any form other than canonical dotted decimal; +//! - an empty, non-numeric or out-of-range port; +//! - dot segments in the path, including percent-encoded ones. +//! +//! `web_url_tests.rs` checks every acceptance against `url`. + +/// The longest host accepted, in bytes. +const MAX_HOST_LEN: usize = 253; +/// The longest host label accepted, in bytes. +const MAX_LABEL_LEN: usize = 63; + +/// An `http` or `https` URL's host and path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WebUrl<'a> { + /// The host, lowercased: what `url::Url::host_str` reports for it. + pub(super) host: String, + /// The path, without query or fragment; empty when the URL has none. + pub(super) path: &'a str, +} + +impl<'a> WebUrl<'a> { + /// `url` split into host and path, or `None` when it is not an `http(s)` + /// URL of a shape this splitter is certain of. + pub(super) fn split(url: &'a str) -> Option { + let (scheme, rest) = url.split_once("://")?; + if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) + || url + .chars() + .any(|c| c == '\\' || c.is_control() || c.is_whitespace()) + { + return None; + } + let (authority, tail) = rest.split_at(rest.find(['/', '?', '#']).unwrap_or(rest.len())); + let path = &tail[..tail.find(['?', '#']).unwrap_or(tail.len())]; + let (host, port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = host.to_ascii_lowercase(); + if port.is_some_and(|port| !valid_port(port)) + || !(valid_ipv4(&host) || valid_domain(&host)) + || has_dot_segment(path) + { + return None; + } + Some(Self { host, path }) + } +} + +/// One to five digits naming a port no larger than 65535. +fn valid_port(port: &str) -> bool { + (1..=5).contains(&port.len()) + && port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().is_ok() +} + +/// Canonical dotted decimal: four labels of 0 to 255, no leading zeros. +fn valid_ipv4(host: &str) -> bool { + let labels: Vec<&str> = host.split('.').collect(); + labels.len() == 4 + && labels.iter().all(|label| { + (1..=3).contains(&label.len()) + && label.bytes().all(|b| b.is_ascii_digit()) + && (label.len() == 1 || !label.starts_with('0')) + && label.parse::().is_ok() + }) +} + +/// Lowercase ASCII labels of letters, digits and inner single hyphens. +/// +/// A last label a WHATWG parser would read as a number (all digits, or `0x`) +/// is refused: that host is an IPv4 address, and only [`valid_ipv4`]'s form +/// is accepted. A `--` anywhere is refused, which covers `xn--` punycode. +fn valid_domain(host: &str) -> bool { + let last = host.rsplit('.').next().unwrap_or_default(); + !host.is_empty() + && host.len() <= MAX_HOST_LEN + && !last.bytes().all(|b| b.is_ascii_digit()) + && !last.starts_with("0x") + && host.split('.').all(|label| { + (1..=MAX_LABEL_LEN).contains(&label.len()) + && label + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') + && !label.contains("--") + }) +} + +/// Whether a path segment is `.` or `..`, literally or percent-encoded. A +/// WHATWG parser removes those, so the path it reports would differ. +fn has_dot_segment(path: &str) -> bool { + path.split('/').any(|segment| { + let decoded = segment.to_ascii_lowercase().replace("%2e", "."); + decoded == "." || decoded == ".." + }) +} + +#[cfg(test)] +#[path = "web_url_tests.rs"] +mod tests; diff --git a/crates/tinymcp-bus/src/ui/web_url_tests.rs b/crates/tinymcp-bus/src/ui/web_url_tests.rs new file mode 100644 index 00000000..ea99b9b1 --- /dev/null +++ b/crates/tinymcp-bus/src/ui/web_url_tests.rs @@ -0,0 +1,237 @@ +//! Differential tests for the hand-written `http(s)` splitter, against the +//! `url` crate's WHATWG parser. +//! +//! The property: whenever link classification calls a URL [`LinkClass::Web`] +//! or [`LinkClass::Image`], `url::Url::parse` accepts it, reports the same +//! host, and reaches the same image verdict. A shape the splitter is unsure +//! of must come back blocked. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use crate::ui::links::image_path; +use crate::ui::{LinkClass, classify_link}; + +/// Asserts the property for one input. Returns whether the splitter +/// accepted it. +fn agrees_with_url(input: &str) -> bool { + let class = classify_link(input); + let split = WebUrl::split(input); + if !matches!(class, LinkClass::Web | LinkClass::Image) { + return split.is_some(); + } + let web = split.unwrap_or_else(|| panic!("{input:?}: classified {class:?} without a split")); + let parsed = url::Url::parse(input) + .unwrap_or_else(|error| panic!("{input:?}: accepted, but url refuses it: {error}")); + assert!( + matches!(parsed.scheme(), "http" | "https"), + "{input:?}: accepted a non-web scheme" + ); + assert_eq!( + parsed.host_str(), + Some(web.host.as_str()), + "{input:?}: the splitter and url disagree on the host" + ); + assert_eq!( + class == LinkClass::Image, + image_path(parsed.path()), + "{input:?}: the splitter and url disagree on the image verdict" + ); + true +} + +/// Hostile and unusual shapes, each of which the splitter must refuse. +const REFUSED: &[&str] = &[ + // userinfo + "https://a@b", + "https://a@b.com/", + "https://user:pass@b.com/", + "https://b.com@evil.com/", + "https://@b.com/", + // backslashes + "https:\\\\a.com", + "https://a.com\\@evil.com", + "https://a.com/x\\y.png", + // tabs, newlines and spaces + "https://a\t.com", + "https://a.co\nm/", + "https://a.com/\r\n", + "https://a .com", + " https://a.com", + // IDN and punycode + "https://bücher.de/", + "https://xn--bcher-kva.de/", + "https://XN--BCHER-KVA.DE/", + "https://a.xn--p1ai/", + "https://ab--cd.com/", + "https://аpple.com/", + // percent-encoded hosts + "https://%61.com/", + "https://a%2ecom/", + "https://a.com%2f@evil.com/", + // IPv4 in non-canonical forms + "https://0x7f.0.0.1/", + "https://0x7f000001/", + "https://2130706433/", + "https://127.1/", + "https://127.0.1/", + "https://010.0.0.1/", + "https://127.0.0.01/", + "https://256.0.0.1/", + "https://1.2.3.4.5/", + "https://a.123/", + "https://a.0x1/", + // IPv6, in any form + "https://[::1]/", + "https://[0:0::1]/", + "https://[::ffff:127.0.0.1]/", + "https://[fe80::1%25eth0]/", + "https://[::1]:8080/", + // ports + "https://a.com:65536/", + "https://a.com:99999/", + "https://a.com:123456/", + "https://a.com:/", + "https://a.com:-1/", + "https://a.com:+80/", + "https://a.com:8o/", + "https://a.com:80:80/", + // empty and malformed hosts + "https://", + "https:///a.com", + "https:/a.com", + "https:a.com", + "https://.com/", + "https://a..com/", + "https://a.com./", + "https://-a.com/", + "https://a-.com/", + "https://a_b.com/", + "https://a!b.com/", + // dot segments + "https://a.com/../b.png", + "https://a.com/x/./b", + "https://a.com/%2e%2e/b.png", + "https://a.com/.%2E/b", + "https://a.com/x/%2E", + // not web schemes at all + "javascript:alert(1)", + "JAVASCRIPT://a.com/%0Aalert(1)", + "data:text/html,", + "data://a.com/x", + "ftp://a.com/x.png", + "file:///etc/passwd", + "httpx://a.com/", + "nonsense", +]; + +/// Plain shapes the splitter must accept, so the property is not vacuous. +const ACCEPTED: &[&str] = &[ + "https://a.com", + "http://a.com/x", + "HTTPS://A.COM/X", + "HtTpS://Pay.Example.com/o/1?id=2#top", + "https://a.com:443/a.png?x=1#y", + "http://127.0.0.1:8080/p", + "http://0.0.0.0/", + "https://255.255.255.255/", + "http://localhost/", + "https://1e.com/", + "https://0x7f.com/", + "https://a-b.c-d.com/image/upload/x", + "https://a.com:08/x.JPG", + "https://a.com/p_(1)", + "https://a.com/%20x", + "https://a.com/ünï.png", +]; + +#[test] +fn refuses_every_hostile_shape() { + for input in REFUSED { + assert!( + WebUrl::split(input).is_none(), + "{input:?}: the splitter accepted a shape it must refuse" + ); + assert!( + !matches!(classify_link(input), LinkClass::Web | LinkClass::Image), + "{input:?}: classified as a web link" + ); + agrees_with_url(input); + } +} + +#[test] +fn accepts_plain_web_urls_exactly_as_url_reads_them() { + for input in ACCEPTED { + assert!( + agrees_with_url(input), + "{input:?}: the splitter refused a plain URL" + ); + } +} + +#[test] +fn splits_host_and_path() { + let web = WebUrl::split("HTTPS://Pay.Example.COM:8443/O/1.png?x=a.png#b").unwrap(); + assert_eq!(web.host, "pay.example.com"); + assert_eq!(web.path, "/O/1.png"); + assert_eq!(WebUrl::split("https://a.com?x").unwrap().path, ""); + assert_eq!(WebUrl::split("https://a.com#x").unwrap().path, ""); +} + +#[test] +fn every_combination_agrees_with_url() { + let schemes = ["http", "https", "HTTPS", "hTtP"]; + let hosts = [ + "a.com", + "A.Com", + "x.y.z.example", + "localhost", + "127.0.0.1", + "1.2.3.4", + "256.0.0.1", + "1.2.3", + "0x7f.0.0.1", + "01.2.3.4", + "[::1]", + "xn--nxasmq6b.com", + "a_b.com", + "a%2eb.com", + "a.com.", + "1e.com", + "a.0x1", + "a.1", + "u@a.com", + "", + ]; + let ports = ["", ":80", ":0", ":08", ":65535", ":65536", ":", ":x"]; + let paths = [ + "", + "/", + "/a.PNG", + "/a.png/", + "/a/../b.png", + "/a.png/..", + "/%2E/x.jpg", + "/image/upload/x", + "/IMAGE/UPLOAD/x", + "/x?y.png", + "/x#a.png", + "?q", + "#f", + "/a.b/c", + ]; + let mut accepted = 0; + for scheme in schemes { + for host in hosts { + for port in ports { + for path in paths { + if agrees_with_url(&format!("{scheme}://{host}{port}{path}")) { + accepted += 1; + } + } + } + } + } + assert!(accepted > 0, "no combination was accepted"); +} diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index b49e5f24..36dd8651 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -34,8 +34,11 @@ /// (`require_identifier`, `ServerDetail::server_json`, `explain_not_connected`) that /// hosts previously copied; no member was added or removed. 1.24 adds tool /// `_meta` on `McpTool` and the `ReadResource` and `StaticReadResource` -/// members. -pub const CONTRACT_VERSION: (u32, u32) = (1, 24); +/// members. 1.25 moves the pure tool-UI rules into `ui`; no member was added +/// or removed. Behaviour change (fail-closed): `http(s)` links with userinfo, +/// IDN/punycode or %-encoded hosts, IPv6, non-canonical IPv4, bad ports or dot +/// segments are now `Blocked` (previously `Web`). +pub const CONTRACT_VERSION: (u32, u32) = (1, 25); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs index 922e4081..49a207ac 100644 --- a/crates/tinymcp-bus/src/version/mod_tests.rs +++ b/crates/tinymcp-bus/src/version/mod_tests.rs @@ -4,7 +4,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 24)); + assert_eq!(CONTRACT_VERSION, (1, 25)); } #[test] diff --git a/crates/tinymcp/Cargo.toml b/crates/tinymcp/Cargo.toml index 26caca43..ef3c58bf 100644 --- a/crates/tinymcp/Cargo.toml +++ b/crates/tinymcp/Cargo.toml @@ -60,8 +60,6 @@ anyhow = { workspace = true, optional = true } # path, not only the optional `tinytools::Tool` adapter. url = { workspace = true } urlencoding = { workspace = true } -# Finding URLs in tool results, for the `ui` feature's link extraction. -regex = { workspace = true, optional = true } [[example]] name = "verify_module" @@ -107,8 +105,9 @@ server-http = ["dep:axum", "dep:tokio-stream", "tokio/net"] tools = ["dep:async-trait", "dep:anyhow"] # 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:regex"] +# rendering policy. The rules live in `tinymcp_bus::ui`, which a host can take +# without this crate; this feature only re-exports them here. Off by default. +ui = [] [lints] workspace = true diff --git a/crates/tinymcp/src/ui/mod.rs b/crates/tinymcp/src/ui/mod.rs index f0260a05..f4e5afa4 100644 --- a/crates/tinymcp/src/ui/mod.rs +++ b/crates/tinymcp/src/ui/mod.rs @@ -1,38 +1,9 @@ -//! Tool-provided UI for hosts: the rules that turn one answered MCP tool call -//! into what a host shows for it. +//! Tool-provided UI for hosts. //! -//! - [`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; +//! The vocabulary and the rules (template resolution, widget document checks, +//! link classification, the widget tool-call and rendering policy) are pure, +//! so they live in the contract, [`tinymcp_bus::ui`], where a host reaches +//! them without linking this crate. This module re-exports them unchanged for +//! hosts that already take `tinymcp` as a library. -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, -}; +pub use tinymcp_bus::ui::*;