use crate::ai::mcp::McpConfig;
use crate::ai::rpc::{Handled, Incoming, RpcPeer};
use crate::ai::types::AiError;
use serde_json::{Value, json};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
const PROTOCOL_VERSION: u32 = 1;
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PermissionOption {
pub id: String,
pub name: String,
pub kind: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AcpCommand {
pub name: String,
pub description: String,
pub hint: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AcpEvent {
Text(String),
Thought(String),
Commands(Vec<AcpCommand>),
ToolCall {
id: String,
title: String,
status: String,
},
ToolUpdate {
id: String,
status: String,
output: String,
title: Option<String>,
},
Permission {
request: Value,
title: String,
options: Vec<PermissionOption>,
},
ReadFile {
request: Value,
path: String,
line: Option<usize>,
limit: Option<usize>,
},
WriteFile {
request: Value,
path: String,
content: String,
},
Done(String),
Error(String),
}
type Sink = Arc<dyn Fn(AcpEvent) + Send + Sync>;
fn text_of(content: &Value) -> String {
content["text"].as_str().unwrap_or_default().to_owned()
}
fn raw_command(update: &Value) -> Option<String> {
update["rawInput"]["command"].as_str().filter(|command| !command.trim().is_empty()).map(str::to_owned)
}
fn update(params: &Value) -> Option<AcpEvent> {
let update = ¶ms["update"];
let id = || update["toolCallId"].as_str().unwrap_or_default().to_owned();
let status = || update["status"].as_str().unwrap_or("pending").to_owned();
match update["sessionUpdate"].as_str()? {
"agent_message_chunk" => Some(AcpEvent::Text(text_of(&update["content"]))),
"agent_thought_chunk" => Some(AcpEvent::Thought(text_of(&update["content"]))),
"available_commands_update" => Some(AcpEvent::Commands(
update["availableCommands"]
.as_array()
.into_iter()
.flatten()
.filter_map(|command| {
let name = command["name"].as_str()?.trim_start_matches('/').to_owned();
Some(AcpCommand {
name,
description: command["description"].as_str().unwrap_or_default().to_owned(),
hint: command["input"]["hint"].as_str().map(str::to_owned),
})
})
.collect(),
)),
"tool_call" => Some(AcpEvent::ToolCall {
id: id(),
title: raw_command(update).unwrap_or_else(|| update["title"].as_str().unwrap_or("herramienta").to_owned()),
status: status(),
}),
"tool_call_update" => {
let output = update["content"]
.as_array()
.into_iter()
.flatten()
.map(|part| text_of(&part["content"]))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n");
let title = raw_command(update).or_else(|| update["title"].as_str().map(str::to_owned));
Some(AcpEvent::ToolUpdate { id: id(), status: status(), output, title })
}
_ => None,
}
}
fn route(incoming: Incoming, sink: &Sink) -> Handled {
match incoming {
Incoming::Notification { method, params } => {
if method == "session/update"
&& let Some(event) = update(¶ms)
{
sink(event);
}
Handled::Later
}
Incoming::Request { id, method, params } => {
let event = match method.as_str() {
"session/request_permission" => AcpEvent::Permission {
request: id,
title: params["toolCall"]["title"].as_str().unwrap_or("una acción").to_owned(),
options: params["options"]
.as_array()
.into_iter()
.flatten()
.map(|option| PermissionOption {
id: option["optionId"].as_str().unwrap_or_default().to_owned(),
name: option["name"].as_str().unwrap_or_default().to_owned(),
kind: option["kind"].as_str().unwrap_or_default().to_owned(),
})
.collect(),
},
"fs/read_text_file" => AcpEvent::ReadFile {
request: id,
path: params["path"].as_str().unwrap_or_default().to_owned(),
line: params["line"].as_u64().map(|line| line as usize),
limit: params["limit"].as_u64().map(|limit| limit as usize),
},
"fs/write_text_file" => AcpEvent::WriteFile {
request: id,
path: params["path"].as_str().unwrap_or_default().to_owned(),
content: params["content"].as_str().unwrap_or_default().to_owned(),
},
_ => return Handled::Unsupported,
};
sink(event);
Handled::Later
}
}
}
pub struct AcpClient {
peer: RpcPeer,
session_id: String,
sink: Sink,
}
impl AcpClient {
pub fn start(
config: &McpConfig,
cwd: &Path,
on_event: impl Fn(AcpEvent) + Send + Sync + 'static,
) -> Result<Self, AiError> {
Self::start_resuming(config, cwd, None, on_event)
}
pub fn start_resuming(
config: &McpConfig,
cwd: &Path,
resume: Option<&str>,
on_event: impl Fn(AcpEvent) + Send + Sync + 'static,
) -> Result<Self, AiError> {
let sink: Sink = Arc::new(on_event);
let handler_sink = sink.clone();
let peer = RpcPeer::spawn(&config.command, &config.args, &config.env, Some(cwd), move |incoming| {
route(incoming, &handler_sink)
})?;
let capabilities = json!({ "fs": { "readTextFile": true, "writeTextFile": true } });
let init = peer.request(
"initialize",
json!({ "protocolVersion": PROTOCOL_VERSION, "clientCapabilities": capabilities }),
HANDSHAKE_TIMEOUT,
)?;
let params = |extra: Value| {
let mut params = json!({ "cwd": cwd.to_string_lossy(), "mcpServers": [] });
params
.as_object_mut()
.into_iter()
.for_each(|map| map.extend(extra.as_object().cloned().unwrap_or_default()));
params
};
let resumable = init["agentCapabilities"]["sessionCapabilities"]["resume"].is_object();
let resumed = resume
.filter(|_| resumable)
.filter(|id| peer.request("session/resume", params(json!({ "sessionId": id })), HANDSHAKE_TIMEOUT).is_ok());
if let Some(id) = resumed {
return Ok(Self { peer, session_id: id.to_owned(), sink });
}
let session = peer.request("session/new", params(json!({})), HANDSHAKE_TIMEOUT)?;
let session_id = session["sessionId"]
.as_str()
.ok_or_else(|| AiError::Decode("session/new sin sessionId".into()))?
.to_owned();
Ok(Self { peer, session_id, sink })
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn authenticate(config: &McpConfig, cwd: &Path, method: &str) -> Result<(), AiError> {
let peer = RpcPeer::spawn(&config.command, &config.args, &config.env, Some(cwd), |_| Handled::Unsupported)?;
peer.request(
"initialize",
json!({ "protocolVersion": PROTOCOL_VERSION, "clientCapabilities": {} }),
HANDSHAKE_TIMEOUT,
)?;
peer.request("authenticate", json!({ "methodId": method }), AUTH_TIMEOUT).map(|_| ())
}
pub fn prompt(&self, text: &str) -> Result<(), AiError> {
let sink = self.sink.clone();
let params = json!({ "sessionId": self.session_id, "prompt": [{ "type": "text", "text": text }] });
self.peer.request_async("session/prompt", params, move |result| match result {
Ok(value) => sink(AcpEvent::Done(value["stopReason"].as_str().unwrap_or("end_turn").to_owned())),
Err(error) => sink(AcpEvent::Error(error.to_string())),
})
}
pub fn cancel(&self) -> Result<(), AiError> {
self.peer.notify("session/cancel", json!({ "sessionId": self.session_id }))
}
pub fn answer_permission(&self, request: &Value, option: Option<&str>) -> Result<(), AiError> {
let outcome = match option {
Some(option_id) => json!({ "outcome": "selected", "optionId": option_id }),
None => json!({ "outcome": "cancelled" }),
};
self.peer.respond(request, Ok(json!({ "outcome": outcome })))
}
pub fn answer(&self, request: &Value, result: Result<Value, String>) -> Result<(), AiError> {
self.peer.respond(request, result)
}
}
#[cfg(test)]
#[path = "acp_tests.rs"]
mod tests;