From b3ca9bad54c4b7adbb46288fd843e9d6c75646f1 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:25 +0530 Subject: [PATCH 01/10] feat(bus): keep tool _meta and add resource and rich-result payloads McpRemoteTool keeps the server's _meta. McpToolResult gains optional structured_content, meta and embedded resources, all host-facing: no rendering method reads them, so model-facing text is unchanged and the wire form is unchanged when they are absent. Adds McpResource, McpResourceContents and MAX_RESOURCE_BYTES for resources/list and resources/read. --- crates/tinymcp-bus/src/lib.rs | 7 +- crates/tinymcp-bus/src/transport/mod.rs | 7 +- crates/tinymcp-bus/src/transport/mod_tests.rs | 93 ++++++++++++++++ crates/tinymcp-bus/src/transport/types.rs | 103 +++++++++++++++++- crates/tinymcp/src/lib.rs | 16 +-- crates/tinymcp/src/tools/mod_tests.rs | 3 + 6 files changed, 212 insertions(+), 17 deletions(-) diff --git a/crates/tinymcp-bus/src/lib.rs b/crates/tinymcp-bus/src/lib.rs index 33cc56ea..4c9592f0 100644 --- a/crates/tinymcp-bus/src/lib.rs +++ b/crates/tinymcp-bus/src/lib.rs @@ -158,8 +158,9 @@ 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 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/src/lib.rs b/crates/tinymcp/src/lib.rs index ca8448b8..eeeb9893 100644 --- a/crates/tinymcp/src/lib.rs +++ b/crates/tinymcp/src/lib.rs @@ -115,14 +115,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, + LATEST_PROTOCOL_VERSION, MAX_DESCRIPTION_BYTES, MAX_LIST_LIMIT, MAX_RESOURCE_BYTES, + 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, + McpRegistryAuthConfig, McpRemoteTool, McpResource, McpResourceContents, 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/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() }) } } From 5e1e95720942f6618761c34e31c5cb0f48a2cec3 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:31 +0530 Subject: [PATCH 02/10] feat(bus): make initialize client capabilities configurable McpClientIdentityConfig gains capabilities, sent as initialize.capabilities. Defaults to an empty object and is left off the wire when empty, so existing configurations serialize unchanged. --- crates/tinymcp-bus/src/config/mod_tests.rs | 34 ++++++++++++++++++++++ crates/tinymcp-bus/src/config/types.rs | 20 +++++++++++++ 2 files changed, 54 insertions(+) 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(), } } } From d54badcc8c4efba5e793eb0bde39a25e7b8e98d4 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:31 +0530 Subject: [PATCH 03/10] feat(bus): add the mcp_result envelope and resource-too-large error McpResultEnvelope (kind mcp_result) carries a call's structured content, _meta, embedded resources and optional McpCallOutcome as host-only metadata. McpCallOutcome::from_metadata reads the outcome from either the envelope or the bare mcp_call shape, so hosts that meter calls through it keep working. Adds Error::ResourceTooLarge and bumps the contract to 1.5. --- crates/tinymcp-bus/src/agent_tools/mod.rs | 12 +- .../src/agent_tools/mod_envelope_tests.rs | 112 +++++++++++++++ crates/tinymcp-bus/src/agent_tools/types.rs | 136 +++++++++++++++++- crates/tinymcp-bus/src/errors/mod.rs | 3 + crates/tinymcp-bus/src/lib.rs | 4 +- crates/tinymcp-bus/src/version/mod.rs | 8 +- crates/tinymcp-bus/src/version/mod_tests.rs | 8 +- crates/tinymcp/src/error/mod.rs | 15 ++ crates/tinymcp/src/error/mod_tests.rs | 5 + crates/tinymcp/src/lib.rs | 18 +-- 10 files changed, 299 insertions(+), 22 deletions(-) create mode 100644 crates/tinymcp-bus/src/agent_tools/mod_envelope_tests.rs 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/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 4c9592f0..26f934e4 100644 --- a/crates/tinymcp-bus/src/lib.rs +++ b/crates/tinymcp-bus/src/lib.rs @@ -130,8 +130,8 @@ pub mod transport; 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, diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index b2c35c02..2bd35e11 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -12,8 +12,10 @@ /// 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. +pub const CONTRACT_VERSION: (u32, u32) = (1, 5); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. @@ -27,7 +29,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, 5))); /// 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..4816ca57 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, 5)); } #[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.5 added tool `_meta` and resource payloads; a 1.4 module has neither. assert!(!is_compatible((1, 0))); - assert!(!is_compatible((1, 3))); + assert!(!is_compatible((1, 4))); } #[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, 5))); assert!(is_compatible((1, 97))); } 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 eeeb9893..7192b378 100644 --- a/crates/tinymcp/src/lib.rs +++ b/crates/tinymcp/src/lib.rs @@ -116,13 +116,13 @@ pub use tinymcp_bus::{ 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_RESOURCE_BYTES, - MAX_TITLE_BYTES, MCP_CALL_RESULT_KIND, METHODS, McpAuthChallenge, McpAuthConfig, McpAuthHint, - McpAuthorizationContext, McpCallError, McpCallOutcome, McpClientConfig, - McpClientIdentityConfig, McpClientInfo, McpInitializeResult, McpProxyConfig, - McpRegistryAuthConfig, McpRemoteTool, McpResource, McpResourceContents, 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, + 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, }; From ef7518b9bea9135254b5c43eb2fd27b977b3b480 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:42 +0530 Subject: [PATCH 04/10] feat(transport): send client capabilities, keep tool _meta, read resources Both transports send the identity's capabilities at initialize (HTTP builder capabilities(), stdio with_capabilities()), cache listed tools with their _meta (cached_tool), and gain list_resources (paginated, bounded) and read_resource (raw contents, refused above MAX_RESOURCE_BYTES). render_tool_result fills structuredContent, _meta and embedded resource blocks into the host-facing fields; the rendered text is byte-identical. --- crates/tinymcp/src/transport/http/mod.rs | 102 +++++++++- .../src/transport/http/mod_ui_tests.rs | 174 ++++++++++++++++++ crates/tinymcp/src/transport/mod.rs | 3 + crates/tinymcp/src/transport/render.rs | 49 ++++- crates/tinymcp/src/transport/render_tests.rs | 91 +++++++++ crates/tinymcp/src/transport/resources.rs | 63 +++++++ .../tinymcp/src/transport/resources_tests.rs | 107 +++++++++++ crates/tinymcp/src/transport/stdio/mod.rs | 98 +++++++++- .../src/transport/stdio/mod_ui_tests.rs | 173 +++++++++++++++++ crates/tinymcp/src/transport/ui_fixture.rs | 117 ++++++++++++ 10 files changed, 964 insertions(+), 13 deletions(-) create mode 100644 crates/tinymcp/src/transport/http/mod_ui_tests.rs create mode 100644 crates/tinymcp/src/transport/render_tests.rs create mode 100644 crates/tinymcp/src/transport/resources.rs create mode 100644 crates/tinymcp/src/transport/resources_tests.rs create mode 100644 crates/tinymcp/src/transport/stdio/mod_ui_tests.rs create mode 100644 crates/tinymcp/src/transport/ui_fixture.rs 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() +} From a733cf62b79cf17cf9e02f29fbc978221a8ec71e Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:42 +0530 Subject: [PATCH 05/10] feat(registry): expose tool _meta and resources by server id Connections captures each tool's _meta at connect (tool_meta, tool_metas) and reads resources of a connected server (list_resources, read_resource). McpServerRegistry answers tool_meta from the last listing, honouring its allow and deny lists, and lists and reads resources by server name. --- crates/tinymcp/src/config_servers/mod.rs | 4 + .../src/config_servers/mod_ui_tests.rs | 156 ++++++++++++++++++ crates/tinymcp/src/config_servers/types.rs | 71 +++++++- .../tinymcp/src/registry/connections/mod.rs | 4 + .../src/registry/connections/mod_ui_tests.rs | 109 ++++++++++++ .../tinymcp/src/registry/connections/types.rs | 86 +++++++++- 6 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 crates/tinymcp/src/config_servers/mod_ui_tests.rs create mode 100644 crates/tinymcp/src/registry/connections/mod_ui_tests.rs 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/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() From afca754368cdfc46b234c24a99a3a85b96e17153 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 15:34:42 +0530 Subject: [PATCH 06/10] feat(tools): attach the mcp_result envelope to MCP tool results tool_result_for maps a result exactly as tool_result does and attaches an McpResultEnvelope as host-only metadata; McpServerTool uses it. mcp_call_tool attaches the envelope with its McpCallOutcome nested under outcome on an answered call, scrubbing secrets from the server-supplied parts; failed calls keep the bare outcome. McpCallOutcome::from_metadata reads both. --- crates/tinymcp/src/tools/bridge.rs | 47 ++++- .../tinymcp/src/tools/bridge_outcome_tests.rs | 7 +- crates/tinymcp/src/tools/envelope_tests.rs | 189 ++++++++++++++++++ crates/tinymcp/src/tools/mod.rs | 5 +- crates/tinymcp/src/tools/result.rs | 17 +- crates/tinymcp/src/tools/tool.rs | 4 +- 6 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 crates/tinymcp/src/tools/envelope_tests.rs 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/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) => { From a13ce46b51621399157e9fa6c473846c1427e533 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 18:59:58 +0530 Subject: [PATCH 07/10] Add the tool UI presentation vocabulary to the contract McpUiPresentation, UiLink and LinkClass, UiResource and UiCsp, the widget call policy and rendering choice, and the MCP Apps constants, so every host reads and writes the same tool UI metadata. --- crates/tinymcp-bus/src/lib.rs | 7 ++ crates/tinymcp-bus/src/ui/mod.rs | 22 ++++ crates/tinymcp-bus/src/ui/mod_tests.rs | 60 +++++++++ crates/tinymcp-bus/src/ui/types.rs | 161 +++++++++++++++++++++++++ 4 files changed, 250 insertions(+) create mode 100644 crates/tinymcp-bus/src/ui/mod.rs create mode 100644 crates/tinymcp-bus/src/ui/mod_tests.rs create mode 100644 crates/tinymcp-bus/src/ui/types.rs diff --git a/crates/tinymcp-bus/src/lib.rs b/crates/tinymcp-bus/src/lib.rs index 26f934e4..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,6 +129,7 @@ pub mod names; pub mod registry; pub mod sanitize; pub mod transport; +pub mod ui; pub mod version; pub use agent_tools::{ @@ -163,4 +166,8 @@ pub use transport::{ 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/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, +} From 0c2af69cf912a12aa64a941e2671cc8bb1de6e76 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 18:59:58 +0530 Subject: [PATCH 08/10] Bump the contract to 1.6 for the tool UI vocabulary --- crates/tinymcp-bus/src/version/mod.rs | 7 ++++--- crates/tinymcp-bus/src/version/mod_tests.rs | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index 2bd35e11..70ea7d52 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -14,8 +14,9 @@ /// the search page's freshness, including answers from the local catalog index, /// 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. -pub const CONTRACT_VERSION: (u32, u32) = (1, 5); +/// 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`. @@ -29,7 +30,7 @@ pub const CONTRACT_VERSION: (u32, u32) = (1, 5); /// ``` /// # use tinymcp_bus::{is_compatible, CONTRACT_VERSION}; /// assert!(is_compatible(CONTRACT_VERSION)); -/// assert!(is_compatible((1, 5))); +/// 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 4816ca57..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, 5)); + assert_eq!(CONTRACT_VERSION, (1, 6)); } #[test] fn a_host_on_this_contract_refuses_a_module_from_before_it() { - // 1.5 added tool `_meta` and resource payloads; a 1.4 module has neither. + // 1.6 added the tool UI presentation vocabulary; a 1.5 module predates it. assert!(!is_compatible((1, 0))); - assert!(!is_compatible((1, 4))); + 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, 5))); + assert!(is_compatible((1, 7))); assert!(is_compatible((1, 97))); } From 91518219577ab30e1bd8b2d92ffd8fc0772cc7f0 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 18:59:58 +0530 Subject: [PATCH 09/10] Add tinymcp::ui: widget templates, documents, links and policy Behind the new ui feature: resolve a call's widget template from the tool's _meta, cap the data a presentation carries, validate widget documents, classify links (web, handoff, image, blocked), and decide widget tool calls and rendering. Pure over the call's JSON; caching stays with the host. --- Cargo.lock | 39 +++ Cargo.toml | 2 + crates/tinymcp/Cargo.toml | 6 + crates/tinymcp/src/lib.rs | 2 + crates/tinymcp/src/ui/links.rs | 180 ++++++++++ crates/tinymcp/src/ui/links_tests.rs | 128 +++++++ crates/tinymcp/src/ui/mod.rs | 38 +++ crates/tinymcp/src/ui/policy.rs | 77 +++++ crates/tinymcp/src/ui/policy_tests.rs | 92 +++++ crates/tinymcp/src/ui/resolve.rs | 456 +++++++++++++++++++++++++ crates/tinymcp/src/ui/resolve_tests.rs | 239 +++++++++++++ 11 files changed, 1259 insertions(+) create mode 100644 crates/tinymcp/src/ui/links.rs create mode 100644 crates/tinymcp/src/ui/links_tests.rs create mode 100644 crates/tinymcp/src/ui/mod.rs create mode 100644 crates/tinymcp/src/ui/policy.rs create mode 100644 crates/tinymcp/src/ui/policy_tests.rs create mode 100644 crates/tinymcp/src/ui/resolve.rs create mode 100644 crates/tinymcp/src/ui/resolve_tests.rs 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/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/lib.rs b/crates/tinymcp/src/lib.rs index 7192b378..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::{ 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"]); +} From 47cd9acf21399ef5598d6849191512b5cfc28f19 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 9 Oct 2026 18:59:59 +0530 Subject: [PATCH 10/10] Document UI presentation for hosts, including non-visual ones --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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