use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::mcp::types::McpResourceContent;
use crate::tools::{Tool, ToolContext, ToolResult};
#[allow(dead_code)]
pub struct McpReadResourceTool;
#[async_trait]
impl Tool for McpReadResourceTool {
fn name(&self) -> &str {
"mcp_read_resource"
}
fn description(&self) -> &str {
"Read the content of an MCP resource by URI. \
Use after listing resources (or when you know the URI) to fetch data \
from the connected MCP server."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"uri": {
"type": "string",
"description": "The URI of the resource to read, e.g. \
'file:///home/user/notes.txt'. \
Obtain this from mcp_list_resources or from \
an MCP tool that returned a resource reference."
}
},
"required": ["uri"]
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
let uri = args["uri"].as_str().unwrap_or("").trim().to_string();
if uri.is_empty() {
return Ok(ToolResult {
output: "Missing required parameter 'uri'. \
Provide the resource URI (e.g. 'file:///path/to/file')."
.to_string(),
is_error: true,
});
}
let client = match &ctx.mcp_client {
Some(c) => Arc::clone(c),
None => {
return Ok(ToolResult {
output: "No MCP server is connected. \
Start xcodeai with an MCP server configured in config.json, \
or use the /connect command to attach one at runtime."
.to_string(),
is_error: true,
});
}
};
let mut locked = client.lock().await;
match locked.read_resource(&uri).await {
Ok(contents) => {
let output = format_resource_contents(&contents);
Ok(ToolResult {
output,
is_error: false,
})
}
Err(e) => Ok(ToolResult {
output: format!("Failed to read MCP resource '{}': {}", uri, e),
is_error: true,
}),
}
}
}
#[allow(dead_code)]
fn format_resource_contents(contents: &[McpResourceContent]) -> String {
let text_parts: Vec<&str> = contents.iter().filter_map(|c| c.text.as_deref()).collect();
if text_parts.is_empty() {
"(Resource has no text content — it may be a binary resource)".to_string()
} else {
text_parts.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_name() {
let tool = McpReadResourceTool;
assert_eq!(tool.name(), "mcp_read_resource");
}
#[test]
fn test_tool_description_mentions_uri() {
let tool = McpReadResourceTool;
assert!(
tool.description().to_lowercase().contains("uri"),
"Description should mention 'uri'"
);
}
#[test]
fn test_tool_schema() {
let tool = McpReadResourceTool;
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["uri"]["type"], "string");
let required = &schema["required"];
assert!(
required
.as_array()
.map(|a| a.iter().any(|v| v == "uri"))
.unwrap_or(false),
"uri must be in the required array"
);
}
#[test]
fn test_format_single_text() {
let contents = vec![McpResourceContent {
uri: "file:///notes.txt".to_string(),
mime_type: Some("text/plain".to_string()),
text: Some("hello world".to_string()),
blob: None,
}];
let result = format_resource_contents(&contents);
assert_eq!(result, "hello world");
}
#[test]
fn test_format_multiple_text_blocks() {
let contents = vec![
McpResourceContent {
uri: "file:///a.txt".to_string(),
mime_type: None,
text: Some("line one".to_string()),
blob: None,
},
McpResourceContent {
uri: "file:///b.txt".to_string(),
mime_type: None,
text: Some("line two".to_string()),
blob: None,
},
];
let result = format_resource_contents(&contents);
assert_eq!(result, "line one\nline two");
}
#[test]
fn test_format_binary_only() {
let contents = vec![McpResourceContent {
uri: "file:///image.png".to_string(),
mime_type: Some("image/png".to_string()),
text: None,
blob: Some("aGVsbG8=".to_string()),
}];
let result = format_resource_contents(&contents);
assert!(
result.contains("binary"),
"Expected binary placeholder, got: {}",
result
);
}
#[test]
fn test_format_empty() {
let result = format_resource_contents(&[]);
assert!(
result.contains("no text content"),
"Expected no-text placeholder, got: {}",
result
);
}
#[tokio::test]
async fn test_execute_empty_uri() {
use crate::io::NullIO;
use crate::tools::ToolContext;
use std::sync::Arc;
use tokio::sync::Mutex;
let tool = McpReadResourceTool;
let ctx = ToolContext {
working_dir: std::path::PathBuf::from("/tmp"),
sandbox_enabled: false,
io: Arc::new(NullIO),
compact_mode: false,
lsp_client: Arc::new(Mutex::new(None)),
mcp_client: None,
nesting_depth: 0,
llm: Arc::new(crate::llm::NullLlmProvider),
tools: Arc::new(crate::tools::ToolRegistry::new()),
permissions: vec![],
formatters: std::collections::HashMap::new(),
};
let result = tool
.execute(serde_json::json!({ "uri": "" }), &ctx)
.await
.unwrap();
assert!(result.is_error);
assert!(
result.output.contains("Missing"),
"Expected 'Missing' in error, got: {}",
result.output
);
}
#[tokio::test]
async fn test_execute_no_mcp_client() {
use crate::io::NullIO;
use crate::tools::ToolContext;
use std::sync::Arc;
use tokio::sync::Mutex;
let tool = McpReadResourceTool;
let ctx = ToolContext {
working_dir: std::path::PathBuf::from("/tmp"),
sandbox_enabled: false,
io: Arc::new(NullIO),
compact_mode: false,
lsp_client: Arc::new(Mutex::new(None)),
mcp_client: None,
nesting_depth: 0,
llm: Arc::new(crate::llm::NullLlmProvider),
tools: Arc::new(crate::tools::ToolRegistry::new()),
permissions: vec![],
formatters: std::collections::HashMap::new(),
};
let result = tool
.execute(serde_json::json!({ "uri": "file:///notes.txt" }), &ctx)
.await
.unwrap();
assert!(result.is_error);
assert!(
result.output.contains("No MCP server"),
"Expected 'No MCP server' in error, got: {}",
result.output
);
}
}