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());
}
}