use crate::auth;
use crate::cli::UI;
use crate::core::{Config, ScanEngine};
use crate::mcp::sanitizer;
use crate::mcp::types::*;
use crate::ops;
use crate::platform;
use crate::platform::server_registry::{ServerPlatform, ServerRegistry};
use rmcp::handler::server::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock};
use std::time::Instant;
struct RateLimiter {
timestamps: Vec<Instant>,
max_requests: usize,
window_secs: u64,
}
impl RateLimiter {
fn new(max_requests: usize, window_secs: u64) -> Self {
Self {
timestamps: Vec::new(),
max_requests,
window_secs,
}
}
fn check(&mut self) -> bool {
let now = Instant::now();
let cutoff = now - std::time::Duration::from_secs(self.window_secs);
self.timestamps.retain(|t| *t > cutoff);
if self.timestamps.len() >= self.max_requests {
return false;
}
self.timestamps.push(now);
true
}
}
pub struct SecuregitMcpServer {
work_dir: PathBuf,
config: Config,
last_scan: Arc<RwLock<Option<ScanResult>>>,
rate_limiter: Arc<Mutex<RateLimiter>>,
redteam_bridge: Arc<crate::redteam::RedteamBridge>,
tool_router: ToolRouter<Self>,
}
fn mcp_err(msg: impl Into<String>) -> McpError {
McpError {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from(msg.into()),
data: None,
}
}
#[tool_router]
impl SecuregitMcpServer {
pub fn new(work_dir: PathBuf) -> Self {
let config = Config::default();
let bridge = Arc::new(crate::redteam::RedteamBridge::new(
config.llm_security.binary.clone(),
));
Self {
work_dir,
config,
last_scan: Arc::new(RwLock::new(None)),
rate_limiter: Arc::new(Mutex::new(RateLimiter::new(60, 60))),
redteam_bridge: bridge,
tool_router: Self::tool_router(),
}
}
fn check_rate_limit(&self) -> Result<(), McpError> {
let mut limiter = self
.rate_limiter
.lock()
.map_err(|e| mcp_err(e.to_string()))?;
if !limiter.check() {
return Err(McpError {
code: ErrorCode::INTERNAL_ERROR,
message: Cow::from("Rate limit exceeded (60 requests/minute). Please wait."),
data: None,
});
}
Ok(())
}
fn resolve_platform_client(
&self,
server_name: Option<&str>,
) -> Result<Box<dyn platform::Platform>, McpError> {
if let Some(name) = server_name {
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load server registry: {}", e)))?;
let server = registry
.get(name)
.ok_or_else(|| mcp_err(format!("Server '{}' not found", name)))?
.clone();
let token = auth::token_for_server(&server)
.ok_or_else(|| mcp_err(format!("No credentials for server '{}'", name)))?;
let remote = platform::detect_remote(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to detect remote: {}", e)))?;
Ok(platform::create_client_for_server(
&server,
token,
&remote.owner,
&remote.repo,
))
} else {
let remote = platform::detect_remote(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to detect remote: {}", e)))?;
let token = platform::resolve_token(&remote.host)
.ok_or_else(|| mcp_err("Not authenticated. Run: securegit auth login"))?;
Ok(platform::create_client(&remote, token))
}
}
fn sanitize_text_result(&self, text: String) -> CallToolResult {
CallToolResult::success(vec![Content::text(sanitizer::sanitize_output(&text))])
}
#[tool(
description = "Scan a directory for security findings (secrets, vulnerabilities, supply-chain risks). Returns structured results with severity levels."
)]
async fn securegit_scan(
&self,
Parameters(params): Parameters<ScanParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let scan_path = params
.path
.map(PathBuf::from)
.unwrap_or_else(|| self.work_dir.clone());
let engine = ScanEngine::new(self.config.clone());
let mut report = engine
.scan_directory(&scan_path)
.await
.map_err(|e| mcp_err(format!("Scan failed: {}", e)))?;
if self.config.llm_security.enabled
&& self.redteam_bridge.is_available()
&& self.config.llm_security.scan_mcp_configs
{
for pattern in &self.config.llm_security.mcp_config_patterns {
let config_path = scan_path.join(pattern);
if config_path.exists() {
match self.scan_mcp_config_file(&config_path).await {
Ok(findings) => report.findings.extend(findings),
Err(e) => report
.warnings
.push(format!("LLM security scan skipped for {}: {}", pattern, e)),
}
}
}
}
if self.config.llm_security.enabled && self.config.llm_security.run_mcp_scan {
let mcp_bridge = crate::toolbridges::mcp_scan::McpScanBridge::new();
if mcp_bridge.is_available() {
for pattern in &self.config.llm_security.mcp_config_patterns {
let config_path = scan_path.join(pattern);
if config_path.exists() {
match mcp_bridge.scan(&config_path).await {
Ok(findings) => report.findings.extend(findings),
Err(crate::toolbridges::CliError::NotInstalled(_)) => {}
Err(e) => report
.warnings
.push(format!("mcp-scan failed for {}: {}", pattern, e)),
}
}
}
}
}
let min_sev = parse_severity(params.min_severity.as_deref());
let findings: Vec<FindingResult> = report
.findings
.iter()
.filter(|f| severity_rank(&format!("{:?}", f.severity)) >= min_sev)
.map(|f| FindingResult {
id: f.id.clone(),
title: f.title.clone(),
severity: format!("{:?}", f.severity),
file: f.file_path.as_ref().map(|p| p.display().to_string()),
line: f.line_start.map(|l| l as usize),
description: f.description.clone(),
})
.collect();
let result = ScanResult {
scanned_files: report.scanned_files,
findings_count: findings.len(),
findings,
};
if let Ok(mut cache) = self.last_scan.write() {
*cache = Some(ScanResult {
scanned_files: result.scanned_files,
findings_count: result.findings_count,
findings: result
.findings
.iter()
.map(|f| FindingResult {
id: f.id.clone(),
title: f.title.clone(),
severity: f.severity.clone(),
file: f.file.clone(),
line: f.line,
description: f.description.clone(),
})
.collect(),
});
}
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Scan only staged (indexed) changes for security issues before committing."
)]
async fn securegit_scan_staged(
&self,
Parameters(params): Parameters<ScanStagedParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let min_sev = parse_severity(params.min_severity.as_deref());
let staged_paths: Vec<String> = {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let mut opts = git2::StatusOptions::new();
opts.include_untracked(false);
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| mcp_err(e.to_string()))?;
statuses
.iter()
.filter(|e| {
e.status().intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_RENAMED,
)
})
.filter_map(|e| e.path().map(|s| s.to_string()))
.collect()
};
if staged_paths.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No staged changes to scan.",
)]));
}
let engine = ScanEngine::new(self.config.clone());
let report = engine
.scan_directory(&self.work_dir)
.await
.map_err(|e| mcp_err(format!("Scan failed: {}", e)))?;
let findings: Vec<FindingResult> = report
.findings
.iter()
.filter(|f| {
if let Some(ref fp) = f.file_path {
let fp_str = fp.display().to_string();
staged_paths.iter().any(|sp| fp_str.ends_with(sp))
} else {
false
}
})
.filter(|f| severity_rank(&format!("{:?}", f.severity)) >= min_sev)
.map(|f| FindingResult {
id: f.id.clone(),
title: f.title.clone(),
severity: format!("{:?}", f.severity),
file: f.file_path.as_ref().map(|p| p.display().to_string()),
line: f.line_start.map(|l| l as usize),
description: f.description.clone(),
})
.collect();
let result = ScanResult {
scanned_files: staged_paths.len(),
findings_count: findings.len(),
findings,
};
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Scan staged changes for security issues, then commit if clean. Blocks commit if findings meet or exceed the max severity threshold."
)]
async fn securegit_safe_commit(
&self,
Parameters(params): Parameters<SafeCommitParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let max_sev = parse_severity(params.max_severity.as_deref().or(Some("high")));
let engine = ScanEngine::new(self.config.clone());
let mut report = engine
.scan_directory(&self.work_dir)
.await
.map_err(|e| mcp_err(format!("Scan failed: {}", e)))?;
if self.config.llm_security.enabled && self.redteam_bridge.is_available() {
if self.config.llm_security.scan_mcp_configs {
for pattern in &self.config.llm_security.mcp_config_patterns {
let config_path = self.work_dir.join(pattern);
if config_path.exists() {
match self.scan_mcp_config_file(&config_path).await {
Ok(findings) => report.findings.extend(findings),
Err(e) => report
.warnings
.push(format!("LLM security scan skipped for {}: {}", pattern, e)),
}
}
}
}
}
let blocking_findings: Vec<_> = report
.findings
.iter()
.filter(|f| severity_rank(&format!("{:?}", f.severity)) >= max_sev)
.collect();
if !blocking_findings.is_empty() {
let mut msg = format!(
"BLOCKED: {} security finding(s) at or above threshold:\n\n",
blocking_findings.len()
);
for f in &blocking_findings {
msg.push_str(&format!(" [{:?}] {}: {}\n", f.severity, f.id, f.title));
if let Some(ref fp) = f.file_path {
msg.push_str(&format!(" File: {}\n", fp.display()));
}
}
msg.push_str("\nResolve these findings before committing.");
return Ok(CallToolResult::error(vec![Content::text(msg)]));
}
let ui = UI::new(false, true, false, false);
ops::commit::execute(&self.work_dir, ¶ms.message, false, false, &ui)
.map_err(|e| mcp_err(format!("Commit failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Security scan passed. Committed: {}",
params.message
))]))
}
async fn scan_mcp_config_file(
&self,
config_path: &std::path::Path,
) -> Result<Vec<crate::core::Finding>, crate::redteam::bridge::BridgeError> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| crate::redteam::bridge::BridgeError::CallFailed(e.to_string()))?;
let parsed: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| crate::redteam::bridge::BridgeError::CallFailed(e.to_string()))?;
let mut all_findings = Vec::new();
if let Some(servers) = parsed.get("mcpServers").and_then(|v| v.as_object()) {
for (name, server_config) in servers {
let command = server_config
.get("command")
.and_then(|v| v.as_str())
.unwrap_or_default();
if command.is_empty() {
continue;
}
let args: Vec<String> = server_config
.get("args")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
match self.redteam_bridge.scan_mcp_server(command, &args).await {
Ok(json_text) => {
let findings = crate::redteam::findings::parse_mcp_scan_findings(
&json_text,
Some(&config_path.display().to_string()),
);
all_findings.extend(findings);
}
Err(crate::redteam::bridge::BridgeError::NotInstalled(_)) => break,
Err(e) => {
tracing::warn!("Failed to scan MCP server '{}': {}", name, e);
}
}
}
}
Ok(all_findings)
}
#[tool(
description = "Review a specific file for security issues. Returns detailed analysis of any findings in the file."
)]
async fn securegit_review(
&self,
Parameters(params): Parameters<ReviewParams>,
) -> Result<CallToolResult, McpError> {
let file_path = self.work_dir.join(¶ms.file);
if !file_path.exists() {
return Ok(CallToolResult::error(vec![Content::text(format!(
"File not found: {}",
params.file
))]));
}
let engine = ScanEngine::new(self.config.clone());
let mut report = engine
.scan_file(&file_path)
.await
.map_err(|e| mcp_err(format!("Scan failed: {}", e)))?;
if self.config.llm_security.enabled && self.redteam_bridge.is_available() {
let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_mcp_config = self
.config
.llm_security
.mcp_config_patterns
.iter()
.any(|pattern| pattern.ends_with(filename) || pattern == ¶ms.file);
if is_mcp_config {
match self.scan_mcp_config_file(&file_path).await {
Ok(findings) => report.findings.extend(findings),
Err(e) => report
.warnings
.push(format!("LLM security scan skipped: {}", e)),
}
}
}
if self.config.llm_security.enabled && self.config.llm_security.run_mcp_scan {
let filename = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_mcp_config = self
.config
.llm_security
.mcp_config_patterns
.iter()
.any(|pattern| pattern.ends_with(filename) || pattern == ¶ms.file);
if is_mcp_config {
let mcp_bridge = crate::toolbridges::mcp_scan::McpScanBridge::new();
if mcp_bridge.is_available() {
match mcp_bridge.scan(&file_path).await {
Ok(findings) => report.findings.extend(findings),
Err(crate::toolbridges::CliError::NotInstalled(_)) => {}
Err(e) => report.warnings.push(format!("mcp-scan failed: {}", e)),
}
}
}
}
let findings: Vec<FindingResult> = report
.findings
.iter()
.map(|f| FindingResult {
id: f.id.clone(),
title: f.title.clone(),
severity: format!("{:?}", f.severity),
file: f.file_path.as_ref().map(|p| p.display().to_string()),
line: f.line_start.map(|l| l as usize),
description: f.description.clone(),
})
.collect();
if findings.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No security findings in '{}'.",
params.file
))]));
}
let json = serde_json::to_string_pretty(&findings).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Found {} security issue(s) in '{}':\n\n{}",
findings.len(),
params.file,
json
))]))
}
#[tool(
description = "Query cached security findings from the last scan. Filter by severity or file pattern."
)]
async fn securegit_findings(
&self,
Parameters(params): Parameters<FindingsParams>,
) -> Result<CallToolResult, McpError> {
let cache = self.last_scan.read().map_err(|e| mcp_err(e.to_string()))?;
let Some(ref cached) = *cache else {
return Ok(CallToolResult::success(vec![Content::text(
"No cached scan results. Run securegit_scan first.",
)]));
};
let min_sev = parse_severity(params.min_severity.as_deref());
let pattern = params.file_pattern.as_deref().unwrap_or("");
let filtered: Vec<&FindingResult> = cached
.findings
.iter()
.filter(|f| severity_rank(&f.severity) >= min_sev)
.filter(|f| {
if pattern.is_empty() {
true
} else {
f.file
.as_ref()
.map(|fp| fp.contains(pattern))
.unwrap_or(false)
}
})
.collect();
let json = serde_json::to_string_pretty(&filtered).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"{} finding(s) matching filters:\n\n{}",
filtered.len(),
json
))]))
}
#[tool(
description = "Show structured repository status including staged, unstaged, and untracked files."
)]
async fn securegit_status(&self) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let head = repo.head().ok();
let branch_name = head
.as_ref()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "(detached)".to_string());
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| mcp_err(e.to_string()))?;
let mut staged = Vec::new();
let mut unstaged = Vec::new();
let mut untracked = Vec::new();
for entry in statuses.iter() {
let status = entry.status();
let path_str = entry.path().unwrap_or("").to_string();
if status.intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED,
) {
let kind = if status.contains(git2::Status::INDEX_NEW) {
"new"
} else if status.contains(git2::Status::INDEX_MODIFIED) {
"modified"
} else if status.contains(git2::Status::INDEX_DELETED) {
"deleted"
} else {
"renamed"
};
staged.push(serde_json::json!({"status": kind, "path": path_str}));
}
if status.intersects(
git2::Status::WT_MODIFIED | git2::Status::WT_DELETED | git2::Status::WT_RENAMED,
) {
let kind = if status.contains(git2::Status::WT_MODIFIED) {
"modified"
} else if status.contains(git2::Status::WT_DELETED) {
"deleted"
} else {
"renamed"
};
unstaged.push(serde_json::json!({"status": kind, "path": path_str}));
}
if status.contains(git2::Status::WT_NEW) {
untracked.push(path_str);
}
}
let result = serde_json::json!({
"branch": branch_name,
"staged": staged,
"unstaged": unstaged,
"untracked": untracked,
"clean": staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
});
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "Show commit history with optional filters for author and date range.")]
async fn securegit_log(
&self,
Parameters(params): Parameters<LogParams>,
) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let mut revwalk = repo.revwalk().map_err(|e| mcp_err(e.to_string()))?;
revwalk.push_head().map_err(|e| mcp_err(e.to_string()))?;
revwalk
.set_sorting(git2::Sort::TIME)
.map_err(|e| mcp_err(e.to_string()))?;
let max_count = params.max_count.unwrap_or(10);
let mut commits = Vec::new();
for (i, oid_result) in revwalk.enumerate() {
if i >= max_count {
break;
}
let oid = oid_result.map_err(|e| mcp_err(e.to_string()))?;
let commit = repo.find_commit(oid).map_err(|e| mcp_err(e.to_string()))?;
let author_name = commit.author().name().unwrap_or("").to_string();
let author_email = commit.author().email().unwrap_or("").to_string();
if let Some(ref filter) = params.author {
let filter_lower = filter.to_lowercase();
if !author_name.to_lowercase().contains(&filter_lower)
&& !author_email.to_lowercase().contains(&filter_lower)
{
continue;
}
}
let oid_str = oid.to_string();
let short_oid = &oid_str[..7.min(oid_str.len())];
commits.push(serde_json::json!({
"oid": short_oid,
"message": commit.summary().unwrap_or(""),
"author": format!("{} <{}>", author_name, author_email),
"time": commit.time().seconds(),
}));
}
let json = serde_json::to_string_pretty(&commits).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Show changes between commits, index, and working tree. Supports staged (cached) diffs and commit ranges."
)]
async fn securegit_diff(
&self,
Parameters(params): Parameters<DiffParams>,
) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let cached = params.cached.unwrap_or(false);
let name_only = params.name_only.unwrap_or(false);
let diff = if cached {
let head_tree = repo.head().ok().and_then(|h| h.peel_to_tree().ok());
repo.diff_tree_to_index(head_tree.as_ref(), None, None)
} else if let Some(ref commit_spec) = params.commit {
let obj = repo
.revparse_single(commit_spec)
.map_err(|e| mcp_err(e.to_string()))?;
let commit = obj.peel_to_commit().map_err(|e| mcp_err(e.to_string()))?;
let tree = commit.tree().map_err(|e| mcp_err(e.to_string()))?;
repo.diff_tree_to_workdir_with_index(Some(&tree), None)
} else {
repo.diff_index_to_workdir(None, None)
}
.map_err(|e| mcp_err(e.to_string()))?;
if name_only {
let mut files = Vec::new();
diff.foreach(
&mut |delta, _| {
if let Some(path) = delta.new_file().path() {
files.push(path.display().to_string());
}
true
},
None,
None,
None,
)
.map_err(|e| mcp_err(e.to_string()))?;
let json = serde_json::to_string_pretty(&files).map_err(|e| mcp_err(e.to_string()))?;
return Ok(CallToolResult::success(vec![Content::text(json)]));
}
let mut output = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' => "+",
'-' => "-",
' ' => " ",
_ => "",
};
output.push_str(prefix);
if let Ok(content) = std::str::from_utf8(line.content()) {
for c in content.chars() {
if c == '\x1b' || (c.is_control() && c != '\n' && c != '\t' && c != '\r') {
} else {
output.push(c);
}
}
}
true
})
.map_err(|e| mcp_err(e.to_string()))?;
if output.is_empty() {
output = "No changes.".to_string();
}
if output.len() > 50_000 {
output.truncate(50_000);
output.push_str("\n\n... (truncated, diff too large)");
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "Show what revision and author last modified each line of a file.")]
async fn securegit_blame(
&self,
Parameters(params): Parameters<BlameParams>,
) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let blame = repo
.blame_file(std::path::Path::new(¶ms.file), None)
.map_err(|e| mcp_err(format!("Blame failed: {}", e)))?;
let file_path = self.work_dir.join(¶ms.file);
let content = std::fs::read_to_string(&file_path).map_err(|e| mcp_err(e.to_string()))?;
let mut output = String::new();
for (i, line) in content.lines().enumerate() {
if let Some(hunk) = blame.get_line(i + 1) {
let oid = hunk.final_commit_id();
let s = oid.to_string();
let short = &s[..7.min(s.len())];
let sig = hunk.final_signature();
let author = sig.name().unwrap_or("?");
output.push_str(&format!(
"{} ({:>12}) {:>4} | {}\n",
short,
author,
i + 1,
line
));
} else {
output.push_str(&format!("{:>7} {:>14} {:>4} | {}\n", "?", "?", i + 1, line));
}
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
#[tool(description = "Show details of a commit, tag, or other git object.")]
async fn securegit_show(
&self,
Parameters(params): Parameters<ShowParams>,
) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let object_spec = params.object.as_deref().unwrap_or("HEAD");
let obj = repo
.revparse_single(object_spec)
.map_err(|e| mcp_err(format!("Object not found: {}", e)))?;
let commit = obj
.peel_to_commit()
.map_err(|e| mcp_err(format!("Not a commit: {}", e)))?;
let result = serde_json::json!({
"oid": commit.id().to_string(),
"message": commit.message().unwrap_or(""),
"author": format!("{} <{}>",
commit.author().name().unwrap_or(""),
commit.author().email().unwrap_or("")),
"committer": format!("{} <{}>",
commit.committer().name().unwrap_or(""),
commit.committer().email().unwrap_or("")),
"time": commit.time().seconds(),
"parent_count": commit.parent_count(),
});
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "List all local and optionally remote branches.")]
async fn securegit_branch_list(&self) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let head_name = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()));
let mut branches = Vec::new();
for branch_result in repo.branches(None).map_err(|e| mcp_err(e.to_string()))? {
let (branch, branch_type) = branch_result.map_err(|e| mcp_err(e.to_string()))?;
if let Ok(Some(name)) = branch.name() {
let is_current = head_name.as_deref() == Some(name);
let kind = match branch_type {
git2::BranchType::Local => "local",
git2::BranchType::Remote => "remote",
};
branches.push(serde_json::json!({
"name": name,
"type": kind,
"current": is_current,
}));
}
}
let json = serde_json::to_string_pretty(&branches).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "List all tags in the repository.")]
async fn securegit_tag_list(&self) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let tag_names = repo.tag_names(None).map_err(|e| mcp_err(e.to_string()))?;
let mut tags = Vec::new();
for tag_name in tag_names.iter().flatten() {
let ref_name = format!("refs/tags/{}", tag_name);
let oid = repo
.find_reference(&ref_name)
.ok()
.and_then(|r| r.target())
.map(|o| {
let s = o.to_string();
s[..7.min(s.len())].to_string()
})
.unwrap_or_default();
tags.push(serde_json::json!({"name": tag_name, "oid": oid}));
}
let json = serde_json::to_string_pretty(&tags).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "List all configured remotes with their URLs.")]
async fn securegit_remote_list(&self) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let remotes = repo.remotes().map_err(|e| mcp_err(e.to_string()))?;
let mut result = Vec::new();
for name in remotes.iter().flatten() {
let url = repo
.find_remote(name)
.ok()
.and_then(|r| r.url().map(|u| u.to_string()))
.unwrap_or_default();
result.push(serde_json::json!({"name": name, "url": url}));
}
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(description = "List all stash entries.")]
async fn securegit_stash_list(&self) -> Result<CallToolResult, McpError> {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let mut stashes = Vec::new();
let mut repo = repo;
repo.stash_foreach(|index, message, oid| {
let oid_str = oid.to_string();
let short_oid = &oid_str[..7.min(oid_str.len())];
stashes.push(serde_json::json!({
"index": index,
"message": message,
"oid": short_oid,
}));
true
})
.map_err(|e| mcp_err(e.to_string()))?;
if stashes.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No stash entries.",
)]));
}
let json = serde_json::to_string_pretty(&stashes).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Stage files for commit. Specify file paths or use all=true to stage everything."
)]
async fn securegit_add(
&self,
Parameters(params): Parameters<AddParams>,
) -> Result<CallToolResult, McpError> {
let all = params.all.unwrap_or(false);
ops::staging::add(&self.work_dir, ¶ms.files, all, false)
.map_err(|e| mcp_err(format!("Add failed: {}", e)))?;
let msg = if all {
"Staged all changes.".to_string()
} else {
format!("Staged {} file(s).", params.files.len())
};
Ok(CallToolResult::success(vec![Content::text(msg)]))
}
#[tool(
description = "Create a commit with the given message. Does NOT run security scan (use securegit_safe_commit for that)."
)]
async fn securegit_commit(
&self,
Parameters(params): Parameters<CommitParams>,
) -> Result<CallToolResult, McpError> {
let allow_empty = params.allow_empty.unwrap_or(false);
let ui = UI::new(false, true, false, false);
ops::commit::execute(&self.work_dir, ¶ms.message, allow_empty, false, &ui)
.map_err(|e| mcp_err(format!("Commit failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Committed: {}",
params.message
))]))
}
#[tool(
description = "Switch branches or checkout a specific commit. Optionally create a new branch."
)]
async fn securegit_checkout(
&self,
Parameters(params): Parameters<CheckoutParams>,
) -> Result<CallToolResult, McpError> {
let create = params.create.unwrap_or(false);
let ui = UI::new(false, true, false, false);
ops::checkout::execute(&self.work_dir, ¶ms.target, create, false, &ui)
.map_err(|e| mcp_err(format!("Checkout failed: {}", e)))?;
let msg = if create {
format!("Created and switched to new branch '{}'.", params.target)
} else {
format!("Switched to '{}'.", params.target)
};
Ok(CallToolResult::success(vec![Content::text(msg)]))
}
#[tool(description = "Create a new branch at the current HEAD or a specified start point.")]
async fn securegit_branch_create(
&self,
Parameters(params): Parameters<BranchCreateParams>,
) -> Result<CallToolResult, McpError> {
let ui = UI::new(false, true, false, false);
ops::branch::create(
&self.work_dir,
¶ms.name,
params.start_point.as_deref(),
&ui,
)
.map_err(|e| mcp_err(format!("Branch create failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Created branch '{}'.",
params.name
))]))
}
#[tool(description = "Delete a branch. Use force=true to delete unmerged branches.")]
async fn securegit_branch_delete(
&self,
Parameters(params): Parameters<BranchDeleteParams>,
) -> Result<CallToolResult, McpError> {
let force = params.force.unwrap_or(false);
let ui = UI::new(false, true, false, false);
ops::branch::delete(&self.work_dir, ¶ms.name, force, &ui)
.map_err(|e| mcp_err(format!("Branch delete failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Deleted branch '{}'.",
params.name
))]))
}
#[tool(description = "Merge another branch into the current branch.")]
async fn securegit_merge(
&self,
Parameters(params): Parameters<MergeParams>,
) -> Result<CallToolResult, McpError> {
let ui = UI::new(false, true, false, false);
ops::merge::execute(
&self.work_dir,
¶ms.branch,
false,
false,
false,
false,
&ui,
)
.map_err(|e| mcp_err(format!("Merge failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Merged branch '{}'.",
params.branch
))]))
}
#[tool(description = "Save current changes to the stash.")]
async fn securegit_stash_save(
&self,
Parameters(params): Parameters<StashSaveParams>,
) -> Result<CallToolResult, McpError> {
let ui = UI::new(false, true, false, false);
ops::stash::save(&self.work_dir, params.message.as_deref(), &ui)
.map_err(|e| mcp_err(format!("Stash save failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(
"Changes stashed.",
)]))
}
#[tool(description = "Pop (apply and remove) a stash entry.")]
async fn securegit_stash_pop(
&self,
Parameters(params): Parameters<StashPopParams>,
) -> Result<CallToolResult, McpError> {
let index = params.index.unwrap_or(0);
let ui = UI::new(false, true, false, false);
ops::stash::pop(&self.work_dir, index, &ui)
.map_err(|e| mcp_err(format!("Stash pop failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Popped stash@{{{}}}.",
index
))]))
}
#[tool(description = "Create a new tag. Optionally create an annotated tag with a message.")]
async fn securegit_tag_create(
&self,
Parameters(params): Parameters<TagCreateParams>,
) -> Result<CallToolResult, McpError> {
let ui = UI::new(false, true, false, false);
ops::tag::create(
&self.work_dir,
¶ms.name,
params.message.as_deref(),
params.target.as_deref(),
&ui,
)
.map_err(|e| mcp_err(format!("Tag create failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Created tag '{}'.",
params.name
))]))
}
#[tool(
description = "Undo the last securegit operation, restoring the repository to its previous state."
)]
async fn securegit_undo(
&self,
Parameters(params): Parameters<UndoParams>,
) -> Result<CallToolResult, McpError> {
let ui = UI::new(false, true, false, false);
if let Some(op_id) = params.op_id {
ops::undo::execute(&self.work_dir, false, Some(&op_id), 1, &ui)
.map_err(|e| mcp_err(format!("Undo failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Undid operation '{}'.",
op_id
))]))
} else {
ops::undo::execute(&self.work_dir, false, None, 1, &ui)
.map_err(|e| mcp_err(format!("Undo failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(
"Undid last operation.",
)]))
}
}
#[tool(
description = "Push the current branch to a git remote. Credentials are resolved automatically from stored credentials, environment variables, or SSH keys."
)]
async fn securegit_push(
&self,
Parameters(params): Parameters<PushParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let remote_name = params.remote.as_deref().unwrap_or("origin");
let ui = UI::new(false, true, false, false);
let push_opts = ops::push::PushOptions {
set_upstream: params.set_upstream.unwrap_or(false),
force: params.force.unwrap_or(false),
tags: false,
all: false,
token: None,
ssh_key: None,
};
if self.config.llm_security.enabled
&& self.config.llm_security.verify_pins
&& self.redteam_bridge.is_available()
{
let pins_path = self.work_dir.join(".redteam-pins.json");
if pins_path.exists() {
if let Ok(pins_content) = std::fs::read_to_string(&pins_path) {
if let Ok(pins) = serde_json::from_str::<serde_json::Value>(&pins_content) {
if let Some(servers) = pins.get("servers").and_then(|v| v.as_array()) {
for server in servers {
let command = server
.get("command")
.and_then(|v| v.as_str())
.unwrap_or_default();
let args: Vec<String> = server
.get("args")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
if command.is_empty() {
continue;
}
match self
.redteam_bridge
.verify_pins(command, &args, &pins_path.display().to_string())
.await
{
Ok(result) => {
if let Ok(parsed) =
serde_json::from_str::<serde_json::Value>(&result)
{
let changes = parsed
.get("changes_found")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if changes > 0 {
return Ok(CallToolResult::error(vec![Content::text(
format!(
"BLOCKED: MCP tool definitions changed ({} change(s) detected). \
Possible rug pull. Review changes and re-pin with \
`armyknife-llm-redteam pin` before pushing.\n\n{}",
changes, result
)
)]));
}
}
}
Err(crate::redteam::bridge::BridgeError::NotInstalled(_)) => {
break
}
Err(e) => {
tracing::warn!("Pin verification failed: {}", e);
}
}
}
}
}
}
}
}
ops::push::execute(
&self.work_dir,
remote_name,
params.branch.as_deref(),
push_opts,
&ui,
)
.map_err(|e| mcp_err(format!("Push failed: {}", e)))?;
let branch_desc = params.branch.as_deref().unwrap_or("current branch");
Ok(CallToolResult::success(vec![Content::text(format!(
"Pushed {} to '{}'.",
branch_desc, remote_name
))]))
}
#[tool(
description = "List all git worktrees in the repository, including main and secondary worktrees with branch and lock status."
)]
async fn securegit_worktree_list(&self) -> Result<CallToolResult, McpError> {
let json = ops::worktree::list_json(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to list worktrees: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Create a new git worktree for parallel development. Optionally specify a branch to checkout."
)]
async fn securegit_worktree_add(
&self,
Parameters(params): Parameters<WorktreeAddParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let target = params
.path
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".worktrees").join(¶ms.name));
let ui = UI::new(false, true, false, false);
ops::worktree::add(
&self.work_dir,
¶ms.name,
&target,
params.branch.as_deref(),
&ui,
)
.map_err(|e| mcp_err(format!("Worktree add failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Created worktree '{}' at '{}'.",
params.name,
target.display()
))]))
}
#[tool(
description = "Remove a git worktree. Use force=true to remove even with uncommitted changes or locked state."
)]
async fn securegit_worktree_remove(
&self,
Parameters(params): Parameters<WorktreeRemoveParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let force = params.force.unwrap_or(false);
let ui = UI::new(false, true, false, false);
ops::worktree::remove(&self.work_dir, ¶ms.name, force, &ui)
.map_err(|e| mcp_err(format!("Worktree remove failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Removed worktree '{}'.",
params.name
))]))
}
#[tool(
description = "Lock a git worktree to prevent it from being pruned. Optionally provide a reason."
)]
async fn securegit_worktree_lock(
&self,
Parameters(params): Parameters<WorktreeLockParams>,
) -> Result<CallToolResult, McpError> {
ops::worktree::lock(
&self.work_dir,
¶ms.name,
params.reason.as_deref(),
&UI::new(false, true, false, false),
)
.map_err(|e| mcp_err(format!("Worktree lock failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Locked worktree '{}'.",
params.name
))]))
}
#[tool(description = "Unlock a previously locked git worktree.")]
async fn securegit_worktree_unlock(
&self,
Parameters(params): Parameters<WorktreeUnlockParams>,
) -> Result<CallToolResult, McpError> {
ops::worktree::unlock(
&self.work_dir,
¶ms.name,
&UI::new(false, true, false, false),
)
.map_err(|e| mcp_err(format!("Worktree unlock failed: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Unlocked worktree '{}'.",
params.name
))]))
}
#[tool(
description = "Add a backup destination for durable code backups. Supports local paths, rsync (user@host:/path), and rclone (remote:bucket/) backends."
)]
async fn securegit_backup_add(
&self,
Parameters(params): Parameters<BackupAddParams>,
) -> Result<CallToolResult, McpError> {
let auto = params.auto.unwrap_or(false);
ops::backup::add_destination(
&self.work_dir,
¶ms.name,
¶ms.destination,
params.backend_type.as_deref(),
auto,
)
.map_err(|e| mcp_err(format!("Failed to add backup destination: {}", e)))?;
let backend = params
.backend_type
.unwrap_or_else(|| ops::backup::detect_backend(¶ms.destination).to_string());
Ok(CallToolResult::success(vec![Content::text(format!(
"Added backup destination '{}' ({}) -> {}\nAuto-backup: {}",
params.name,
backend,
params.destination,
if auto { "enabled" } else { "disabled" }
))]))
}
#[tool(description = "Remove a backup destination by name.")]
async fn securegit_backup_remove(
&self,
Parameters(params): Parameters<BackupRemoveParams>,
) -> Result<CallToolResult, McpError> {
ops::backup::remove_destination(&self.work_dir, ¶ms.name)
.map_err(|e| mcp_err(format!("Failed to remove destination: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Removed backup destination '{}'.",
params.name
))]))
}
#[tool(
description = "List all configured backup destinations with their type, path, and auto-backup status."
)]
async fn securegit_backup_list(&self) -> Result<CallToolResult, McpError> {
let config = ops::backup::load_config(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to load backup config: {}", e)))?;
if config.destinations.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No backup destinations configured. Use securegit_backup_add to add one.",
)]));
}
let json = serde_json::to_string_pretty(&config.destinations)
.map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Push a git bundle backup to one or all configured destinations. Creates a portable bundle containing full repository history."
)]
async fn securegit_backup_push(
&self,
Parameters(params): Parameters<BackupPushParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let all_branches = params.all_branches.unwrap_or(false);
let mut config = ops::backup::load_config(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to load backup config: {}", e)))?;
if config.destinations.is_empty() {
return Ok(CallToolResult::error(vec![Content::text(
"No backup destinations configured. Use securegit_backup_add first.",
)]));
}
let targets: Vec<usize> = if let Some(ref n) = params.name {
let idx = config
.destinations
.iter()
.position(|d| d.name == *n)
.ok_or_else(|| mcp_err(format!("No backup destination named '{}'", n)))?;
vec![idx]
} else {
(0..config.destinations.len()).collect()
};
let bundle = ops::backup::create_bundle(&self.work_dir, all_branches)
.map_err(|e| mcp_err(format!("Failed to create bundle: {}", e)))?;
let mut results = Vec::new();
let now = chrono::Utc::now().to_rfc3339();
for &idx in &targets {
let dest = &config.destinations[idx];
match ops::backup::push_to_destination(dest, &bundle.path) {
Ok(()) => {
results.push(format!(" ✓ {}: uploaded ({})", dest.name, dest.backend));
config.destinations[idx].last_backup = Some(now.clone());
}
Err(e) => {
results.push(format!(" ✗ {}: failed ({})", dest.name, e));
}
}
}
let _ = ops::backup::save_config(&self.work_dir, &config);
let _ = std::fs::remove_file(&bundle.path);
if let Some(parent) = bundle.path.parent() {
let _ = std::fs::remove_dir(parent);
}
let bundle_name = bundle
.path
.file_name()
.unwrap_or_default()
.to_string_lossy();
let msg = format!(
"Bundle: {} ({})\n\n{}",
bundle_name,
ops::backup::format_size(bundle.size_bytes),
results.join("\n")
);
Ok(CallToolResult::success(vec![Content::text(msg)]))
}
#[tool(
description = "Show authentication status for Git hosting providers (GitHub/GitLab). Shows which providers have stored or environment-based tokens, plus any registered servers."
)]
async fn securegit_auth_status(&self) -> Result<CallToolResult, McpError> {
let stored = crate::auth::store::list_stored_hosts();
let hosts: &[(&str, &[&str])] = &[
("github.com", &["GITHUB_TOKEN", "GH_TOKEN"]),
("gitlab.com", &["GITLAB_TOKEN", "GL_TOKEN"]),
];
let mut lines = Vec::new();
for (host, env_vars) in hosts {
let has_stored = stored.iter().any(|h| h == host);
let env_var = env_vars.iter().find(|v| std::env::var(v).is_ok());
if has_stored {
lines.push(format!("{}: authenticated (stored credentials)", host));
} else if let Some(var) = env_var {
lines.push(format!("{}: authenticated (via {})", host, var));
} else {
lines.push(format!("{}: not authenticated", host));
}
}
if let Ok(registry) = ServerRegistry::load() {
if !registry.servers.is_empty() {
lines.push(String::new());
lines.push("Registered servers:".to_string());
for server in ®istry.servers {
let auth_status = if auth::token_for_server(server).is_some() {
"authenticated"
} else {
"no credentials"
};
lines.push(format!(
" {} ({}): {} [push: {}]",
server.name,
server.platform,
auth_status,
if server.push_enabled { "on" } else { "off" }
));
}
}
}
match platform::detect_remote(&self.work_dir) {
Ok(remote) => {
lines.push(format!(
"\nCurrent repo: {}/{} ({})",
remote.owner, remote.repo, remote.host
));
}
Err(_) => {
lines.push("\nCurrent repo: no GitHub/GitLab remote detected".to_string());
}
}
Ok(CallToolResult::success(vec![Content::text(
lines.join("\n"),
)]))
}
#[tool(
description = "List pull requests (or merge requests on GitLab) for the current repository. Requires authentication."
)]
async fn securegit_pr_list(
&self,
Parameters(params): Parameters<PrListParams>,
) -> Result<CallToolResult, McpError> {
let state = params.state.as_deref().unwrap_or("open");
let client = self.resolve_platform_client(params.server.as_deref())?;
let prs = client
.list_pull_requests(state)
.await
.map_err(|e| mcp_err(format!("Failed to list PRs: {}", e)))?;
if prs.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No {} pull requests found.",
state
))]));
}
let json = serde_json::to_string_pretty(&prs).map_err(|e| mcp_err(e.to_string()))?;
Ok(self.sanitize_text_result(json))
}
#[tool(
description = "Show CI/CD pipeline status (GitHub Actions checks or GitLab pipelines) for the current branch or a specified branch."
)]
async fn securegit_ci_status(
&self,
Parameters(params): Parameters<CiStatusParams>,
) -> Result<CallToolResult, McpError> {
let client = self.resolve_platform_client(params.server.as_deref())?;
let ref_name = if let Some(ref branch) = params.branch {
branch.clone()
} else {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let head = repo.head().map_err(|e| mcp_err(e.to_string()))?;
head.shorthand().unwrap_or("HEAD").to_string()
};
let status = client
.get_check_runs(&ref_name)
.await
.map_err(|e| mcp_err(format!("Failed to get CI status: {}", e)))?;
let json = serde_json::to_string_pretty(&status).map_err(|e| mcp_err(e.to_string()))?;
Ok(self.sanitize_text_result(json))
}
#[tool(description = "List releases for the current repository from GitHub or GitLab.")]
async fn securegit_release_list(
&self,
Parameters(params): Parameters<ReleaseListParams>,
) -> Result<CallToolResult, McpError> {
let count = params.count.unwrap_or(10);
let client = self.resolve_platform_client(params.server.as_deref())?;
let releases = client
.list_releases(count)
.await
.map_err(|e| mcp_err(format!("Failed to list releases: {}", e)))?;
if releases.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No releases found.",
)]));
}
let json = serde_json::to_string_pretty(&releases).map_err(|e| mcp_err(e.to_string()))?;
Ok(self.sanitize_text_result(json))
}
#[tool(
description = "Register a new git hosting server (GitHub/GitLab, including self-hosted). Token is stored encrypted and never returned in any response."
)]
async fn securegit_server_add(
&self,
Parameters(params): Parameters<ServerAddParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let platform = match params.platform.to_lowercase().as_str() {
"github" | "gh" => ServerPlatform::GitHub,
"gitlab" | "gl" => ServerPlatform::GitLab,
other => {
return Err(mcp_err(format!(
"Unknown platform '{}'. Use 'github' or 'gitlab'.",
other
)))
}
};
url::Url::parse(¶ms.api_url)
.map_err(|e| mcp_err(format!("Invalid API URL '{}': {}", params.api_url, e)))?;
let secure_token = auth::SecureString::from_string(params.token);
let store_key = format!("server:{}", params.name);
auth::store::store_token(&store_key, &secure_token)
.map_err(|e| mcp_err(format!("Failed to store token: {}", e)))?;
let temp_client = platform::create_client_for_server(
&crate::platform::server_registry::ServerConfig {
name: params.name.clone(),
platform: platform.clone(),
api_url: params.api_url.clone(),
web_url: None,
push_enabled: params.push_enabled.unwrap_or(true),
},
secure_token,
"validation",
"check",
);
let username = match temp_client.get_authenticated_user().await {
Ok(user) => user,
Err(e) => {
let _ = auth::store::delete_token(&store_key);
return Err(mcp_err(format!(
"Token validation failed for '{}': {}",
params.name, e
)));
}
};
let mut registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load registry: {}", e)))?;
let server_config = crate::platform::server_registry::ServerConfig {
name: params.name.clone(),
platform,
api_url: params.api_url.clone(),
web_url: None,
push_enabled: params.push_enabled.unwrap_or(true),
};
registry
.add(server_config)
.map_err(|e| mcp_err(e.to_string()))?;
registry
.save()
.map_err(|e| mcp_err(format!("Failed to save registry: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Server '{}' registered. Authenticated as '{}'.",
params.name, username
))]))
}
#[tool(description = "Remove a registered git hosting server and its stored credentials.")]
async fn securegit_server_remove(
&self,
Parameters(params): Parameters<ServerRemoveParams>,
) -> Result<CallToolResult, McpError> {
let mut registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load registry: {}", e)))?;
registry
.remove(¶ms.name)
.map_err(|e| mcp_err(e.to_string()))?;
registry
.save()
.map_err(|e| mcp_err(format!("Failed to save registry: {}", e)))?;
let store_key = format!("server:{}", params.name);
let _ = auth::store::delete_token(&store_key);
Ok(CallToolResult::success(vec![Content::text(format!(
"Server '{}' removed.",
params.name
))]))
}
#[tool(
description = "List all registered git hosting servers with their platform, URL, and authentication status. Never reveals token values."
)]
async fn securegit_server_list(&self) -> Result<CallToolResult, McpError> {
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load registry: {}", e)))?;
if registry.servers.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No servers registered. Use securegit_server_add to register one.",
)]));
}
let mut servers_info = Vec::new();
for server in ®istry.servers {
let auth_status = if auth::token_for_server(server).is_some() {
"authenticated"
} else {
"no credentials"
};
servers_info.push(serde_json::json!({
"name": server.name,
"platform": server.platform.to_string(),
"api_url": server.api_url,
"push_enabled": server.push_enabled,
"auth_status": auth_status,
}));
}
let json =
serde_json::to_string_pretty(&servers_info).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Push the current branch to one or more registered git hosting servers. Credentials are resolved internally and never exposed."
)]
async fn securegit_server_push(
&self,
Parameters(params): Parameters<ServerPushParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load registry: {}", e)))?;
let targets: Vec<_> = if let Some(ref names) = params.servers {
let mut found = Vec::new();
for name in names {
let server = registry
.get(name)
.ok_or_else(|| mcp_err(format!("Server '{}' not found", name)))?;
found.push(server);
}
found
} else {
let targets = registry.push_targets();
if targets.is_empty() {
return Ok(CallToolResult::error(vec![Content::text(
"No push-enabled servers found. Register servers with securegit_server_add.",
)]));
}
targets
};
let branch = if let Some(ref b) = params.branch {
b.clone()
} else {
let repo = git2::Repository::open(&self.work_dir)
.map_err(|e| mcp_err(format!("Failed to open repo: {}", e)))?;
let head = repo.head().map_err(|e| mcp_err(e.to_string()))?;
head.shorthand().unwrap_or("main").to_string()
};
let force = params.force.unwrap_or(false);
let ui = UI::new(false, true, false, false);
let mut results = Vec::new();
for server in &targets {
let token = match auth::token_for_server(server) {
Some(t) => t,
None => {
results.push(format!(" x {}: no credentials found", server.name));
continue;
}
};
let remote_url =
build_authenticated_url(&server.api_url, &server.platform, token.as_str());
match push_to_url(&self.work_dir, &remote_url, &branch, force, &ui) {
Ok(()) => {
results.push(format!(" ok {}: pushed {}", server.name, branch));
}
Err(e) => {
let sanitized = sanitizer::sanitize_output(&e.to_string());
results.push(format!(" x {}: {}", server.name, sanitized));
}
}
}
let msg = format!("Push results:\n\n{}", results.join("\n"));
Ok(CallToolResult::success(vec![Content::text(
sanitizer::sanitize_output(&msg),
)]))
}
#[tool(
description = "Download a model from HuggingFace Hub to the local cache (~/.cache/huggingface/hub/). Supports revision selection and glob-based file filtering. Returns the local snapshot path."
)]
async fn securegit_hf_pull(
&self,
Parameters(params): Parameters<HfPullParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let client = crate::huggingface::client::HfClient::from_env();
let cache = crate::huggingface::cache::HfCache::new();
let opts = crate::huggingface::download::DownloadOptions {
revision: params.revision.unwrap_or_else(|| "main".to_string()),
include: params.include.unwrap_or_default(),
exclude: params.exclude.unwrap_or_default(),
};
let result =
crate::huggingface::download::download_model(&client, &cache, ¶ms.model_id, &opts)
.await
.map_err(|e| mcp_err(format!("Model pull failed: {}", e)))?;
let json = serde_json::json!({
"model_id": result.model_id,
"revision": result.revision,
"commit_sha": result.commit_sha,
"snapshot_path": result.snapshot_path.display().to_string(),
"files_downloaded": result.files_downloaded,
"total_bytes": result.total_bytes,
"from_cache": result.from_cache,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&json).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Upload model files to a HuggingFace Hub repository. Creates the repo if it doesn't exist."
)]
async fn securegit_hf_push(
&self,
Parameters(params): Parameters<HfPushParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let client = crate::huggingface::client::HfClient::from_env();
let full_repo = if let Some(ref org) = params.hf_org {
format!("{}/{}", org, params.repo_id)
} else {
params.repo_id.clone()
};
client
.create_repo(&full_repo, false)
.await
.map_err(|e| mcp_err(format!("Failed to create HF repo '{}': {}", full_repo, e)))?;
let path = PathBuf::from(¶ms.path);
let opts = crate::huggingface::upload::UploadOptions {
repo_id: full_repo,
revision: "main".to_string(),
commit_message: "Upload model via securegit MCP".to_string(),
};
let result = crate::huggingface::upload::upload_model(&client, &path, &opts)
.await
.map_err(|e| mcp_err(format!("Model push failed: {}", e)))?;
let json = serde_json::json!({
"repo_id": result.repo_id,
"files_uploaded": result.files_uploaded,
"url": result.commit_url,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&json).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Search for models on HuggingFace Hub by keyword, task, or library. Returns model IDs, download counts, and metadata."
)]
async fn securegit_hf_search(
&self,
Parameters(params): Parameters<HfSearchParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let client = crate::huggingface::client::HfClient::from_env();
let limit = params.limit.unwrap_or(10);
let models = client
.search_models(
¶ms.query,
params.task.as_deref(),
params.library.as_deref(),
limit,
)
.await
.map_err(|e| mcp_err(format!("Search failed: {}", e)))?;
let results: Vec<serde_json::Value> = models
.iter()
.map(|m| {
serde_json::json!({
"model_id": m.model_id.as_deref().unwrap_or(&m.id),
"pipeline_tag": m.pipeline_tag,
"library_name": m.library_name,
"downloads": m.downloads,
"likes": m.likes,
"private": m.private,
})
})
.collect();
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&results).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Get detailed model info from HuggingFace Hub including metadata, tags, and file listing."
)]
async fn securegit_hf_info(
&self,
Parameters(params): Parameters<HfInfoParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let client = crate::huggingface::client::HfClient::from_env();
let info = client
.model_info(¶ms.model_id)
.await
.map_err(|e| mcp_err(format!("Model info failed: {}", e)))?;
let files: Vec<serde_json::Value> = info
.siblings
.as_ref()
.map(|s| {
s.iter()
.map(|f| {
serde_json::json!({
"filename": f.filename,
"size": f.size.or(f.lfs.as_ref().map(|l| l.size)),
})
})
.collect()
})
.unwrap_or_default();
let json = serde_json::json!({
"model_id": info.model_id.as_deref().unwrap_or(&info.id),
"sha": info.sha,
"pipeline_tag": info.pipeline_tag,
"library_name": info.library_name,
"tags": info.tags,
"downloads": info.downloads,
"likes": info.likes,
"private": info.private,
"files": files,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&json).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Scan a HuggingFace model for security vulnerabilities using the LLM redteam bridge. Returns findings with severity levels."
)]
async fn securegit_hf_scan(
&self,
Parameters(params): Parameters<HfScanParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let result = self
.redteam_bridge
.pipeline_scan(¶ms.model_id, None)
.await
.map_err(|e| mcp_err(format!("HF scan failed: {}", e)))?;
Ok(self.sanitize_text_result(result))
}
#[tool(
description = "Trigger a model hardening CI/CD pipeline on a GPU-enabled GitLab server. Uses the server registry for authentication."
)]
async fn securegit_hf_pipeline_trigger(
&self,
Parameters(params): Parameters<HfPipelineTriggerParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let server_name = params.server.as_deref().unwrap_or("gpubox");
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load server registry: {}", e)))?;
let server = registry
.get(server_name)
.ok_or_else(|| mcp_err(format!("Server '{}' not found", server_name)))?;
let project_id = params
.project_id
.or_else(|| {
std::env::var("SECUREGIT_PIPELINE_PROJECT_ID")
.ok()?
.parse()
.ok()
})
.ok_or_else(|| {
mcp_err("project_id required (param or SECUREGIT_PIPELINE_PROJECT_ID)")
})?;
let token = params
.token
.or_else(|| std::env::var("SECUREGIT_PIPELINE_TOKEN").ok())
.ok_or_else(|| {
mcp_err("Pipeline trigger token required (param or SECUREGIT_PIPELINE_TOKEN)")
})?;
let url = format!(
"{}/projects/{}/trigger/pipeline",
server.api_url, project_id
);
let client = reqwest::Client::new();
let resp = client
.post(&url)
.form(&[
("token", token.as_str()),
("ref", "main"),
("variables[MODEL_ID]", params.model_id.as_str()),
])
.send()
.await
.map_err(|e| mcp_err(format!("Pipeline trigger failed: {}", e)))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(mcp_err(format!("Pipeline trigger failed: {}", text)));
}
let result: serde_json::Value = resp.json().await.map_err(|e| mcp_err(e.to_string()))?;
let json = serde_json::json!({
"pipeline_id": result["id"],
"status": result["status"],
"web_url": result["web_url"],
"ref": result["ref"],
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&json).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Check the status of a CI/CD pipeline run on a GitLab server. Omit pipeline_id to see recent pipelines."
)]
async fn securegit_hf_pipeline_status(
&self,
Parameters(params): Parameters<HfPipelineStatusParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let server_name = params.server.as_deref().unwrap_or("gpubox");
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load server registry: {}", e)))?;
let server = registry
.get(server_name)
.ok_or_else(|| mcp_err(format!("Server '{}' not found", server_name)))?;
let project_id: u64 = std::env::var("SECUREGIT_PIPELINE_PROJECT_ID")
.ok()
.and_then(|v| v.parse().ok())
.ok_or_else(|| mcp_err("SECUREGIT_PIPELINE_PROJECT_ID required"))?;
let token = auth::token_for_server(server)
.ok_or_else(|| mcp_err(format!("No credentials for server '{}'", server_name)))?;
let url = if let Some(pid) = params.pipeline_id {
format!(
"{}/projects/{}/pipelines/{}",
server.api_url, project_id, pid
)
} else {
format!(
"{}/projects/{}/pipelines?per_page=5&order_by=id&sort=desc",
server.api_url, project_id
)
};
let client = reqwest::Client::new();
let resp = client
.get(&url)
.header("PRIVATE-TOKEN", token.as_str())
.send()
.await
.map_err(|e| mcp_err(format!("Pipeline status check failed: {}", e)))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(mcp_err(format!("Pipeline status failed: {}", text)));
}
let result: serde_json::Value = resp.json().await.map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
#[tool(
description = "Run the full cloud automation pipeline: scan model on HF Inference, generate DPO training data, train on AutoTrain, verify fixes via targeted re-scan, and publish hardened model. Zero local GPU required. Estimated cost: $3.50-5.50 per model."
)]
async fn securegit_hf_fullpipeline(
&self,
Parameters(params): Parameters<HfFullPipelineParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let hf_org = params.hf_org.as_deref().unwrap_or("ArmyknifeLabs");
let hf_hardware = params.hf_hardware.as_deref().unwrap_or("a10g-large");
let min_fix_rate = params.min_fix_rate.unwrap_or(0.30);
let epochs = params.epochs.unwrap_or(3);
let fail_on_regression = params.fail_on_regression.unwrap_or(true);
let mut steps_log = Vec::new();
let scan_model_spec = format!("huggingface://{}", params.model_id);
let scan_result = self
.redteam_bridge
.pipeline_scan(&scan_model_spec, Some("scan-results"))
.await
.map_err(|e| mcp_err(format!("Cloud scan failed: {}", e)))?;
let scan_json: serde_json::Value =
serde_json::from_str(&scan_result).unwrap_or(serde_json::json!({}));
let total_findings = scan_json["total_findings"]
.as_u64()
.or_else(|| {
scan_json["content"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c["text"].as_str())
.and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
.and_then(|v| v["total_findings"].as_u64())
})
.unwrap_or(0);
steps_log.push(format!("scan: {} findings", total_findings));
if total_findings == 0 {
let result = HfFullPipelineResult {
original_model: params.model_id.clone(),
hardened_model: params.model_id.clone(),
model_url: format!("https://huggingface.co/{}", params.model_id),
total_findings: 0,
fix_rate: 1.0,
verdict: "CLEAN".to_string(),
regression_count: 0,
status: "Model already clean — no hardening needed".to_string(),
};
let json =
serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
return Ok(CallToolResult::success(vec![Content::text(json)]));
}
let _harden_result = self
.redteam_bridge
.pipeline_harden(
¶ms.model_id,
"scan-results",
Some("hardened-model"),
Some("dpo"),
Some("firm"),
Some(epochs),
Some("hf"),
Some(hf_org),
Some(hf_hardware),
)
.await
.map_err(|e| mcp_err(format!("Hardening failed: {}", e)))?;
steps_log.push("harden: DPO pairs generated + cloud training started".to_string());
let hardened_name = params
.model_id
.split('/')
.last()
.unwrap_or(¶ms.model_id)
.replace('.', "-");
let hardened_repo = format!("{}/{}-Hardened", hf_org, hardened_name);
let hardened_spec = format!("huggingface://{}", hardened_repo);
let verify_result = self
.redteam_bridge
.pipeline_verify(
"scan-results/findings.json",
&hardened_spec,
Some(¶ms.model_id),
Some("verification-report"),
Some(min_fix_rate),
Some(fail_on_regression),
)
.await
.map_err(|e| mcp_err(format!("Verification failed: {}", e)))?;
let verify_json: serde_json::Value =
serde_json::from_str(&verify_result).unwrap_or(serde_json::json!({}));
let verify_data = verify_json["content"]
.as_array()
.and_then(|arr| arr.first())
.and_then(|c| c["text"].as_str())
.and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
.unwrap_or(verify_json);
let fix_rate = verify_data["fix_rate"].as_f64().unwrap_or(0.0);
let verdict = verify_data["verdict"]
.as_str()
.unwrap_or("UNKNOWN")
.to_string();
let regression_count = verify_data["regression_count"].as_u64().unwrap_or(0);
let passed = verify_data["passed"].as_bool().unwrap_or(false);
steps_log.push(format!(
"verify: {:.1}% fix rate, {} regressions, verdict={}",
fix_rate * 100.0,
regression_count,
verdict
));
let status = if passed {
let _publish_result = self
.redteam_bridge
.pipeline_publish("hardened-model", "verification-report", &hardened_name, Some(hf_org))
.await
.map_err(|e| mcp_err(format!("Publish failed: {}", e)))?;
steps_log.push("publish: model published to HuggingFace".to_string());
"completed".to_string()
} else {
steps_log.push("publish: skipped (verification failed)".to_string());
format!(
"verification failed: {:.1}% fix rate (threshold: {:.1}%)",
fix_rate * 100.0,
min_fix_rate * 100.0
)
};
let result = HfFullPipelineResult {
original_model: params.model_id,
hardened_model: hardened_repo.clone(),
model_url: format!("https://huggingface.co/{}", hardened_repo),
total_findings,
fix_rate,
verdict,
regression_count,
status,
};
let json = serde_json::to_string_pretty(&result).map_err(|e| mcp_err(e.to_string()))?;
Ok(CallToolResult::success(vec![Content::text(json)]))
}
#[tool(
description = "Create a new repository on a registered GitHub or GitLab server. Returns the repo URL and clone URLs."
)]
async fn securegit_repo_create(
&self,
Parameters(params): Parameters<CreateRepoParams>,
) -> Result<CallToolResult, McpError> {
self.check_rate_limit()?;
let registry = ServerRegistry::load()
.map_err(|e| mcp_err(format!("Failed to load registry: {}", e)))?;
let server = if let Some(ref name) = params.server {
registry
.get(name)
.ok_or_else(|| mcp_err(format!("Server '{}' not found", name)))?
.clone()
} else {
let remote = platform::detect_remote(&self.work_dir)
.map_err(|_| mcp_err("No server specified and could not auto-detect from remote. Use the 'server' parameter."))?;
let platform_str = match remote.host {
platform::PlatformHost::GitHub => "github",
platform::PlatformHost::GitLab => "gitlab",
};
registry
.servers
.iter()
.find(|s| s.platform.to_string().to_lowercase() == platform_str)
.ok_or_else(|| mcp_err(format!("No registered {} server found", platform_str)))?
.clone()
};
let token = auth::token_for_server(&server)
.ok_or_else(|| mcp_err(format!("No credentials found for server '{}'", server.name)))?;
let client = platform::create_client_for_server(&server, token, "", "");
let create_req = platform::types::CreateRepo {
name: params.name.clone(),
description: params.description.clone(),
private: params.private.unwrap_or(false),
namespace: params.namespace.clone(),
};
let repo = client
.create_repo(&create_req)
.await
.map_err(|e| mcp_err(format!("Failed to create repository: {}", e)))?;
let json = serde_json::json!({
"name": repo.name,
"full_name": repo.full_name,
"web_url": repo.web_url,
"clone_url_http": repo.clone_url_http,
"clone_url_ssh": repo.clone_url_ssh,
"private": repo.private,
"server": server.name,
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&json).map_err(|e| mcp_err(e.to_string()))?,
)]))
}
}
#[tool_handler]
impl ServerHandler for SecuregitMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "securegit-mcp".into(),
title: Some("SecureGit MCP Server".into()),
version: env!("CARGO_PKG_VERSION").into(),
description: Some("Security-aware git operations via MCP".into()),
icons: None,
website_url: None,
},
instructions: Some(
"SecureGit MCP Server — a security-aware git tool server. \
Provides 50 tools for repository operations with integrated \
security scanning. Use securegit_safe_commit for security-gated \
commits, securegit_scan for vulnerability detection, \
standard git operations (status, log, diff, push, etc.), \
backup management (backup_add, backup_push, backup_list), \
platform integration (auth_status, pr_list, ci_status, release_list), \
and multi-server management (server_add, server_remove, server_list, server_push)."
.into(),
),
}
}
}
fn parse_severity(s: Option<&str>) -> u8 {
severity_rank(s.unwrap_or("low"))
}
fn severity_rank(s: &str) -> u8 {
match s.to_lowercase().as_str() {
"critical" => 4,
"high" => 3,
"medium" => 2,
"low" => 1,
_ => 0,
}
}
fn build_authenticated_url(api_url: &str, platform: &ServerPlatform, token: &str) -> String {
if let Ok(parsed) = url::Url::parse(api_url) {
let scheme = parsed.scheme();
let host = parsed.host_str().unwrap_or("localhost");
let port = parsed.port().map(|p| format!(":{}", p)).unwrap_or_default();
match platform {
ServerPlatform::GitHub => {
let git_host = host.strip_prefix("api.").unwrap_or(host);
format!("{}://x-access-token:{}@{}{}", scheme, token, git_host, port)
}
ServerPlatform::GitLab => {
format!("{}://oauth2:{}@{}{}", scheme, token, host, port)
}
}
} else {
api_url.to_string()
}
}
fn push_to_url(
work_dir: &std::path::Path,
remote_url: &str,
branch: &str,
force: bool,
_ui: &UI,
) -> anyhow::Result<()> {
let repo = git2::Repository::open(work_dir)?;
let mut remote = repo.remote_anonymous(remote_url)?;
let refspec = if force {
format!("+refs/heads/{}:refs/heads/{}", branch, branch)
} else {
format!("refs/heads/{}:refs/heads/{}", branch, branch)
};
let mut push_opts = git2::PushOptions::new();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, _username_from_url, _allowed_types| {
Err(git2::Error::from_str("credentials embedded in URL"))
});
push_opts.remote_callbacks(callbacks);
remote.push(&[&refspec], Some(&mut push_opts))?;
Ok(())
}