use crate::capability::CapabilityClass;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolSelection {
Unspecified,
Explicit(Vec<String>),
}
pub fn selection_from_request(mcp: Option<&serde_json::Value>) -> ToolSelection {
let tools = mcp.and_then(|v| v.get("tools"));
match tools {
None => ToolSelection::Unspecified,
Some(serde_json::Value::Array(items)) => ToolSelection::Explicit(
items
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect(),
),
Some(_) => ToolSelection::Unspecified,
}
}
pub fn default_read_slots(slots: &[(String, Vec<(String, CapabilityClass)>)]) -> Vec<String> {
slots
.iter()
.filter(|(_, tools)| {
tools
.iter()
.any(|(_, class)| *class == CapabilityClass::Read)
&& !tools
.iter()
.all(|(_, class)| *class == CapabilityClass::Exec)
})
.map(|(slot, _)| slot.clone())
.collect()
}
pub fn resolve_slots(
selection: &ToolSelection,
connected: &[(String, Vec<(String, CapabilityClass)>)],
) -> Vec<String> {
match selection {
ToolSelection::Explicit(slots) => slots.clone(),
ToolSelection::Unspecified => default_read_slots(connected),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn read(name: &str) -> (String, CapabilityClass) {
(name.to_string(), CapabilityClass::Read)
}
fn write(name: &str) -> (String, CapabilityClass) {
(name.to_string(), CapabilityClass::Write)
}
fn exec(name: &str) -> (String, CapabilityClass) {
(name.to_string(), CapabilityClass::Exec)
}
#[test]
fn an_absent_tools_field_means_unspecified() {
assert_eq!(selection_from_request(None), ToolSelection::Unspecified);
assert_eq!(
selection_from_request(Some(&json!({}))),
ToolSelection::Unspecified
);
assert_eq!(
selection_from_request(Some(&json!({"other": 1}))),
ToolSelection::Unspecified
);
}
#[test]
fn an_explicit_empty_list_is_honored_as_none() {
let sel = selection_from_request(Some(&json!({ "tools": [] })));
assert_eq!(sel, ToolSelection::Explicit(vec![]));
let connected = vec![("memory".to_string(), vec![read("memory_search")])];
assert!(resolve_slots(&sel, &connected).is_empty());
}
#[test]
fn an_explicit_list_is_passed_through_verbatim() {
let sel = selection_from_request(Some(&json!({ "tools": ["rag", "memory"] })));
assert_eq!(
resolve_slots(&sel, &[]),
vec!["rag".to_string(), "memory".to_string()]
);
}
#[test]
fn a_malformed_tools_value_does_not_silently_disarm_the_assistant() {
let sel = selection_from_request(Some(&json!({ "tools": "memory" })));
assert_eq!(sel, ToolSelection::Unspecified);
}
#[test]
fn unspecified_defaults_to_read_only_slots() {
let connected = vec![
(
"memory".to_string(),
vec![read("memory_search"), read("memory_catalog")],
),
("rag".to_string(), vec![read("search")]),
];
let slots = resolve_slots(&ToolSelection::Unspecified, &connected);
assert!(slots.contains(&"memory".to_string()));
assert!(slots.contains(&"rag".to_string()));
}
#[test]
fn a_slot_with_no_tools_is_not_offered() {
let connected = vec![("memory".to_string(), vec![])];
assert!(default_read_slots(&connected).is_empty());
}
#[test]
fn a_disconnected_server_contributes_nothing() {
let connected = vec![("memory".to_string(), vec![read("memory_search")])];
let slots = resolve_slots(&ToolSelection::Unspecified, &connected);
assert_eq!(slots, vec!["memory".to_string()]);
assert!(resolve_slots(&ToolSelection::Unspecified, &[]).is_empty());
}
#[test]
fn a_slot_qualifies_when_it_has_any_read() {
let connected = vec![
(
"memory".to_string(),
vec![read("memory_search"), write("memory_remember")],
),
(
"rag".to_string(),
vec![read("search"), write("index_document")],
),
(
"sequential-thinking".to_string(),
vec![read("sequentialthinking")],
),
];
let slots = default_read_slots(&connected);
for expected in ["memory", "rag", "sequential-thinking"] {
assert!(
slots.iter().any(|s| s == expected),
"{expected} should be in the default, got {slots:?}"
);
}
}
#[test]
fn a_write_becomes_visible_but_stays_gated() {
let connected = vec![(
"memory".to_string(),
vec![read("memory_search"), write("memory_remember")],
)];
assert!(default_read_slots(&connected).contains(&"memory".to_string()));
let forget = crate::capability::decide(
"ws",
"memory",
"memory_forget",
None,
std::path::Path::new("."),
);
assert!(
matches!(forget, crate::capability::Decision::NeedsApproval { .. }),
"memory_forget must still require approval, got {forget:?}"
);
let gateway = crate::capability::decide(
"ws",
"docker-mcp-gateway",
"mcp-exec",
None,
std::path::Path::new("."),
);
assert!(
matches!(gateway, crate::capability::Decision::NeedsApproval { .. }),
"docker exec must still require approval, got {gateway:?}"
);
}
#[test]
fn an_exec_only_slot_is_never_offered_by_default() {
let connected = vec![("shell".to_string(), vec![exec("run_command")])];
assert!(default_read_slots(&connected).is_empty());
}
#[test]
fn the_default_matches_the_real_classifier_for_shipped_servers() {
let connected = vec![
(
"docker-mcp-gateway".to_string(),
vec![
(
"mcp-exec".to_string(),
crate::capability::classify("docker-mcp-gateway", "mcp-exec"),
),
(
"code-mode".to_string(),
crate::capability::classify("docker-mcp-gateway", "code-mode"),
),
],
),
(
"rag".to_string(),
vec![
(
"search".to_string(),
crate::capability::classify("rag", "search"),
),
(
"index_document".to_string(),
crate::capability::classify("rag", "index_document"),
),
],
),
(
"memory".to_string(),
vec![
(
"memory_search".to_string(),
crate::capability::classify("memory", "memory_search"),
),
(
"memory_remember".to_string(),
crate::capability::classify("memory", "memory_remember"),
),
],
),
];
let slots = default_read_slots(&connected);
assert!(
slots.contains(&"rag".to_string()),
"rag has a read: {slots:?}"
);
assert!(
slots.contains(&"memory".to_string()),
"memory has a read: {slots:?}"
);
assert!(
!slots.contains(&"docker-mcp-gateway".to_string()),
"all-Exec gateway must stay out of the default: {slots:?}"
);
}
}