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)
350 lines
13 KiB
Rust
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);
|