diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index 1848e075..02b713d3 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -77,6 +77,19 @@ URL parser. No member was added or removed. Behaviour change (fail-closed): non-canonical IPv4, bad ports or dot segments are now `Blocked` (previously `Web`). +Contract 1.26 adds `McpClientConfig::stdio_sandbox` and +`McpServerConfig::operator_trusted`: a stdio server runs in the host's jail, or +unconfined only when the operator trusts it; with neither it is refused. +Contract 1.27 adds `StdioSandboxConfig::enforcement` +(`StdioSandboxEnforcement`), letting the host choose `Declared` for a backend +with partial enforcement; the default stays `Enforced`. + +Contract 1.28 keeps a listed tool's `annotations` on `McpRemoteTool` +(`readOnlyHint`, `destructiveHint`, …), with `McpRemoteTool::is_read_only`, +so a host can tell a tool that only reads from one that changes something +before it calls it. The field is optional and omitted when absent, and +`StaticScrubTools` scrubs it like the rest of a tool's metadata. + Library consumers bring implementation extension traits into scope for moved DTO methods. Contract-only hosts call the compiled module. See [the relocation specification](../../docs/specs/pure-vocabulary.md) for public diff --git a/crates/tinymcp-bus/src/processing/mod_tests.rs b/crates/tinymcp-bus/src/processing/mod_tests.rs index ebed0526..90d4360e 100644 --- a/crates/tinymcp-bus/src/processing/mod_tests.rs +++ b/crates/tinymcp-bus/src/processing/mod_tests.rs @@ -72,6 +72,7 @@ fn processing_requests_pin_enum_tags_and_optional_render_defaults() { description: Some("Read the forecast".into()), input_schema: json!({"type":"object"}), meta: None, + annotations: None, }], }; let wire = serde_json::to_value(&scrub).unwrap(); diff --git a/crates/tinymcp-bus/src/transport/mod.rs b/crates/tinymcp-bus/src/transport/mod.rs index a6f53a52..0e1b1f86 100644 --- a/crates/tinymcp-bus/src/transport/mod.rs +++ b/crates/tinymcp-bus/src/transport/mod.rs @@ -36,3 +36,7 @@ pub use types::{ McpServerToolResult, McpSseEvent, McpToolContent, McpToolResult, ProtectedResourceMetadata, SUPPORTED_PROTOCOL_VERSIONS, }; + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinymcp-bus/src/transport/mod_tests.rs b/crates/tinymcp-bus/src/transport/mod_tests.rs new file mode 100644 index 00000000..a16f9bbd --- /dev/null +++ b/crates/tinymcp-bus/src/transport/mod_tests.rs @@ -0,0 +1,53 @@ +//! The remote tool's wire form, annotations included. + +use serde_json::json; + +use super::*; + +type TestResult = Result<(), serde_json::Error>; + +#[test] +fn a_listed_tools_annotations_are_kept() -> TestResult { + let tool: McpRemoteTool = serde_json::from_value(json!({ + "name": "list_meetings", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": true, "openWorldHint": false}, + }))?; + + assert_eq!( + tool.annotations, + Some(json!({"readOnlyHint": true, "openWorldHint": false})) + ); + assert!(tool.is_read_only()); + Ok(()) +} + +#[test] +fn a_tool_without_the_hint_or_with_it_false_is_not_read_only() { + for annotations in [ + None, + Some(json!({})), + Some(json!({"readOnlyHint": false})), + Some(json!({"readOnlyHint": "yes"})), + ] { + let mut tool = McpRemoteTool::new("sign_out"); + tool.annotations = annotations.clone(); + assert!(!tool.is_read_only(), "{annotations:?}"); + } +} + +#[test] +fn absent_annotations_stay_off_the_wire() -> TestResult { + let wire = serde_json::to_value(McpRemoteTool::new("forecast"))?; + assert_eq!( + wire, + json!({"name": "forecast", "title": null, "description": null, "inputSchema": null}) + ); + + let mut annotated = McpRemoteTool::new("forecast"); + annotated.annotations = Some(json!({"readOnlyHint": true})); + let wire = serde_json::to_value(&annotated)?; + assert_eq!(wire["annotations"], json!({"readOnlyHint": true})); + assert_eq!(serde_json::from_value::(wire)?, annotated); + Ok(()) +} diff --git a/crates/tinymcp-bus/src/transport/types.rs b/crates/tinymcp-bus/src/transport/types.rs index 87a0e263..a1078070 100644 --- a/crates/tinymcp-bus/src/transport/types.rs +++ b/crates/tinymcp-bus/src/transport/types.rs @@ -61,6 +61,14 @@ pub struct McpRemoteTool { /// Untrusted remote data; never place it in a model's context. #[serde(default, rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, + /// The tool's `annotations` object, verbatim: behaviour hints such as + /// `readOnlyHint` and `destructiveHint`. + /// + /// Hints, not guarantees: a server may mislabel a tool. A host may use + /// them to decide what to ask the user before calling, never to skip a + /// check it would make anyway. Untrusted remote data. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, } impl McpRemoteTool { @@ -82,8 +90,31 @@ impl McpRemoteTool { description: None, input_schema: Value::Null, meta: None, + annotations: None, } } + + /// Whether the server marks the tool as changing nothing + /// (`annotations.readOnlyHint` is `true`). A tool without the hint is not + /// read-only, as the protocol's default says. + /// + /// # Examples + /// + /// ``` + /// # use tinymcp_bus::McpRemoteTool; + /// let mut tool = McpRemoteTool::new("forecast"); + /// assert!(!tool.is_read_only()); + /// tool.annotations = Some(serde_json::json!({"readOnlyHint": true})); + /// assert!(tool.is_read_only()); + /// ``` + #[must_use] + pub fn is_read_only(&self) -> bool { + self.annotations + .as_ref() + .and_then(|annotations| annotations.get("readOnlyHint")) + .and_then(Value::as_bool) + .unwrap_or(false) + } } /// Who the client says it is, as sent in `initialize.clientInfo`. diff --git a/crates/tinymcp-bus/src/version/mod.rs b/crates/tinymcp-bus/src/version/mod.rs index cc545d88..200713df 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -43,8 +43,8 @@ /// trusts it; with neither it is refused. 1.27 adds /// `StdioSandboxConfig::enforcement` (`StdioSandboxEnforcement`), letting the /// host choose `Declared` for a backend with partial enforcement; the default -/// stays `Enforced`. -pub const CONTRACT_VERSION: (u32, u32) = (1, 27); +/// stays `Enforced`. 1.28 adds the optional `annotations` on `McpRemoteTool`. +pub const CONTRACT_VERSION: (u32, u32) = (1, 28); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs index bf965db9..33c85162 100644 --- a/crates/tinymcp-bus/src/version/mod_tests.rs +++ b/crates/tinymcp-bus/src/version/mod_tests.rs @@ -4,7 +4,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 27)); + assert_eq!(CONTRACT_VERSION, (1, 28)); } #[test] diff --git a/crates/tinymcp/src/processing/mod.rs b/crates/tinymcp/src/processing/mod.rs index b99b77bb..30ae1769 100644 --- a/crates/tinymcp/src/processing/mod.rs +++ b/crates/tinymcp/src/processing/mod.rs @@ -204,6 +204,9 @@ pub fn scrub_static_tools( if let Some(meta) = &mut tool.meta { scrubber.scrub_value(meta); } + if let Some(annotations) = &mut tool.annotations { + scrubber.scrub_value(annotations); + } } Ok(tools) } diff --git a/crates/tinymcp/src/tinybus_module/mod_tests.rs b/crates/tinymcp/src/tinybus_module/mod_tests.rs index 7eb64f7e..9dae3f5b 100644 --- a/crates/tinymcp/src/tinybus_module/mod_tests.rs +++ b/crates/tinymcp/src/tinybus_module/mod_tests.rs @@ -1498,7 +1498,11 @@ async fn static_tool_metadata_redacts_configured_credentials_before_bus_return() "title": "Forecast for mcp-static-metadata-secret-731", "description": "Echoed mcp-static-metadata-secret-731", "inputSchema": {"type": "object", "description": "mcp-static-metadata-secret-731"}, - "_meta": {"credential": "mcp-static-metadata-secret-731"} + "_meta": {"credential": "mcp-static-metadata-secret-731"}, + "annotations": { + "title": "Forecast mcp-static-metadata-secret-731", + "readOnlyHint": true + } }] }]), ) @@ -1510,6 +1514,8 @@ async fn static_tool_metadata_redacts_configured_credentials_before_bus_return() assert_eq!(tools[0]["description"], "Echoed [redacted]"); assert_eq!(tools[0]["inputSchema"]["description"], "[redacted]"); assert_eq!(tools[0]["_meta"]["credential"], "[redacted]"); + assert_eq!(tools[0]["annotations"]["title"], "Forecast [redacted]"); + assert_eq!(tools[0]["annotations"]["readOnlyHint"], true); } #[tokio::test]