use super::create_client_info;
use crate::{ClientError, KodegenClient, KodegenConnection};
use rmcp::{ServiceExt, transport::TokioChildProcess};
use std::{collections::HashMap, path::PathBuf};
use tokio::{process::Command, time::Duration};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct StdioClientBuilder {
command: String,
args: Vec<String>,
envs: HashMap<String, String>,
clear_env: bool,
env_removes: Vec<String>,
current_dir: Option<PathBuf>,
timeout: Duration,
client_name: Option<String>,
}
impl StdioClientBuilder {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
envs: HashMap::new(),
clear_env: false,
env_removes: Vec::new(),
current_dir: None,
timeout: DEFAULT_TIMEOUT,
client_name: None,
}
}
#[must_use]
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
#[must_use]
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.envs.insert(key.into(), value.into());
self
}
#[must_use]
pub fn envs(mut self, envs: HashMap<String, String>) -> Self {
self.envs.extend(envs);
self
}
#[must_use]
pub fn env_clear(mut self) -> Self {
self.clear_env = true;
self
}
#[must_use]
pub fn env_remove(mut self, key: impl Into<String>) -> Self {
self.env_removes.push(key.into());
self
}
#[must_use]
pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.current_dir = Some(dir.into());
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn client_name(mut self, name: impl Into<String>) -> Self {
self.client_name = Some(name.into());
self
}
pub async fn build(self) -> Result<(KodegenClient, KodegenConnection), ClientError> {
let trimmed_command = self.command.trim();
if trimmed_command.is_empty() {
return Err(ClientError::Connection {
message: "Command cannot be empty or whitespace-only".to_string(),
transport_type: Some(crate::TransportType::Stdio),
endpoint: None,
});
}
if self.command.contains(' ') {
return Err(ClientError::Connection {
message: format!(
"Command '{}' contains spaces. Arguments should be passed via .arg() method, not in the command string.\n\
Example: StdioClientBuilder::new(\"node\").arg(\"server.js\")",
self.command
),
transport_type: Some(crate::TransportType::Stdio),
endpoint: Some(self.command.clone()),
});
}
if let Err(e) = which::which(&self.command) {
return Err(ClientError::Connection {
message: format!(
"Command '{}' not found in PATH: {}\n\
Please ensure the command is installed and available in your system PATH.",
self.command, e
),
transport_type: Some(crate::TransportType::Stdio),
endpoint: Some(self.command.clone()),
});
}
let mut cmd = Command::new(&self.command);
cmd.args(&self.args);
if self.clear_env {
cmd.env_clear();
}
for key in &self.env_removes {
cmd.env_remove(key);
}
if !self.envs.is_empty() {
cmd.envs(&self.envs);
}
if let Some(dir) = &self.current_dir {
cmd.current_dir(dir);
}
let transport = TokioChildProcess::new(cmd).map_err(|e| ClientError::Connection {
message: format!("Failed to spawn process '{}': {}", self.command, e),
transport_type: Some(crate::TransportType::Stdio),
endpoint: Some(self.command.clone()),
})?;
let client_info = create_client_info(
self.client_name
.unwrap_or_else(|| "kodegen-stdio-client".to_string()),
);
let service = client_info
.serve(transport)
.await?;
let connection = KodegenConnection::from_service(service);
let client = connection.client().with_timeout(self.timeout);
Ok((client, connection))
}
}
pub async fn create_stdio_client(
command: &str,
args: &[&str],
) -> Result<(KodegenClient, KodegenConnection), ClientError> {
StdioClientBuilder::new(command)
.args(args.iter().copied())
.build()
.await
}