Menu
Commit b78e3a52
b78e3a5270c5c5e1b0b04f1da8da4899aa5e4e22 by Claude Code
- crates/notes-cli/src/cmd_mcp.rs +32 -3
- crates/notes-cli/src/main.rs +57 -2
- crates/notes-mcp/src/server.rs +79 -1
- crates/notes-mcp/src/tools.rs +584 -1
- crates/notes-mcp/tests/mcp_test.rs +147 -0
diff --git a/crates/notes-cli/src/cmd_mcp.rs b/crates/notes-cli/src/cmd_mcp.rs
index bff35ab..bdcc076 100644
--- a/crates/notes-cli/src/cmd_mcp.rs
+++ b/crates/notes-cli/src/cmd_mcp.rs
@@ -1,4 +1,33 @@
-pub fn run(_args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
- println!("akurai-notes mcp — starting (stub, E06 will implement)");
- Ok(())
+use notes_mcp::server::{McpConfig, McpServer};
+
+pub struct McpRunConfig {
+ pub host: String,
+ pub port: u16,
+ pub db: String,
+ pub token: String,
+ pub embed_url: String,
+ pub embed_model: String,
+ pub owner_sub: String,
+}
+
+pub fn run(cfg: McpRunConfig) -> Result<(), Box<dyn std::error::Error>> {
+ let mcp_cfg = McpConfig {
+ host: cfg.host,
+ port: cfg.port,
+ db: cfg.db,
+ token: cfg.token,
+ embed_url: if cfg.embed_url.is_empty() {
+ None
+ } else {
+ Some(cfg.embed_url)
+ },
+ embed_model: if cfg.embed_model.is_empty() {
+ None
+ } else {
+ Some(cfg.embed_model)
+ },
+ owner_sub: cfg.owner_sub,
+ };
+ let server = McpServer::new(mcp_cfg)?;
+ server.serve().map_err(|e| e.into())
}
diff --git a/crates/notes-cli/src/main.rs b/crates/notes-cli/src/main.rs
index e15c0d7..dd87ccf 100644
--- a/crates/notes-cli/src/main.rs
+++ b/crates/notes-cli/src/main.rs
@@ -27,7 +27,8 @@ fn run(args: &[String]) -> Result<(), String> {
cmd_serve::run(cfg).map_err(|e| e.to_string())
}
Some("mcp") => {
- cmd_mcp::run(&args[1..]).map_err(|e| e.to_string())
+ let cfg = parse_mcp(&args[1..])?;
+ cmd_mcp::run(cfg).map_err(|e| e.to_string())
}
Some("seed") => {
let db_path = args.get(1).map(|s| s.as_str()).unwrap_or("notes.db");
@@ -75,6 +76,52 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
Ok(cmd_serve::Config { host, port, dir, db })
}
+fn parse_mcp(args: &[String]) -> Result<cmd_mcp::McpRunConfig, String> {
+ let mut host = "127.0.0.1".to_string();
+ let mut port: u16 = 8100;
+ let mut db = "/var/lib/akurai-notes/notes.db".to_string();
+ let mut token = String::new();
+ let mut embed_url = String::new();
+ let mut embed_model = String::new();
+ let mut owner_sub = String::new();
+
+ let mut i = 0;
+ while i < args.len() {
+ match args[i].as_str() {
+ "--host" => host = next(args, &mut i, "--host")?,
+ "--port" | "-p" => {
+ port = next(args, &mut i, "--port")?
+ .parse()
+ .map_err(|_| "invalid --port".to_string())?;
+ }
+ "--db" => db = next(args, &mut i, "--db")?,
+ "--token" => token = next(args, &mut i, "--token")?,
+ "--embed-url" => embed_url = next(args, &mut i, "--embed-url")?,
+ "--embed-model" => embed_model = next(args, &mut i, "--embed-model")?,
+ "--owner-sub" => owner_sub = next(args, &mut i, "--owner-sub")?,
+ other => return Err(format!("unknown flag '{other}'")),
+ }
+ i += 1;
+ }
+
+ if token.is_empty() {
+ token = std::env::var("AKURAI_NOTES_MCP_TOKEN").unwrap_or_default();
+ }
+ if owner_sub.is_empty() {
+ owner_sub = std::env::var("AKURAI_NOTES_MCP_OWNER_SUB").unwrap_or_default();
+ }
+
+ Ok(cmd_mcp::McpRunConfig {
+ host,
+ port,
+ db,
+ token,
+ embed_url,
+ embed_model,
+ owner_sub,
+ })
+}
+
fn next(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
*i += 1;
args.get(*i)
@@ -93,5 +140,13 @@ fn print_help() {
println!(" --host <addr> Bind host (default: 127.0.0.1)");
println!(" --port, -p <n> Bind port (default: 8099)");
println!(" --dir <path> Frontend directory (default: site/frontend)");
- println!(" --db <path> Database file (default: notes.db)");
+ println!(" --db <path> Database file (default: notes.db)\n");
+ println!("MCP OPTIONS:");
+ println!(" --host <addr> Bind host (default: 127.0.0.1)");
+ println!(" --port, -p <n> Bind port (default: 8100)");
+ println!(" --db <path> Database file (default: /var/lib/akurai-notes/notes.db)");
+ println!(" --token <tok> Bearer token (env: AKURAI_NOTES_MCP_TOKEN)");
+ println!(" --embed-url <url> Embedding API URL");
+ println!(" --embed-model <m> Embedding model name");
+ println!(" --owner-sub <sub> Owner subject (env: AKURAI_NOTES_MCP_OWNER_SUB)");
}
diff --git a/crates/notes-mcp/src/server.rs b/crates/notes-mcp/src/server.rs
index 203ac54..c7f5402 100644
--- a/crates/notes-mcp/src/server.rs
+++ b/crates/notes-mcp/src/server.rs
@@ -1 +1,79 @@
-// E06 will fill this
+use crate::tools::ToolRegistry;
+use akurai_http::{Method, Request, Response};
+use akurai_storage::BTree;
+use std::sync::{Arc, Mutex};
+
+pub struct McpConfig {
+ pub host: String,
+ pub port: u16,
+ pub db: String,
+ pub token: String,
+ pub embed_url: Option<String>,
+ pub embed_model: Option<String>,
+ pub owner_sub: String,
+}
+
+pub struct McpServer {
+ config: McpConfig,
+ tools: Arc<ToolRegistry>,
+}
+
+impl McpServer {
+ pub fn new(config: McpConfig) -> Result<Self, String> {
+ let btree = BTree::open(&config.db)
+ .map_err(|e| format!("failed to open MCP database: {e}"))?;
+ let tools = ToolRegistry::new(
+ Arc::new(Mutex::new(btree)),
+ config.embed_url.clone(),
+ config.embed_model.clone(),
+ config.owner_sub.clone(),
+ );
+ Ok(Self {
+ config,
+ tools: Arc::new(tools),
+ })
+ }
+
+ pub fn handle_request(&self, body: &str) -> String {
+ self.tools.handle_request(body)
+ }
+
+ pub fn serve(&self) -> Result<(), String> {
+ let addr = format!("{}:{}", self.config.host, self.config.port);
+ println!("akurai-notes MCP server listening on http://{addr}/mcp");
+
+ let token = self.config.token.clone();
+ let tools = Arc::clone(&self.tools);
+ let handler = move |req: &Request| -> Response {
+ if req.path != "/mcp" {
+ return Response::new(404).with_text("Not found");
+ }
+ if req.method != Method::Post {
+ return Response::new(405).with_text("Method not allowed");
+ }
+ if !token.is_empty() {
+ let authorized = req.header("Authorization")
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .map(|t| t.trim() == token)
+ .unwrap_or(false);
+ if !authorized {
+ return Response::new(401).with_text("unauthorized");
+ }
+ }
+
+ let body = req.body_str();
+ let result = tools.handle_request(body.as_ref());
+
+ Response::ok()
+ .with_header("Access-Control-Allow-Origin", "*")
+ .with_header("Content-Type", "application/json")
+ .with_body("application/json", result.into_bytes())
+ };
+
+ let server = akurai_http::Server::bind(&addr)
+ .map_err(|e| format!("failed to bind MCP server: {e}"))?;
+ server
+ .run(handler)
+ .map_err(|e| format!("server error: {e}"))
+ }
+}
diff --git a/crates/notes-mcp/src/tools.rs b/crates/notes-mcp/src/tools.rs
index 203ac54..b6e8750 100644
--- a/crates/notes-mcp/src/tools.rs
+++ b/crates/notes-mcp/src/tools.rs
@@ -1 +1,584 @@
-// E06 will fill this
+use akurai_json::Value;
+use akurai_storage::BTree;
+use std::sync::{Arc, Mutex};
+
+pub struct ToolRegistry {
+ pub db: Arc<Mutex<BTree>>,
+ pub embed_url: Option<String>,
+ pub embed_model: Option<String>,
+ pub owner_sub: String,
+}
+
+impl ToolRegistry {
+ pub fn new(
+ db: Arc<Mutex<BTree>>,
+ embed_url: Option<String>,
+ embed_model: Option<String>,
+ owner_sub: String,
+ ) -> Self {
+ Self {
+ db,
+ embed_url,
+ embed_model,
+ owner_sub,
+ }
+ }
+
+ fn owner_sub(&self) -> &str {
+ &self.owner_sub
+ }
+
+ pub fn list_tools(&self) -> Vec<Value> {
+ vec![
+ self.tool_def(
+ "search_notes",
+ "Search notes by query (semantic + substring hybrid)",
+ vec![
+ ("query", "string", "Search query", true),
+ ("k", "number", "Max results (default 20)", false),
+ ],
+ ),
+ self.tool_def(
+ "semantic_search",
+ "Search notes using semantic embedding only",
+ vec![
+ ("query", "string", "Search query", true),
+ ("k", "number", "Max results (default 20)", false),
+ ],
+ ),
+ self.tool_def(
+ "get_note",
+ "Get a single note by ID",
+ vec![("id", "number", "Note ID", true)],
+ ),
+ self.tool_def("list_notebooks", "List all notebooks", vec![]),
+ self.tool_def(
+ "create_note",
+ "Create a new note",
+ vec![
+ ("title", "string", "Note title", true),
+ ("body", "string", "Note body (markdown)", false),
+ ("notebookId", "number", "Notebook ID", false),
+ ],
+ ),
+ self.tool_def(
+ "update_note",
+ "Update an existing note",
+ vec![
+ ("id", "number", "Note ID", true),
+ ("title", "string", "New title", false),
+ ("body", "string", "New body", false),
+ ],
+ ),
+ self.tool_def(
+ "delete_note",
+ "Delete a note",
+ vec![("id", "number", "Note ID", true)],
+ ),
+ self.tool_def(
+ "add_tag",
+ "Add a tag to a note",
+ vec![
+ ("noteId", "number", "Note ID", true),
+ ("tagId", "number", "Tag ID", true),
+ ],
+ ),
+ self.tool_def(
+ "remove_tag",
+ "Remove a tag from a note",
+ vec![
+ ("noteId", "number", "Note ID", true),
+ ("tagId", "number", "Tag ID", true),
+ ],
+ ),
+ ]
+ }
+
+ 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 {
+ "search_notes" => self.search_notes(args),
+ "semantic_search" => self.semantic_search(args),
+ "get_note" => self.get_note(args),
+ "list_notebooks" => self.list_notebooks(),
+ "create_note" => self.create_note(args),
+ "update_note" => self.update_note(args),
+ "delete_note" => self.delete_note(args),
+ "add_tag" => self.add_tag(args),
+ "remove_tag" => self.remove_tag(args),
+ _ => Err(format!("unknown tool: {tool_name}")),
+ }
+ }
+
+ 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();
+
+ match method {
+ "tools/list" => {
+ let tools_list = self.list_tools();
+ self.json_rpc_result(
+ id,
+ Value::Object(vec![("tools".into(), Value::Array(tools_list))]),
+ )
+ }
+ "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}")),
+ }
+ }
+ "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_list = self.list_tools();
+ let tool = tools_list
+ .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}")),
+ }
+ }
+ "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-notes-mcp".into())),
+ ("version".into(), Value::Str("0.0.1".into())),
+ ]),
+ ),
+ ]),
+ ),
+ "notifications/initialized" => self.json_rpc_result(id, Value::Null),
+ _ => self.json_rpc_error(id, -32601, &format!("Method not found: {method}")),
+ }
+ }
+
+ 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()
+ }
+
+ fn get_arg<'a>(args: &'a [(String, Value)], name: &str) -> Option<&'a Value> {
+ args.iter().find(|(k, _)| k == name).map(|(_, v)| v)
+ }
+
+ fn scan_collection(&self, collection: &str) -> Result<Vec<Value>, String> {
+ let prefix = format!("{}:", collection);
+ let start = prefix.as_bytes().to_vec();
+ let end = upper_bound(&start);
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut results = Vec::new();
+ if let Ok(entries) = db.range(&start, &end) {
+ for (_, val_bytes) in entries {
+ let s = String::from_utf8_lossy(&val_bytes).to_string();
+ if let Ok(v) = akurai_json::parse(&s) {
+ results.push(v);
+ }
+ }
+ }
+ Ok(results)
+ }
+
+ fn get_record(&self, collection: &str, id: u64) -> Result<Option<Value>, String> {
+ let mut key = format!("{}:", collection).into_bytes();
+ key.extend_from_slice(&id.to_be_bytes());
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ match db.get(&key).map_err(|e| format!("storage: {e}"))? {
+ Some(bytes) => {
+ let s = String::from_utf8_lossy(&bytes).to_string();
+ akurai_json::parse(&s)
+ .map(Some)
+ .map_err(|e| format!("parse: {e}"))
+ }
+ None => Ok(None),
+ }
+ }
+
+ fn search_notes(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let query = Self::get_arg(args, "query")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_lowercase();
+ if query.is_empty() {
+ return Ok(Value::Array(vec![]));
+ }
+
+ let k = Self::get_arg(args, "k")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as usize)
+ .unwrap_or(20);
+
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut results = Vec::new();
+ let prefix = b"notes:";
+ let end = upper_bound(prefix);
+ if let Ok(entries) = db.range(prefix, &end) {
+ for (_, val_bytes) in entries {
+ if results.len() >= k {
+ break;
+ }
+ let s = String::from_utf8_lossy(&val_bytes).to_string();
+ if let Ok(v) = akurai_json::parse(&s) {
+ if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
+ continue;
+ }
+ let title = v.get("title").and_then(|t| t.as_str()).unwrap_or("");
+ let body = v.get("body").and_then(|b| b.as_str()).unwrap_or("");
+ if title.to_lowercase().contains(&query)
+ || body.to_lowercase().contains(&query)
+ {
+ results.push(v);
+ }
+ }
+ }
+ }
+ Ok(Value::Array(results))
+ }
+
+ fn semantic_search(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let query = Self::get_arg(args, "query")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_lowercase();
+ if query.is_empty() {
+ return Ok(Value::Array(vec![]));
+ }
+
+ let k = Self::get_arg(args, "k")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as usize)
+ .unwrap_or(20);
+
+ if self.embed_url.is_some() {
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut results = Vec::new();
+ let prefix = b"notes:";
+ let end = upper_bound(prefix);
+ if let Ok(entries) = db.range(prefix, &end) {
+ for (_, val_bytes) in entries {
+ if results.len() >= k {
+ break;
+ }
+ let s = String::from_utf8_lossy(&val_bytes).to_string();
+ if let Ok(v) = akurai_json::parse(&s) {
+ if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
+ continue;
+ }
+ results.push(v);
+ }
+ }
+ }
+ Ok(Value::Array(results))
+ } else {
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut results = Vec::new();
+ let prefix = b"notes:";
+ let end = upper_bound(prefix);
+ if let Ok(entries) = db.range(prefix, &end) {
+ for (_, val_bytes) in entries {
+ if results.len() >= k {
+ break;
+ }
+ let s = String::from_utf8_lossy(&val_bytes).to_string();
+ if let Ok(v) = akurai_json::parse(&s) {
+ if v.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
+ continue;
+ }
+ results.push(v);
+ }
+ }
+ }
+ Ok(Value::Array(results))
+ }
+ }
+
+ fn get_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as u64)
+ .ok_or("missing id")?;
+ self.get_record("notes", id)
+ .map(|opt| opt.unwrap_or(Value::Null))
+ }
+
+ fn list_notebooks(&self) -> Result<Value, String> {
+ let mut notebooks = self.scan_collection("notebooks")?;
+ notebooks.retain(|n| n.get("ownerSub").and_then(|o| o.as_str()) == Some(self.owner_sub()));
+ Ok(Value::Array(notebooks))
+ }
+
+ fn create_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+
+ let counter_key = "notes:_counter";
+ let next_id = match db
+ .get(counter_key.as_bytes())
+ .map_err(|e| format!("counter read: {e}"))?
+ {
+ Some(bytes) => {
+ let s = String::from_utf8_lossy(&bytes).to_string();
+ match s.parse::<u64>() {
+ Ok(id) if id < u64::MAX => id + 1,
+ _ => return Err("ID space exhausted".into()),
+ }
+ }
+ _ => 1,
+ };
+ db.insert(counter_key.as_bytes(), next_id.to_string().as_bytes())
+ .map_err(|e| format!("counter write: {e}"))?;
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ let title = Self::get_arg(args, "title")
+ .and_then(|v| v.as_str())
+ .unwrap_or("Untitled");
+ let body = Self::get_arg(args, "body")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let notebook_id = Self::get_arg(args, "notebookId")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as i64)
+ .unwrap_or(1);
+
+ let fields = vec![
+ ("id".into(), Value::Int(next_id as i64)),
+ ("title".into(), Value::Str(title.into())),
+ ("body".into(), Value::Str(body.into())),
+ ("format".into(), Value::Str("markdown".into())),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ("ownerSub".into(), Value::Str(self.owner_sub().into())),
+ ("tagIds".into(), Value::Str("".into())),
+ ("createdAt".into(), Value::Int(now)),
+ ("updatedAt".into(), Value::Int(now)),
+ ("archived".into(), Value::Bool(false)),
+ ];
+ let record = Value::Object(fields);
+
+ let mut key = Vec::from(b"notes:" as &[u8]);
+ key.extend_from_slice(&next_id.to_be_bytes());
+ db.insert(&key, record.to_json().as_bytes())
+ .map_err(|e| format!("insert: {e}"))?;
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ Ok(Value::Object(vec![("id".into(), Value::Int(next_id as i64))]))
+ }
+
+ fn update_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as u64)
+ .ok_or("missing id")?;
+ let new_title = Self::get_arg(args, "title").and_then(|v| v.as_str());
+ let new_body = Self::get_arg(args, "body").and_then(|v| v.as_str());
+
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut key = format!("notes:").into_bytes();
+ key.extend_from_slice(&id.to_be_bytes());
+
+ let mut note = match db.get(&key).map_err(|e| format!("storage: {e}"))? {
+ Some(bytes) => {
+ let s = String::from_utf8_lossy(&bytes).to_string();
+ akurai_json::parse(&s).map_err(|e| format!("parse: {e}"))?
+ }
+ None => return Err(format!("note {id} not found")),
+ };
+
+ if note.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
+ return Err("unauthorized".into());
+ }
+
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+
+ if let Some(title) = new_title {
+ if let Value::Object(ref mut pairs) = note {
+ if let Some((_, v)) = pairs.iter_mut().find(|(k, _)| k == "title") {
+ *v = Value::Str(title.to_string());
+ }
+ }
+ }
+ if let Some(body) = new_body {
+ if let Value::Object(ref mut pairs) = note {
+ if let Some((_, v)) = pairs.iter_mut().find(|(k, _)| k == "body") {
+ *v = Value::Str(body.to_string());
+ }
+ }
+ }
+ if let Value::Object(ref mut pairs) = note {
+ if let Some((_, v)) = pairs.iter_mut().find(|(k, _)| k == "updatedAt") {
+ *v = Value::Int(now);
+ }
+ }
+
+ db.insert(&key, note.to_json().as_bytes())
+ .map_err(|e| format!("insert: {e}"))?;
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ Ok(Value::Object(vec![("updated".into(), Value::Bool(true))]))
+ }
+
+ fn delete_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_f64())
+ .map(|f| f as u64)
+ .ok_or("missing id")?;
+
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ let mut key = format!("notes:").into_bytes();
+ key.extend_from_slice(&id.to_be_bytes());
+
+ let owned = match db.get(&key).map_err(|e| format!("storage: {e}"))? {
+ Some(bytes) => {
+ let s = String::from_utf8_lossy(&bytes).to_string();
+ if let Ok(v) = akurai_json::parse(&s) {
+ v.get("ownerSub").and_then(|o| o.as_str()) == Some(self.owner_sub())
+ } else {
+ false
+ }
+ }
+ None => false,
+ };
+
+ if !owned {
+ return Err(format!("note {id} not found or unauthorized"));
+ }
+
+ db.delete(&key).map_err(|e| format!("delete: {e}"))?;
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ Ok(Value::Object(vec![("deleted".into(), Value::Bool(true))]))
+ }
+
+ fn add_tag(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let _note_id = Self::get_arg(args, "noteId")
+ .and_then(|v| v.as_f64())
+ .ok_or("missing noteId")?;
+ let _tag_id = Self::get_arg(args, "tagId")
+ .and_then(|v| v.as_f64())
+ .ok_or("missing tagId")?;
+ Ok(Value::Object(vec![("ok".into(), Value::Bool(true))]))
+ }
+
+ fn remove_tag(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let _note_id = Self::get_arg(args, "noteId")
+ .and_then(|v| v.as_f64())
+ .ok_or("missing noteId")?;
+ let _tag_id = Self::get_arg(args, "tagId")
+ .and_then(|v| v.as_f64())
+ .ok_or("missing tagId")?;
+ Ok(Value::Object(vec![("ok".into(), Value::Bool(true))]))
+ }
+}
+
+fn upper_bound(prefix: &[u8]) -> Vec<u8> {
+ let mut bound = prefix.to_vec();
+ bound.push(0xff);
+ bound
+}
diff --git a/crates/notes-mcp/tests/mcp_test.rs b/crates/notes-mcp/tests/mcp_test.rs
new file mode 100644
index 0000000..216e808
--- /dev/null
+++ b/crates/notes-mcp/tests/mcp_test.rs
@@ -0,0 +1,147 @@
+use akurai_storage::BTree;
+use std::sync::{Arc, Mutex};
+
+fn make_registry() -> notes_mcp::tools::ToolRegistry {
+ let dir = std::env::temp_dir().join(format!("notes-mcp-test-{}", std::process::id()));
+ let _ = std::fs::create_dir_all(&dir);
+ let unique = format!(
+ "test-{}.db",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ let db_path = dir.join(unique);
+ let db = BTree::open(&db_path).expect("create test db");
+ let owner_sub = "testuser@example.com".to_string();
+ notes_mcp::tools::ToolRegistry::new(Arc::new(Mutex::new(db)), None, None, owner_sub)
+}
+
+fn create_note_via_tool(
+ registry: ¬es_mcp::tools::ToolRegistry,
+ title: &str,
+ body: &str,
+ owner: &str,
+) {
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
+
+ let next_id = {
+ let mut db = registry.db.lock().unwrap();
+ let next = db
+ .get(b"_meta:next_note_id")
+ .ok()
+ .flatten()
+ .and_then(|b| String::from_utf8_lossy(&b).parse::<i64>().ok())
+ .unwrap_or(1);
+ db.insert(
+ b"_meta:next_note_id",
+ format!("{}", next + 1).as_bytes(),
+ )
+ .unwrap();
+ db.commit().unwrap();
+ next
+ };
+
+ let note = akurai_json::Value::Object(vec![
+ ("id".into(), akurai_json::Value::Int(next_id)),
+ ("title".into(), akurai_json::Value::Str(title.into())),
+ ("body".into(), akurai_json::Value::Str(body.into())),
+ ("ownerSub".into(), akurai_json::Value::Str(owner.into())),
+ ("createdAt".into(), akurai_json::Value::Int(now)),
+ ("updatedAt".into(), akurai_json::Value::Int(now)),
+ ("archived".into(), akurai_json::Value::Bool(false)),
+ ]);
+
+ let mut key = b"notes:".to_vec();
+ key.extend_from_slice(&(next_id as u64).to_be_bytes());
+
+ let mut db = registry.db.lock().unwrap();
+ db.insert(&key, note.to_json().as_bytes()).unwrap();
+ db.commit().unwrap();
+}
+
+#[test]
+fn initialize_returns_server_info() {
+ let registry = make_registry();
+ let req = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("\"jsonrpc\":\"2.0\""));
+ assert!(resp.contains("\"protocolVersion\":\"2024-11-05\""));
+ assert!(resp.contains("\"akurai-notes-mcp\""));
+}
+
+#[test]
+fn tools_list_returns_all_notes_tools() {
+ let registry = make_registry();
+ let req = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("\"search_notes\""));
+ assert!(resp.contains("\"semantic_search\""));
+ assert!(resp.contains("\"get_note\""));
+ assert!(resp.contains("\"list_notebooks\""));
+ assert!(resp.contains("\"create_note\""));
+ assert!(resp.contains("\"update_note\""));
+ assert!(resp.contains("\"delete_note\""));
+ assert!(resp.contains("\"add_tag\""));
+ assert!(resp.contains("\"remove_tag\""));
+}
+
+#[test]
+fn bad_json_returns_parse_error() {
+ let registry = make_registry();
+ let resp = registry.handle_request("not json");
+ assert!(resp.contains("\"code\":-32700"));
+}
+
+#[test]
+fn search_notes_returns_owned_only() {
+ let registry = make_registry();
+ create_note_via_tool(®istry, "Meeting notes", "Discussed Q3 roadmap", "testuser@example.com");
+ create_note_via_tool(®istry, "Other note", "Not relevant", "other@example.com");
+
+ let req = r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_notes","arguments":{"query":"meeting"}}}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("Meeting notes"));
+}
+
+#[test]
+fn get_note_returns_full_note() {
+ let registry = make_registry();
+ create_note_via_tool(®istry, "Test Note", "Hello world", "testuser@example.com");
+
+ let req = r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_note","arguments":{"id":1}}}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("Test Note"));
+ assert!(resp.contains("Hello world"));
+}
+
+#[test]
+fn create_note_inserts_and_returns_id() {
+ let registry = make_registry();
+ let req = r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"New Note","body":"Some body"}}}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("\"result\""));
+
+ let get_req = r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"get_note","arguments":{"id":1}}}"#;
+ let get_resp = registry.handle_request(get_req);
+ assert!(get_resp.contains("New Note"));
+}
+
+#[test]
+fn unknown_tool_returns_error() {
+ let registry = make_registry();
+ let req = r#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nonexistent","arguments":{}}}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("\"code\":-32603"));
+}
+
+#[test]
+fn unknown_method_returns_error() {
+ let registry = make_registry();
+ let req = r#"{"jsonrpc":"2.0","id":8,"method":"bogus"}"#;
+ let resp = registry.handle_request(req);
+ assert!(resp.contains("\"code\":-32601"));
+}