use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ToolId {
server: String,
name: String,
}
impl ToolId {
#[must_use]
pub fn new(server: impl Into<String>, name: impl Into<String>) -> Self {
Self {
server: server.into(),
name: name.into(),
}
}
#[must_use]
pub fn server(&self) -> &str {
&self.server
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ToolAnnotations {
#[cfg_attr(
feature = "serde",
serde(
default,
rename = "readOnlyHint",
skip_serializing_if = "Option::is_none"
)
)]
read_only: Option<bool>,
#[cfg_attr(
feature = "serde",
serde(
default,
rename = "destructiveHint",
skip_serializing_if = "Option::is_none"
)
)]
destructive: Option<bool>,
#[cfg_attr(
feature = "serde",
serde(
default,
rename = "idempotentHint",
skip_serializing_if = "Option::is_none"
)
)]
idempotent: Option<bool>,
}
impl ToolAnnotations {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn read_only(&self) -> Option<bool> {
self.read_only
}
#[must_use]
pub fn destructive(&self) -> Option<bool> {
self.destructive
}
#[must_use]
pub fn idempotent(&self) -> Option<bool> {
self.idempotent
}
#[must_use]
pub fn with_read_only(mut self, value: bool) -> Self {
self.read_only = Some(value);
self
}
#[must_use]
pub fn with_destructive(mut self, value: bool) -> Self {
self.destructive = Some(value);
self
}
#[must_use]
pub fn with_idempotent(mut self, value: bool) -> Self {
self.idempotent = Some(value);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ToolDescriptor {
#[cfg_attr(feature = "serde", serde(flatten))]
id: ToolId,
description: String,
#[cfg_attr(feature = "serde", serde(default, alias = "inputSchema"))]
input_schema: Value,
#[cfg_attr(feature = "serde", serde(default))]
annotations: ToolAnnotations,
}
impl ToolDescriptor {
#[must_use]
pub fn new(id: ToolId, description: impl Into<String>, input_schema: Value) -> Self {
Self {
id,
description: description.into(),
input_schema,
annotations: ToolAnnotations::default(),
}
}
#[must_use]
pub fn with_annotations(mut self, annotations: ToolAnnotations) -> Self {
self.annotations = annotations;
self
}
#[must_use]
pub fn id(&self) -> &ToolId {
&self.id
}
#[must_use]
pub fn server(&self) -> &str {
self.id.server()
}
#[must_use]
pub fn name(&self) -> &str {
self.id.name()
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn input_schema(&self) -> &Value {
&self.input_schema
}
#[must_use]
pub fn annotations(&self) -> ToolAnnotations {
self.annotations
}
pub(crate) fn parameter_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self
.input_schema
.as_object()
.and_then(|schema| schema.get("properties"))
.and_then(Value::as_object)
.map(|properties| properties.keys().map(String::as_str).collect())
.unwrap_or_default();
names.sort_unstable();
names
}
pub(crate) fn enriched_text(&self) -> String {
let parameters = self.parameter_names();
let mut text = String::with_capacity(
self.name().len() + self.description.len() + parameters.len() * 8,
);
let mut wrote = false;
let name = self.name().replace('_', " ");
if !name.is_empty() {
text.push_str(&name);
wrote = true;
}
if !self.description.is_empty() {
if wrote {
text.push_str(". ");
}
text.push_str(&self.description);
wrote = true;
}
if !parameters.is_empty() {
if wrote {
text.push_str(". ");
}
text.push_str("parameters: ");
for (index, parameter) in parameters.iter().enumerate() {
if index > 0 {
text.push_str(", ");
}
text.push_str(parameter);
}
}
text
}
}
pub type CatalogIter<'a> = std::slice::Iter<'a, ToolDescriptor>;
pub type CatalogIterMut<'a> = std::slice::IterMut<'a, ToolDescriptor>;
pub type CatalogIntoIter = std::vec::IntoIter<ToolDescriptor>;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[non_exhaustive]
pub struct Catalog {
tools: Vec<ToolDescriptor>,
}
impl Catalog {
#[must_use]
pub fn new(tools: Vec<ToolDescriptor>) -> Self {
Self { tools }
}
#[must_use]
pub fn len(&self) -> usize {
self.tools.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
#[must_use = "iterators are lazy and visit nothing unless consumed"]
pub fn iter(&self) -> CatalogIter<'_> {
self.tools.iter()
}
#[must_use = "iterators are lazy and visit nothing unless consumed"]
pub fn iter_mut(&mut self) -> CatalogIterMut<'_> {
self.tools.iter_mut()
}
#[must_use]
pub fn get(&self, id: &ToolId) -> Option<&ToolDescriptor> {
self.tools.iter().find(|tool| tool.id() == id)
}
pub(crate) fn as_slice(&self) -> &[ToolDescriptor] {
&self.tools
}
}
impl<'a> IntoIterator for &'a Catalog {
type Item = &'a ToolDescriptor;
type IntoIter = CatalogIter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.tools.iter()
}
}
impl<'a> IntoIterator for &'a mut Catalog {
type Item = &'a mut ToolDescriptor;
type IntoIter = CatalogIterMut<'a>;
fn into_iter(self) -> Self::IntoIter {
self.tools.iter_mut()
}
}
impl IntoIterator for Catalog {
type Item = ToolDescriptor;
type IntoIter = CatalogIntoIter;
fn into_iter(self) -> Self::IntoIter {
self.tools.into_iter()
}
}
impl FromIterator<ToolDescriptor> for Catalog {
fn from_iter<I: IntoIterator<Item = ToolDescriptor>>(iter: I) -> Self {
Self::new(iter.into_iter().collect())
}
}
impl From<Vec<ToolDescriptor>> for Catalog {
fn from(tools: Vec<ToolDescriptor>) -> Self {
Self::new(tools)
}
}
#[cfg(test)]
mod tests {
use super::{Catalog, ToolAnnotations, ToolDescriptor, ToolId};
use serde_json::{Value, json};
fn descriptor(schema: Value) -> ToolDescriptor {
ToolDescriptor::new(
ToolId::new("files", "read_file"),
"Read a file from disk",
schema,
)
}
#[test]
fn enriched_text_appends_sorted_parameter_names() {
let tool = descriptor(json!({
"type": "object",
"properties": {
"path": {"type": "string"},
"encoding": {"type": "string"},
"offset": {"type": "integer"}
}
}));
assert_eq!(
tool.enriched_text(),
"read file. Read a file from disk. parameters: encoding, offset, path"
);
}
#[test]
fn enriched_text_without_parameters_is_name_and_description() {
for schema in [json!({}), json!({"type": "object", "properties": {}})] {
let tool = descriptor(schema);
assert!(tool.parameter_names().is_empty());
assert_eq!(tool.enriched_text(), "read file. Read a file from disk");
}
}
#[test]
fn empty_description_does_not_double_the_separator() {
let tool = ToolDescriptor::new(
ToolId::new("files", "read_file"),
"",
json!({"properties": {"path": {"type": "string"}}}),
);
assert_eq!(tool.enriched_text(), "read file. parameters: path");
}
#[test]
fn a_description_ending_in_a_period_keeps_the_doubled_period() {
let tool = ToolDescriptor::new(
ToolId::new("files", "read_file"),
"Read a file from disk.",
json!({"properties": {"path": {}, "encoding": {}}}),
);
assert_eq!(
tool.enriched_text(),
"read file. Read a file from disk.. parameters: encoding, path"
);
}
#[test]
fn a_tool_without_parameters_omits_the_parameters_part() {
let tool = ToolDescriptor::new(ToolId::new("meta", "list_tools"), "List tools.", json!({}));
assert_eq!(tool.enriched_text(), "list tools. List tools.");
}
#[test]
fn non_object_schema_yields_no_parameters() {
for schema in [Value::Null, json!(true), json!("string"), json!([1, 2, 3])] {
let tool = descriptor(schema);
assert!(tool.parameter_names().is_empty());
assert_eq!(tool.enriched_text(), "read file. Read a file from disk");
}
}
#[test]
fn enriched_text_is_stable_across_key_order() {
let one = descriptor(json!({"properties": {"zeta": {}, "alpha": {}, "mid": {}}}));
let other = descriptor(json!({"properties": {"mid": {}, "alpha": {}, "zeta": {}}}));
assert_eq!(one.enriched_text(), other.enriched_text());
}
#[test]
fn identity_is_the_server_and_name_pair() {
let id = ToolId::new("files", "read_file");
assert_eq!(id, ToolId::new("files", "read_file"));
assert_ne!(id, ToolId::new("blobs", "read_file"));
assert_ne!(id, ToolId::new("files", "write_file"));
assert_eq!(id.server(), "files");
assert_eq!(id.name(), "read_file");
}
#[test]
fn identities_with_a_delimiter_do_not_collide() {
assert_ne!(
ToolId::new("a\u{1f}b", "c"),
ToolId::new("a", "b\u{1f}c"),
"structural identity keeps a delimiter-bearing pair distinct"
);
}
#[test]
fn annotation_builders_and_accessors_cover_absent_true_and_false() {
let hints = ToolAnnotations::new()
.with_read_only(true)
.with_destructive(false);
assert_eq!(hints.read_only(), Some(true));
assert_eq!(hints.destructive(), Some(false));
assert_eq!(hints.idempotent(), None);
}
#[test]
fn catalog_reports_size_iterates_and_looks_up_first_match() {
let empty = Catalog::default();
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
let first = descriptor(json!({}));
let second = ToolDescriptor::new(ToolId::new("net", "fetch"), "Fetch a URL", json!({}));
let catalog = Catalog::new(vec![first.clone(), second.clone()]);
assert_eq!(catalog.len(), 2);
assert_eq!(catalog.iter().collect::<Vec<_>>(), vec![&first, &second]);
assert_eq!(catalog.get(second.id()), Some(&second));
assert_eq!(catalog.get(&ToolId::new("net", "missing")), None);
}
#[test]
fn catalog_iterates_mutably_and_owns_from_vec_and_from_iter() {
let mut catalog = Catalog::from(vec![
ToolDescriptor::new(ToolId::new("a", "one"), "one", json!({})),
ToolDescriptor::new(ToolId::new("b", "two"), "two", json!({})),
]);
for tool in &mut catalog {
*tool = tool
.clone()
.with_annotations(ToolAnnotations::new().with_read_only(true));
}
assert!(
catalog
.iter()
.all(|tool| tool.annotations().read_only() == Some(true))
);
let collected: Catalog = catalog.clone().into_iter().collect();
assert_eq!(collected, catalog);
}
#[cfg(feature = "serde")]
#[test]
fn descriptor_deserializes_from_a_flat_mcp_shaped_object() {
let parsed: ToolDescriptor = serde_json::from_value(json!({
"server": "files",
"name": "read_file",
"description": "Read a file from disk",
"inputSchema": {"properties": {"path": {"type": "string"}}},
"annotations": {"readOnlyHint": true}
}))
.expect("flat MCP descriptor deserializes");
assert_eq!(parsed.id(), &ToolId::new("files", "read_file"));
assert_eq!(parsed.annotations().read_only(), Some(true));
assert_eq!(parsed.annotations().destructive(), None);
}
#[cfg(feature = "serde")]
#[test]
fn absent_optional_fields_default() {
let parsed: ToolDescriptor = serde_json::from_value(json!({
"server": "files",
"name": "read_file",
"description": "Read a file from disk"
}))
.expect("descriptor with absent optionals deserializes");
assert_eq!(parsed.input_schema(), &Value::Null);
assert_eq!(parsed.annotations(), ToolAnnotations::default());
}
#[cfg(feature = "serde")]
#[test]
fn catalog_round_trips_as_an_array() {
let catalog = Catalog::new(vec![
descriptor(json!({"properties": {"path": {"type": "string"}}}))
.with_annotations(ToolAnnotations::new().with_read_only(true)),
ToolDescriptor::new(ToolId::new("net", "fetch"), "Fetch a URL", json!({})),
]);
let text = serde_json::to_string(&catalog).expect("serialize");
assert!(text.starts_with('['), "a catalog serializes as an array");
let parsed: Catalog = serde_json::from_str(&text).expect("deserialize");
assert_eq!(parsed, catalog);
}
}