Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions crates/tinymcp-bus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
14 changes: 12 additions & 2 deletions crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Option<Regex>> = 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]
Expand All @@ -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,
}
Expand All @@ -95,11 +89,11 @@ pub fn link_kind(class: LinkClass) -> Option<UiLinkKind> {
/// 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;
}
Expand All @@ -112,22 +106,18 @@ fn image_path(parsed: &url::Url) -> bool {
#[must_use]
pub fn extract_links(structured: Option<&Value>, text: &str) -> Vec<UiLink> {
let mut out: Vec<UiLink> = 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) {
Expand All @@ -142,6 +132,59 @@ pub fn extract_links(structured: Option<&Value>, text: &str) -> Vec<UiLink> {
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<usize> {
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;
Expand Down Expand Up @@ -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;
81 changes: 81 additions & 0 deletions crates/tinymcp-bus/src/ui/links_scan_tests.rs
Original file line number Diff line number Diff line change
@@ -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://s.com/c>`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"
);
}
}
}
}
45 changes: 39 additions & 6 deletions crates/tinymcp-bus/src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
File renamed without changes.
Loading
Loading