use aion_mcp::tools::service::{CatalogError, ToolCatalog};
use super::{authoring, mutations, reads};
pub(crate) fn aion_tool_catalog() -> Result<ToolCatalog, CatalogError> {
ToolCatalog::new(vec![
reads::describe_run(),
reads::read_transcript(),
reads::read_history(),
reads::list_runs(),
reads::query(),
authoring::list_documents(),
authoring::read_document(),
authoring::check_document(),
mutations::start_run(),
mutations::signal(),
mutations::cancel(),
authoring::save_document(),
authoring::deploy_document(),
])
}
#[cfg(test)]
mod tests {
use aion_mcp::tools::descriptor::Modification;
use aion_mcp::tools::service::CatalogError;
use super::aion_tool_catalog;
const TOOL_NAMES: &[&str] = &[
"describe_run",
"read_transcript",
"read_history",
"list_runs",
"query",
"list_documents",
"read_document",
"check_document",
"start_run",
"signal",
"cancel",
"save_document",
"deploy_document",
];
#[test]
fn the_catalog_publishes_exactly_the_named_tools_in_order() -> Result<(), CatalogError> {
let catalog = aion_tool_catalog()?;
let names: Vec<&str> = catalog
.tools()
.iter()
.map(|tool| tool.name.as_str())
.collect();
assert_eq!(names, TOOL_NAMES);
Ok(())
}
#[test]
fn every_tool_declares_an_output_schema() -> Result<(), CatalogError> {
for tool in aion_tool_catalog()?.tools() {
assert!(
tool.output_schema.is_some(),
"{} must declare an outputSchema",
tool.name
);
assert!(
tool.description.is_some(),
"{} needs a description",
tool.name
);
}
Ok(())
}
#[test]
fn read_tools_are_annotated_read_only_and_mutations_are_not() -> Result<(), CatalogError> {
let catalog = aion_tool_catalog()?;
for name in [
"describe_run",
"read_transcript",
"read_history",
"list_runs",
"query",
"list_documents",
"read_document",
"check_document",
] {
let tool = catalog.find(name).ok_or(CatalogError::InvalidName {
name: name.to_owned(),
})?;
assert_eq!(
tool.annotations.modification,
Modification::ReadOnly,
"{name} must be annotated read-only"
);
}
for name in [
"start_run",
"signal",
"cancel",
"save_document",
"deploy_document",
] {
let tool = catalog.find(name).ok_or(CatalogError::InvalidName {
name: name.to_owned(),
})?;
assert_eq!(
tool.annotations.modification,
Modification::Mutating,
"{name} must NOT be annotated read-only"
);
}
Ok(())
}
#[test]
fn cancel_is_the_only_tool_marked_destructive() -> Result<(), CatalogError> {
use aion_mcp::tools::descriptor::Destructiveness;
for tool in aion_tool_catalog()?.tools() {
let expected = if tool.name == "cancel" {
Destructiveness::Destructive
} else {
Destructiveness::Additive
};
assert_eq!(tool.annotations.destructiveness, expected, "{}", tool.name);
}
Ok(())
}
#[test]
fn save_converges_and_deploy_appends_and_both_are_additive() -> Result<(), CatalogError> {
use aion_mcp::tools::descriptor::{Destructiveness, Idempotence};
let catalog = aion_tool_catalog()?;
for (name, idempotence) in [
("save_document", Idempotence::Idempotent),
("deploy_document", Idempotence::Repeating),
] {
let tool = catalog.find(name).ok_or(CatalogError::InvalidName {
name: name.to_owned(),
})?;
assert_eq!(
tool.annotations.destructiveness,
Destructiveness::Additive,
"{name}"
);
assert_eq!(tool.annotations.idempotence, idempotence, "{name}");
}
Ok(())
}
#[test]
fn deploy_requires_the_hash_save_returns_and_takes_no_source() -> Result<(), CatalogError> {
let catalog = aion_tool_catalog()?;
let save = catalog
.find("save_document")
.ok_or(CatalogError::InvalidName {
name: "save_document".to_owned(),
})?;
let save_output = save
.output_schema
.as_ref()
.and_then(|schema| schema["required"].as_array())
.cloned()
.unwrap_or_default();
assert!(save_output.iter().any(|value| value == "content_hash"));
let deploy = catalog
.find("deploy_document")
.ok_or(CatalogError::InvalidName {
name: "deploy_document".to_owned(),
})?;
let required = deploy.input_schema["required"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(required.iter().any(|value| value == "path"));
assert!(required.iter().any(|value| value == "content_hash"));
assert!(
deploy.input_schema["properties"].get("source").is_none(),
"a `source` argument on deploy_document would be the deploy-this-string seam \
the design refuses"
);
Ok(())
}
#[test]
fn read_transcript_requires_a_run_id() -> Result<(), CatalogError> {
let catalog = aion_tool_catalog()?;
let tool = catalog
.find("read_transcript")
.ok_or(CatalogError::InvalidName {
name: "read_transcript".to_owned(),
})?;
let required = tool.input_schema["required"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
required.iter().any(|value| value == "run_id"),
"run_id is a REQUIRED axis of a transcript handle, not an option: {required:?}"
);
let output_required = tool
.output_schema
.as_ref()
.and_then(|schema| schema["required"].as_array())
.cloned()
.unwrap_or_default();
assert!(output_required.iter().any(|value| value == "run_id"));
Ok(())
}
#[test]
fn no_published_schema_annotates_a_property_with_x_mcp_header()
-> Result<(), Box<dyn std::error::Error>> {
let catalog = aion_tool_catalog()?;
let rendered = serde_json::to_string(catalog.tools())?;
assert!(
!rendered.contains("x-mcp-header"),
"an x-mcp-header annotation would put a namespace or an id into a header every \
intermediary on the path can read"
);
Ok(())
}
#[test]
fn no_schema_uses_a_ref_or_a_composition_keyword() -> Result<(), Box<dyn std::error::Error>> {
let catalog = aion_tool_catalog()?;
let rendered = serde_json::to_string(catalog.tools())?;
for keyword in [
"\"$ref\"",
"\"anyOf\"",
"\"oneOf\"",
"\"allOf\"",
"\"not\"",
"\"if\"",
"\"$defs\"",
] {
assert!(
!rendered.contains(keyword),
"{keyword} in a published schema is either an unresolvable external reference \
or unbounded composition; neither belongs in a tool contract"
);
}
Ok(())
}
}