Files
tendril/src/mcp_server_gitea.rs
Ryan Parmeter 1ec1ef5def fix: Prioritize Homebrew paths over Linux paths in PATH search
Previous version returned the first match found in PATH, which meant
/usr/local/bin would be chosen before /opt/homebrew/bin if it appeared
first in the PATH variable.

Changes:
- Collect ALL candidate paths from PATH first
- Assign priority scores (1=Homebrew, 2=.local, 3=.cargo, 4=/usr/local, 5=/usr/bin)
- Sort by priority (lowest number = highest priority)
- Return the highest priority path

This ensures macOS Homebrew paths (/opt/homebrew/bin) are always
preferred over Linux/Intel Mac paths (/usr/local/bin), regardless
of PATH order.

Priority scheme:
1. /opt/homebrew/bin (M1/M2/M3/M4 Macs with Homebrew)
2. ~/.local/bin (user-local installs)
3. ~/.cargo/bin (Rust/Cargo installs)
4. /usr/local/bin (Linux/Intel Mac standard)
5. /usr/bin (system binaries)
2025-11-10 20:46:11 -07:00

350 lines
13 KiB
Rust

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<String>,
#[serde(default)]
gitea_insecure: Option<bool>,
#[serde(default)]
gitea_mcp_binary_path: Option<String>,
#[serde(default)]
use_docker: Option<bool>,
#[serde(default)]
docker_image: Option<String>,
}
impl zed::Extension for GiteaModelContextExtension {
fn new() -> Self {
Self
}
fn context_server_command(
&mut self,
_context_server_id: &ContextServerId,
project: &Project,
) -> Result<Command> {
// 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<Option<ContextServerConfiguration>> {
// 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::<GiteaContextServerSettings>(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<Command> {
// 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<Command> {
// 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<String> {
// 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<String>) -> Result<String> {
// 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);