use std::collections::BTreeMap;
use std::collections::HashSet;
use super::protocol;
pub const SUPPORTED_HARNESS_VERSION: &str = "0.1.5";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BuiltinTool {
ListDir,
SearchDir,
FindFile,
ViewFile,
CreateFile,
EditFile,
RunCommand,
AskQuestion,
StartSubagent,
GenerateImage,
SearchWeb,
Finish,
}
impl BuiltinTool {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Self::ListDir => "list_directory",
Self::SearchDir => "search_directory",
Self::FindFile => "find_file",
Self::ViewFile => "view_file",
Self::CreateFile => "create_file",
Self::EditFile => "edit_file",
Self::RunCommand => "run_command",
Self::AskQuestion => "ask_question",
Self::StartSubagent => "start_subagent",
Self::GenerateImage => "generate_image",
Self::SearchWeb => "search_web",
Self::Finish => "finish",
}
}
#[must_use]
pub fn all() -> Vec<Self> {
vec![
Self::ListDir,
Self::SearchDir,
Self::FindFile,
Self::ViewFile,
Self::CreateFile,
Self::EditFile,
Self::RunCommand,
Self::AskQuestion,
Self::StartSubagent,
Self::GenerateImage,
Self::SearchWeb,
Self::Finish,
]
}
#[must_use]
pub fn read_only() -> Vec<Self> {
vec![
Self::ListDir,
Self::SearchDir,
Self::FindFile,
Self::ViewFile,
Self::Finish,
]
}
#[must_use]
pub fn is_write_capable(self) -> bool {
!Self::read_only().contains(&self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capabilities {
enabled: HashSet<BuiltinTool>,
}
impl Default for Capabilities {
fn default() -> Self {
Self::read_only()
}
}
impl Capabilities {
#[must_use]
pub fn read_only() -> Self {
Self {
enabled: BuiltinTool::read_only().into_iter().collect(),
}
}
#[must_use]
pub fn all() -> Self {
Self {
enabled: BuiltinTool::all().into_iter().collect(),
}
}
#[must_use]
pub fn none() -> Self {
Self {
enabled: HashSet::new(),
}
}
#[must_use]
pub fn enable(mut self, tool: BuiltinTool) -> Self {
self.enabled.insert(tool);
self
}
#[must_use]
pub fn disable(mut self, tool: BuiltinTool) -> Self {
self.enabled.remove(&tool);
self
}
#[must_use]
pub fn is_enabled(&self, tool: BuiltinTool) -> bool {
self.enabled.contains(&tool)
}
#[must_use]
pub fn has_write_tools(&self) -> bool {
self.enabled.iter().any(|t| t.is_write_capable())
}
pub(crate) fn to_harness_side_tools(&self) -> protocol::HarnessSideTools {
let on = |tool: BuiltinTool| Some(protocol::ToolToggle::new(self.is_enabled(tool)));
protocol::HarnessSideTools {
find: on(BuiltinTool::FindFile),
run_command: on(BuiltinTool::RunCommand),
subagents: on(BuiltinTool::StartSubagent),
user_questions: on(BuiltinTool::AskQuestion),
file_edit: on(BuiltinTool::EditFile),
view_file: on(BuiltinTool::ViewFile),
write_to_file: on(BuiltinTool::CreateFile),
grep_search: on(BuiltinTool::SearchDir),
list_dir: on(BuiltinTool::ListDir),
permissions: None,
generate_image: on(BuiltinTool::GenerateImage),
search_web: on(BuiltinTool::SearchWeb),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subagent {
name: String,
description: Option<String>,
system_instructions: Option<String>,
capabilities: Capabilities,
tool_names: Vec<String>,
}
impl Subagent {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
system_instructions: None,
capabilities: Capabilities::read_only(),
tool_names: Vec::new(),
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn with_system_instructions(mut self, instructions: impl Into<String>) -> Self {
self.system_instructions = Some(instructions.into());
self
}
#[must_use]
pub fn with_capabilities(mut self, capabilities: Capabilities) -> Self {
self.capabilities = capabilities;
self
}
#[must_use]
pub fn add_tool(mut self, name: impl Into<String>) -> Self {
self.tool_names.push(name.into());
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn tool_names(&self) -> &[String] {
&self.tool_names
}
pub(crate) fn to_wire(
&self,
parent_tools: &[protocol::Tool],
workspace_note: Option<&str>,
) -> protocol::CustomAgent {
if self.capabilities.is_enabled(BuiltinTool::StartSubagent) {
tracing::warn!(
"Subagent '{}' enables start_subagent, but nested subagents are not \
supported by the harness; disabling it.",
self.name
);
}
let mut harness_side_tools = self.capabilities.to_harness_side_tools();
harness_side_tools.subagents = Some(protocol::ToolToggle::new(false));
let tools = self
.tool_names
.iter()
.filter_map(|name| {
parent_tools
.iter()
.find(|tool| tool.name.as_deref() == Some(name))
.cloned()
})
.collect();
let mut appended_sections = Vec::new();
if let Some(text) = &self.system_instructions {
appended_sections.push(protocol::InstructionSection {
title: Some("System".to_string()),
content: Some(text.clone()),
});
}
if let Some(note) = workspace_note {
appended_sections.push(protocol::InstructionSection {
title: Some("Workspace".to_string()),
content: Some(note.to_string()),
});
}
let system_instructions =
(!appended_sections.is_empty()).then_some(protocol::SystemInstructions {
custom: None,
appended: Some(protocol::AppendedSystemInstructions {
custom_identity: None,
appended_sections,
}),
});
protocol::CustomAgent {
name: Some(self.name.clone()),
description: self.description.clone(),
system_instructions,
harness_side_tools: Some(harness_side_tools),
tools,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServer {
name: String,
transport: McpTransport,
enabled_tools: Vec<String>,
disabled_tools: Vec<String>,
timeout_seconds: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum McpTransport {
Stdio {
command: String,
args: Vec<String>,
env: BTreeMap<String, String>,
},
Http {
url: String,
headers: BTreeMap<String, String>,
},
}
impl McpServer {
#[must_use]
pub fn stdio(
command: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
let command = command.into();
let name = std::path::Path::new(&command)
.file_stem()
.map_or_else(|| command.clone(), |s| s.to_string_lossy().into_owned());
Self {
name,
transport: McpTransport::Stdio {
command,
args: args.into_iter().map(Into::into).collect(),
env: BTreeMap::new(),
},
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
timeout_seconds: None,
}
}
#[must_use]
pub fn http(url: impl Into<String>) -> Self {
let url = url.into();
let name = url
.split("//")
.nth(1)
.and_then(|rest| rest.split(['/', ':']).next())
.filter(|h| !h.is_empty())
.map_or_else(|| "http".to_string(), ToString::to_string);
Self {
name,
transport: McpTransport::Http {
url,
headers: BTreeMap::new(),
},
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
timeout_seconds: None,
}
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn add_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
if let McpTransport::Stdio { env, .. } = &mut self.transport {
env.insert(key.into(), value.into());
}
self
}
#[must_use]
pub fn add_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
if let McpTransport::Http { headers, .. } = &mut self.transport {
headers.insert(key.into(), value.into());
}
self
}
#[must_use]
pub fn with_enabled_tools(
mut self,
tools: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.enabled_tools = tools.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_disabled_tools(
mut self,
tools: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.disabled_tools = tools.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_timeout_seconds(mut self, seconds: i32) -> Self {
self.timeout_seconds = Some(seconds);
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub(crate) fn to_wire(&self) -> protocol::McpServerConfig {
let mut config = protocol::McpServerConfig {
name: Some(self.name.clone()),
enabled_tools: self.enabled_tools.clone(),
disabled_tools: self.disabled_tools.clone(),
timeout_seconds: self.timeout_seconds,
..Default::default()
};
match &self.transport {
McpTransport::Stdio { command, args, env } => {
config.stdio = Some(protocol::McpStdioTransport {
command: Some(command.clone()),
args: args.clone(),
env: env.clone(),
});
}
McpTransport::Http { url, headers } => {
config.http = Some(protocol::McpHttpTransport {
url: Some(url.clone()),
headers: headers.clone(),
});
}
}
config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_capabilities_are_read_only() {
let caps = Capabilities::default();
assert!(caps.is_enabled(BuiltinTool::ViewFile));
assert!(caps.is_enabled(BuiltinTool::ListDir));
assert!(caps.is_enabled(BuiltinTool::Finish));
assert!(!caps.is_enabled(BuiltinTool::RunCommand));
assert!(!caps.is_enabled(BuiltinTool::EditFile));
assert!(!caps.is_enabled(BuiltinTool::StartSubagent));
assert!(!caps.has_write_tools());
}
#[test]
fn test_capabilities_enable_disable() {
let caps = Capabilities::read_only()
.enable(BuiltinTool::RunCommand)
.disable(BuiltinTool::ViewFile);
assert!(caps.is_enabled(BuiltinTool::RunCommand));
assert!(!caps.is_enabled(BuiltinTool::ViewFile));
assert!(caps.has_write_tools());
}
#[test]
fn test_capabilities_all_and_none() {
assert!(Capabilities::all().has_write_tools());
assert!(!Capabilities::none().has_write_tools());
assert!(!Capabilities::none().is_enabled(BuiltinTool::ViewFile));
}
#[test]
fn test_harness_side_tools_flags_all_explicit() {
let flags = Capabilities::read_only().to_harness_side_tools();
assert!(flags.view_file.unwrap().enabled);
assert!(flags.list_dir.unwrap().enabled);
assert!(flags.grep_search.unwrap().enabled);
assert!(flags.find.unwrap().enabled);
assert!(!flags.run_command.unwrap().enabled);
assert!(!flags.file_edit.unwrap().enabled);
assert!(!flags.write_to_file.unwrap().enabled);
assert!(!flags.subagents.unwrap().enabled);
assert!(!flags.generate_image.unwrap().enabled);
assert!(!flags.search_web.unwrap().enabled);
assert!(!flags.user_questions.unwrap().enabled);
}
#[test]
fn test_builtin_wire_names_match_reference_sdk() {
let expected = [
(BuiltinTool::ListDir, "list_directory"),
(BuiltinTool::SearchDir, "search_directory"),
(BuiltinTool::FindFile, "find_file"),
(BuiltinTool::ViewFile, "view_file"),
(BuiltinTool::CreateFile, "create_file"),
(BuiltinTool::EditFile, "edit_file"),
(BuiltinTool::RunCommand, "run_command"),
(BuiltinTool::AskQuestion, "ask_question"),
(BuiltinTool::StartSubagent, "start_subagent"),
(BuiltinTool::GenerateImage, "generate_image"),
(BuiltinTool::SearchWeb, "search_web"),
(BuiltinTool::Finish, "finish"),
];
for (tool, name) in expected {
assert_eq!(tool.wire_name(), name);
}
}
fn parent_tool(name: &str) -> protocol::Tool {
protocol::Tool {
name: Some(name.to_string()),
description: Some(format!("{name} description")),
parameters_json_schema: Some(r#"{"type":"object"}"#.to_string()),
response_json_schema: None,
}
}
#[test]
fn test_subagent_defaults() {
let subagent = Subagent::new("auditor");
assert_eq!(subagent.name(), "auditor");
assert!(subagent.tool_names().is_empty());
let wire = subagent.to_wire(&[], None);
assert_eq!(wire.name.as_deref(), Some("auditor"));
assert!(wire.description.is_none());
assert!(wire.system_instructions.is_none());
assert!(wire.tools.is_empty());
let side_tools = wire.harness_side_tools.unwrap();
assert!(side_tools.view_file.unwrap().enabled);
assert!(!side_tools.run_command.unwrap().enabled);
assert!(!side_tools.subagents.unwrap().enabled);
}
#[test]
fn test_subagent_to_wire_resolves_parent_tools() {
let subagent = Subagent::new("auditor")
.with_description("Audits files.")
.with_system_instructions("Focus on injection vectors.")
.add_tool("severity_classifier");
assert_eq!(subagent.tool_names(), ["severity_classifier"]);
let parent_tools = [parent_tool("other"), parent_tool("severity_classifier")];
let wire = subagent.to_wire(&parent_tools, None);
assert_eq!(wire.description.as_deref(), Some("Audits files."));
let instructions = wire.system_instructions.unwrap();
assert!(instructions.custom.is_none());
let appended = instructions.appended.unwrap();
assert_eq!(appended.appended_sections.len(), 1);
assert_eq!(
appended.appended_sections[0].title.as_deref(),
Some("System")
);
assert_eq!(
appended.appended_sections[0].content.as_deref(),
Some("Focus on injection vectors.")
);
assert_eq!(wire.tools.len(), 1);
assert_eq!(wire.tools[0].name.as_deref(), Some("severity_classifier"));
assert_eq!(
wire.tools[0].parameters_json_schema.as_deref(),
Some(r#"{"type":"object"}"#)
);
}
#[test]
fn test_subagent_to_wire_appends_workspace_note() {
let with_both = Subagent::new("auditor")
.with_system_instructions("Focus on injection vectors.")
.to_wire(&[], Some("ROOTS: /repo"));
let sections = with_both
.system_instructions
.unwrap()
.appended
.unwrap()
.appended_sections;
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].title.as_deref(), Some("System"));
assert_eq!(sections[1].title.as_deref(), Some("Workspace"));
assert_eq!(sections[1].content.as_deref(), Some("ROOTS: /repo"));
let note_only = Subagent::new("auditor").to_wire(&[], Some("ROOTS: /repo"));
let sections = note_only
.system_instructions
.unwrap()
.appended
.unwrap()
.appended_sections;
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].title.as_deref(), Some("Workspace"));
}
#[test]
fn test_subagent_start_subagent_forced_off() {
let subagent = Subagent::new("nested")
.with_capabilities(Capabilities::read_only().enable(BuiltinTool::StartSubagent));
let wire = subagent.to_wire(&[], None);
assert!(!wire.harness_side_tools.unwrap().subagents.unwrap().enabled);
}
#[test]
fn test_subagent_wire_serialization_fixture() {
let subagent = Subagent::new("auditor")
.with_description("Audits files.")
.with_system_instructions("Be thorough.")
.add_tool("severity_classifier");
let wire = subagent.to_wire(&[parent_tool("severity_classifier")], None);
let value = serde_json::to_value(&wire).unwrap();
assert_eq!(
value["systemInstructions"]["appended"]["appendedSections"][0]["content"],
"Be thorough."
);
assert_eq!(value["tools"][0]["name"], "severity_classifier");
assert_eq!(
value["tools"][0]["parametersJsonSchema"],
r#"{"type":"object"}"#
);
assert_eq!(value["harnessSideTools"]["subagents"]["enabled"], false);
assert_eq!(value["harnessSideTools"]["viewFile"]["enabled"], true);
}
#[test]
fn test_mcp_stdio_defaults_name_from_command() {
let server = McpServer::stdio("/usr/bin/uvx", ["mcp-server-git"]);
assert_eq!(server.name(), "uvx");
let wire = server.to_wire();
let stdio = wire.stdio.unwrap();
assert_eq!(stdio.command.as_deref(), Some("/usr/bin/uvx"));
assert_eq!(stdio.args, vec!["mcp-server-git"]);
assert!(wire.http.is_none());
}
#[test]
fn test_mcp_http_defaults_name_from_host() {
let server = McpServer::http("http://localhost:8931/mcp");
assert_eq!(server.name(), "localhost");
let wire = server.to_wire();
assert_eq!(
wire.http.unwrap().url.as_deref(),
Some("http://localhost:8931/mcp")
);
assert!(wire.stdio.is_none());
}
#[test]
fn test_mcp_builders_accumulate() {
let server = McpServer::stdio("uvx", ["x"])
.with_name("git")
.add_env("A", "1")
.add_env("B", "2")
.with_enabled_tools(["status"])
.with_timeout_seconds(30);
assert_eq!(server.name(), "git");
let wire = server.to_wire();
assert_eq!(wire.stdio.unwrap().env.len(), 2);
assert_eq!(wire.enabled_tools, vec!["status"]);
assert_eq!(wire.timeout_seconds, Some(30));
}
}