Menu
Public repository / 4a9bb7ff66e2a742e69339756a58a367d5bf6183
AkurAI-PassVault / crates/passvault-mcp/src/tools.rs
Latest change 98eaefbfeac4a34b8323a05191f4e6be0fdef059 - AKURAI-PASSVAULT-3 clear folder on explicit null by Ólafur Búi Ólafsson
use akurai_json::Value;
use akurai_storage::BTree;
use passvault_core::crypto::Crypto;
use passvault_core::entities::{normalize_entry_name, Folder, VaultEntry};
use passvault_core::types::Timestamp;
use std::sync::{Arc, Mutex};
fn upper_bound(prefix: &[u8]) -> Vec<u8> {
let mut bound = prefix.to_vec();
bound.push(0xff);
bound
}
fn now() -> Timestamp {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub struct ToolRegistry {
db: Arc<Mutex<BTree>>,
crypto: Arc<Crypto>,
}
impl ToolRegistry {
pub fn new(db: Arc<Mutex<BTree>>, crypto: Arc<Crypto>) -> Self {
ToolRegistry { db, crypto }
}
fn json_rpc_result(&self, id: Option<Value>, result: Value) -> String {
let mut resp = Vec::new();
resp.push(("jsonrpc".into(), Value::Str("2.0".into())));
resp.push(("result".into(), result));
if let Some(id_val) = id {
resp.push(("id".into(), id_val));
}
Value::Object(resp).to_json()
}
fn json_rpc_error(&self, id: Option<Value>, code: i64, message: &str) -> String {
let mut resp = Vec::new();
resp.push(("jsonrpc".into(), Value::Str("2.0".into())));
resp.push((
"error".into(),
Value::Object(vec![
("code".into(), Value::Int(code)),
("message".into(), Value::Str(message.into())),
]),
));
if let Some(id_val) = id {
resp.push(("id".into(), id_val));
}
Value::Object(resp).to_json()
}
pub fn handle_request(&self, body: &str) -> String {
let req = match akurai_json::parse(body) {
Ok(v) => v,
Err(e) => return self.json_rpc_error(None, -32700, &format!("Parse error: {e}")),
};
let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("");
let id = req.get("id").cloned();
// JSON-RPC notifications carry no `id` and MUST NOT receive a response body.
// Return an empty string; the HTTP layer turns this into `202 Accepted` with no
// body (per the MCP Streamable-HTTP spec). Strict clients (codex/rmcp) error out
// if a notification gets a JSON-RPC response.
if id.is_none() || method.starts_with("notifications/") {
return String::new();
}
match method {
"initialize" => self.json_rpc_result(
id,
Value::Object(vec![
("protocolVersion".into(), Value::Str("2024-11-05".into())),
(
"capabilities".into(),
Value::Object(vec![("tools".into(), Value::Object(vec![]))]),
),
(
"serverInfo".into(),
Value::Object(vec![
("name".into(), Value::Str("akurai-passvault".into())),
("version".into(), Value::Str("0.0.1".into())),
]),
),
]),
),
"tools/list" => {
let tools = self.list_tools();
self.json_rpc_result(
id,
Value::Object(vec![("tools".into(), Value::Array(tools))]),
)
}
"tools/get" => {
let params = match req.get("params") {
Some(Value::Object(pairs)) => pairs.clone(),
_ => vec![],
};
let tool_name = params
.iter()
.find(|(k, _)| k == "name")
.and_then(|(_, v)| v.as_str())
.unwrap_or("");
let tools = self.list_tools();
let tool = tools
.into_iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some(tool_name));
match tool {
Some(t) => self.json_rpc_result(id, Value::Object(vec![("tool".into(), t)])),
None => self.json_rpc_error(id, -32602, &format!("Unknown tool: {tool_name}")),
}
}
"tools/call" => {
let params = match req.get("params") {
Some(Value::Object(pairs)) => pairs.clone(),
_ => vec![],
};
let tool_name = params
.iter()
.find(|(k, _)| k == "name")
.and_then(|(_, v)| v.as_str())
.unwrap_or("");
let arguments: Vec<(String, Value)> = params
.iter()
.find(|(k, _)| k == "arguments")
.and_then(|(_, v)| match v {
Value::Object(pairs) => Some(pairs.clone()),
_ => None,
})
.unwrap_or_default();
match self.execute(tool_name, &arguments) {
Ok(result) => self.json_rpc_result(
id,
Value::Object(vec![(
"content".into(),
Value::Array(vec![Value::Object(vec![
("type".into(), Value::Str("text".into())),
("text".into(), Value::Str(result.to_json())),
])]),
)]),
),
Err(e) => self.json_rpc_error(id, -32603, &format!("Tool error: {e}")),
}
}
_ => self.json_rpc_error(id, -32601, &format!("Method not found: {method}")),
}
}
pub fn list_tools(&self) -> Vec<Value> {
vec![
self.tool_def(
"list_entries",
"List all vault entries (name, uri, username)",
vec![],
),
self.tool_def(
"get_entry",
"Get a single entry by ID with all fields",
vec![("id", "number", "Entry ID", true)],
),
self.tool_def(
"create_entry",
"Create a new vault entry",
vec![
("name", "string", "Entry name", true),
("username", "string", "Username", false),
("password", "string", "Password", false),
("uri", "string", "URI/URL", false),
("notes", "string", "Notes", false),
("folderId", "number", "Folder ID", false),
],
),
self.tool_def(
"update_entry",
"Update an existing entry",
vec![
("id", "number", "Entry ID", true),
("name", "string", "Entry name", false),
("username", "string", "Username", false),
("password", "string", "Password", false),
("uri", "string", "URI/URL", false),
("notes", "string", "Notes", false),
("folderId", "number", "Folder ID", false),
],
),
self.tool_def(
"delete_entry",
"Delete a vault entry",
vec![("id", "number", "Entry ID", true)],
),
self.tool_def(
"generate_password",
"Generate a random password",
vec![
("length", "number", "Password length (default 24)", false),
(
"symbols",
"boolean",
"Include symbols (default true)",
false,
),
],
),
self.tool_def(
"search_entries",
"Search entries by name, URI, or username",
vec![("query", "string", "Search query", true)],
),
self.tool_def("list_folders", "List all folders", vec![]),
self.tool_def(
"create_folder",
"Create a new folder",
vec![("name", "string", "Folder name", true)],
),
self.tool_def(
"delete_folder",
"Delete a folder",
vec![("id", "number", "Folder ID", true)],
),
self.tool_def(
"system_status",
"Report PassVault storage health and record counts",
vec![],
),
]
}
fn tool_def(
&self,
name: &str,
description: &str,
params: Vec<(&str, &str, &str, bool)>,
) -> Value {
let mut properties = Vec::new();
let mut required = Vec::new();
for (pname, ptype, pdesc, is_required) in params {
properties.push((
pname.to_string(),
Value::Object(vec![
("type".into(), Value::Str(ptype.into())),
("description".into(), Value::Str(pdesc.into())),
]),
));
if is_required {
required.push(Value::Str(pname.to_string()));
}
}
Value::Object(vec![
("name".into(), Value::Str(name.into())),
("description".into(), Value::Str(description.into())),
(
"inputSchema".into(),
Value::Object(vec![
("type".into(), Value::Str("object".into())),
("properties".into(), Value::Object(properties)),
("required".into(), Value::Array(required)),
]),
),
])
}
pub fn execute(&self, tool_name: &str, args: &[(String, Value)]) -> Result<Value, String> {
match tool_name {
"list_entries" => self.list_entries(),
"get_entry" => self.get_entry(args),
"create_entry" => self.create_entry(args),
"update_entry" => self.update_entry(args),
"delete_entry" => self.delete_entry(args),
"generate_password" => self.generate_password(args),
"search_entries" => self.search_entries(args),
"list_folders" => self.list_folders(),
"create_folder" => self.create_folder(args),
"delete_folder" => self.delete_folder(args),
"system_status" => self.system_status(),
_ => Err(format!("unknown tool: {tool_name}")),
}
}
fn system_status(&self) -> Result<Value, String> {
let entries = self.scan_entries()?.len() as i64;
let folders = match self.list_folders()? {
Value::Array(values) => values.len() as i64,
_ => 0,
};
Ok(Value::Object(vec![
("status".into(), Value::Str("ok".into())),
("entries".into(), Value::Int(entries)),
("folders".into(), Value::Int(folders)),
]))
}
fn scan_entries(&self) -> Result<Vec<(i64, VaultEntry)>, String> {
let prefix = b"vault:entry:";
let start = prefix.to_vec();
let end = upper_bound(prefix);
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let entries = db.range(&start, &end).map_err(|e| format!("range: {e}"))?;
let mut results = Vec::new();
for (key, val) in entries {
let key_str = String::from_utf8_lossy(&key).to_string();
if key_str.contains("counter") {
continue;
}
let id = key_str
.strip_prefix("vault:entry:")
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(0);
let decrypted = self
.crypto
.decrypt(&val)
.map_err(|e| format!("decrypt: {e}"))?;
if let Ok(v) = akurai_json::parse(&String::from_utf8_lossy(&decrypted)) {
if let Ok(entry) = VaultEntry::from_value(&v) {
results.push((id, entry));
}
}
}
Ok(results)
}
fn get_entry_raw(&self, id: i64) -> Result<VaultEntry, String> {
let key = format!("vault:entry:{id}");
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let bytes = db
.get(key.as_bytes())
.map_err(|e| format!("storage: {e}"))?
.ok_or_else(|| "entry not found".to_string())?;
let decrypted = self
.crypto
.decrypt(&bytes)
.map_err(|e| format!("decrypt: {e}"))?;
let v = akurai_json::parse(&String::from_utf8_lossy(&decrypted))
.map_err(|e| format!("json: {e}"))?;
VaultEntry::from_value(&v).map_err(|e| format!("parse: {e}"))
}
fn list_entries(&self) -> Result<Value, String> {
let entries = self.scan_entries()?;
Ok(Value::Array(
entries
.into_iter()
.map(|(_, e)| e.summary_value())
.collect(),
))
}
fn get_entry(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = get_i64(args, "id").ok_or("missing id")?;
let entry = self.get_entry_raw(id)?;
Ok(entry.to_value())
}
fn create_entry(&self, args: &[(String, Value)]) -> Result<Value, String> {
let name = normalize_entry_name(&get_str(args, "name").ok_or("name is required")?)
.map_err(|e| e.to_string())?;
let username = get_str(args, "username").unwrap_or_default();
let password = get_str(args, "password").unwrap_or_default();
let uri = get_str(args, "uri").unwrap_or_default();
let notes = get_str(args, "notes").unwrap_or_default();
let folder_id = get_i64(args, "folderId");
let ts = now();
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let next_id = next_counter(&mut db, "vault:entry:counter");
let entry = VaultEntry {
id: next_id,
name,
username,
password,
uri,
notes,
folder_id,
created_at: ts,
updated_at: ts,
};
let encrypted = self
.crypto
.encrypt(entry.to_value().to_json().as_bytes())
.map_err(|e| format!("encrypt: {e}"))?;
db.insert(format!("vault:entry:{next_id}").as_bytes(), &encrypted)
.map_err(|e| format!("insert: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
Ok(entry.to_value())
}
fn update_entry(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = get_i64(args, "id").ok_or("missing id")?;
let mut entry = self.get_entry_raw(id)?;
if let Some(v) = get_str(args, "name") {
entry.name = normalize_entry_name(&v).map_err(|e| e.to_string())?;
}
if let Some(v) = get_str(args, "username") {
entry.username = v;
}
if let Some(v) = get_str(args, "password") {
entry.password = v;
}
if let Some(v) = get_str(args, "uri") {
entry.uri = v;
}
if let Some(v) = get_str(args, "notes") {
entry.notes = v;
}
if let Some(value) = get_arg(args, "folderId") {
entry.folder_id = value.as_i64();
}
entry.updated_at = now();
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let encrypted = self
.crypto
.encrypt(entry.to_value().to_json().as_bytes())
.map_err(|e| format!("encrypt: {e}"))?;
db.insert(format!("vault:entry:{id}").as_bytes(), &encrypted)
.map_err(|e| format!("insert: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
Ok(entry.to_value())
}
fn delete_entry(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = get_i64(args, "id").ok_or("missing id")?;
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
db.delete(format!("vault:entry:{id}").as_bytes())
.map_err(|e| format!("delete: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
Ok(Value::Object(vec![("deleted".into(), Value::Bool(true))]))
}
fn generate_password(&self, args: &[(String, Value)]) -> Result<Value, String> {
let length = get_i64(args, "length").unwrap_or(24).clamp(8, 128) as usize;
let symbols = get_bool(args, "symbols").unwrap_or(true);
let mut charset = Vec::new();
charset.extend(b'a'..=b'z');
charset.extend(b'A'..=b'Z');
charset.extend(b'0'..=b'9');
if symbols {
charset.extend(b"!@#$%^&*()_+-=[]{}|;:,.<>?");
}
use rand::Rng;
let password: String = (0..length)
.map(|_| charset[rand::thread_rng().gen_range(0..charset.len())] as char)
.collect();
Ok(Value::Object(vec![(
"password".into(),
Value::Str(password),
)]))
}
fn search_entries(&self, args: &[(String, Value)]) -> Result<Value, String> {
let query = get_str(args, "query")
.ok_or("missing query")?
.to_lowercase();
let entries = self.scan_entries()?;
let results: Vec<Value> = entries
.into_iter()
.filter(|(_, e)| {
e.name.to_lowercase().contains(&query)
|| e.uri.to_lowercase().contains(&query)
|| e.username.to_lowercase().contains(&query)
|| e.notes.to_lowercase().contains(&query)
})
.map(|(_, e)| e.summary_value())
.collect();
Ok(Value::Array(results))
}
fn list_folders(&self) -> Result<Value, String> {
let prefix = b"vault:folder:";
let start = prefix.to_vec();
let end = upper_bound(prefix);
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let entries = db.range(&start, &end).map_err(|e| format!("range: {e}"))?;
let folders: Vec<Value> = entries
.into_iter()
.filter_map(|(_, val)| {
let v = akurai_json::parse(&String::from_utf8_lossy(&val)).ok()?;
Folder::from_value(&v).ok().map(|f| f.to_value())
})
.collect();
Ok(Value::Array(folders))
}
fn create_folder(&self, args: &[(String, Value)]) -> Result<Value, String> {
let name = get_str(args, "name").ok_or("name is required")?;
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let next_id = next_counter(&mut db, "vault:folder:counter");
let folder = Folder {
id: next_id,
name,
created_at: now(),
};
db.insert(
format!("vault:folder:{next_id}").as_bytes(),
folder.to_value().to_json().as_bytes(),
)
.map_err(|e| format!("insert: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
Ok(folder.to_value())
}
fn delete_folder(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = get_i64(args, "id").ok_or("missing id")?;
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
db.delete(format!("vault:folder:{id}").as_bytes())
.map_err(|e| format!("delete: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
Ok(Value::Object(vec![("deleted".into(), Value::Bool(true))]))
}
}
fn get_arg<'a>(args: &'a [(String, Value)], name: &str) -> Option<&'a Value> {
args.iter().find(|(k, _)| k == name).map(|(_, v)| v)
}
fn get_str(args: &[(String, Value)], name: &str) -> Option<String> {
get_arg(args, name).and_then(|v| v.as_str().map(String::from))
}
fn get_i64(args: &[(String, Value)], name: &str) -> Option<i64> {
get_arg(args, name).and_then(|v| v.as_i64())
}
fn get_bool(args: &[(String, Value)], name: &str) -> Option<bool> {
get_arg(args, name).and_then(|v| v.as_bool())
}
fn next_counter(db: &mut BTree, key: &str) -> i64 {
let next = db
.get(key.as_bytes())
.ok()
.flatten()
.and_then(|v| std::str::from_utf8(&v).ok()?.parse::<i64>().ok())
.unwrap_or(0)
+ 1;
let _ = db.insert(key.as_bytes(), next.to_string().as_bytes());
next
}