Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
495304f3fa | ||
|
|
ecfb0ebcd5 | ||
|
|
91055b1eda | ||
|
|
706e9ad172 | ||
|
|
febd48c65d | ||
|
|
8f32a09399 | ||
|
|
f8925050db | ||
|
|
0772c7fca3 | ||
|
|
c990f5cd3a | ||
|
|
52c4c7c479 | ||
|
|
36e35d4fbd | ||
|
|
d2b4f69f97 | ||
|
|
f970e1219d | ||
|
|
45577a99c4 | ||
|
|
41461a41ef |
No files matched your search
@@ -0,0 +1,65 @@
|
|||||||
|
# Integrating MCP Memory: A Strategy Guide for LLMs and Agents
|
||||||
|
|
||||||
|
This guide documents the approach and rationale for integrating the `mcp-memory` server with agentic LLMs (like Antigravity). Because `mcp-memory` is a central hub for context, tasks, and environment state, it is critical that LLMs interact with it efficiently without exhausting their primary context window or causing workflow ambiguity.
|
||||||
|
|
||||||
|
## 1. The Core Philosophy: "The Central Brain"
|
||||||
|
The `mcp-memory` server is the persistence layer for the AI development lifecycle. It holds:
|
||||||
|
* **The Knowledge Graph:** Code changes, bug fixes, architecture decisions, and tech debt.
|
||||||
|
* **Project State:** Milestones, tasks, acceptance criteria, and PR checklists.
|
||||||
|
* **Environment State:** Handoff memos, standup reports, and environment fingerprints.
|
||||||
|
* **Live UI Integrations:** Neovim buffer manipulation and user-action webhooks.
|
||||||
|
|
||||||
|
**Rationale:** The LLM's context window is ephemeral and expensive. By pushing state to the `mcp-memory` server (via a local database and Tantivy index), the LLM can selectively retrieve only what it needs, when it needs it.
|
||||||
|
|
||||||
|
## 2. Global Rules vs. Subagents
|
||||||
|
To maximize efficiency, we split interactions into two categories: **Synchronous Rules** (executed by the primary conversational agent) and **Asynchronous Subagents** (delegated background tasks).
|
||||||
|
|
||||||
|
### A. Synchronous Rules (The Primary Loop)
|
||||||
|
The main LLM interacting with the user should be constrained by global system rules to ensure basic context synchronization. These actions must happen synchronously so the main agent never loses the plot.
|
||||||
|
|
||||||
|
* **Context Initialization (`list_active_tasks`, `list_pinned_files`):** Executed when a session starts. This gives the LLM immediate awareness of the current workflow.
|
||||||
|
* **Context Switching (`save_context_workspace`, `load_context_workspace`):** Executed when moving between branches or large features. This prevents context bleed between disparate tasks.
|
||||||
|
* **End-of-Day Handoff (`add_session_summary`, `generate_standup_report`):** Triggered when the user logs off, seamlessly serializing the mental state of the LLM for tomorrow.
|
||||||
|
|
||||||
|
### B. Subagent Orchestration (The Background Team)
|
||||||
|
Heavy or verbose interactions with the MCP server are delegated to specialized background subagents. This keeps the primary chat fast and focused on the code, while the "team" handles project management.
|
||||||
|
|
||||||
|
#### 1. `MemoryLibrarian` (The Graph Curator)
|
||||||
|
* **Role:** Analyzes git diffs and chat history to structure the Knowledge Graph.
|
||||||
|
* **Tools:** `log_code_change`, `log_error_fix`, `create_entities`, `log_tech_debt`.
|
||||||
|
* **Rationale:** Parsing diffs and determining entity relationships is token-heavy. Delegating this prevents the main agent from wasting reasoning cycles on database normalization.
|
||||||
|
|
||||||
|
#### 2. `ScrumMaster` (The Project Manager)
|
||||||
|
* **Role:** Manages the task lifecycle and acceptance criteria.
|
||||||
|
* **Tools:** `add_task`, `update_task_status`, `add_milestone`, `verify_acceptance_criteria`.
|
||||||
|
* **Rationale:** The main agent shouldn't have to repeatedly query "are we done yet?" The `ScrumMaster` runs alongside the session, validating criteria in the background and updating the board autonomously.
|
||||||
|
|
||||||
|
#### 3. `DevOpsSRE` (The Environment Manager)
|
||||||
|
* **Role:** Monitors dependencies and manages session transitions.
|
||||||
|
* **Tools:** `update_env_fingerprint`, `leave_handoff_memo`.
|
||||||
|
* **Rationale:** Prevents "it works on my machine" failures by passively updating fingerprints when build files (e.g., `Cargo.toml`) change.
|
||||||
|
|
||||||
|
## 3. Graceful Degradation & Server Resilience
|
||||||
|
The `mcp-memory` server is a distinct background process (typically port 3000). The LLM ecosystem must handle server downtime gracefully:
|
||||||
|
|
||||||
|
1. **Event Webhooks:** If the server goes down, waiting webhook tasks (e.g., waiting for a user to save a file in Neovim) will drop. These *do not* self-heal. The LLM must recognize the dropped connection and prompt the user to retry the action.
|
||||||
|
2. **Persistent Storage:** Data (tasks, graph, pins) is persisted to `mcp_store.redb`. When the server comes back online, no data is lost. The LLM can immediately resume querying.
|
||||||
|
3. **Subagent Fast-Failing:** If the `MemoryLibrarian` attempts to log a change while the server is offline, it will instantly fail. It is designed to abandon the background task and notify the primary agent. To recover, the primary agent can manually re-invoke the Librarian once the connection is restored, instructing it to analyze recent commits to backfill the graph.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
By treating `mcp-memory` as the durable brain, and enforcing a strict division of labor between the primary agent loop and background subagents, we achieve a highly autonomous, highly resilient AI pair-programming environment that scales across long-running projects and multiple terminal sessions.
|
||||||
|
|
||||||
|
## 4. MCP Feature Differentiation (Cognitive Boundaries)
|
||||||
|
The MCP protocol exposes three primary primitives. To prevent LLM confusion and API hallucination, the LLM must strictly adhere to the following interaction boundaries:
|
||||||
|
|
||||||
|
### A. Tools (For Stateful Mutation)
|
||||||
|
* **When to use:** Use tools *exclusively* for mutating state (e.g., dd_task, log_code_change) or for highly targeted semantic searches (e.g., search_nodes, query_graph_path).
|
||||||
|
* **LLM Awareness:** The LLM must not use tools to repeatedly poll for state changes. Tools represent active, expensive computing steps.
|
||||||
|
|
||||||
|
### B. Resources (For Passive Awareness)
|
||||||
|
* **When to use:** Use URIs (e.g., memory://tasks/active, memory://pinned_files) to read holistic project state.
|
||||||
|
* **LLM Awareness:** The client integration should map these URIs to the LLM's context window. Instead of the LLM invoking a list_active_tasks tool (which costs a round-trip), the LLM should simply read the memory://tasks/active resource content if it needs to know what to do next. Resources are for passive, zero-cost reading.
|
||||||
|
|
||||||
|
### C. Prompts (For Macro-Workflows)
|
||||||
|
* **When to use:** Use server-defined prompts to execute complex, multi-step routines that require bundled context.
|
||||||
|
* **LLM Awareness:** Instead of the user or main agent trying to manually figure out the correct sequence of tools to end a session, the LLM should trigger the handoff_routine prompt. The server will respond with a strictly formatted message array that perfectly primes the LLM on exactly what to do next. Prompts act as "macro-instructions" to prevent the LLM from wandering off-script during complex transitions.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Architecture Design: MCP Resources & Prompts
|
||||||
|
|
||||||
|
## 1. Current Architecture (Tools)
|
||||||
|
Currently, the `mcp-memory` server handles MCP tools using an elegant trait-based approach in `router.rs`:
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpTool: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn schema(&self) -> Value;
|
||||||
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Tools are registered into a `HashMap<String, Box<dyn McpTool>>` within the `MemoryHandler`. This prevents the main JSON-RPC match block from becoming a monolithic switch statement.
|
||||||
|
|
||||||
|
## 2. The Problem
|
||||||
|
Currently, the `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` endpoints are hardcoded directly inside the `MemoryHandler::handle_request` match block in `router.rs`.
|
||||||
|
As we expand our usage of Resources (to expose the database state dynamically) and Prompts (to bundle complex workflows), continuing to hardcode them in `router.rs` will result in massive code duplication and tearup.
|
||||||
|
|
||||||
|
## 3. The Proposed Solution (Trait Extensibility)
|
||||||
|
We will replicate the success of the `McpTool` trait by introducing `McpResource` and `McpPrompt` traits.
|
||||||
|
|
||||||
|
### A. MCP Resources
|
||||||
|
**Trait Definition (`router.rs` or `resources.rs`):**
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpResource: Send + Sync {
|
||||||
|
/// The exact URI the client requests (e.g. "memory://tasks/active")
|
||||||
|
fn uri(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Human-readable name for the client UI
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Description for the client UI
|
||||||
|
fn description(&self) -> Option<&'static str> { None }
|
||||||
|
|
||||||
|
/// Mime type of the content (usually "application/json" or "text/markdown")
|
||||||
|
fn mime_type(&self) -> Option<&'static str> { Some("application/json") }
|
||||||
|
|
||||||
|
/// Retrieve the resource content
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Implementation:**
|
||||||
|
* Add `pub resources: std::collections::HashMap<String, Box<dyn McpResource>>` to `MemoryHandler`.
|
||||||
|
* In `handle_request("resources/list")`, iterate over `self.resources.values()` and build the JSON payload.
|
||||||
|
* In `handle_request("resources/read")`, lookup the requested URI in `self.resources` and call `.read(state).await`.
|
||||||
|
* Move the existing `memory://graph/entities` logic into its own handler struct.
|
||||||
|
|
||||||
|
### B. MCP Prompts
|
||||||
|
**Trait Definition (`router.rs` or `prompts.rs`):**
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpPrompt: Send + Sync {
|
||||||
|
/// The unique name of the prompt (e.g. "analyze_tech_debt")
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
/// Description for the client UI
|
||||||
|
fn description(&self) -> Option<&'static str> { None }
|
||||||
|
|
||||||
|
/// Schema or array defining arguments (can default to empty)
|
||||||
|
fn arguments(&self) -> serde_json::Value { serde_json::json!([]) }
|
||||||
|
|
||||||
|
/// Execute the prompt and return the `messages` array payload
|
||||||
|
async fn get(&self, args: Value, state: Arc<MemoryState>) -> Result<serde_json::Value, String>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Implementation:**
|
||||||
|
* Add `pub prompts: std::collections::HashMap<String, Box<dyn McpPrompt>>` to `MemoryHandler`.
|
||||||
|
* In `handle_request("prompts/list")`, map over `self.prompts.values()`.
|
||||||
|
* In `handle_request("prompts/get")`, call `.get(args, state).await`.
|
||||||
|
|
||||||
|
## 4. Execution Plan
|
||||||
|
1. **Refactor `router.rs` (No functional changes yet):** Define the `McpResource` and `McpPrompt` traits. Update the `MemoryHandler` struct to hold these HashMaps. Migrate the existing hardcoded stubs (`memory://graph/entities` and `analyze_tech_debt`) into structs implementing these traits.
|
||||||
|
2. **Expand Resources (Phase 1):** Add new handlers for `memory://tasks/active`, `memory://pinned_files`, etc.
|
||||||
|
3. **Expand Prompts (Phase 2):** Add new handlers for `handoff_routine`, etc.
|
||||||
|
|
||||||
|
This design guarantees we do not needlessly tear up code—we merely extend the existing robust `McpTool` pattern to the rest of the protocol.
|
||||||
@@ -8,7 +8,11 @@ pub static HEADLESS_PROC: LazyLock<Arc<std::sync::Mutex<Option<tokio::process::C
|
|||||||
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None)));
|
LazyLock::new(|| Arc::new(std::sync::Mutex::new(None)));
|
||||||
|
|
||||||
pub async fn spawn_headless_nvim() -> Result<String, String> {
|
pub async fn spawn_headless_nvim() -> Result<String, String> {
|
||||||
if std::process::Command::new("nvim").arg("--version").output().is_err() {
|
if std::process::Command::new("nvim")
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
|
return Err("Error: Neovim is not installed or not in PATH. Cannot execute nvim tool. Fallback to basic terminal tools.".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-40
@@ -169,18 +169,22 @@ static NVIM_STATE: LazyLock<Arc<std::sync::Mutex<NvimState>>> =
|
|||||||
LazyLock::new(|| Arc::new(std::sync::Mutex::new(NvimState::default())));
|
LazyLock::new(|| Arc::new(std::sync::Mutex::new(NvimState::default())));
|
||||||
|
|
||||||
fn handle_nvim_notification(params: &[rmpv::Value]) {
|
fn handle_nvim_notification(params: &[rmpv::Value]) {
|
||||||
if params.is_empty() { return; }
|
if params.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let rmpv::Value::String(event) = ¶ms[0] {
|
if let rmpv::Value::String(event) = ¶ms[0] {
|
||||||
match event.as_str().unwrap_or("") {
|
match event.as_str().unwrap_or("") {
|
||||||
"CursorMoved" => {
|
"CursorMoved" => {
|
||||||
if params.len() > 1
|
if params.len() > 1
|
||||||
&& let rmpv::Value::Array(pos) = ¶ms[1]
|
&& let rmpv::Value::Array(pos) = ¶ms[1]
|
||||||
&& pos.len() >= 4
|
&& pos.len() >= 4
|
||||||
&& let (rmpv::Value::Integer(row), rmpv::Value::Integer(col)) = (&pos[1], &pos[2]) {
|
&& let (rmpv::Value::Integer(row), rmpv::Value::Integer(col)) =
|
||||||
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
|
(&pos[1], &pos[2])
|
||||||
state.cursor = format!("Line: {}, Column: {}", row, col);
|
{
|
||||||
}
|
let mut state = NVIM_STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
},
|
state.cursor = format!("Line: {}, Column: {}", row, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
"TextChanged" => {
|
"TextChanged" => {
|
||||||
tracing::debug!("Shadow buffer text changed event received");
|
tracing::debug!("Shadow buffer text changed event received");
|
||||||
}
|
}
|
||||||
@@ -195,28 +199,32 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let mut conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner());
|
let mut conn_lock = NVIM_CONN.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
if let Some(sender) = conn_lock.as_ref() {
|
if let Some(sender) = conn_lock.as_ref()
|
||||||
if !sender.is_closed() {
|
&& !sender.is_closed()
|
||||||
let current_socket = {
|
{
|
||||||
let active_lock = ACTIVE_SOCKET.lock().unwrap_or_else(|e| e.into_inner());
|
let current_socket = {
|
||||||
active_lock.clone()
|
let active_lock = ACTIVE_SOCKET.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
};
|
active_lock.clone()
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref interactive) = interactive_socket {
|
if let Some(ref interactive) = interactive_socket {
|
||||||
if Some(interactive.clone()) != current_socket {
|
if Some(interactive.clone()) != current_socket {
|
||||||
tracing::info!("Interactive Preemption: New Neovim instance detected at {}. Disconnecting from current.", interactive);
|
tracing::info!(
|
||||||
should_kill_headless = true;
|
"Interactive Preemption: New Neovim instance detected at {}. Disconnecting from current.",
|
||||||
*conn_lock = None;
|
interactive
|
||||||
{
|
);
|
||||||
let mut active_lock2 = ACTIVE_SOCKET.lock().unwrap_or_else(|e| e.into_inner());
|
should_kill_headless = true;
|
||||||
*active_lock2 = None;
|
*conn_lock = None;
|
||||||
}
|
{
|
||||||
} else {
|
let mut active_lock2 =
|
||||||
return Ok(sender.clone());
|
ACTIVE_SOCKET.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
*active_lock2 = None;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Ok(sender.clone());
|
return Ok(sender.clone());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return Ok(sender.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +246,10 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
|||||||
spawn_headless_nvim().await?
|
spawn_headless_nvim().await?
|
||||||
};
|
};
|
||||||
|
|
||||||
tracing::info!("Establishing new persistent connection to Neovim at {}", socket_path);
|
tracing::info!(
|
||||||
|
"Establishing new persistent connection to Neovim at {}",
|
||||||
|
socket_path
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let stream = {
|
let stream = {
|
||||||
@@ -308,12 +319,14 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
|||||||
if let Some((_, reply_sender)) = pending_clone2.remove(&msgid) {
|
if let Some((_, reply_sender)) = pending_clone2.remove(&msgid) {
|
||||||
let _ = reply_sender.send(Ok(val));
|
let _ = reply_sender.send(Ok(val));
|
||||||
}
|
}
|
||||||
} else if arr.len() >= 3 && arr[0] == rmpv::Value::Integer(2.into())
|
} else if arr.len() >= 3
|
||||||
|
&& arr[0] == rmpv::Value::Integer(2.into())
|
||||||
&& let rmpv::Value::String(method) = &arr[1]
|
&& let rmpv::Value::String(method) = &arr[1]
|
||||||
&& method.as_str().unwrap_or("") == "mcp_event"
|
&& method.as_str().unwrap_or("") == "mcp_event"
|
||||||
&& let rmpv::Value::Array(params) = &arr[2] {
|
&& let rmpv::Value::Array(params) = &arr[2]
|
||||||
handle_nvim_notification(params);
|
{
|
||||||
}
|
handle_nvim_notification(params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -407,11 +420,13 @@ async fn get_nvim_connection() -> Result<mpsc::Sender<NvimRequest>, String> {
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
||||||
let _ = tx_clone.send(NvimRequest {
|
let _ = tx_clone
|
||||||
msgid,
|
.send(NvimRequest {
|
||||||
req,
|
msgid,
|
||||||
reply: reply_tx,
|
req,
|
||||||
}).await;
|
reply: reply_tx,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
tracing::info!("Injected bidirectional event tracking autocmds into Neovim");
|
tracing::info!("Injected bidirectional event tracking autocmds into Neovim");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -684,7 +699,7 @@ async fn execute_nvim_lua_with_args(code: &str, args: Vec<rmpv::Value>) -> Resul
|
|||||||
if let serde_json::Value::String(s) = json_res {
|
if let serde_json::Value::String(s) = json_res {
|
||||||
Ok(s)
|
Ok(s)
|
||||||
} else {
|
} else {
|
||||||
Ok(serde_json::to_string_pretty(&json_res).unwrap_or_default())
|
serde_json::to_string_pretty(&json_res).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,6 +789,35 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
"notifications/initialized" => {}
|
"notifications/initialized" => {}
|
||||||
|
|
||||||
|
"resources/list" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({"resources": []})),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
"resources/templates/list" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({"resourceTemplates": []})),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
"prompts/list" => {
|
||||||
|
send_response(JsonRpcResponse {
|
||||||
|
jsonrpc: "2.0".to_string(),
|
||||||
|
id,
|
||||||
|
result: Some(json!({"prompts": []})),
|
||||||
|
error: None,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
"tools/list" => {
|
"tools/list" => {
|
||||||
send_response(JsonRpcResponse {
|
send_response(JsonRpcResponse {
|
||||||
jsonrpc: "2.0".to_string(),
|
jsonrpc: "2.0".to_string(),
|
||||||
@@ -1405,8 +1449,10 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
|
|
||||||
"nvim_read_file" => {
|
"nvim_read_file" => {
|
||||||
if let Some(file) = args.get("file").and_then(|v| v.as_str()) {
|
if let Some(file) = args.get("file").and_then(|v| v.as_str()) {
|
||||||
let start_line = args.get("start_line").and_then(|v| v.as_i64()).unwrap_or(1);
|
let start_line =
|
||||||
let end_line = args.get("end_line").and_then(|v| v.as_i64()).unwrap_or(-1);
|
args.get("start_line").and_then(|v| v.as_i64()).unwrap_or(1);
|
||||||
|
let end_line =
|
||||||
|
args.get("end_line").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||||
|
|
||||||
let escaped_file = file.replace('\\', "\\\\").replace('"', "\\\"");
|
let escaped_file = file.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
let code = format!("
|
let code = format!("
|
||||||
@@ -1440,7 +1486,8 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
args.get("pattern").and_then(|v| v.as_str()),
|
args.get("pattern").and_then(|v| v.as_str()),
|
||||||
) {
|
) {
|
||||||
let escaped_file = file.replace('\\', "\\\\").replace('"', "\\\"");
|
let escaped_file = file.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
let escaped_pattern = pattern.replace('\\', "\\\\").replace('"', "\\\"");
|
let escaped_pattern =
|
||||||
|
pattern.replace('\\', "\\\\").replace('"', "\\\"");
|
||||||
let code = format!("
|
let code = format!("
|
||||||
local file = \"{escaped_file}\"
|
local file = \"{escaped_file}\"
|
||||||
local pattern = \"{escaped_pattern}\"
|
local pattern = \"{escaped_pattern}\"
|
||||||
@@ -1519,7 +1566,9 @@ pub async fn run_mcp_loop(app_name: &str, app_version: &str) {
|
|||||||
let exe_path = std::env::current_exe()
|
let exe_path = std::env::current_exe()
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|_| "unknown".to_string());
|
.unwrap_or_else(|_| "unknown".to_string());
|
||||||
let socket = get_socket_path().await.unwrap_or_else(|e| format!("Error: {}", e));
|
let socket = get_socket_path()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| format!("Error: {}", e));
|
||||||
let info = format!(
|
let info = format!(
|
||||||
"Server: {}\nVersion: {}\nOS: {}\nExecutable: {}\nBackend Socket: {}",
|
"Server: {}\nVersion: {}\nOS: {}\nExecutable: {}\nBackend Socket: {}",
|
||||||
app_name, app_version, os, exe_path, socket
|
app_name, app_version, os, exe_path, socket
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
use crate::state::GenericEvent;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{Query, State};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use crate::state::GenericEvent;
|
|
||||||
|
|
||||||
pub async fn wait_for_event_handler(
|
pub async fn wait_for_event_handler(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
@@ -17,8 +17,10 @@ pub async fn wait_for_event_handler(
|
|||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
let topic_matches = topic.as_ref().map_or(true, |t| t == &event.topic);
|
let topic_matches = topic.as_ref().is_none_or(|t| t == &event.topic);
|
||||||
let session_matches = session_id.as_ref().map_or(true, |s| Some(s) == event.session_id.as_ref());
|
let session_matches = session_id
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|s| Some(s) == event.session_id.as_ref());
|
||||||
|
|
||||||
if topic_matches && session_matches {
|
if topic_matches && session_matches {
|
||||||
return axum::Json(event);
|
return axum::Json(event);
|
||||||
@@ -46,14 +48,14 @@ pub async fn post_event_handler(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::state::MemoryState;
|
|
||||||
use crate::router::MemoryHandler;
|
use crate::router::MemoryHandler;
|
||||||
use tempfile::tempdir;
|
use crate::state::MemoryState;
|
||||||
use axum::extract::Query;
|
use axum::extract::Query;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::AtomicUsize;
|
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_events_wait_and_post() {
|
async fn test_events_wait_and_post() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
pub mod events;
|
||||||
pub mod rest;
|
pub mod rest;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
pub mod events;
|
|
||||||
@@ -148,6 +148,86 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/tech_debts",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.tech_debts.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/adrs",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.adrs.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/context_workspaces",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.context_workspaces.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/handoff_memos",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.handoff_memos.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/milestones",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.milestones.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/snippets",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.snippets.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/pr_checklists",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.pr_checklists.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/error_fixes",
|
||||||
|
get({
|
||||||
|
let state_clone = app_state.handler.state.clone();
|
||||||
|
move || async move {
|
||||||
|
let json = state_clone.error_fixes.read_with(|items| serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()));
|
||||||
|
([(axum::http::header::CONTENT_TYPE, "application/json")], json)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/stats",
|
"/api/stats",
|
||||||
get({
|
get({
|
||||||
|
|||||||
+11
-11
@@ -50,17 +50,17 @@ pub async fn nvim_telemetry_handler(
|
|||||||
let _ = tx.try_send(msg_str.clone());
|
let _ = tx.try_send(msg_str.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.event == "BufWritePost" {
|
if payload.event == "BufWritePost"
|
||||||
if let Some(ref file_path) = payload.file {
|
&& let Some(ref file_path) = payload.file
|
||||||
let normalized_file = file_path.replace("\\", "/");
|
{
|
||||||
let topic = format!("nvim:save:{}", normalized_file);
|
let normalized_file = file_path.replace("\\", "/");
|
||||||
let event = crate::state::GenericEvent {
|
let topic = format!("nvim:save:{}", normalized_file);
|
||||||
topic,
|
let event = crate::state::GenericEvent {
|
||||||
session_id: Some(payload.session_id.clone()),
|
topic,
|
||||||
payload: serde_json::json!(&payload),
|
session_id: Some(payload.session_id.clone()),
|
||||||
};
|
payload: serde_json::json!(&payload),
|
||||||
let _ = state.handler.state.event_bus_tx.send(event);
|
};
|
||||||
}
|
let _ = state.handler.state.event_bus_tx.send(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
axum::Json(serde_json::json!({"status": "ok"}))
|
axum::Json(serde_json::json!({"status": "ok"}))
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
|
|||||||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&text) {
|
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
// Process MCP request
|
// Process MCP request
|
||||||
if let Some(response) = handler.handle_request(payload).await {
|
if let Some(response) = handler.handle_request(payload).await {
|
||||||
let res_str = serde_json::to_string(&response).unwrap_or_default();
|
let res_str = serde_json::to_string(&response).unwrap_or_else(|e| format!(r#"{{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{{\"code\":-32603,\"message\":\"{}\"}}}}"#, e));
|
||||||
let tx_opt = state_clone
|
let tx_opt = state_clone
|
||||||
.clients
|
.clients
|
||||||
.read()
|
.read()
|
||||||
|
|||||||
+25
-12
@@ -481,6 +481,7 @@
|
|||||||
<button class="tab-button" onclick="switchTab('techdebt-tab', this)">Tech Debt</button>
|
<button class="tab-button" onclick="switchTab('techdebt-tab', this)">Tech Debt</button>
|
||||||
<button class="tab-button" onclick="switchTab('adrs-tab', this)">Decisions (ADR)</button>
|
<button class="tab-button" onclick="switchTab('adrs-tab', this)">Decisions (ADR)</button>
|
||||||
<button class="tab-button" onclick="switchTab('workspaces-tab', this)">Workspaces</button>
|
<button class="tab-button" onclick="switchTab('workspaces-tab', this)">Workspaces</button>
|
||||||
|
<button class="tab-button" onclick="switchTab('pinned-tab', this)">Pinned Files</button>
|
||||||
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
<button class="tab-button" onclick="switchTab('memos-tab', this)">Handoffs</button>
|
||||||
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
<button class="tab-button" onclick="switchTab('snippets-tab', this)">Snippets</button>
|
||||||
<button class="tab-button" onclick="switchTab('pr-tab', this)">PRs</button>
|
<button class="tab-button" onclick="switchTab('pr-tab', this)">PRs</button>
|
||||||
@@ -558,10 +559,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pinned-tab" class="tab-content">
|
||||||
|
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
||||||
|
<h2>Pinned Files</h2>
|
||||||
|
<div id="pinned-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="memos-tab" class="tab-content">
|
<div id="memos-tab" class="tab-content">
|
||||||
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
<div class="panel" style="flex:1; display:flex; flex-direction:column; overflow-y:auto;">
|
||||||
<h2>Handoff Memos & Milestones</h2>
|
<h2>Handoff Memos & Session Summaries</h2>
|
||||||
<div id="memos-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
<div id="memos-container" style="display:flex; flex-direction:column; gap:15px; margin-bottom: 30px;"></div>
|
||||||
|
<h2>Project Milestones</h2>
|
||||||
|
<div id="milestones-container" style="display:flex; flex-direction:column; gap:15px;"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -602,7 +612,7 @@
|
|||||||
loadGraph();
|
loadGraph();
|
||||||
} else if (tabId === 'activity-tab') {
|
} else if (tabId === 'activity-tab') {
|
||||||
const feed = document.getElementById('activity-feed');
|
const feed = document.getElementById('activity-feed');
|
||||||
feed.scrollTop = feed.scrollHeight;
|
feed.scrollTop = 0;
|
||||||
} else if (tabId === 'task-tab') {
|
} else if (tabId === 'task-tab') {
|
||||||
loadTasks();
|
loadTasks();
|
||||||
} else if (tabId === 'sticky-tab') {
|
} else if (tabId === 'sticky-tab') {
|
||||||
@@ -967,15 +977,15 @@
|
|||||||
const history = await response.json();
|
const history = await response.json();
|
||||||
const feed = document.getElementById('activity-feed');
|
const feed = document.getElementById('activity-feed');
|
||||||
feed.innerHTML = '';
|
feed.innerHTML = '';
|
||||||
history.forEach(item => {
|
[...history].reverse().forEach(item => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'feed-entry';
|
div.className = 'feed-entry';
|
||||||
const timeStr = new Date(item.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
|
const timeStr = new Date(item.time).toLocaleString([], {month: '2-digit', day: '2-digit', hour: '2-digit', minute:'2-digit', second:'2-digit'});
|
||||||
div.innerHTML = `<span class="time">[${timeStr}]</span> ${item.message || item.data}`;
|
div.innerHTML = `<span class="time">[${timeStr}]</span> ${item.message || item.data}`;
|
||||||
feed.appendChild(div);
|
feed.appendChild(div);
|
||||||
});
|
});
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
feed.scrollTop = feed.scrollHeight;
|
feed.scrollTop = 0;
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
@@ -990,22 +1000,22 @@
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'activity') {
|
if (data.type === 'activity') {
|
||||||
// Check scroll state before appending
|
// Check scroll state before appending
|
||||||
const isScrolledToBottom = feed.scrollHeight - feed.clientHeight <= feed.scrollTop + 20;
|
const isScrolledToTop = feed.scrollTop <= 20;
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'feed-entry';
|
div.className = 'feed-entry';
|
||||||
const timeStr = new Date(data.data.time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
|
const timeStr = new Date(data.data.time).toLocaleString([], {month: '2-digit', day: '2-digit', hour: '2-digit', minute:'2-digit', second:'2-digit'});
|
||||||
div.innerHTML = `<span class="time">[${timeStr}]</span> ${data.data.message || data.data.data || data.data}`;
|
div.innerHTML = `<span class="time">[${timeStr}]</span> ${data.data.message || data.data.data || data.data}`;
|
||||||
feed.appendChild(div);
|
feed.prepend(div);
|
||||||
|
|
||||||
// Enforce max history
|
// Enforce max history
|
||||||
while (feed.children.length > MAX_ACTIVITY_HISTORY) {
|
while (feed.children.length > MAX_ACTIVITY_HISTORY) {
|
||||||
feed.removeChild(feed.firstChild);
|
feed.removeChild(feed.lastChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Auto-scroll logic
|
||||||
if (isScrolledToBottom) {
|
if (isScrolledToTop) {
|
||||||
feed.scrollTop = feed.scrollHeight;
|
feed.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1068,6 +1078,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadAllExtras() {
|
function loadAllExtras() {
|
||||||
|
|
||||||
|
|
||||||
loadGenericList('/api/tech_debts', 'techdebt-container', item => `
|
loadGenericList('/api/tech_debts', 'techdebt-container', item => `
|
||||||
<strong>${item.id}</strong> ${item.is_resolved ? '<span style="color:var(--success-color);">(Resolved)</span>' : '<span style="color:var(--error-color);">(Open)</span>'}
|
<strong>${item.id}</strong> ${item.is_resolved ? '<span style="color:var(--success-color);">(Resolved)</span>' : '<span style="color:var(--error-color);">(Open)</span>'}
|
||||||
<div style="margin-top:5px;"><strong>Description:</strong> ${item.description}</div>
|
<div style="margin-top:5px;"><strong>Description:</strong> ${item.description}</div>
|
||||||
@@ -1157,3 +1169,4 @@
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ impl McpTool for ReadEnvFingerprintHandler {
|
|||||||
.env_fingerprints
|
.env_fingerprints
|
||||||
.read_with(|fps| fps.get(&req.namespace).cloned());
|
.read_with(|fps| fps.get(&req.namespace).cloned());
|
||||||
if let Some(fp) = data {
|
if let Some(fp) = data {
|
||||||
let data = serde_json::to_string(&fp).unwrap_or_default();
|
let data = serde_json::to_string(&fp).map_err(|e| e.to_string())?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
} else {
|
} else {
|
||||||
Ok("{}".to_string())
|
Ok("{}".to_string())
|
||||||
|
|||||||
@@ -151,8 +151,14 @@ impl McpTool for CreateRelationsHandler {
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let err_msg = e.to_string();
|
let err_msg = e.to_string();
|
||||||
if err_msg.contains("missing field `from`") || err_msg.contains("missing field `to`") || err_msg.contains("missing field `relation_type`") {
|
if err_msg.contains("missing field `from`")
|
||||||
return Err(format!("Schema error: {}. Note that the relation schema strictly uses 'from', 'to', and 'relation_type' (not 'source', 'target', or 'relationType'). Please correct your tool call arguments.", err_msg));
|
|| err_msg.contains("missing field `to`")
|
||||||
|
|| err_msg.contains("missing field `relation_type`")
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"Schema error: {}. Note that the relation schema strictly uses 'from', 'to', and 'relation_type' (not 'source', 'target', or 'relationType'). Please correct your tool call arguments.",
|
||||||
|
err_msg
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return Err(err_msg);
|
return Err(err_msg);
|
||||||
}
|
}
|
||||||
@@ -166,15 +172,22 @@ impl McpTool for CreateRelationsHandler {
|
|||||||
if from_exists && to_exists {
|
if from_exists && to_exists {
|
||||||
g.relations.push(relation);
|
g.relations.push(relation);
|
||||||
} else {
|
} else {
|
||||||
if !from_exists { missing_nodes.insert(relation.from); }
|
if !from_exists {
|
||||||
if !to_exists { missing_nodes.insert(relation.to); }
|
missing_nodes.insert(relation.from);
|
||||||
|
}
|
||||||
|
if !to_exists {
|
||||||
|
missing_nodes.insert(relation.to);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !missing_nodes.is_empty() {
|
if !missing_nodes.is_empty() {
|
||||||
let missing: Vec<_> = missing_nodes.into_iter().collect();
|
let missing: Vec<_> = missing_nodes.into_iter().collect();
|
||||||
return Err(format!("Error: Relations dropped due to missing entities: {}", missing.join(", ")));
|
return Err(format!(
|
||||||
|
"Error: Relations dropped due to missing entities: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Relations created".to_string())
|
Ok("Relations created".to_string())
|
||||||
}
|
}
|
||||||
@@ -205,7 +218,10 @@ impl McpTool for AddObservationsHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !missing_entities.is_empty() {
|
if !missing_entities.is_empty() {
|
||||||
return Err(format!("Error: Observations dropped for missing entities: {}", missing_entities.join(", ")));
|
return Err(format!(
|
||||||
|
"Error: Observations dropped for missing entities: {}",
|
||||||
|
missing_entities.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Observations added".to_string())
|
Ok("Observations added".to_string())
|
||||||
}
|
}
|
||||||
@@ -239,7 +255,10 @@ impl McpTool for DeleteEntitiesHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
return Err(format!("Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.", missing.join(", ")));
|
return Err(format!(
|
||||||
|
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let idx = state.get_search_index();
|
let idx = state.get_search_index();
|
||||||
@@ -280,7 +299,10 @@ impl McpTool for DeleteObservationsHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
return Err(format!("Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.", missing.join(", ")));
|
return Err(format!(
|
||||||
|
"Error: Entities not found: {}. Please use the search_nodes or read_graph tools to verify the exact entity names.",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Observations deleted".to_string())
|
Ok("Observations deleted".to_string())
|
||||||
}
|
}
|
||||||
@@ -308,7 +330,10 @@ impl McpTool for DeleteRelationsHandler {
|
|||||||
missing_count = to_rem.len() - (initial_len - master.relations.len());
|
missing_count = to_rem.len() - (initial_len - master.relations.len());
|
||||||
});
|
});
|
||||||
if missing_count > 0 {
|
if missing_count > 0 {
|
||||||
return Err(format!("Error: {} relation(s) not found in graph. Please verify exact relation properties using read_graph.", missing_count));
|
return Err(format!(
|
||||||
|
"Error: {} relation(s) not found in graph. Please verify exact relation properties using read_graph.",
|
||||||
|
missing_count
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Relations deleted".to_string())
|
Ok("Relations deleted".to_string())
|
||||||
}
|
}
|
||||||
@@ -328,7 +353,7 @@ impl McpTool for ReadGraphHandler {
|
|||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: ReadGraphTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let data = state.read_graph(|full| {
|
let data = state.read_graph(|full| -> Result<String, String> {
|
||||||
if let Some(ns) = req.namespace {
|
if let Some(ns) = req.namespace {
|
||||||
let mut filtered = BorrowedGraph::default();
|
let mut filtered = BorrowedGraph::default();
|
||||||
for (k, v) in &full.entities {
|
for (k, v) in &full.entities {
|
||||||
@@ -341,11 +366,11 @@ impl McpTool for ReadGraphHandler {
|
|||||||
filtered.relations.push(r);
|
filtered.relations.push(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serde_json::to_string(&filtered).unwrap_or_default()
|
serde_json::to_string(&filtered).map_err(|e| e.to_string())
|
||||||
} else {
|
} else {
|
||||||
serde_json::to_string(full).unwrap_or_default()
|
serde_json::to_string(full).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
});
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +396,7 @@ impl McpTool for SearchNodesHandler {
|
|||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let data = state.read_graph(|full| {
|
let data = state.read_graph(|full| -> Result<String, String> {
|
||||||
let mut result = BorrowedGraph::default();
|
let mut result = BorrowedGraph::default();
|
||||||
for (id, doc_type, _, _, _) in &matches {
|
for (id, doc_type, _, _, _) in &matches {
|
||||||
if doc_type == "entity"
|
if doc_type == "entity"
|
||||||
@@ -400,7 +425,7 @@ impl McpTool for OpenNodesHandler {
|
|||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
let req: OpenNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: OpenNodesTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let data = state.read_graph(|full| {
|
let data = state.read_graph(|full| -> Result<String, String> {
|
||||||
let targets: HashSet<&str> = req.names.iter().map(|s| s.as_str()).collect();
|
let targets: HashSet<&str> = req.names.iter().map(|s| s.as_str()).collect();
|
||||||
let mut result = BorrowedGraph::default();
|
let mut result = BorrowedGraph::default();
|
||||||
let mut connected: HashSet<&str> = HashSet::new();
|
let mut connected: HashSet<&str> = HashSet::new();
|
||||||
@@ -536,7 +561,10 @@ impl McpTool for CondenseEntityHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if missing {
|
if missing {
|
||||||
return Err(format!("Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.", req.entity_name));
|
return Err(format!(
|
||||||
|
"Error: Entity '{}' not found. Please verify the exact entity name using search_nodes.",
|
||||||
|
req.entity_name
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Entity condensed".to_string())
|
Ok("Entity condensed".to_string())
|
||||||
}
|
}
|
||||||
@@ -588,7 +616,10 @@ impl McpTool for MergeEntitiesHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
if missing {
|
if missing {
|
||||||
return Err(format!("Error: Source entity '{}' not found. Please verify the exact entity name using search_nodes.", req.source_entity));
|
return Err(format!(
|
||||||
|
"Error: Source entity '{}' not found. Please verify the exact entity name using search_nodes.",
|
||||||
|
req.source_entity
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok("Entities merged".to_string())
|
Ok("Entities merged".to_string())
|
||||||
}
|
}
|
||||||
@@ -619,7 +650,7 @@ impl McpTool for FindOrphansHandler {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
});
|
});
|
||||||
let data = serde_json::to_string(&orphans).unwrap_or_default();
|
let data = serde_json::to_string(&orphans).map_err(|e| e.to_string())?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,11 +98,15 @@ impl McpTool for DeleteDecisionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
crate::mcp::tool_def::<crate::tools::DeleteDecisionTool>("delete_decision", "Delete an architectural decision record")
|
crate::mcp::tool_def::<crate::tools::DeleteDecisionTool>(
|
||||||
|
"delete_decision",
|
||||||
|
"Delete an architectural decision record",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
let req: crate::tools::DeleteDecisionTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: crate::tools::DeleteDecisionTool =
|
||||||
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
state.adrs.modify(|adrs| {
|
state.adrs.modify(|adrs| {
|
||||||
if let Some(pos) = adrs.iter().position(|a| a.id == req.id) {
|
if let Some(pos) = adrs.iter().position(|a| a.id == req.id) {
|
||||||
@@ -270,10 +274,9 @@ impl McpTool for ReadPreferencesHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
async fn execute(&self, _args: Value, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
let data = state
|
state
|
||||||
.prefs
|
.prefs
|
||||||
.read_with(|prefs| serde_json::to_string(prefs).unwrap_or_default());
|
.read_with(|prefs| serde_json::to_string(prefs).map_err(|e| e.to_string()))
|
||||||
Ok(data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +340,10 @@ impl McpTool for ResolveTechDebtHandler {
|
|||||||
if found {
|
if found {
|
||||||
Ok("Tech debt resolved".to_string())
|
Ok("Tech debt resolved".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err("Tech debt not found. Please verify the tech debt ID using list_tech_debt.".to_string())
|
Err(
|
||||||
|
"Tech debt not found. Please verify the tech debt ID using list_tech_debt."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,7 +391,10 @@ impl McpTool for OmniSearchHandler {
|
|||||||
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
let req: OmniSearchTool = serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let limit = req.limit.unwrap_or(5);
|
let limit = req.limit.unwrap_or(5);
|
||||||
let include_body = req.include_body.unwrap_or(false);
|
let include_body = req.include_body.unwrap_or(false);
|
||||||
let matches = match state.get_search_index().search(&req.query, req.namespace.as_deref()) {
|
let matches = match state
|
||||||
|
.get_search_index()
|
||||||
|
.search(&req.query, req.namespace.as_deref())
|
||||||
|
{
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -403,7 +412,9 @@ impl McpTool for OmniSearchHandler {
|
|||||||
if doc_type == "entity"
|
if doc_type == "entity"
|
||||||
&& let Some(e) = full.entities.get(id)
|
&& let Some(e) = full.entities.get(id)
|
||||||
{
|
{
|
||||||
if count >= limit { continue; }
|
if count >= limit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
count += 1;
|
count += 1;
|
||||||
if !include_body {
|
if !include_body {
|
||||||
let mut summary = e.clone();
|
let mut summary = e.clone();
|
||||||
@@ -808,7 +819,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
println!("OMNI RES: {}", omni_res);
|
println!("OMNI RES: {}", omni_res);
|
||||||
assert!(omni_res.contains("omni-1"), "omni search should return results containing the task id");
|
assert!(
|
||||||
|
omni_res.contains("omni-1"),
|
||||||
|
"omni search should return results containing the task id"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -444,7 +444,10 @@ impl McpTool for UpdateMilestoneHandler {
|
|||||||
if found {
|
if found {
|
||||||
Ok("Milestone updated".to_string())
|
Ok("Milestone updated".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err("Milestone not found. Please verify the milestone ID using list_milestones.".to_string())
|
Err(
|
||||||
|
"Milestone not found. Please verify the milestone ID using list_milestones."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,10 @@ impl McpTool for DeleteSnippetHandler {
|
|||||||
drop(idx.delete_document(&req.name));
|
drop(idx.delete_document(&req.name));
|
||||||
Ok("Snippet deleted.".to_string())
|
Ok("Snippet deleted.".to_string())
|
||||||
} else {
|
} else {
|
||||||
Err("Snippet not found. Please verify the snippet ID using search_snippets.".to_string())
|
Err(
|
||||||
|
"Snippet not found. Please verify the snippet ID using search_snippets."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,7 +273,10 @@ impl McpTool for ListContextWorkspacesHandler {
|
|||||||
let req: ListContextWorkspacesTool =
|
let req: ListContextWorkspacesTool =
|
||||||
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
serde_json::from_value(args).map_err(|e| e.to_string())?;
|
||||||
let data = state.context_workspaces.read_with(|ws| {
|
let data = state.context_workspaces.read_with(|ws| {
|
||||||
let filtered: Vec<_> = ws.iter().filter(|w| req.namespace.as_ref().map_or(true, |ns| &w.namespace == ns)).collect();
|
let filtered: Vec<_> = ws
|
||||||
|
.iter()
|
||||||
|
.filter(|w| req.namespace.as_ref().is_none_or(|ns| &w.namespace == ns))
|
||||||
|
.collect();
|
||||||
serde_json::to_string(&filtered).map_err(|e| e.to_string())
|
serde_json::to_string(&filtered).map_err(|e| e.to_string())
|
||||||
})?;
|
})?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
@@ -298,7 +304,10 @@ impl McpTool for DeleteContextWorkspaceHandler {
|
|||||||
|
|
||||||
let mut found = false;
|
let mut found = false;
|
||||||
state.context_workspaces.modify(|ws| {
|
state.context_workspaces.modify(|ws| {
|
||||||
if let Some(pos) = ws.iter().position(|w| w.namespace == req.namespace && w.name == req.name) {
|
if let Some(pos) = ws
|
||||||
|
.iter()
|
||||||
|
.position(|w| w.namespace == req.namespace && w.name == req.name)
|
||||||
|
{
|
||||||
ws.remove(pos);
|
ws.remove(pos);
|
||||||
found = true;
|
found = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -24,7 +24,8 @@ pub fn tool_def<T: JsonSchema>(name: &str, fallback_desc: &str) -> serde_json::V
|
|||||||
let schema = schemars::schema_for!(T);
|
let schema = schemars::schema_for!(T);
|
||||||
let schema_val = serde_json::to_value(&schema).unwrap_or_default();
|
let schema_val = serde_json::to_value(&schema).unwrap_or_default();
|
||||||
|
|
||||||
let desc = schema_val.get("description")
|
let desc = schema_val
|
||||||
|
.get("description")
|
||||||
.and_then(|d| d.as_str())
|
.and_then(|d| d.as_str())
|
||||||
.unwrap_or(fallback_desc)
|
.unwrap_or(fallback_desc)
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
+388
-88
@@ -15,15 +15,246 @@ pub trait McpTool: Send + Sync {
|
|||||||
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String>;
|
async fn execute(&self, args: Value, state: Arc<MemoryState>) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpResource: Send + Sync {
|
||||||
|
fn uri(&self) -> &'static str;
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn mime_type(&self) -> Option<&'static str> {
|
||||||
|
Some("application/json")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait McpPrompt: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn arguments(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!([])
|
||||||
|
}
|
||||||
|
async fn get(&self, args: Value, state: Arc<MemoryState>) -> Result<serde_json::Value, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GraphEntitiesResource;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpResource for GraphEntitiesResource {
|
||||||
|
fn uri(&self) -> &'static str {
|
||||||
|
"memory://graph/entities"
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Graph Entities"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("All nodes and entities currently stored in the knowledge graph")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let graph = state_clone.graph.cache.read().unwrap();
|
||||||
|
let data: Vec<_> = graph.entities.values().collect();
|
||||||
|
serde_json::to_string_pretty(&data).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GraphRelationsResource;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpResource for GraphRelationsResource {
|
||||||
|
fn uri(&self) -> &'static str {
|
||||||
|
"memory://graph/relations"
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Graph Relations"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("All relationships between entities currently stored in the knowledge graph")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let graph = state_clone.graph.cache.read().unwrap();
|
||||||
|
let data = &graph.relations;
|
||||||
|
serde_json::to_string_pretty(&data).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TasksActiveResource;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpResource for TasksActiveResource {
|
||||||
|
fn uri(&self) -> &'static str {
|
||||||
|
"memory://tasks/active"
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Active Tasks"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("List of currently active tasks")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let tasks = state_clone.tasks.cache.read().unwrap();
|
||||||
|
let data: Vec<_> = tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.status != "completed" && t.status != "done")
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string_pretty(&data).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnalyzeTechDebtPrompt;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpPrompt for AnalyzeTechDebtPrompt {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"analyze_tech_debt"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("Analyze the project's current technical debt")
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_args: Value,
|
||||||
|
_state: Arc<MemoryState>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "Please read the current technical debt from the knowledge graph (or use the list_tech_debt tool) and provide a prioritization plan for addressing it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MemoryHandler {
|
pub struct MemoryHandler {
|
||||||
pub state: Arc<MemoryState>,
|
pub state: Arc<MemoryState>,
|
||||||
pub tools: std::collections::HashMap<String, Box<dyn McpTool>>,
|
pub tools: std::collections::HashMap<String, Box<dyn McpTool>>,
|
||||||
|
pub resources: std::collections::HashMap<String, Box<dyn McpResource>>,
|
||||||
|
pub prompts: std::collections::HashMap<String, Box<dyn McpPrompt>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryHandler {
|
impl MemoryHandler {
|
||||||
pub fn new(state: Arc<MemoryState>) -> Self {
|
pub fn new(state: Arc<MemoryState>) -> Self {
|
||||||
let mut tools: std::collections::HashMap<String, Box<dyn McpTool>> =
|
let mut tools: std::collections::HashMap<String, Box<dyn McpTool>> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
let mut resources: std::collections::HashMap<String, Box<dyn McpResource>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
let mut prompts: std::collections::HashMap<String, Box<dyn McpPrompt>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
|
||||||
|
macro_rules! register_resource {
|
||||||
|
($handler:ident) => {
|
||||||
|
let h = $handler;
|
||||||
|
resources.insert(h.uri().to_string(), Box::new(h));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! register_prompt {
|
||||||
|
($handler:ident) => {
|
||||||
|
let h = $handler;
|
||||||
|
prompts.insert(h.name().to_string(), Box::new(h));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
register_resource!(GraphEntitiesResource);
|
||||||
|
register_resource!(GraphRelationsResource);
|
||||||
|
register_resource!(TasksActiveResource);
|
||||||
|
|
||||||
|
register_prompt!(AnalyzeTechDebtPrompt);
|
||||||
|
struct PinnedFilesResource;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpResource for PinnedFilesResource {
|
||||||
|
fn uri(&self) -> &'static str {
|
||||||
|
"memory://pinned_files"
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Pinned Files"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("Currently pinned files in the workspace")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let items = state_clone.pinned_files.cache.read().unwrap();
|
||||||
|
serde_json::to_string_pretty(&*items).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MilestonesResource;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpResource for MilestonesResource {
|
||||||
|
fn uri(&self) -> &'static str {
|
||||||
|
"memory://milestones"
|
||||||
|
}
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"Milestones"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("Project milestones and their status")
|
||||||
|
}
|
||||||
|
async fn read(&self, state: Arc<MemoryState>) -> Result<String, String> {
|
||||||
|
let state_clone = Arc::clone(&state);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let items = state_clone.milestones.cache.read().unwrap();
|
||||||
|
serde_json::to_string_pretty(&*items).map_err(|e| e.to_string())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HandoffRoutinePrompt;
|
||||||
|
#[async_trait]
|
||||||
|
impl McpPrompt for HandoffRoutinePrompt {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"handoff_routine"
|
||||||
|
}
|
||||||
|
fn description(&self) -> Option<&'static str> {
|
||||||
|
Some("Initiate the end-of-session handoff and standup report generation")
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_args: Value,
|
||||||
|
_state: Arc<MemoryState>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": {
|
||||||
|
"type": "text",
|
||||||
|
"text": "I am logging off. Please invoke the DevOpsSRE subagent to generate a standup report and leave a handoff memo for the next session. Make sure to check active tasks and recent code changes."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register_resource!(PinnedFilesResource);
|
||||||
|
register_resource!(MilestonesResource);
|
||||||
|
register_prompt!(HandoffRoutinePrompt);
|
||||||
|
|
||||||
macro_rules! register {
|
macro_rules! register {
|
||||||
($module:ident::$handler:ident) => {
|
($module:ident::$handler:ident) => {
|
||||||
@@ -102,7 +333,12 @@ impl MemoryHandler {
|
|||||||
register!(workspaces::GetPrChecklistHandler);
|
register!(workspaces::GetPrChecklistHandler);
|
||||||
register!(workspaces::ClearPrChecklistHandler);
|
register!(workspaces::ClearPrChecklistHandler);
|
||||||
|
|
||||||
Self { state, tools }
|
Self {
|
||||||
|
state,
|
||||||
|
tools,
|
||||||
|
resources,
|
||||||
|
prompts,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_request(&self, req: serde_json::Value) -> Option<serde_json::Value> {
|
pub async fn handle_request(&self, req: serde_json::Value) -> Option<serde_json::Value> {
|
||||||
@@ -165,30 +401,28 @@ impl MemoryHandler {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
"resources/list" => {
|
"resources/list" => {
|
||||||
let payload = serde_json::json!({
|
let resources: Vec<_> = self
|
||||||
"resources": [
|
.resources
|
||||||
{
|
.values()
|
||||||
"uri": "memory://graph/entities",
|
.map(|r| {
|
||||||
"name": "Graph Entities",
|
let mut obj = serde_json::json!({
|
||||||
"mimeType": "application/json",
|
"uri": r.uri(),
|
||||||
"description": "All nodes and entities currently stored in the knowledge graph"
|
"name": r.name(),
|
||||||
},
|
});
|
||||||
{
|
if let Some(desc) = r.description() {
|
||||||
"uri": "memory://graph/relations",
|
obj["description"] = serde_json::json!(desc);
|
||||||
"name": "Graph Relations",
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"description": "All edge relationships between entities in the knowledge graph"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"uri": "memory://tasks/active",
|
|
||||||
"name": "Active Tasks",
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"description": "All currently active or uncompleted tracking tasks"
|
|
||||||
}
|
}
|
||||||
]
|
if let Some(mime) = r.mime_type() {
|
||||||
});
|
obj["mimeType"] = serde_json::json!(mime);
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let payload = serde_json::json!({ "resources": resources });
|
||||||
Some(crate::mcp::success(id, payload))
|
Some(crate::mcp::success(id, payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
"resources/templates/list" => {
|
"resources/templates/list" => {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"resourceTemplates": []
|
"resourceTemplates": []
|
||||||
@@ -197,83 +431,65 @@ impl MemoryHandler {
|
|||||||
}
|
}
|
||||||
"resources/read" => {
|
"resources/read" => {
|
||||||
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
let uri = params
|
let uri = params.get("uri").and_then(|u| u.as_str()).unwrap_or("");
|
||||||
.get("uri")
|
|
||||||
.and_then(|u| u.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let state_clone = Arc::clone(&self.state);
|
if let Some(resource) = self.resources.get(uri) {
|
||||||
let uri_clone = uri.clone();
|
match resource.read(Arc::clone(&self.state)).await {
|
||||||
let text = match tokio::task::spawn_blocking(move || match uri_clone.as_str() {
|
Ok(text) => {
|
||||||
"memory://graph/entities" => {
|
let payload = serde_json::json!({
|
||||||
let graph = state_clone.graph.cache.read().unwrap();
|
"contents": [{
|
||||||
let data: Vec<_> = graph.entities.values().collect();
|
"uri": uri,
|
||||||
Some(serde_json::to_string_pretty(&data).unwrap_or_default())
|
"mimeType": resource.mime_type().unwrap_or("application/json"),
|
||||||
}
|
"text": text
|
||||||
"memory://graph/relations" => {
|
}]
|
||||||
let graph = state_clone.graph.cache.read().unwrap();
|
});
|
||||||
let data = &graph.relations;
|
Some(crate::mcp::success(id, payload))
|
||||||
Some(serde_json::to_string_pretty(&data).unwrap_or_default())
|
|
||||||
}
|
|
||||||
"memory://tasks/active" => {
|
|
||||||
let tasks = state_clone.tasks.cache.read().unwrap();
|
|
||||||
let data: Vec<_> = tasks
|
|
||||||
.iter()
|
|
||||||
.filter(|t| t.status != "completed" && t.status != "done")
|
|
||||||
.collect();
|
|
||||||
Some(serde_json::to_string_pretty(&data).unwrap_or_default())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(text)) => text,
|
|
||||||
_ => return Some(crate::mcp::error(id, -32602, "Resource not found")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"contents": [{
|
|
||||||
"uri": uri,
|
|
||||||
"mimeType": "application/json",
|
|
||||||
"text": text
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
Some(crate::mcp::success(id, payload))
|
|
||||||
}
|
|
||||||
"prompts/list" => {
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"prompts": [
|
|
||||||
{
|
|
||||||
"name": "analyze_tech_debt",
|
|
||||||
"description": "Analyze the project's current technical debt",
|
|
||||||
"arguments": []
|
|
||||||
}
|
}
|
||||||
]
|
Err(e) => Some(crate::mcp::error(id, -32603, &e)),
|
||||||
});
|
}
|
||||||
|
} else {
|
||||||
|
Some(crate::mcp::error(id, -32602, "Resource not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"prompts/list" => {
|
||||||
|
let prompts: Vec<_> = self
|
||||||
|
.prompts
|
||||||
|
.values()
|
||||||
|
.map(|p| {
|
||||||
|
let mut obj = serde_json::json!({
|
||||||
|
"name": p.name(),
|
||||||
|
"arguments": p.arguments(),
|
||||||
|
});
|
||||||
|
if let Some(desc) = p.description() {
|
||||||
|
obj["description"] = serde_json::json!(desc);
|
||||||
|
}
|
||||||
|
obj
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let payload = serde_json::json!({ "prompts": prompts });
|
||||||
Some(crate::mcp::success(id, payload))
|
Some(crate::mcp::success(id, payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
"prompts/get" => {
|
"prompts/get" => {
|
||||||
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
|
let args = params
|
||||||
|
.get("arguments")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!({}));
|
||||||
|
|
||||||
if name == "analyze_tech_debt" {
|
if let Some(prompt) = self.prompts.get(name) {
|
||||||
let payload = serde_json::json!({
|
match prompt.get(args, Arc::clone(&self.state)).await {
|
||||||
"messages": [
|
Ok(messages) => Some(crate::mcp::success(id, messages)),
|
||||||
{
|
Err(e) => Some(crate::mcp::error(id, -32603, &e)),
|
||||||
"role": "user",
|
}
|
||||||
"content": {
|
|
||||||
"type": "text",
|
|
||||||
"text": "Please read the current technical debt from the knowledge graph (or use the list_tech_debt tool) and provide a prioritization plan for addressing it."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
Some(crate::mcp::success(id, payload))
|
|
||||||
} else {
|
} else {
|
||||||
Some(crate::mcp::error(id, -32602, "Prompt not found"))
|
Some(crate::mcp::error(id, -32602, "Prompt not found"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"tools/call" => {
|
"tools/call" => {
|
||||||
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
let params = req.get("params").unwrap_or(&serde_json::Value::Null);
|
||||||
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
let name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||||
@@ -356,6 +572,85 @@ mod tests {
|
|||||||
assert!(res_list["result"]["tools"].as_array().unwrap().len() > 10);
|
assert!(res_list["result"]["tools"].as_array().unwrap().len() > 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resources_and_prompts_endpoints() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
|
||||||
|
let handler = MemoryHandler::new(state);
|
||||||
|
|
||||||
|
// Test resources/list
|
||||||
|
let req_list_res = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 10,
|
||||||
|
"method": "resources/list",
|
||||||
|
"params": {}
|
||||||
|
});
|
||||||
|
let res_list = handler.handle_request(req_list_res).await.unwrap();
|
||||||
|
let resources_arr = res_list["result"]["resources"].as_array().unwrap();
|
||||||
|
assert!(
|
||||||
|
resources_arr
|
||||||
|
.iter()
|
||||||
|
.any(|r| r["uri"] == "memory://tasks/active")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
resources_arr
|
||||||
|
.iter()
|
||||||
|
.any(|r| r["uri"] == "memory://pinned_files")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test resources/read
|
||||||
|
let req_read_res = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 11,
|
||||||
|
"method": "resources/read",
|
||||||
|
"params": {
|
||||||
|
"uri": "memory://tasks/active"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let res_read = handler.handle_request(req_read_res).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
res_read["result"]["contents"][0]["uri"],
|
||||||
|
"memory://tasks/active"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
res_read["result"]["contents"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("[]")
|
||||||
|
); // Empty tasks
|
||||||
|
|
||||||
|
// Test prompts/list
|
||||||
|
let req_list_prompts = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 12,
|
||||||
|
"method": "prompts/list",
|
||||||
|
"params": {}
|
||||||
|
});
|
||||||
|
let res_prompts = handler.handle_request(req_list_prompts).await.unwrap();
|
||||||
|
let prompts_arr = res_prompts["result"]["prompts"].as_array().unwrap();
|
||||||
|
assert!(prompts_arr.iter().any(|p| p["name"] == "handoff_routine"));
|
||||||
|
|
||||||
|
// Test prompts/get
|
||||||
|
let req_get_prompt = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 13,
|
||||||
|
"method": "prompts/get",
|
||||||
|
"params": {
|
||||||
|
"name": "handoff_routine",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let res_get = handler.handle_request(req_get_prompt).await.unwrap();
|
||||||
|
let messages = res_get["result"]["messages"].as_array().unwrap();
|
||||||
|
assert_eq!(messages[0]["role"], "user");
|
||||||
|
assert!(
|
||||||
|
messages[0]["content"]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("standup report")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_tool_call_success_and_error_responses() {
|
async fn test_tool_call_success_and_error_responses() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -397,7 +692,12 @@ mod tests {
|
|||||||
assert_eq!(res_fail["id"], 3);
|
assert_eq!(res_fail["id"], 3);
|
||||||
// Semantic failures must explicitly return isError: true inside the result to halt the LLM
|
// Semantic failures must explicitly return isError: true inside the result to halt the LLM
|
||||||
assert_eq!(res_fail["result"]["isError"], true);
|
assert_eq!(res_fail["result"]["isError"], true);
|
||||||
assert!(res_fail["result"]["content"][0]["text"].as_str().unwrap().contains("not found"));
|
assert!(
|
||||||
|
res_fail["result"]["content"][0]["text"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("not found")
|
||||||
|
);
|
||||||
|
|
||||||
// 3. Test unknown JSON-RPC method returns JSON-RPC protocol error
|
// 3. Test unknown JSON-RPC method returns JSON-RPC protocol error
|
||||||
let req_unknown = json!({
|
let req_unknown = json!({
|
||||||
|
|||||||
+10
-5
@@ -244,14 +244,16 @@ impl MemoryIndex {
|
|||||||
self.type_field => "entity",
|
self.type_field => "entity",
|
||||||
self.namespace_field => e.namespace.as_str()
|
self.namespace_field => e.namespace.as_str()
|
||||||
));
|
));
|
||||||
self.needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_all(&self) {
|
pub fn delete_all(&self) {
|
||||||
if let Ok(writer) = self.writer.lock() {
|
if let Ok(writer) = self.writer.lock() {
|
||||||
let _ = writer.delete_all_documents();
|
let _ = writer.delete_all_documents();
|
||||||
self.needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +268,8 @@ impl MemoryIndex {
|
|||||||
self.namespace_field => "global"
|
self.namespace_field => "global"
|
||||||
));
|
));
|
||||||
println!("Writer add_document returned id/result");
|
println!("Writer add_document returned id/result");
|
||||||
self.needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
println!("Needs_commit set to true in add_task_sync");
|
println!("Needs_commit set to true in add_task_sync");
|
||||||
} else {
|
} else {
|
||||||
println!("Failed to acquire writer lock in add_task_sync");
|
println!("Failed to acquire writer lock in add_task_sync");
|
||||||
@@ -282,7 +285,8 @@ impl MemoryIndex {
|
|||||||
self.type_field => "snippet",
|
self.type_field => "snippet",
|
||||||
self.namespace_field => "global"
|
self.namespace_field => "global"
|
||||||
));
|
));
|
||||||
self.needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +299,8 @@ impl MemoryIndex {
|
|||||||
self.type_field => "adr",
|
self.type_field => "adr",
|
||||||
self.namespace_field => "global"
|
self.namespace_field => "global"
|
||||||
));
|
));
|
||||||
self.needs_commit.store(true, std::sync::atomic::Ordering::SeqCst);
|
self.needs_commit
|
||||||
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -84,7 +84,6 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn broadcast_activity(&self, message: &str) {
|
pub fn broadcast_activity(&self, message: &str) {
|
||||||
let time = std::time::SystemTime::now()
|
let time = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -124,19 +123,28 @@ impl MemoryState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_search_index(&self) -> MemoryIndex {
|
pub fn get_search_index(&self) -> MemoryIndex {
|
||||||
self.search_index.read().unwrap_or_else(|e| e.into_inner()).clone()
|
self.search_index
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn rebuild_index(self: &Arc<Self>) {
|
pub async fn rebuild_index(self: &Arc<Self>) {
|
||||||
let idx = self.search_index.read().unwrap().clone();
|
let idx = self.search_index.read().unwrap().clone();
|
||||||
idx.delete_all();
|
idx.delete_all();
|
||||||
|
|
||||||
let entities: Vec<_> = self.graph.read_with(|g| g.entities.values().cloned().collect());
|
let entities: Vec<_> = self
|
||||||
|
.graph
|
||||||
|
.read_with(|g| g.entities.values().cloned().collect());
|
||||||
let tasks = self.tasks.read_with(|t| t.clone());
|
let tasks = self.tasks.read_with(|t| t.clone());
|
||||||
let snippets = self.snippets.read_with(|s| s.clone());
|
let snippets = self.snippets.read_with(|s| s.clone());
|
||||||
let adrs = self.adrs.read_with(|a| a.clone());
|
let adrs = self.adrs.read_with(|a| a.clone());
|
||||||
|
|
||||||
println!("rebuild_index: found {} entities, {} tasks", entities.len(), tasks.len());
|
println!(
|
||||||
|
"rebuild_index: found {} entities, {} tasks",
|
||||||
|
entities.len(),
|
||||||
|
tasks.len()
|
||||||
|
);
|
||||||
|
|
||||||
let idx_clone = idx.clone();
|
let idx_clone = idx.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
@@ -209,7 +217,10 @@ mod tests {
|
|||||||
let idx = arc_state.search_index.read().unwrap();
|
let idx = arc_state.search_index.read().unwrap();
|
||||||
// Force reload reader to ensure it sees the commit made by rebuild_index
|
// Force reload reader to ensure it sees the commit made by rebuild_index
|
||||||
idx.reader.reload().unwrap();
|
idx.reader.reload().unwrap();
|
||||||
println!("Index reader doc count: {}", idx.reader.searcher().num_docs());
|
println!(
|
||||||
|
"Index reader doc count: {}",
|
||||||
|
idx.reader.searcher().num_docs()
|
||||||
|
);
|
||||||
|
|
||||||
let all_docs = idx.search("Test", None).expect("Search failed");
|
let all_docs = idx.search("Test", None).expect("Search failed");
|
||||||
println!("All docs for 'Test': {:?}", all_docs);
|
println!("All docs for 'Test': {:?}", all_docs);
|
||||||
@@ -217,7 +228,10 @@ mod tests {
|
|||||||
// Verify the task added synchronously is actually searchable
|
// Verify the task added synchronously is actually searchable
|
||||||
let results = idx.search("Test", None).expect("Search failed");
|
let results = idx.search("Test", None).expect("Search failed");
|
||||||
assert_eq!(results.len(), 1, "Expected exactly 1 search result");
|
assert_eq!(results.len(), 1, "Expected exactly 1 search result");
|
||||||
assert_eq!(results[0].0, "123", "Expected the result to be the task we just added");
|
assert_eq!(
|
||||||
|
results[0].0, "123",
|
||||||
|
"Expected the result to be the task we just added"
|
||||||
|
);
|
||||||
assert_eq!(results[0].1, "task", "Expected document type to be task");
|
assert_eq!(results[0].1, "task", "Expected document type to be task");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+3
-1
@@ -28,7 +28,9 @@ impl<T: DeserializeOwned + Default + Serialize + Clone + Send + Sync + 'static>
|
|||||||
let key_inner = key_clone.clone();
|
let key_inner = key_clone.clone();
|
||||||
let json_data = {
|
let json_data = {
|
||||||
let lock = cache_clone.read().unwrap_or_else(|e| e.into_inner());
|
let lock = cache_clone.read().unwrap_or_else(|e| e.into_inner());
|
||||||
serde_json::to_vec(&*lock).ok()
|
serde_json::to_vec(&*lock)
|
||||||
|
.map_err(|e| tracing::error!("Failed to serialize memory store: {}", e))
|
||||||
|
.ok()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(json_data) = json_data {
|
if let Some(json_data) = json_data {
|
||||||
|
|||||||
+16
-4
@@ -613,15 +613,27 @@ mod tests {
|
|||||||
let schema = schema_for!(SetAcceptanceCriteriaTool);
|
let schema = schema_for!(SetAcceptanceCriteriaTool);
|
||||||
let schema_json = serde_json::to_value(&schema).unwrap();
|
let schema_json = serde_json::to_value(&schema).unwrap();
|
||||||
|
|
||||||
let desc = schema_json.get("description").and_then(|d| d.as_str()).unwrap_or("");
|
let desc = schema_json
|
||||||
assert!(desc.contains("Define a strict checklist of acceptance criteria"), "Schema should include struct docstring as description");
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
desc.contains("Define a strict checklist of acceptance criteria"),
|
||||||
|
"Schema should include struct docstring as description"
|
||||||
|
);
|
||||||
|
|
||||||
let schema2 = schema_for!(LogCodeChangeTool);
|
let schema2 = schema_for!(LogCodeChangeTool);
|
||||||
let schema2_json = serde_json::to_value(&schema2).unwrap();
|
let schema2_json = serde_json::to_value(&schema2).unwrap();
|
||||||
let props = schema2_json.get("properties").expect("Missing properties");
|
let props = schema2_json.get("properties").expect("Missing properties");
|
||||||
|
|
||||||
let file_path_prop = props.get("file_path").expect("Missing file_path property");
|
let file_path_prop = props.get("file_path").expect("Missing file_path property");
|
||||||
let field_desc = file_path_prop.get("description").and_then(|d| d.as_str()).unwrap_or("");
|
let field_desc = file_path_prop
|
||||||
assert!(field_desc.contains("The path of the file that was changed"), "Schema should include field docstring as description");
|
.get("description")
|
||||||
|
.and_then(|d| d.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
assert!(
|
||||||
|
field_desc.contains("The path of the file that was changed"),
|
||||||
|
"Schema should include field docstring as description"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::io::{BufRead, BufReader, Write, Read};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
|
fn send_message(stdin: &mut std::process::ChildStdin, msg: Value) {
|
||||||
@@ -161,7 +161,11 @@ fn test_mcp_initialization_and_tools_list() {
|
|||||||
|
|
||||||
send_message(&mut stdin, call_req);
|
send_message(&mut stdin, call_req);
|
||||||
let call_resp = read_message(&mut stdout).expect("Failed to read tools/call response");
|
let call_resp = read_message(&mut stdout).expect("Failed to read tools/call response");
|
||||||
println!("Response for {}: {}", tool_name, serde_json::to_string(&call_resp).unwrap());
|
println!(
|
||||||
|
"Response for {}: {}",
|
||||||
|
tool_name,
|
||||||
|
serde_json::to_string(&call_resp).unwrap()
|
||||||
|
);
|
||||||
assert_eq!(call_resp["jsonrpc"], "2.0");
|
assert_eq!(call_resp["jsonrpc"], "2.0");
|
||||||
assert_eq!(call_resp["id"], req_id);
|
assert_eq!(call_resp["id"], req_id);
|
||||||
}
|
}
|
||||||
@@ -202,5 +206,9 @@ fn test_mcp_initialization_and_tools_list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let status = child.wait().expect("Failed to wait on child");
|
let status = child.wait().expect("Failed to wait on child");
|
||||||
assert!(status.success(), "Child process did not exit successfully. Stderr: {}", stderr_output);
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"Child process did not exit successfully. Stderr: {}",
|
||||||
|
stderr_output
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user