tfmcp

use crate::config::{self, Config}; use crate::mcp::handler::McpHandler; use crate::mcp::stdio::StdioTransport; use crate::shared::logging; use crate::terraform::service::TerraformService; use std::path::PathBuf; #[derive(Debug, thiserror::Error)] pub enum TfMcpError { #[error("Terraform executable not found")] TerraformNotFound, #[error("Invalid Terraform project directory: {0}")] #[allow(dead_code)] InvalidProjectDirectory(String), #[error("Error running Terraform command: {0}")] #[allow(dead_code)] TerraformCommandError(String), #[error(transparent)] Other(#[from] anyhow::Error), } #[derive(Debug)] #[allow(dead_code)] pub enum JsonRpcErrorCode { ParseError = -32700, InvalidRequest = -32600, MethodNotFound = -32601, InvalidParams = -32602, InternalError = -32603, // Custom error codes should be in the range -32000 to -32099 TerraformNotFound = -32000, InvalidProjectDirectory = -32001, } pub struct TfMcp { #[allow(dead_code)] config: Config, terraform_service: TerraformService, } impl TfMcp { pub fn new(config_path: Option<String>, project_dir: Option<String>) -> anyhow::Result<Self> { // Check environment variable for Terraform directory first let env_terraform_dir = std::env::var("TERRAFORM_DIR").ok(); if let Some(dir) = &env_terraform_dir { logging::info(&format!( "Found TERRAFORM_DIR environment variable: {}", dir )); } // Initialize config let config = match config_path { Some(path) => { let path_buf = PathBuf::from(&path); if path_buf.is_absolute() { logging::info(&format!("Using absolute config path: {}", path)); config::init_from_path(&path)? } else { // Convert to absolute path let abs_path = std::env::current_dir()?.join(&path); logging::info(&format!( "Converting relative config path to absolute: {}", abs_path.display() )); config::init_from_path(abs_path.to_str().unwrap_or(&path))? } } None => { logging::info("No config path provided, using default configuration"); config::init_default()? } }; // Priority for project directory: // 1. Command line argument // 2. Environment variable // 3. Config file // 4. Current directory let project_directory = match project_dir { Some(dir) => { let dir_buf = PathBuf::from(&dir); if dir_buf.is_absolute() { logging::info(&format!( "Using absolute project directory from CLI arg: {}", dir )); dir_buf } else { // Convert to absolute path let abs_dir = std::env::current_dir()?.join(dir); logging::info(&format!( "Converting relative project directory from CLI to absolute: {}", abs_dir.display() )); abs_dir } } None => { match env_terraform_dir { Some(dir) => { logging::info(&format!( "Using project directory from TERRAFORM_DIR env var: {}", dir )); PathBuf::from(dir) } None => { match &config.terraform.project_directory { Some(dir) => { let dir_buf = PathBuf::from(dir); if dir_buf.is_absolute() { logging::info(&format!( "Using project directory from config: {}", dir )); dir_buf } else { // Convert to absolute path let abs_dir = std::env::current_dir()?.join(dir); logging::info(&format!("Converting relative project directory from config to absolute: {}", abs_dir.display())); abs_dir } } None => { // If we're in root (/) directory and it's not a valid Terraform directory, // let's use HOME directory as fallback let current_dir = std::env::current_dir()?; if current_dir == PathBuf::from("/") { // We're likely running from Claude Desktop with undefined working dir let home_dir = dirs::home_dir().unwrap_or(current_dir.clone()); let tf_dir = home_dir.join("terraform"); logging::info(&format!("Working directory is root (/), falling back to home directory: {}", tf_dir.display())); tf_dir } else { logging::info(&format!("No project directory specified, using current directory: {}", current_dir.display())); current_dir } } } } } } }; // Check if terraform is installed let terraform_path = match &config.terraform.executable_path { Some(path) => { let path_buf = PathBuf::from(path); if path_buf.is_absolute() { logging::info(&format!("Using specified Terraform executable: {}", path)); path_buf } else { // Convert to absolute path let abs_path = std::env::current_dir()?.join(path); logging::info(&format!( "Converting relative Terraform path to absolute: {}", abs_path.display() )); abs_path } } None => { // Try to find terraform in PATH match which::which("terraform") { Ok(path) => { logging::info(&format!("Found Terraform in PATH: {}", path.display())); path } Err(_) => { logging::error("Terraform executable not found in PATH"); return Err(TfMcpError::TerraformNotFound.into()); } } } }; // Verify terraform executable exists if !terraform_path.exists() { logging::error(&format!( "Terraform executable not found at: {}", terraform_path.display() )); return Err(TfMcpError::TerraformNotFound.into()); } // Create a sample Terraform file if the directory doesn't have .tf files // This ensures we can always start the MCP server even without a valid Terraform project let has_tf_files = std::fs::read_dir(&project_directory) .map(|entries| { entries .filter_map(Result::ok) .any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf")) }) .unwrap_or(false); if !has_tf_files { // Directory doesn't exist or has no .tf files, create a sample project logging::info(&format!( "No Terraform (.tf) files found in {}. Creating a sample project.", project_directory.display() )); // Create directory if it doesn't exist if !project_directory.exists() { logging::info(&format!( "Creating directory: {}", project_directory.display() )); std::fs::create_dir_all(&project_directory)?; } // Create a sample main.tf file let main_tf_path = project_directory.join("main.tf"); logging::info(&format!( "Creating sample Terraform file at: {}", main_tf_path.display() )); let sample_tf_content = r#"# This is a sample Terraform file created by tfmcp terraform { required_providers { local = { source = "hashicorp/local" version = "~> 2.0" } } } resource "local_file" "example" { content = "Hello from tfmcp!" filename = "${path.module}/example.txt" } "#; std::fs::write(&main_tf_path, sample_tf_content)?; } let terraform_service = match TerraformService::new(terraform_path, project_directory) { Ok(service) => service, Err(e) => { logging::error(&format!("Error creating TerraformService: {}", e)); // Instead of immediately returning error, create a dummy service // This allows the MCP server to start but operations will fail gracefully return Err(e.into()); } }; logging::info("TfMcp initialized successfully"); Ok(Self { config, terraform_service, }) } pub async fn launch_mcp(&mut self) -> anyhow::Result<()> { let (transport, _sender) = StdioTransport::new(); // Log environment information let cwd = std::env::current_dir()?; logging::info(&format!("Current working directory: {}", cwd.display())); // Check if TERRAFORM_DIR environment variable is set, and if not, set it to a default // location inside the user's home directory to avoid root directory issues if std::env::var("TERRAFORM_DIR").is_err() { let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let default_tf_dir = home_dir.join("terraform"); // Create the directory if it doesn't exist if !default_tf_dir.exists() { logging::info(&format!( "Creating default Terraform directory: {}", default_tf_dir.display() )); std::fs::create_dir_all(&default_tf_dir)?; } // Create a sample Terraform file to ensure valid Terraform directory let main_tf_path = default_tf_dir.join("main.tf"); if !main_tf_path.exists() { logging::info(&format!( "Creating sample Terraform file at: {}", main_tf_path.display() )); let sample_tf_content = r#"# This is a sample Terraform file created by tfmcp terraform { required_providers { local = { source = "hashicorp/local" version = "~> 2.0" } } } resource "local_file" "example" { content = "Hello from tfmcp!" filename = "${path.module}/example.txt" } "#; std::fs::write(&main_tf_path, sample_tf_content)?; } // Set the environment variable for future uses in this process std::env::set_var( "TERRAFORM_DIR", default_tf_dir.to_string_lossy().to_string(), ); logging::info(&format!( "Set TERRAFORM_DIR to: {}", default_tf_dir.display() )); } // Create the handler and launch MCP let mut handler = McpHandler::new(self); handler.launch_mcp(&transport).await } pub async fn analyze_terraform(&mut self) -> anyhow::Result<()> { let analysis = self.terraform_service.analyze_configurations().await?; println!("{}", serde_json::to_string_pretty(&analysis)?); Ok(()) } #[allow(dead_code)] pub async fn get_terraform_version(&self) -> anyhow::Result<String> { self.terraform_service.get_version().await } pub async fn get_terraform_plan(&self) -> anyhow::Result<String> { self.terraform_service.get_plan().await } pub async fn apply_terraform(&self, auto_approve: bool) -> anyhow::Result<String> { self.terraform_service.apply(auto_approve).await } pub async fn init_terraform(&self) -> anyhow::Result<String> { self.terraform_service.init().await } pub async fn get_state(&self) -> anyhow::Result<String> { self.terraform_service.get_state().await } pub async fn list_resources(&self) -> anyhow::Result<Vec<String>> { self.terraform_service.list_resources().await } pub async fn validate_configuration(&self) -> anyhow::Result<String> { self.terraform_service.validate().await } pub async fn destroy_terraform(&self, auto_approve: bool) -> anyhow::Result<String> { // Check if delete functionality is enabled via environment variable let delete_enabled = std::env::var("TFMCP_DELETE_ENABLED") .map(|val| val.to_lowercase() == "true") .unwrap_or(false); if !delete_enabled { return Err(anyhow::anyhow!( "Delete functionality is disabled. Set TFMCP_DELETE_ENABLED=true to enable it." )); } logging::info("Executing Terraform destroy operation"); self.terraform_service.destroy(auto_approve).await } // プロジェクトディレクトリを変更するメソッド pub fn change_project_directory(&mut self, new_directory: String) -> anyhow::Result<()> { let dir_path = PathBuf::from(new_directory); let project_directory = if dir_path.is_absolute() { logging::info(&format!( "Changing to absolute project directory: {}", dir_path.display() )); dir_path } else { // 相対パスを絶対パスに変換 let abs_dir = std::env::current_dir()?.join(dir_path); logging::info(&format!( "Converting relative project directory to absolute: {}", abs_dir.display() )); abs_dir }; // ディレクトリが存在しない場合は作成 if !project_directory.exists() { logging::info(&format!( "Creating directory: {}", project_directory.display() )); std::fs::create_dir_all(&project_directory)?; } // .tfファイルがあるか確認し、なければサンプルプロジェクトを作成 let has_tf_files = std::fs::read_dir(&project_directory) .map(|entries| { entries .filter_map(Result::ok) .any(|entry| entry.path().extension().is_some_and(|ext| ext == "tf")) }) .unwrap_or(false); if !has_tf_files { // .tfファイルがないのでサンプルプロジェクトを作成 logging::info(&format!( "No Terraform (.tf) files found in {}. Creating a sample project.", project_directory.display() )); // サンプルのmain.tfファイルを作成 let main_tf_path = project_directory.join("main.tf"); logging::info(&format!( "Creating sample Terraform file at: {}", main_tf_path.display() )); let sample_tf_content = r#"# This is a sample Terraform file created by tfmcp terraform { required_providers { local = { source = "hashicorp/local" version = "~> 2.0" } } } resource "local_file" "example" { content = "Hello from tfmcp!" filename = "${path.module}/example.txt" } "#; std::fs::write(&main_tf_path, sample_tf_content)?; } // TerraformServiceのプロジェクトディレクトリを変更 match self .terraform_service .change_project_directory(project_directory.clone()) { Ok(_) => { // 環境変数も更新 std::env::set_var( "TERRAFORM_DIR", project_directory.to_string_lossy().to_string(), ); logging::info(&format!( "Successfully changed project directory to: {}", project_directory.display() )); Ok(()) } Err(e) => { logging::error(&format!("Failed to change project directory: {}", e)); Err(e.into()) } } } // 現在のプロジェクトディレクトリを取得するメソッド pub fn get_project_directory(&self) -> PathBuf { self.terraform_service.get_project_directory().clone() } }