use std::sync::Arc;
use serde_json::{json, Value};
use crate::ToolKind;
use super::client::{McpClient, McpResource, McpToolDef};
use crate::openai_compatible::tools::{Tool, ToolCtx, ToolOutcome};
pub(crate) struct McpTool {
client: Arc<McpClient>,
id: String,
remote_name: String,
description: String,
schema: Value,
}
impl McpTool {
pub(crate) fn new(client: Arc<McpClient>, server: &str, def: McpToolDef) -> Self {
Self {
client,
id: format!("{server}_{}", def.name),
remote_name: def.name,
description: def.description,
schema: def.input_schema,
}
}
}
impl Tool for McpTool {
fn id(&self) -> &str {
&self.id
}
fn description(&self) -> &str {
&self.description
}
fn parameters(&self) -> Value {
self.schema.clone()
}
fn kind(&self) -> ToolKind {
ToolKind::Other
}
fn mutating(&self) -> bool {
true
}
fn execute(&self, args: &Value, _ctx: &ToolCtx) -> ToolOutcome {
let arguments = if args.is_object() { args.clone() } else { Value::Object(Default::default()) };
match self.client.call(&self.remote_name, &arguments) {
Ok(text) => ToolOutcome::ok(if text.trim().is_empty() { "(no content)".to_owned() } else { text }),
Err(e) => ToolOutcome::err(e),
}
}
}
pub(crate) struct McpResourceTool {
client: Arc<McpClient>,
id: String,
description: String,
}
impl McpResourceTool {
pub(crate) fn new(client: Arc<McpClient>, server: &str, resources: &[McpResource]) -> Self {
let mut description =
format!("Read a resource exposed by the `{server}` MCP server, by URI. Available resources:\n");
for r in resources {
let name = if r.name.is_empty() { String::new() } else { format!(" ({})", r.name) };
let desc = if r.description.is_empty() { String::new() } else { format!(" — {}", r.description) };
description.push_str(&format!("- `{}`{name}{desc}\n", r.uri));
}
Self { client, id: format!("{server}_read_resource"), description }
}
}
impl Tool for McpResourceTool {
fn id(&self) -> &str {
&self.id
}
fn description(&self) -> &str {
&self.description
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": { "uri": { "type": "string", "description": "The resource URI to read (one listed in this tool's description)." } },
"required": ["uri"]
})
}
fn kind(&self) -> ToolKind {
ToolKind::Read
}
fn mutating(&self) -> bool {
false }
fn execute(&self, args: &Value, _ctx: &ToolCtx) -> ToolOutcome {
let Some(uri) = args.get("uri").and_then(Value::as_str) else {
return ToolOutcome::err("read_resource: a `uri` string is required");
};
match self.client.read_resource(uri) {
Ok(text) => ToolOutcome::ok(if text.trim().is_empty() { "(empty resource)".to_owned() } else { text }),
Err(e) => ToolOutcome::err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::client::ScriptedConnection;
fn def(name: &str) -> McpToolDef {
McpToolDef {
name: name.to_owned(),
description: format!("does {name}"),
input_schema: json!({ "type": "object", "properties": { "q": { "type": "string" } } }),
}
}
fn client(conn: ScriptedConnection) -> Arc<McpClient> {
Arc::new(McpClient::over(Box::new(conn)))
}
fn ctx(cancel: &std::sync::atomic::AtomicBool) -> ToolCtx<'_> {
ToolCtx {
cwd: std::path::Path::new("."),
mode: crate::RunMode::Edit,
cancel,
run_id: "t",
call_id: "c",
skills: &[],
subagent: None,
model: None,
}
}
#[test]
fn a_tool_is_namespaced_by_its_server_but_called_by_its_own_name() {
let tool = McpTool::new(client(ScriptedConnection::new()), "github", def("search"));
assert_eq!(tool.id(), "github_search");
assert_eq!(tool.remote_name, "search");
assert_eq!(tool.description(), "does search");
assert_eq!(tool.parameters()["properties"]["q"]["type"], "string", "the server's schema, verbatim");
}
#[test]
fn an_mcp_tool_is_treated_as_mutating_because_its_effect_is_unknown() {
let tool = McpTool::new(client(ScriptedConnection::new()), "s", def("anything"));
assert!(tool.mutating());
assert_eq!(tool.kind(), ToolKind::Other);
}
#[test]
fn a_call_sends_the_arguments_through_and_returns_the_flattened_text() {
let conn = ScriptedConnection::new().on("tools/call", json!({ "content": [{ "type": "text", "text": "found it" }] }));
let recorder = conn.clone();
let tool = McpTool::new(client(conn), "github", def("search"));
let outcome = tool.execute(&json!({ "q": "rust" }), &ctx(&Default::default()));
assert_eq!(outcome.output, "found it");
let (method, params) = recorder.asked().into_iter().next().expect("one call");
assert_eq!(method, "tools/call");
assert_eq!(params["name"], "search", "the un-namespaced name");
assert_eq!(params["arguments"], json!({ "q": "rust" }));
}
#[test]
fn a_non_object_argument_becomes_an_empty_object_rather_than_a_bad_request() {
let conn = ScriptedConnection::new().on("tools/call", json!({ "content": [] }));
let recorder = conn.clone();
let tool = McpTool::new(client(conn), "s", def("ping"));
let outcome = tool.execute(&json!("not an object"), &ctx(&Default::default()));
assert_eq!(recorder.asked()[0].1["arguments"], json!({}));
assert_eq!(outcome.output, "(no content)", "an empty result is said out loud, not returned blank");
}
#[test]
fn a_failing_call_surfaces_the_servers_reason() {
let tool = McpTool::new(
client(ScriptedConnection::new().failing("tools/call", "the server went away")),
"s",
def("search"),
);
let outcome = tool.execute(&json!({}), &ctx(&Default::default()));
assert!(!outcome.ok, "a failure is not an answer");
assert!(outcome.output.contains("went away"), "got {}", outcome.output);
}
fn resources() -> Vec<McpResource> {
vec![
McpResource { uri: "file:///a.txt".into(), name: "A".into(), description: "the first".into() },
McpResource { uri: "file:///b.txt".into(), name: String::new(), description: String::new() },
]
}
#[test]
fn the_resource_tool_lists_what_can_be_read_in_its_description() {
let tool = McpResourceTool::new(client(ScriptedConnection::new()), "docs", &resources());
assert_eq!(tool.id(), "docs_read_resource");
assert!(tool.description().contains("`file:///a.txt` (A) — the first"));
assert!(tool.description().contains("`file:///b.txt`"), "a nameless resource is still listed");
assert!(!tool.mutating(), "reading is offered in read-only runs too");
assert_eq!(tool.kind(), ToolKind::Read);
assert_eq!(tool.parameters()["required"], json!(["uri"]));
}
#[test]
fn reading_a_resource_requires_a_uri_and_reports_an_empty_one() {
let conn = ScriptedConnection::new().on("resources/read", json!({ "contents": [{ "text": " " }] }));
let tool = McpResourceTool::new(client(conn), "docs", &resources());
let missing = tool.execute(&json!({}), &ctx(&Default::default()));
assert!(!missing.ok, "without a uri there is nothing to read");
let empty = tool.execute(&json!({ "uri": "file:///a.txt" }), &ctx(&Default::default()));
assert_eq!(empty.output, "(empty resource)", "distinguishable from a failure");
}
}