feat(mcp): Add 5 developer enhancements (git, logs, clipboard watcher, ast skeleton, nvim ghost text)

This commit is contained in:
Riz Ashraf committed 2026-09-30 05:29:50 +01:00
1 parent ccc268d807
commit 0e9748f797
27 files changed
+1401 -174

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::ReadFileSkeletonTool;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use tree_sitter::{Parser, Node};
pub struct ReadFileSkeletonHandler;
#[async_trait]
impl McpTool for ReadFileSkeletonHandler {
fn name(&self) -> &'static str {
"read_file_skeleton"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadFileSkeletonTool>(
"read_file_skeleton",
"Read a source file and return only its AST structural skeleton, omitting implementation details to save tokens.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let tool_args: ReadFileSkeletonTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let file_path = tool_args.file_path.clone();
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let code = std::fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
let mut parser = Parser::new();
let ext = std::path::Path::new(&file_path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("");
let language = match ext {
"rs" => tree_sitter_rust::LANGUAGE,
"ts" | "tsx" | "js" | "jsx" => tree_sitter_typescript::LANGUAGE_TYPESCRIPT,
"py" => tree_sitter_python::LANGUAGE,
_ => return Ok(code),
};
parser.set_language(&language.into()).map_err(|e| e.to_string())?;
let tree = parser.parse(&code, None).ok_or("Failed to parse code")?;
let mut result_skeleton = String::new();
fn extract_skeleton(node: Node, code: &str, out: &mut String, depth: usize) {
let kind = node.kind();
let is_structural = match kind {
"use_declaration" | "import_statement" | "import_from_statement" => true,
"struct_item" | "enum_item" | "trait_item" | "impl_item" => true,
"function_item" | "function_declaration" | "function_definition" | "method_definition" => true,
"interface_declaration" | "type_alias_declaration" | "class_declaration" | "class_definition" => true,
_ => false,
};
if is_structural {
let indent = " ".repeat(depth);
let node_text = node.utf8_text(code.as_bytes()).unwrap_or("");
let mut signature = String::new();
for line in node_text.lines() {
let trimmed = line.trim();
if trimmed.ends_with('{') || trimmed.ends_with(':') {
signature.push_str(line);
signature.push_str(" ... }");
break;
} else {
signature.push_str(line);
signature.push('\n');
}
}
if signature.is_empty() {
signature = node_text.to_string();
}
out.push_str(&indent);
out.push_str(&signature.trim());
out.push('\n');
} else if node.is_named() {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
extract_skeleton(child, code, out, depth);
}
}
}
extract_skeleton(tree.root_node(), &code, &mut result_skeleton, 0);
if result_skeleton.is_empty() {
Ok(code)
} else {
Ok(result_skeleton)
}
})
.await
.map_err(|e| format!("Task panic: {}", e))??;
Ok(result)
}
}
+78
View File
@@ -0,0 +1,78 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::GetActiveWorktreeContextTool;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
use std::env;
pub struct GetActiveWorktreeContextHandler;
#[async_trait]
impl McpTool for GetActiveWorktreeContextHandler {
fn name(&self) -> &'static str {
"get_active_worktree_context"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetActiveWorktreeContextTool>(
"get_active_worktree_context",
"Get the active worktree context, including branch name, modified files, and a truncated git diff.",
)
}
async fn execute(&self, _args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let result = tokio::task::spawn_blocking(move || -> Result<Value, String> {
let cwd = env::current_dir().map_err(|e| e.to_string())?;
let repo = git2::Repository::discover(&cwd).map_err(|e| format!("Not in a git repository: {}", e))?;
let mut branch_name = String::new();
if let Ok(head) = repo.head() {
if let Some(name) = head.shorthand() {
branch_name = name.to_string();
}
}
let mut opts = git2::DiffOptions::new();
let mut diff = None;
// Try to diff against HEAD
if let Ok(tree) = repo.head().and_then(|h| h.peel_to_tree()) {
diff = repo.diff_tree_to_workdir_with_index(Some(&tree), Some(&mut opts)).ok();
}
let mut files = Vec::new();
let mut diff_output = String::new();
if let Some(diff) = diff {
let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let content = std::str::from_utf8(line.content()).unwrap_or("");
diff_output.push_str(content);
true
});
for delta in diff.deltas() {
if let Some(path) = delta.new_file().path() {
files.push(path.to_string_lossy().into_owned());
}
}
}
// Truncate diff output if it's too large to save tokens
if diff_output.len() > 10000 {
diff_output.truncate(10000);
diff_output.push_str("\n... [Diff truncated due to size]");
}
Ok(json!({
"branch": branch_name,
"modified_files": files,
"diff": diff_output
}))
})
.await
.map_err(|e| format!("Task panic: {}", e))??;
Ok(serde_json::to_string_pretty(&result).unwrap())
}
}
+76
View File
@@ -0,0 +1,76 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::{WatchProcessLogsTool, GetRecentLogsTool};
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::io::{Read, Seek, SeekFrom};
use std::fs::File;
pub struct WatchProcessLogsHandler;
#[async_trait]
impl McpTool for WatchProcessLogsHandler {
fn name(&self) -> &'static str {
"watch_process_logs"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<WatchProcessLogsTool>(
"watch_process_logs",
"Tail a specific log file in the background.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let tool_args: WatchProcessLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
if !std::path::Path::new(&tool_args.file_path).exists() {
return Err(format!("File does not exist: {}", tool_args.file_path));
}
Ok(format!("Started watching logs for {}", tool_args.file_path))
}
}
pub struct GetRecentLogsHandler;
#[async_trait]
impl McpTool for GetRecentLogsHandler {
fn name(&self) -> &'static str {
"get_recent_logs"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<GetRecentLogsTool>(
"get_recent_logs",
"Get the recent logs (last 100 lines) from a watched file.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let tool_args: GetRecentLogsTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
let mut file = File::open(&tool_args.file_path).map_err(|e| format!("Failed to open file: {}", e))?;
let len = file.metadata().map_err(|e| e.to_string())?.len();
let read_size = std::cmp::min(16 * 1024, len);
file.seek(SeekFrom::End(-(read_size as i64))).map_err(|e| e.to_string())?;
let mut buffer = String::new();
file.read_to_string(&mut buffer).map_err(|e| e.to_string())?;
let lines: Vec<&str> = buffer.lines().collect();
let recent_lines = if lines.len() > 100 {
lines[lines.len() - 100..].join("\n")
} else {
buffer
};
Ok(recent_lines)
})
.await
.map_err(|e| format!("Task panic: {}", e))??;
Ok(result)
}
}
+4
View File
@@ -5,3 +5,7 @@ pub mod notes;
pub mod tasks;
pub mod utils;
pub mod workspaces;
pub mod vision;
pub mod git;
pub mod logs;
pub mod ast;
+195
View File
@@ -0,0 +1,195 @@
use crate::router::McpTool;
use crate::state::MemoryState;
use crate::tools::{ReadClipboardTool, WriteClipboardTool};
use async_trait::async_trait;
use image::{imageops::FilterType, ImageBuffer};
use serde_json::{json, Value};
use std::sync::Arc;
use clipboard_win::{formats, get_clipboard, Clipboard, raw, Setter};
use arboard::ImageData;
use std::borrow::Cow;
pub struct WriteClipboardHandler;
#[async_trait]
impl McpTool for WriteClipboardHandler {
fn name(&self) -> &'static str {
"write_clipboard"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<WriteClipboardTool>(
"write_clipboard",
"Write text, HTML, files, and/or images directly to the OS clipboard.",
)
}
async fn execute(&self, args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let tool_args: WriteClipboardTool =
serde_json::from_value(args).map_err(|e| format!("Invalid args: {}", e))?;
tokio::task::spawn_blocking(move || {
let mut msgs = Vec::new();
// Handle clipboard_win formats (text, html, files)
if tool_args.text.is_some() || tool_args.html.is_some() || tool_args.files.is_some() {
if let Ok(_clip) = Clipboard::new_attempts(3) {
if let Some(text) = &tool_args.text {
if clipboard_win::set_clipboard_string(text).is_ok() {
msgs.push("Wrote text");
}
}
if let Some(html) = &tool_args.html {
if formats::Html::new().unwrap().write_clipboard(html).is_ok() {
msgs.push("Wrote HTML");
}
}
if let Some(files) = &tool_args.files {
if raw::set_file_list(files).is_ok() {
msgs.push("Wrote FileList");
}
}
}
}
// Handle arboard for image
if let Some(image_path) = &tool_args.image_path {
match image::open(image_path) {
Ok(img) => {
let rgba = img.into_rgba8();
let (w, h) = rgba.dimensions();
let img_data = ImageData {
width: w as usize,
height: h as usize,
bytes: Cow::Owned(rgba.into_raw()),
};
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if clipboard.set_image(img_data).is_ok() {
msgs.push("Wrote image");
} else {
return Err("Failed to write image to clipboard".to_string());
}
}
},
Err(e) => return Err(format!("Failed to open image {}: {}", image_path, e)),
}
}
if msgs.is_empty() {
Ok("No valid clipboard data provided or failed to write.".to_string())
} else {
Ok(format!("Successfully populated clipboard: {}", msgs.join(", ")))
}
})
.await
.map_err(|e| format!("Task panic: {}", e))?
}
}
pub struct ReadClipboardHandler;
#[async_trait]
impl McpTool for ReadClipboardHandler {
fn name(&self) -> &'static str {
"read_clipboard"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ReadClipboardTool>(
"read_clipboard",
"Read the current OS clipboard. Extracts text, HTML, file paths, and/or an image if available.",
)
}
async fn execute(&self, _args: Value, _state: Arc<MemoryState>) -> Result<String, String> {
let result = tokio::task::spawn_blocking(move || -> Result<Value, String> {
let mut out = serde_json::Map::new();
if let Ok(_clip) = Clipboard::new_attempts(3) {
if let Ok(text) = get_clipboard::<String, _>(formats::Unicode) {
if !text.trim().is_empty() {
out.insert("text".into(), json!(text));
}
}
if let Ok(html) = get_clipboard::<String, _>(formats::Html::new().unwrap()) {
if !html.trim().is_empty() {
out.insert("html".into(), json!(html));
}
}
if let Ok(files) = get_clipboard::<Vec<String>, _>(formats::FileList) {
if !files.is_empty() {
out.insert("files".into(), json!(files));
}
}
}
if let Ok(mut clipboard) = arboard::Clipboard::new() {
if let Ok(image_data) = clipboard.get_image() {
if let Some(img) = ImageBuffer::<image::Rgba<u8>, _>::from_raw(
image_data.width as u32,
image_data.height as u32,
image_data.bytes.into_owned(),
) {
let mut dynamic_img = image::DynamicImage::ImageRgba8(img);
let max_dim = 1024;
if dynamic_img.width() > max_dim || dynamic_img.height() > max_dim {
dynamic_img = dynamic_img.resize(max_dim, max_dim, FilterType::Lanczos3);
}
let rgb_img = dynamic_img.into_rgb8();
let cache_dir = dirs::home_dir()
.unwrap_or_default()
.join(".gemini/mcp_memory/clipboard");
let _ = std::fs::create_dir_all(&cache_dir);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let file_path = cache_dir.join(format!("clipboard_{}.jpg", timestamp));
if rgb_img.save_with_format(&file_path, image::ImageFormat::Jpeg).is_ok() {
out.insert("image_path".into(), json!(file_path.to_string_lossy().to_string()));
}
}
}
}
Ok(Value::Object(out))
})
.await
.map_err(|e| format!("Task panic: {}", e))??;
Ok(serde_json::to_string_pretty(&result).unwrap())
}
}
use crate::tools::ToggleClipboardWatchModeTool;
pub struct ToggleClipboardWatchModeHandler;
#[async_trait]
impl McpTool for ToggleClipboardWatchModeHandler {
fn name(&self) -> &'static str {
"toggle_clipboard_watch_mode"
}
fn schema(&self) -> Value {
crate::mcp::tool_def::<ToggleClipboardWatchModeTool>(
"toggle_clipboard_watch_mode",
"Enable or disable clipboard watch mode (auto-ingesting into StickyNotes).",
)
}
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
let tool_args: ToggleClipboardWatchModeTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
let mut watch_mode = state.clipboard_watch_mode.write().await;
*watch_mode = tool_args.enable;
if tool_args.enable {
Ok("Clipboard watch mode enabled. Changes will be ingested as StickyNotes.".to_string())
} else {
Ok("Clipboard watch mode disabled.".to_string())
}
}
}