use schemars::JsonSchema; use serde::Deserialize; use zed::settings::ContextServerSettings; use zed_extension_api::{ self as zed, serde_json, Command, ContextServerConfiguration, ContextServerId, Project, Result, }; /// Tendril: Gitea MCP Extension for Zed /// /// This extension launches a gitea-mcp binary (locally or via Docker) and communicates with it /// to provide Gitea repository access through Zed's AI assistant. /// /// Binary Resolution Strategy: /// 1. If `gitea_mcp_binary_path` is set in settings, use that exact path /// 2. If `use_docker` is true, use Docker to run the gitea-mcp image /// 3. Otherwise, search common system paths: /// - /usr/local/bin/gitea-mcp /// - ~/.local/bin/gitea-mcp /// - ~/.cargo/bin/gitea-mcp /// - /opt/homebrew/bin/gitea-mcp (macOS) /// - Search in PATH environment variable /// /// Transport modes: /// - STDIO (default, recommended): Direct stdin/stdout communication /// Works with Zed's extension API and gitea-mcp binary /// - Docker: Runs gitea-mcp in a Docker container /// Useful when binary isn't available on host system struct GiteaModelContextExtension; #[derive(Debug, Deserialize, JsonSchema)] struct GiteaContextServerSettings { gitea_access_token: String, #[serde(default)] gitea_host: Option, #[serde(default)] gitea_insecure: Option, #[serde(default)] gitea_mcp_binary_path: Option, #[serde(default)] use_docker: Option, #[serde(default)] docker_image: Option, } impl zed::Extension for GiteaModelContextExtension { fn new() -> Self { Self } fn context_server_command( &mut self, _context_server_id: &ContextServerId, project: &Project, ) -> Result { // Get settings from project settings let settings = ContextServerSettings::for_project("tendril-gitea-mcp", project)?; let Some(settings_value) = settings.settings else { return Err("missing `gitea_access_token` setting".into()); }; // Parse settings let settings: GiteaContextServerSettings = serde_json::from_value(settings_value).map_err(|e| e.to_string())?; // Check if Docker mode is enabled if settings.use_docker.unwrap_or(false) { build_docker_command(&settings) } else { build_binary_command(&settings) } } fn context_server_configuration( &mut self, _context_server_id: &ContextServerId, project: &Project, ) -> Result> { // Load installation instructions shown in Zed UI let installation_instructions = include_str!("../configuration/installation_instructions.md").to_string(); // Load default settings template let mut default_settings = include_str!("../configuration/default_settings.jsonc").to_string(); // Try to get existing settings and populate template with current values if let Ok(settings) = ContextServerSettings::for_project("tendril-gitea-mcp", project) { if let Some(settings_value) = settings.settings { if let Ok(gitea_settings) = serde_json::from_value::(settings_value) { // Replace placeholder token with actual value default_settings = default_settings.replace( "\"YOUR_GITEA_TOKEN\"", &format!("\"{}\"", gitea_settings.gitea_access_token), ); // Replace host placeholder if specified if let Some(host) = gitea_settings.gitea_host { default_settings = default_settings .replace("// \"gitea_host\"", "\"gitea_host\"") .replace( "\"https://your-gitea-instance.com\"", &format!("\"{}\"", host), ); } // Replace insecure flag placeholder if specified if let Some(insecure) = gitea_settings.gitea_insecure { default_settings = default_settings .replace("// \"gitea_insecure\"", "\"gitea_insecure\"") .replace("false", &format!("{}", insecure)); } // Replace binary path if specified if let Some(binary_path) = gitea_settings.gitea_mcp_binary_path { default_settings = default_settings .replace("// \"gitea_mcp_binary_path\"", "\"gitea_mcp_binary_path\"") .replace("\"path/to/gitea-mcp\"", &format!("\"{}\"", binary_path)); } // Replace Docker settings if specified if let Some(true) = gitea_settings.use_docker { default_settings = default_settings .replace("// \"use_docker\"", "\"use_docker\"") .replace("false,", "true,"); if let Some(docker_image) = gitea_settings.docker_image { default_settings = default_settings .replace("// \"docker_image\"", "\"docker_image\"") .replace( "\"gitea/gitea-mcp:latest\"", &format!("\"{}\"", docker_image), ); } } } } } // Generate settings schema from the struct let settings_schema = serde_json::to_string(&schemars::schema_for!(GiteaContextServerSettings)) .map_err(|e| e.to_string())?; // Return configuration with instructions, defaults, and schema Ok(Some(ContextServerConfiguration { installation_instructions, default_settings, settings_schema, })) } } /// Build a command to run gitea-mcp as a binary fn build_binary_command(settings: &GiteaContextServerSettings) -> Result { // Resolve binary path with smart fallbacks let binary_path = resolve_binary_path(&settings.gitea_mcp_binary_path)?; // Set up environment variables let mut env_vars = vec![( "GITEA_ACCESS_TOKEN".into(), settings.gitea_access_token.clone(), )]; // Add insecure flag if specified (for self-signed certificates) if let Some(true) = settings.gitea_insecure { env_vars.push(("GITEA_INSECURE".into(), "true".to_string())); } // Use STDIO mode (the standard MCP transport and only mode supported by Zed extensions) // gitea-mcp communicates with Zed via stdin/stdout let mut args = vec!["-t".to_string(), "stdio".to_string()]; // Add host if specified (for self-hosted Gitea instances) if let Some(host) = &settings.gitea_host { args.push("--host".to_string()); args.push(host.clone()); } // Return command to launch gitea-mcp Ok(Command { command: binary_path, args, env: env_vars, }) } /// Build a command to run gitea-mcp in Docker fn build_docker_command(settings: &GiteaContextServerSettings) -> Result { // Find docker binary - MUST have a full path for Zed's Command struct let docker_cmd = find_docker_binary()?; // Use configured docker image or default let docker_image = settings .docker_image .as_deref() .unwrap_or("gitea/gitea-mcp-server:latest"); // Build docker run command let mut args = vec![ "run".to_string(), "--rm".to_string(), "-i".to_string(), // stdin for STDIO mode ]; // Add docker image name args.push(docker_image.to_string()); // Add gitea-mcp binary path (it's /app/gitea-mcp inside the container) args.push("/app/gitea-mcp".to_string()); // Add gitea-mcp arguments (using command-line flags, not env vars) args.push("-token".to_string()); args.push(settings.gitea_access_token.clone()); // Add transport mode args.push("-t".to_string()); args.push("stdio".to_string()); // Add host if specified if let Some(host) = &settings.gitea_host { args.push("-host".to_string()); args.push(host.clone()); } // Add insecure flag if specified if let Some(true) = settings.gitea_insecure { args.push("-insecure".to_string()); } // Docker command - docker_cmd is guaranteed to be a full path Ok(Command { command: docker_cmd, args, env: vec![], }) } /// Find the docker binary in common locations /// Returns the full absolute path to the docker executable /// Tries: which command → /usr/bin/docker → /usr/local/bin/docker fn find_docker_binary() -> Result { // Try using 'which' to find docker in PATH if let Ok(output) = std::process::Command::new("which").arg("docker").output() { if output.status.success() { if let Ok(path) = String::from_utf8(output.stdout) { let path = path.trim(); if !path.is_empty() { return Ok(path.to_string()); } } } } // Standard docker locations as fallback // Return the first common location - Zed will execute it and fail clearly if not found Ok("/usr/bin/docker".to_string()) } /// Resolve the gitea-mcp binary path with intelligent fallbacks /// /// Resolution strategy: /// 1. If explicit path provided in settings, use it directly /// 2. Search PATH environment variable for gitea-mcp or gitea-mcp-server /// 3. Try common installation locations as fallback /// 4. Return error with helpful instructions if not found /// /// Note: WASM sandbox restricts process spawning and filesystem checks, /// but we CAN read environment variables and construct paths. /// /// Returns the path to the binary (as a string) to use, or an error with guidance fn resolve_binary_path(explicit_path: &Option) -> Result { // If explicit path provided, use it (prioritize user configuration) if let Some(path) = explicit_path { return Ok(path.clone()); } // Try to search PATH environment variable manually // WASM can't spawn 'which', but CAN read env vars if let Ok(path_env) = std::env::var("PATH") { let binary_names = ["gitea-mcp", "gitea-mcp-server"]; // Collect all candidate paths from PATH, then prioritize them let mut candidates = Vec::new(); // Parse PATH and collect all plausible paths for path_dir in path_env.split(':') { for binary_name in &binary_names { let full_path = format!("{}/{}", path_dir, binary_name); // Categorize by priority (lower number = higher priority) let priority = if path_dir.contains("/opt/homebrew/bin") { 1 // Highest: macOS Homebrew (M1/M2/M3/M4 Macs) } else if path_dir.contains("/.local/bin") { 2 // User-local installations } else if path_dir.contains("/.cargo/bin") { 3 // Cargo installations } else if path_dir.contains("/usr/local/bin") { 4 // Linux standard location } else if path_dir.contains("/usr/bin") { 5 // System binaries } else { continue; // Skip non-standard locations }; candidates.push((priority, full_path)); } } // Sort by priority and return the highest priority candidate candidates.sort_by_key(|(priority, _)| *priority); if let Some((_, path)) = candidates.first() { return Ok(path.clone()); } } // Fallback: Try common absolute paths // Order by likelihood across platforms let common_paths = [ "/usr/local/bin/gitea-mcp", "/usr/local/bin/gitea-mcp-server", "/opt/homebrew/bin/gitea-mcp", "/opt/homebrew/bin/gitea-mcp-server", "/usr/bin/gitea-mcp", "/usr/bin/gitea-mcp-server", ]; // Return first path and let Zed try it // If it fails, the error will show which path was attempted if let Some(path) = common_paths.first() { return Ok(path.to_string()); } // Last resort error message Err( "gitea-mcp binary not found. Please set 'gitea_mcp_binary_path' in settings.\n\ \n\ Examples:\n\ • macOS: \"gitea_mcp_binary_path\": \"/opt/homebrew/bin/gitea-mcp-server\"\n\ • Linux: \"gitea_mcp_binary_path\": \"/usr/local/bin/gitea-mcp\"\n\ \n\ Or use Docker: \"use_docker\": true" .into(), ) } // Register the extension with Zed zed::register_extension!(GiteaModelContextExtension);