From fb7353480741ded9d221a68e9cb3b05d3b4c3962 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 16:55:27 +0530 Subject: [PATCH 1/6] feat(transport): expose tool annotations on McpRemoteTool McpRemoteTool now carries the server's annotations object verbatim and offers is_read_only() to read the readOnlyHint, treating a missing hint as not read-only per the protocol default. The contract version moves to 1.25 to record the added optional field. Auto-committed-on: macbook Co-authored-by: Medulla --- .../tinymcp-bus/src/processing/mod_tests.rs | 1 + crates/tinymcp-bus/src/transport/mod.rs | 4 ++ crates/tinymcp-bus/src/transport/mod_tests.rs | 47 +++++++++++++++++++ crates/tinymcp-bus/src/transport/types.rs | 31 ++++++++++++ crates/tinymcp-bus/src/version/mod.rs | 4 +- crates/tinymcp-bus/src/version/mod_tests.rs | 2 +- 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 crates/tinymcp-bus/src/transport/mod_tests.rs 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..dab187e2 --- /dev/null +++ b/crates/tinymcp-bus/src/transport/mod_tests.rs @@ -0,0 +1,47 @@ +//! The remote tool's wire form, annotations included. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; + +use super::*; + +#[test] +fn a_listed_tools_annotations_are_kept() { + let tool: McpRemoteTool = serde_json::from_value(json!({ + "name": "list_meetings", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": true, "openWorldHint": false}, + })) + .unwrap(); + + assert_eq!( + tool.annotations, + Some(json!({"readOnlyHint": true, "openWorldHint": false})) + ); + assert!(tool.is_read_only()); +} + +#[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() { + let wire = serde_json::to_value(McpRemoteTool::new("forecast")).unwrap(); + 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).unwrap(); + assert_eq!(wire["annotations"], json!({"readOnlyHint": true})); + assert_eq!(serde_json::from_value::(wire).unwrap(), annotated); +} 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 b49e5f24..ffc750be 100644 --- a/crates/tinymcp-bus/src/version/mod.rs +++ b/crates/tinymcp-bus/src/version/mod.rs @@ -34,8 +34,8 @@ /// (`require_identifier`, `ServerDetail::server_json`, `explain_not_connected`) that /// hosts previously copied; no member was added or removed. 1.24 adds tool /// `_meta` on `McpTool` and the `ReadResource` and `StaticReadResource` -/// members. -pub const CONTRACT_VERSION: (u32, u32) = (1, 24); +/// members. 1.25 adds the optional `annotations` on `McpRemoteTool`. +pub const CONTRACT_VERSION: (u32, u32) = (1, 25); /// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module /// reporting `module`. diff --git a/crates/tinymcp-bus/src/version/mod_tests.rs b/crates/tinymcp-bus/src/version/mod_tests.rs index 922e4081..49a207ac 100644 --- a/crates/tinymcp-bus/src/version/mod_tests.rs +++ b/crates/tinymcp-bus/src/version/mod_tests.rs @@ -4,7 +4,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible}; #[test] fn the_shipped_contract_version_is_pinned() { - assert_eq!(CONTRACT_VERSION, (1, 24)); + assert_eq!(CONTRACT_VERSION, (1, 25)); } #[test] From 0a2cb52fed84940b2f41ada1ccff91357742216d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 16:56:33 +0530 Subject: [PATCH 2/6] docs(tinymcp-bus): document tool annotations in contract 1.25 Describe the annotations field kept on McpRemoteTool and the is_read_only helper so hosts can tell read-only tools from ones that change state. The transport tests were also reformatted to satisfy rustfmt. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinymcp-bus/README.md | 5 +++++ crates/tinymcp-bus/src/transport/mod_tests.rs | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index 8fdccbb4..c5f56327 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -67,6 +67,11 @@ Contract 1.24 carries each tool's `_meta` on `McpTool`, so `ListTools`, with `ResourceTooLarge`, and redact the server's known credentials from the contents' text and `_meta`. +Contract 1.25 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. + 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/transport/mod_tests.rs b/crates/tinymcp-bus/src/transport/mod_tests.rs index dab187e2..96163929 100644 --- a/crates/tinymcp-bus/src/transport/mod_tests.rs +++ b/crates/tinymcp-bus/src/transport/mod_tests.rs @@ -24,7 +24,12 @@ fn a_listed_tools_annotations_are_kept() { #[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"}))] { + 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:?}"); @@ -43,5 +48,8 @@ fn absent_annotations_stay_off_the_wire() { annotated.annotations = Some(json!({"readOnlyHint": true})); let wire = serde_json::to_value(&annotated).unwrap(); assert_eq!(wire["annotations"], json!({"readOnlyHint": true})); - assert_eq!(serde_json::from_value::(wire).unwrap(), annotated); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + annotated + ); } From 80183df93774152c90c41df3c0a251b4f2ca44d5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 17:42:14 +0530 Subject: [PATCH 3/6] test(tinymcp): cover redaction of tool annotations metadata Extend the static tool metadata redaction test to include an annotations object with a credential-bearing title and a readOnlyHint, asserting that the title is redacted while the boolean hint is preserved. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinymcp/src/tinybus_module/mod_tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tinymcp/src/tinybus_module/mod_tests.rs b/crates/tinymcp/src/tinybus_module/mod_tests.rs index 619dc4a3..6c840725 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] From ff43df7567f6a48377ba35794418fa819a664245 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 17:42:46 +0530 Subject: [PATCH 4/6] fix(processing): scrub tool annotations in scrub_static_tools Tool annotations were left untouched when scrubbing static tools, so any sensitive values they carried could leak through. They are now passed through the scrubber alongside the rest of the tool metadata. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinymcp/src/processing/mod.rs | 3 +++ 1 file changed, 3 insertions(+) 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) } From 22797613813ba57bebca19cd176971a27aa35f39 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 17:44:14 +0530 Subject: [PATCH 5/6] docs(tinymcp-bus): note that StaticScrubTools scrubs tool annotations Clarify in the README that the optional annotations field on McpRemoteTool is scrubbed by StaticScrubTools along with the rest of a tool's metadata. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinymcp-bus/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinymcp-bus/README.md b/crates/tinymcp-bus/README.md index c5f56327..9923f992 100644 --- a/crates/tinymcp-bus/README.md +++ b/crates/tinymcp-bus/README.md @@ -70,7 +70,8 @@ contents' text and `_meta`. Contract 1.25 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. +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 From 9dbb3f6321c513f6394dd26610bb16486f458b9a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 15 Oct 2026 17:52:58 +0530 Subject: [PATCH 6/6] chore(tinymcp-bus): add transport module tests Adds a test module for the bus transport, covering the behaviour that was previously unverified. Auto-committed-on: macbook Co-authored-by: Medulla --- crates/tinymcp-bus/src/transport/mod_tests.rs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/tinymcp-bus/src/transport/mod_tests.rs b/crates/tinymcp-bus/src/transport/mod_tests.rs index 96163929..a16f9bbd 100644 --- a/crates/tinymcp-bus/src/transport/mod_tests.rs +++ b/crates/tinymcp-bus/src/transport/mod_tests.rs @@ -1,25 +1,25 @@ //! The remote tool's wire form, annotations included. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use serde_json::json; use super::*; +type TestResult = Result<(), serde_json::Error>; + #[test] -fn a_listed_tools_annotations_are_kept() { +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}, - })) - .unwrap(); + }))?; assert_eq!( tool.annotations, Some(json!({"readOnlyHint": true, "openWorldHint": false})) ); assert!(tool.is_read_only()); + Ok(()) } #[test] @@ -37,8 +37,8 @@ fn a_tool_without_the_hint_or_with_it_false_is_not_read_only() { } #[test] -fn absent_annotations_stay_off_the_wire() { - let wire = serde_json::to_value(McpRemoteTool::new("forecast")).unwrap(); +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}) @@ -46,10 +46,8 @@ fn absent_annotations_stay_off_the_wire() { let mut annotated = McpRemoteTool::new("forecast"); annotated.annotations = Some(json!({"readOnlyHint": true})); - let wire = serde_json::to_value(&annotated).unwrap(); + let wire = serde_json::to_value(&annotated)?; assert_eq!(wire["annotations"], json!({"readOnlyHint": true})); - assert_eq!( - serde_json::from_value::(wire).unwrap(), - annotated - ); + assert_eq!(serde_json::from_value::(wire)?, annotated); + Ok(()) }