feat(mcp): Add 5 developer enhancements (git, logs, clipboard watcher, ast skeleton, nvim ghost text)
This commit is contained in:
1 parent
ccc268d807
commit
0e9748f797
27 files changed
+1401
-174
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user