AkurAI Build
Menu

Public repository / c7bf22ef78ad08f1f59b04d08e88d86e4ca91815

AkurAI-CRM / crates/crm-mcp/src/tools.rs

Latest change c7bf22ef78ad08f1f59b04d08e88d86e4ca91815 - feat: add tenant-safe account graph by Ólafur Búi Ólafsson

use akurai_json::Value;
use akurai_storage::BTree;
use crm_api::graph::{self, LinkKind};
use std::sync::{Arc, Mutex};

pub struct ToolRegistry {
    db: Arc<Mutex<BTree>>,
}

impl ToolRegistry {
    pub fn new(db: Arc<Mutex<BTree>>) -> Self {
        Self { db }
    }

    /// List all available tools with their schemas
    pub fn list_tools(&self) -> Vec<Value> {
        vec![
            self.tool_def(
                "list_people",
                "List all CRM contacts",
                vec![("limit", "integer", "Maximum number of results", false)],
            ),
            self.tool_def(
                "get_person",
                "Get a person by ID",
                vec![("id", "integer", "Person record ID", true)],
            ),
            self.tool_def(
                "create_person",
                "Create a new person",
                vec![
                    ("firstName", "string", "First name", true),
                    ("lastName", "string", "Last name", true),
                    ("email", "string", "Email address", false),
                    ("jobTitle", "string", "Job title", false),
                    ("companyId", "integer", "Company ID", false),
                ],
            ),
            self.tool_def(
                "list_companies",
                "List all companies",
                vec![("limit", "integer", "Maximum number of results", false)],
            ),
            self.tool_def(
                "get_company",
                "Get a company by ID",
                vec![("id", "integer", "Company record ID", true)],
            ),
            self.tool_def(
                "list_opportunities",
                "List all sales opportunities",
                vec![
                    (
                        "stage",
                        "string",
                        "Filter by stage (new/screening/meeting/proposal/negotiation/won/lost)",
                        false,
                    ),
                    ("limit", "integer", "Maximum number of results", false),
                ],
            ),
            self.tool_def(
                "get_opportunity",
                "Get an opportunity by ID",
                vec![("id", "integer", "Opportunity record ID", true)],
            ),
            self.tool_def(
                "get_pipeline",
                "Get pipeline summary with counts per stage",
                vec![],
            ),
            self.tool_def(
                "search_crm",
                "Search across all CRM entities",
                vec![("query", "string", "Search term", true)],
            ),
            self.tool_def(
                "list_tasks",
                "List tasks",
                vec![
                    (
                        "status",
                        "string",
                        "Filter by status (todo/in_progress/done/cancelled)",
                        false,
                    ),
                    ("limit", "integer", "Maximum number of results", false),
                ],
            ),
            self.tool_def(
                "get_account_graph",
                "Read a bounded tenant-authorized customer account graph",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("companyId", "integer", "CRM company ID", true),
                    (
                        "limit",
                        "integer",
                        "Maximum links and related records (1-100)",
                        false,
                    ),
                ],
            ),
            self.tool_def(
                "list_account_links",
                "List bounded account links, including stale and orphaned status",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("companyId", "integer", "CRM company ID", true),
                    (
                        "status",
                        "string",
                        "Optional active/stale/orphaned/unlinked/merged filter",
                        false,
                    ),
                    ("limit", "integer", "Maximum links (1-100)", false),
                ],
            ),
            self.tool_def(
                "link_account_resource",
                "Link a CRM company to a source-owned opaque resource ID",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("companyId", "integer", "CRM company ID", true),
                    (
                        "kind",
                        "string",
                        "organization/workspace/subscription/opportunity/installation",
                        true,
                    ),
                    ("externalId", "string", "Opaque source resource ID", true),
                    ("sourceOwner", "string", "idp/platform/crm", false),
                ],
            ),
            self.tool_def(
                "unlink_account_resource",
                "Audit and unlink a CRM account resource",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("companyId", "integer", "CRM company ID", true),
                    ("linkId", "integer", "Account link ID", true),
                ],
            ),
            self.tool_def(
                "merge_account_companies",
                "Merge one tenant CRM company into another while retaining history",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("sourceCompanyId", "integer", "Company being merged", true),
                    ("targetCompanyId", "integer", "Company that remains", true),
                ],
            ),
            self.tool_def(
                "list_orphaned_account_links",
                "List bounded orphaned account links for an authorized tenant",
                vec![
                    ("tenantId", "string", "Authorized tenant identifier", true),
                    ("limit", "integer", "Maximum links (1-100)", false),
                ],
            ),
        ]
    }

    fn tool_def(
        &self,
        name: &str,
        description: &str,
        mut params: Vec<(&str, &str, &str, bool)>,
    ) -> Value {
        if !params.iter().any(|(pname, _, _, _)| *pname == "tenantId") {
            params.insert(
                0,
                ("tenantId", "string", "Authenticated tenant scope", true),
            );
        }
        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)),
                ]),
            ),
        ])
    }

    /// Execute a tool using an authenticated tenant scope.
    pub fn execute_authenticated(
        &self,
        tool_name: &str,
        args: &[(String, Value)],
        authenticated_tenant: &str,
    ) -> Result<Value, String> {
        let requested = Self::tenant_arg(args)?;
        if requested != authenticated_tenant {
            return Err("tenant argument does not match authenticated tenant".into());
        }
        self.execute(tool_name, args)
    }

    fn execute(&self, tool_name: &str, args: &[(String, Value)]) -> Result<Value, String> {
        match tool_name {
            "list_people" => self.list_people(args),
            "get_person" => self.get_person(args),
            "create_person" => self.create_person(args),
            "list_companies" => self.list_companies(args),
            "get_company" => self.get_company(args),
            "list_opportunities" => self.list_opportunities(args),
            "get_opportunity" => self.get_opportunity(args),
            "get_pipeline" => self.get_pipeline(args),
            "search_crm" => self.search_crm(args),
            "list_tasks" => self.list_tasks(args),
            "get_account_graph" => self.get_account_graph(args),
            "list_account_links" => self.list_account_links(args),
            "list_orphaned_account_links" => self.list_orphaned_account_links(args),
            "link_account_resource" => self.link_account_resource(args),
            "unlink_account_resource" => self.unlink_account_resource(args),
            "merge_account_companies" => self.merge_account_companies(args),
            _ => Err(format!("unknown tool: {tool_name}")),
        }
    }

    pub fn handle_request(&self, body: &str) -> String {
        self.handle_request_inner(body, None)
    }

    pub fn handle_request_authenticated(&self, body: &str, tenant: &str) -> String {
        self.handle_request_inner(body, Some(tenant))
    }

    fn handle_request_inner(&self, body: &str, authenticated_tenant: Option<&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();

                let execution = match authenticated_tenant {
                    Some(tenant) => self.execute_authenticated(tool_name, &arguments, tenant),
                    None => Err("authenticated MCP credential required".into()),
                };
                match execution {
                    Ok(result) => self.json_rpc_result(
                        id,
                        Value::Object(vec![(
                            "content".into(),
                            Value::Array(vec![Value::Object(vec![
                                ("type".into(), Value::Str("json".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("2025-03-26".into())),
                    (
                        "capabilities".into(),
                        Value::Object(vec![("tools".into(), Value::Object(vec![]))]),
                    ),
                    (
                        "serverInfo".into(),
                        Value::Object(vec![
                            ("name".into(), Value::Str("akurai-crm-mcp".into())),
                            (
                                "version".into(),
                                Value::Str(env!("CARGO_PKG_VERSION").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 tenant_arg(args: &[(String, Value)]) -> Result<String, String> {
        let tenant = Self::get_arg(args, "tenantId")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|v| !v.is_empty())
            .ok_or("missing tenantId")?;
        if tenant.len() > 128
            || tenant
                .chars()
                .any(|c| c.is_control() || c == ':' || c == '/')
        {
            return Err("invalid tenantId".into());
        }
        Ok(tenant.to_string())
    }

    fn bounded_limit(args: &[(String, Value)]) -> usize {
        Self::get_arg(args, "limit")
            .and_then(|v| v.as_i64())
            .and_then(|v| usize::try_from(v).ok())
            .unwrap_or(50)
            .clamp(1, graph::MAX_GRAPH_LIMIT)
    }

    fn company_arg(args: &[(String, Value)], name: &str) -> Result<u64, String> {
        Self::get_arg(args, name)
            .and_then(|v| v.as_i64())
            .and_then(|v| u64::try_from(v).ok())
            .ok_or_else(|| format!("missing {name}"))
    }

    fn get_account_graph(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let company = Self::company_arg(args, "companyId")?;
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::get_company_graph(&mut db, &tenant, company, Self::bounded_limit(args))
            .map_err(|e| format!("{e:?}"))
    }

    fn list_account_links(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let company = Self::company_arg(args, "companyId")?;
        let status = Self::get_arg(args, "status").and_then(|v| v.as_str());
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::list_links(&mut db, &tenant, company, status, Self::bounded_limit(args))
            .map_err(|e| format!("{e:?}"))
    }

    fn list_orphaned_account_links(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::list_orphaned_links(&mut db, &tenant, Self::bounded_limit(args))
            .map_err(|e| format!("{e:?}"))
    }

    fn link_account_resource(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let company = Self::company_arg(args, "companyId")?;
        let kind = Self::get_arg(args, "kind")
            .and_then(|v| v.as_str())
            .and_then(LinkKind::parse)
            .ok_or("invalid kind")?;
        let external_id = Self::get_arg(args, "externalId")
            .and_then(|v| v.as_str())
            .ok_or("missing externalId")?;
        let owner = Self::get_arg(args, "sourceOwner").and_then(|v| v.as_str());
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::link_company_resource(&mut db, &tenant, company, kind, external_id, owner)
            .map_err(|e| format!("{e:?}"))
    }

    fn unlink_account_resource(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let company = Self::company_arg(args, "companyId")?;
        let link_id = Self::company_arg(args, "linkId")?;
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::unlink_company_resource(&mut db, &tenant, company, link_id)
            .map_err(|e| format!("{e:?}"))
    }

    fn merge_account_companies(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let source = Self::company_arg(args, "sourceCompanyId")?;
        let target = Self::company_arg(args, "targetCompanyId")?;
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        graph::merge_companies(&mut db, &tenant, source, target).map_err(|e| format!("{e:?}"))
    }

    fn scan_collection(&self, collection: &str, tenant: &str) -> Result<Vec<Value>, String> {
        let prefix = format!("{collection}:");
        let start = prefix.as_bytes();
        let end = upper_bound(start);
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        let mut results = Vec::new();
        let entries = db.range(start, &end).map_err(|e| format!("storage: {e}"))?;
        for (key, val_bytes) in entries {
            if key.ends_with(b":_counter") {
                continue;
            }
            let value = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
                .map_err(|e| format!("parse: {e}"))?;
            if value.get("tenantId").and_then(Value::as_str) == Some(tenant) {
                results.push(value);
            }
        }
        Ok(results)
    }

    fn get_record(&self, collection: &str, id: u64, tenant: &str) -> 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 value = akurai_json::parse(std::str::from_utf8(&bytes).unwrap_or(""))
                    .map_err(|e| format!("parse: {e}"))?;
                if value.get("tenantId").and_then(Value::as_str) != Some(tenant) {
                    return Err("record not found in authenticated tenant".into());
                }
                Ok(Some(value))
            }
            None => Ok(None),
        }
    }

    fn page(values: Vec<Value>, args: &[(String, Value)]) -> Value {
        let requested = Self::get_arg(args, "limit")
            .and_then(Value::as_i64)
            .and_then(|value| usize::try_from(value).ok());
        let truncated = requested.is_some_and(|limit| values.len() > limit);
        let records = match requested {
            Some(limit) => values.into_iter().take(limit).collect(),
            None => values,
        };
        Value::Object(vec![
            ("records".into(), Value::Array(records)),
            ("truncated".into(), Value::Bool(truncated)),
            ("complete".into(), Value::Bool(!truncated)),
        ])
    }

    fn list_people(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        Ok(Self::page(self.scan_collection("people", &tenant)?, args))
    }

    fn get_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let id = Self::get_arg(args, "id")
            .and_then(Value::as_i64)
            .and_then(|id| u64::try_from(id).ok())
            .ok_or("missing id")?;
        self.get_record("people", id, &tenant)
            .map(|opt| opt.unwrap_or(Value::Null))
    }

    fn create_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
        if let Some(company_id) = Self::get_arg(args, "companyId")
            .and_then(Value::as_i64)
            .and_then(|id| u64::try_from(id).ok())
        {
            let company = db
                .get(&build_key("companies", company_id))
                .map_err(|e| format!("storage: {e}"))?
                .ok_or("company not found")?;
            let company = akurai_json::parse(std::str::from_utf8(&company).unwrap_or(""))
                .map_err(|e| format!("parse: {e}"))?;
            if company.get("tenantId").and_then(Value::as_str) != Some(&tenant) {
                return Err("company belongs to another tenant".into());
            }
        }
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs() as i64;
        let counter_key = b"people:_counter";
        let next_id = db
            .get(counter_key)
            .map_err(|e| format!("counter read: {e}"))?
            .and_then(|bytes| String::from_utf8(bytes).ok())
            .and_then(|value| value.parse::<u64>().ok())
            .and_then(|value| value.checked_add(1))
            .unwrap_or(1);
        let first = Self::get_arg(args, "firstName")
            .and_then(Value::as_str)
            .unwrap_or("");
        let last = Self::get_arg(args, "lastName")
            .and_then(Value::as_str)
            .unwrap_or("");
        let mut fields = vec![
            ("id".into(), Value::Int(next_id as i64)),
            ("tenantId".into(), Value::Str(tenant)),
            ("firstName".into(), Value::Str(first.into())),
            ("lastName".into(), Value::Str(last.into())),
            ("name".into(), Value::Str(format!("{first} {last}"))),
            ("createdAt".into(), Value::Int(now)),
            ("updatedAt".into(), Value::Int(now)),
        ];
        for name in ["email", "jobTitle"] {
            if let Some(value) = Self::get_arg(args, name).and_then(Value::as_str) {
                fields.push((name.into(), Value::Str(value.into())));
            }
        }
        if let Some(cid) = Self::get_arg(args, "companyId").and_then(Value::as_i64) {
            fields.push(("companyId".into(), Value::Int(cid)));
        }
        let record = Value::Object(fields);
        let key = build_key("people", next_id);
        db.insert(counter_key, next_id.to_string().as_bytes())
            .map_err(|e| format!("counter: {e}"))?;
        db.insert(&key, record.to_json().as_bytes())
            .map_err(|e| format!("insert: {e}"))?;
        db.commit().map_err(|e| format!("commit: {e}"))?;
        Ok(record)
    }

    fn list_companies(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        Ok(Self::page(
            self.scan_collection("companies", &tenant)?,
            args,
        ))
    }

    fn get_company(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let id = Self::get_arg(args, "id")
            .and_then(Value::as_i64)
            .and_then(|id| u64::try_from(id).ok())
            .ok_or("missing id")?;
        self.get_record("companies", id, &tenant)
            .map(|opt| opt.unwrap_or(Value::Null))
    }

    fn list_opportunities(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let mut opps = self.scan_collection("opportunities", &tenant)?;
        if let Some(stage) = Self::get_arg(args, "stage").and_then(Value::as_str) {
            opps.retain(|o| o.get("stage").and_then(Value::as_str) == Some(stage));
        }
        Ok(Self::page(opps, args))
    }

    fn get_opportunity(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let id = Self::get_arg(args, "id")
            .and_then(Value::as_i64)
            .and_then(|id| u64::try_from(id).ok())
            .ok_or("missing id")?;
        self.get_record("opportunities", id, &tenant)
            .map(|opt| opt.unwrap_or(Value::Null))
    }

    fn get_pipeline(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let opps = self.scan_collection("opportunities", &tenant)?;
        let stages = [
            "new",
            "screening",
            "meeting",
            "proposal",
            "negotiation",
            "won",
            "lost",
        ];
        let stage_counts: Vec<Value> = stages
            .iter()
            .map(|&stage| {
                let matching = opps
                    .iter()
                    .filter(|o| o.get("stage").and_then(Value::as_str) == Some(stage));
                let list = matching.collect::<Vec<_>>();
                let total_amount = list
                    .iter()
                    .filter_map(|o| o.get("amount").and_then(Value::as_i64))
                    .sum();
                Value::Object(vec![
                    ("stage".into(), Value::Str(stage.into())),
                    ("count".into(), Value::Int(list.len() as i64)),
                    ("totalAmount".into(), Value::Int(total_amount)),
                ])
            })
            .collect();
        Ok(Value::Object(vec![
            ("pipeline".into(), Value::Array(stage_counts)),
            ("totalDeals".into(), Value::Int(opps.len() as i64)),
            ("complete".into(), Value::Bool(true)),
        ]))
    }
    fn search_crm(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let query = Self::get_arg(args, "query")
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_lowercase();
        if query.is_empty() {
            return Ok(Value::Object(vec![
                ("records".into(), Value::Array(vec![])),
                ("truncated".into(), Value::Bool(false)),
                ("complete".into(), Value::Bool(true)),
            ]));
        }
        let mut results = Vec::new();
        for collection in ["people", "companies", "opportunities", "tasks", "notes"] {
            for value in self.scan_collection(collection, &tenant)? {
                if value.to_json().to_lowercase().contains(&query) {
                    results.push(Value::Object(vec![
                        ("entity".into(), Value::Str(collection.into())),
                        ("record".into(), value),
                    ]));
                }
            }
        }
        Ok(Self::page(results, args))
    }

    fn list_tasks(&self, args: &[(String, Value)]) -> Result<Value, String> {
        let tenant = Self::tenant_arg(args)?;
        let mut tasks = self.scan_collection("tasks", &tenant)?;
        if let Some(status) = Self::get_arg(args, "status").and_then(Value::as_str) {
            tasks.retain(|task| task.get("status").and_then(Value::as_str) == Some(status));
        }
        Ok(Self::page(tasks, args))
    }
}

/// Upper bound helper for BTree range queries: appends a 0xFF byte so the bound
/// is strictly greater than every key with the given prefix.
fn upper_bound(prefix: &[u8]) -> Vec<u8> {
    let mut bound = prefix.to_vec();
    bound.push(0xff);
    bound
}

fn build_key(collection: &str, id: u64) -> Vec<u8> {
    let mut key = format!("{collection}:").into_bytes();
    key.extend_from_slice(&id.to_be_bytes());
    key
}

#[cfg(test)]
mod graph_tests {
    use super::*;

    #[test]
    fn graph_tools_are_tenant_authorized_and_bounded() {
        let path = format!("/tmp/akurai-crm-mcp-graph-{}.db", std::process::id());
        let _ = std::fs::remove_file(&path);
        let mut db = BTree::open(&path).unwrap();
        let company = Value::Object(vec![
            ("id".into(), Value::Int(1)),
            ("name".into(), Value::Str("Acme".into())),
            ("tenantId".into(), Value::Str("tenant-a".into())),
        ]);
        db.insert(
            b"companies:\x00\x00\x00\x00\x00\x00\x00\x01",
            company.to_json().as_bytes(),
        )
        .unwrap();
        db.commit().unwrap();
        let registry = ToolRegistry::new(Arc::new(Mutex::new(db)));
        let args = vec![
            ("tenantId".into(), Value::Str("tenant-a".into())),
            ("companyId".into(), Value::Int(1)),
            ("kind".into(), Value::Str("organization".into())),
            ("externalId".into(), Value::Str("org_1".into())),
        ];
        registry.execute("link_account_resource", &args).unwrap();
        let graph = registry
            .execute(
                "get_account_graph",
                &[
                    ("tenantId".into(), Value::Str("tenant-a".into())),
                    ("companyId".into(), Value::Int(1)),
                    ("limit".into(), Value::Int(1)),
                ],
            )
            .unwrap();
        assert_eq!(graph.get("bounded").and_then(Value::as_bool), Some(true));
        assert!(registry
            .execute(
                "get_account_graph",
                &[
                    ("tenantId".into(), Value::Str("tenant-b".into())),
                    ("companyId".into(), Value::Int(1)),
                ],
            )
            .is_err());
    }
}