diff --git a/Cargo.lock b/Cargo.lock index e279f8f..88af7ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2651,7 +2651,7 @@ dependencies = [ "reqwest", "rmcp", "rten", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "serde_yaml", @@ -3009,6 +3009,7 @@ dependencies = [ "mcp-stdio", "rmcp", "rmpv", + "schemars 0.8.22", "serde", "serde_json", "tokio", @@ -3859,7 +3860,7 @@ dependencies = [ "pastey 0.2.3", "pin-project-lite", "rmcp-macros", - "schemars", + "schemars 1.2.2", "serde", "serde_json", "thiserror 2.0.20", @@ -4179,6 +4180,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.2" @@ -4188,11 +4201,23 @@ dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.2", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.119", +] + [[package]] name = "schemars_derive" version = "1.2.2" @@ -4201,7 +4226,7 @@ checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.30.0", "syn 3.0.6", ] @@ -4276,6 +4301,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_derive_internals" version = "0.30.0" diff --git a/nvim-core/Cargo.toml b/nvim-core/Cargo.toml index eea6158..04e97fd 100644 --- a/nvim-core/Cargo.toml +++ b/nvim-core/Cargo.toml @@ -16,6 +16,7 @@ rmcp = { version = "3.4.0", features = ["server"] } mcp-stdio = { version = "1.0.0", path = "../mcp-stdio" } bytes = "1.12.1" dashmap = "6.2.1" +schemars = "0.8" [build-dependencies] chrono = "0.4.45" diff --git a/nvim-core/src/lib.rs b/nvim-core/src/lib.rs index 3afc596..ccefd92 100644 --- a/nvim-core/src/lib.rs +++ b/nvim-core/src/lib.rs @@ -1,3 +1,5 @@ +mod tools; +use tools::*; mod headless; use headless::*; use serde::{Deserialize, Serialize}; @@ -22,6 +24,22 @@ pub struct JsonRpcResponse { pub error: Option, } +pub fn tool_def(name: &str, fallback_desc: &str) -> serde_json::Value { + let schema = schemars::schema_for!(T); + let schema_val = serde_json::to_value(&schema).unwrap_or_default(); + let desc = schema_val + .get("description") + .and_then(|d| d.as_str()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(fallback_desc) + .to_string(); + serde_json::json!({ + "name": name, + "description": desc, + "inputSchema": schema_val + }) +} + pub async fn send_response(response: JsonRpcResponse) { let msg = serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()); tracing::info!( @@ -977,124 +995,13 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) { jsonrpc: "2.0".to_string(), id, result: Some(json!({ - "tools": -[ - { - "name": "nvim_buffer", - "description": "Core Text Editing: read, replace, save, and manipulate Neovim buffers in memory.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["read", "replace", "save", "undo", "redo", "create_scratch"], - "description": "Action to perform on the buffer" - }, - "file": { "type": "string", "description": "Target file path (for read, replace, save)" }, - "start_line": { "type": "integer", "description": "1-indexed start line (for read)" }, - "end_line": { "type": "integer", "description": "1-indexed end line (for read)" }, - "content": { "type": "string", "description": "Initial text content (for create_scratch)" }, - "name": { "type": "string", "description": "Buffer display name (for create_scratch)" }, - "steps": { "type": "integer", "description": "Number of undo/redo steps to apply (default: 1)" }, - "edits": { - "type": "array", - "description": "Array of edits to apply sequentially (for replace). Grouped by file, applied in descending order.", - "items": { - "type": "object", - "properties": { - "file": { "type": "string" }, - "start_line": { "type": "integer" }, - "end_line": { "type": "integer" }, - "replacement_content": { "type": "string" }, - "expected_content": { "type": "string" } - }, - "required": ["file", "start_line", "end_line", "replacement_content"] - } - } - }, - "required": ["action"] - } - }, - { - "name": "nvim_workspace", - "description": "Window & Editor State: list buffers, windows, focus splits, and manage cwd.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["list_buffers", "list_windows", "focus", "split", "cwd"], - "description": "Workspace operation to perform" - }, - "win_id": { "type": "integer", "description": "Window ID to focus" }, - "file": { "type": "string", "description": "File to open in split" }, - "direction": { "type": "string", "enum": ["vertical", "horizontal"], "description": "Split orientation (default: vertical)" }, - "path": { "type": "string", "description": "Target directory (for cwd action)" } - }, - "required": ["action"] - } - }, - { - "name": "nvim_intelligence", - "description": "Code Semantics: LSP queries (hover, definition, references, code_action, rename, diagnostics) and AST outlining/queries.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["hover", "definition", "references", "outline", "query", "diagnostics", "rename", "code_action"], - "description": "Intelligence action to perform" - }, - "file": { "type": "string", "description": "Target file path" }, - "line": { "type": "integer", "description": "1-indexed line number (for LSP)" }, - "col": { "type": "integer", "description": "0-indexed column number (for LSP)" }, - "new_name": { "type": "string", "description": "New name (for rename action)" }, - "query": { "type": "string", "description": "Tree-sitter query string (for AST query)" }, - "preset": { "type": "string", "description": "Query preset (e.g., 'functions', 'classes' for AST query)" } - }, - "required": ["action"] - } - }, - { - "name": "nvim_ui", - "description": "Visual Feedback: communicating visually with the human user via highlights and ghost text.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["highlight", "ghost_text", "clear"], - "description": "UI action" - }, - "buf_id": { "type": "integer", "description": "Buffer ID to apply to" }, - "start_line": { "type": "integer", "description": "1-indexed start line (for highlight)" }, - "end_line": { "type": "integer", "description": "1-indexed end line (for highlight)" }, - "line": { "type": "integer", "description": "1-indexed line number (for ghost_text)" }, - "text": { "type": "string", "description": "Virtual text to display (for ghost_text)" }, - "highlight_group": { "type": "string", "description": "Highlight group (e.g. 'Comment', 'IncSearch')" }, - "duration_ms": { "type": "integer", "description": "Auto-clear duration in ms (for highlight, default: 5000)" } - }, - "required": ["action"] - } - }, - { - "name": "nvim_exec", - "description": "The Escape Hatch: execute lua read-only queries, run vimscript commands, or send commands to the terminal.", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["lua", "vimscript", "terminal"], - "description": "Execution action" - }, - "code": { "type": "string", "description": "Lua code or Vimscript command to execute" }, - "command": { "type": "string", "description": "Shell command to send (for terminal)" } - }, - "required": ["action"] - } - } -] + "tools": [ + tool_def::("nvim_buffer", "Core Text Editing: read, replace, save, and manipulate Neovim buffers in memory."), + tool_def::("nvim_workspace", "Window & Editor State: list buffers, windows, focus splits, and manage cwd."), + tool_def::("nvim_intelligence", "Code Semantics: LSP queries (hover, definition, references, code_action, rename, diagnostics) and AST outlining/queries."), + tool_def::("nvim_ui", "Visual Feedback: communicating visually with the human user via highlights and ghost text."), + tool_def::("nvim_exec", "The Escape Hatch: execute lua read-only queries, run vimscript commands, or send commands to the terminal."), + ] })), error: None, diff --git a/nvim-core/src/tools.rs b/nvim-core/src/tools.rs new file mode 100644 index 0000000..872dae6 --- /dev/null +++ b/nvim-core/src/tools.rs @@ -0,0 +1,101 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NvimBufferTool { + Read { + file: String, + start_line: Option, + end_line: Option, + }, + Replace { + file: Option, + start_line: Option, + end_line: Option, + replacement_content: Option, + expected_content: Option, + edits: Option>, + }, + Save { + file: Option, + }, + Undo { + steps: Option, + }, + Redo { + steps: Option, + }, + CreateScratch { + name: Option, + content: Option, + }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct BufferEdit { + pub file: String, + pub start_line: i64, + pub end_line: i64, + pub replacement_content: String, + pub expected_content: Option, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NvimWorkspaceTool { + ListBuffers, + ListWindows, + Focus { + win_id: i64, + }, + Split { + direction: Option, + file: Option, + buf_id: Option, + }, + Cwd { + path: Option, + }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NvimIntelligenceTool { + Hover { file: String, line: i64, col: i64 }, + Definition { file: String, line: i64, col: i64 }, + References { file: String, line: i64, col: i64 }, + CodeAction { file: String, line: i64, col: i64 }, + Rename { file: String, line: i64, col: i64, new_name: String }, + Diagnostics { file: String }, + Outline { file: String }, + Query { file: String, query: Option, preset: Option }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NvimUiTool { + Highlight { + buf_id: Option, + start_line: i64, + end_line: Option, + highlight_group: Option, + duration_ms: Option, + }, + GhostText { + line: i64, + text: String, + highlight_group: Option, + }, + Clear { + buf_id: Option, + }, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum NvimExecTool { + Lua { code: String }, + Vimscript { code: String }, + Terminal { command: String }, +} diff --git a/server/src/tools.rs b/server/src/tools.rs index 4a1a002..6b4b7ba 100644 --- a/server/src/tools.rs +++ b/server/src/tools.rs @@ -597,11 +597,8 @@ pub struct AutoSessionCheckpointTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum HandoffMemoAction { - #[serde(alias = "leave", alias = "LEAVE", alias = "Leave")] Leave, - #[serde(alias = "read", alias = "READ", alias = "Read")] Read, - #[serde(alias = "clear", alias = "CLEAR", alias = "Clear")] Clear, } @@ -627,22 +624,11 @@ pub struct HandoffMemosTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum TaskAction { - #[serde(alias = "add", alias = "ADD", alias = "Add")] Add, - #[serde(alias = "update", alias = "UPDATE", alias = "Update")] Update, - #[serde(alias = "delete", alias = "DELETE", alias = "Delete")] Delete, - #[serde(alias = "list", alias = "LIST", alias = "List")] List, - #[serde( - alias = "set_criteria", - alias = "setCriteria", - alias = "SET_CRITERIA", - alias = "SetCriteria" - )] SetCriteria, - #[serde(alias = "verify", alias = "VERIFY", alias = "Verify")] Verify, } @@ -706,11 +692,8 @@ pub struct TasksTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum MilestoneAction { - #[serde(alias = "add", alias = "ADD", alias = "Add")] Add, - #[serde(alias = "update", alias = "UPDATE", alias = "Update")] Update, - #[serde(alias = "list", alias = "LIST", alias = "List")] List, } @@ -750,13 +733,9 @@ pub struct MilestonesTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SnippetAction { - #[serde(alias = "store", alias = "STORE", alias = "Store")] Store, - #[serde(alias = "search", alias = "SEARCH", alias = "Search")] Search, - #[serde(alias = "delete", alias = "DELETE", alias = "Delete")] Delete, - #[serde(alias = "tag", alias = "TAG", alias = "Tag")] Tag, } @@ -801,13 +780,9 @@ pub struct SnippetsTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq, Clone)] #[serde(rename_all = "snake_case")] pub enum DecisionAction { - #[serde(alias = "log", alias = "LOG", alias = "Log")] Log, - #[serde(alias = "query", alias = "QUERY", alias = "Query")] Query, - #[serde(alias = "delete", alias = "DELETE", alias = "Delete")] Delete, - #[serde(alias = "update", alias = "UPDATE", alias = "Update")] Update, } @@ -865,11 +840,8 @@ pub struct DecisionsTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum TechDebtAction { - #[serde(alias = "log", alias = "LOG", alias = "Log")] Log, - #[serde(alias = "resolve", alias = "RESOLVE", alias = "Resolve")] Resolve, - #[serde(alias = "list", alias = "LIST", alias = "List")] List, } @@ -928,35 +900,10 @@ pub struct TechDebtTool { #[derive(Debug, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum EnvAction { - #[serde( - alias = "update_fingerprint", - alias = "updateFingerprint", - alias = "UPDATE_FINGERPRINT", - alias = "UpdateFingerprint" - )] UpdateFingerprint, - #[serde( - alias = "read_fingerprint", - alias = "readFingerprint", - alias = "READ_FINGERPRINT", - alias = "ReadFingerprint" - )] ReadFingerprint, - #[serde( - alias = "log_requirement", - alias = "logRequirement", - alias = "LOG_REQUIREMENT", - alias = "LogRequirement" - )] LogRequirement, - #[serde(alias = "register", alias = "REGISTER", alias = "Register")] Register, - #[serde( - alias = "get_details", - alias = "getDetails", - alias = "GET_DETAILS", - alias = "GetDetails" - )] GetDetails, } @@ -998,21 +945,10 @@ pub struct EnvironmentTool { #[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ClipboardAction { - #[serde( - alias = "image", - alias = "IMAGE", - alias = "Image", - alias = "screenshot", - alias = "SCREENSHOT" - )] Image, - #[serde(alias = "text", alias = "TEXT", alias = "Text")] Text, - #[serde(alias = "history", alias = "HISTORY", alias = "History")] History, - #[serde(alias = "clear", alias = "CLEAR", alias = "Clear")] Clear, - #[serde(alias = "write", alias = "WRITE", alias = "Write")] Write, } /// Smart OS Clipboard management with overwrite-immune screenshot caching and OCR. diff --git a/server/tests/parity_test.rs b/server/tests/parity_test.rs index 1797b91..0722a21 100644 --- a/server/tests/parity_test.rs +++ b/server/tests/parity_test.rs @@ -29,11 +29,10 @@ fn test_eager_tools_parity() { let nvim_source = std::fs::read_to_string("../nvim-core/src/lib.rs").expect("Failed to read nvim lib.rs"); let mut nvim_tools = HashSet::new(); - for line in nvim_source.lines() { - if line.contains("\"name\": \"nvim_") - && let Some(start) = line.find("\"name\": \"") - { - let rest = &line[start + 9..]; + let parts: Vec<&str> = nvim_source.split("tool_def").collect(); + for part in parts.iter().skip(1) { + if let Some(start) = part.find("(\"") { + let rest = &part[start + 2..]; if let Some(end) = rest.find('"') { nvim_tools.insert(rest[..end].to_string()); }