Skip to content
13 changes: 13 additions & 0 deletions crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/tinymcp-bus/src/processing/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions crates/tinymcp-bus/src/transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,7 @@ pub use types::{
McpServerToolResult, McpSseEvent, McpToolContent, McpToolResult, ProtectedResourceMetadata,
SUPPORTED_PROTOCOL_VERSIONS,
};

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
53 changes: 53 additions & 0 deletions crates/tinymcp-bus/src/transport/mod_tests.rs
Original file line number Diff line number Diff line change
@@ -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::<McpRemoteTool>(wire)?, annotated);
Ok(())
}
31 changes: 31 additions & 0 deletions crates/tinymcp-bus/src/transport/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value>,
/// 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<Value>,
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
}

impl McpRemoteTool {
Expand All @@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions crates/tinymcp-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinymcp-bus/src/version/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions crates/tinymcp/src/processing/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
8 changes: 7 additions & 1 deletion crates/tinymcp/src/tinybus_module/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}]
}]),
)
Expand All @@ -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]
Expand Down
Loading