AkurAI Build
Menu

AkurAI-NotesCommit b78e3a52

Commit b78e3a52

b78e3a5270c5c5e1b0b04f1da8da4899aa5e4e22 by Claude Code

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: &notes_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(&registry, "Meeting notes", "Discussed Q3 roadmap", "testuser@example.com");
+    create_note_via_tool(&registry, "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(&registry, "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"));
+}