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
39 changes: 39 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions crates/tinymcp-bus/src/agent_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand All @@ -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;
112 changes: 112 additions & 0 deletions crates/tinymcp-bus/src/agent_tools/mod_envelope_tests.rs
Original file line number Diff line number Diff line change
@@ -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": "<p>" })).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": "<p>" }],
"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);
}
Loading
Loading