use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ToolAttrs {
pub read_only: bool,
pub destructive: bool,
pub idempotent: bool,
pub open_world: bool,
}
impl ToolAttrs {
const fn read() -> Self {
Self {
read_only: true,
destructive: false,
idempotent: true,
open_world: true,
}
}
const fn local_read() -> Self {
Self {
open_world: false,
..Self::read()
}
}
const fn write(destructive: bool, idempotent: bool) -> Self {
Self {
read_only: false,
destructive,
idempotent,
open_world: true,
}
}
}
pub(super) const TOOL_ATTRS: &[(&str, ToolAttrs)] = &[
("list_environments", ToolAttrs::read()),
("recent_events", ToolAttrs::read()),
("get_option_settings", ToolAttrs::read()),
("list_versions", ToolAttrs::read()),
("lint", ToolAttrs::read()),
("drift", ToolAttrs::read()),
("fleet_cost", ToolAttrs::read()),
("audit_log", ToolAttrs::local_read()),
("restart", ToolAttrs::write(false, true)),
("rebuild", ToolAttrs::write(true, true)),
("deploy", ToolAttrs::write(false, true)),
("set_option", ToolAttrs::write(false, true)),
("terminate", ToolAttrs::write(true, true)),
("confirm_action", ToolAttrs::write(true, false)),
];
pub(super) fn attrs_for(name: &str) -> Option<ToolAttrs> {
TOOL_ATTRS.iter().find(|(n, _)| *n == name).map(|(_, a)| *a)
}
pub(super) fn annotations_for(name: &str) -> Option<Value> {
let a = attrs_for(name)?;
Some(json!({
"readOnlyHint": a.read_only,
"destructiveHint": a.destructive,
"idempotentHint": a.idempotent,
"openWorldHint": a.open_world,
}))
}
pub(super) fn annotate(tools: &mut Value) {
let Some(arr) = tools.as_array_mut() else {
return;
};
for tool in arr {
let Some(name) = tool.get("name").and_then(Value::as_str).map(str::to_string) else {
continue;
};
if let Some(ann) = annotations_for(&name) {
if let Some(obj) = tool.as_object_mut() {
obj.insert("annotations".into(), ann);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_advertised_tool_is_classified() {
for allow_writes in [false, true] {
let table = super::super::tools::tool_table(allow_writes);
let arr = table.as_array().expect("tools/list is an array");
assert!(
arr.len() >= 8,
"only {} tools — the table failed to build and this guard \
would pass on an empty result",
arr.len()
);
for tool in arr {
let name = tool["name"].as_str().expect("tool has a name");
let ann = tool.get("annotations").unwrap_or_else(|| {
panic!(
"`{name}` is advertised with no annotations — add it \
to TOOL_ATTRS and decide what it does"
)
});
for key in [
"readOnlyHint",
"destructiveHint",
"idempotentHint",
"openWorldHint",
] {
assert!(ann.get(key).is_some(), "`{name}` is missing {key}");
}
}
}
}
#[test]
fn no_table_entry_names_a_tool_that_is_gone() {
let advertised: Vec<String> = super::super::tools::tool_table(true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(
advertised.len() >= 8,
"the tool table failed to build; this guard would pass vacuously"
);
let stale: Vec<&str> = TOOL_ATTRS
.iter()
.map(|(n, _)| *n)
.filter(|n| !advertised.iter().any(|a| a == n))
.collect();
assert!(
stale.is_empty(),
"TOOL_ATTRS classifies tools that are no longer advertised — \
drop them: {stale:?}"
);
}
#[test]
fn the_local_read_is_not_marked_open_world() {
assert!(!attrs_for("audit_log").expect("classified").open_world);
for aws_backed in ["list_environments", "drift", "fleet_cost", "lint"] {
assert!(
attrs_for(aws_backed).expect(aws_backed).open_world,
"`{aws_backed}` reaches AWS and should say so"
);
}
}
#[test]
fn reads_are_marked_read_only_and_writes_are_not() {
let reads = super::super::tools::tool_table(false);
for tool in reads.as_array().expect("array") {
let name = tool["name"].as_str().expect("name");
assert_eq!(
tool["annotations"]["readOnlyHint"], true,
"`{name}` is in the read-only table but is not marked read-only"
);
}
let with_writes = super::super::tools::tool_table(true);
let write_names: Vec<&str> = with_writes
.as_array()
.expect("array")
.iter()
.filter(|t| t["annotations"]["readOnlyHint"] == false)
.map(|t| t["name"].as_str().expect("name"))
.collect();
assert!(
write_names.len() >= 5,
"expected the write surface to be marked; got {write_names:?}"
);
assert_eq!(
with_writes.as_array().expect("array").len(),
reads.as_array().expect("array").len() + write_names.len()
);
}
#[test]
fn the_destructive_tools_are_the_ones_that_destroy_something() {
for t in ["terminate", "rebuild"] {
assert!(
attrs_for(t).expect(t).destructive,
"`{t}` destroys something and must say so"
);
}
for t in ["restart", "deploy", "set_option"] {
assert!(
!attrs_for(t).expect(t).destructive,
"`{t}` is reversible; flagging it teaches clients to ignore \
the hint on the ones that are not"
);
}
}
#[test]
fn confirm_action_is_annotated_at_its_worst_case() {
let a = attrs_for("confirm_action").expect("classified");
assert!(!a.read_only, "it dispatches a write");
assert!(a.destructive, "it may dispatch a terminate");
assert!(!a.idempotent);
}
#[test]
fn an_unclassified_tool_gets_no_annotations_rather_than_wrong_ones() {
assert!(annotations_for("no_such_tool").is_none());
assert!(attrs_for("no_such_tool").is_none());
}
}