use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use crate::api::{NeighborEdge, NeighborhoodResponse, NodeView};
use crate::ast::Document;
use crate::embedding::EmbeddingClient;
use crate::storage::{self, NodeFullData};
use super::protocol::{RpcError, invalid_params};
use super::server::{MethodHandler, Methods};
pub struct ToolsState {
pub conn: Arc<Mutex<rusqlite::Connection>>,
pub embedding_client: Option<Arc<dyn EmbeddingClient>>,
pub embedding_model: Option<String>,
pub runtime: Option<tokio::runtime::Handle>,
}
impl ToolsState {
#[must_use]
pub fn new(
conn: Arc<Mutex<rusqlite::Connection>>,
embedding_client: Option<Arc<dyn EmbeddingClient>>,
embedding_model: Option<String>,
runtime: Option<tokio::runtime::Handle>,
) -> Self {
Self {
conn,
embedding_client,
embedding_model,
runtime,
}
}
}
fn compute_doc_embedding_blocking(
state: &ToolsState,
document: &Document,
node_id: &str,
) -> Option<Vec<f32>> {
let client = state.embedding_client.as_ref()?;
state.embedding_model.as_deref()?;
let runtime = state.runtime.as_ref()?;
let title = storage::extract_title(document);
let body_text = storage::extract_body_text(document);
let payload = format!("{title}\n{body_text}");
let client = Arc::clone(client);
let payload_owned = payload;
match runtime.block_on(async move { client.embed(&payload_owned).await }) {
Ok(v) => Some(v),
Err(err) => {
tracing::error!(node_id, error = %err, "kb embed generation failed");
None
}
}
}
fn compute_query_embedding_blocking<'a>(
state: &'a ToolsState,
query: &str,
) -> Option<(Vec<f32>, &'a str)> {
let client = state.embedding_client.as_ref()?;
let model = state.embedding_model.as_deref()?;
let runtime = state.runtime.as_ref()?;
let client = Arc::clone(client);
let q = query.to_string();
match runtime.block_on(async move { client.embed(&q).await }) {
Ok(v) => Some((v, model)),
Err(err) => {
tracing::error!(error = %err, "kb mcp search: embedding query failed");
None
}
}
}
pub struct Tool {
pub name: String,
pub description: String,
pub input_schema: Value,
pub handler: ToolHandler,
}
pub type ToolHandler = Box<dyn Fn(&Value) -> Result<Value, RpcError> + Send + Sync>;
#[must_use]
pub fn success_content(v: &Value) -> Value {
let text = serde_json::to_string(v).unwrap_or_else(|_| "null".to_string());
json!({
"content": [ { "type": "text", "text": text } ],
"isError": false,
})
}
#[must_use]
pub fn error_content(msg: &str) -> Value {
json!({
"content": [ { "type": "text", "text": msg } ],
"isError": true,
})
}
fn require_object(args: &Value) -> Result<&serde_json::Map<String, Value>, RpcError> {
match args {
Value::Object(o) => Ok(o),
Value::Null => Err(invalid_params("expected an object for tool arguments")),
_ => Err(invalid_params("expected an object for tool arguments")),
}
}
fn require_str_field<'a>(
o: &'a serde_json::Map<String, Value>,
key: &str,
) -> Result<&'a str, RpcError> {
match o.get(key) {
Some(Value::String(s)) => Ok(s.as_str()),
Some(_) => Err(invalid_params(&format!("field {key} must be a string"))),
None => Err(invalid_params(&format!("missing required field: {key}"))),
}
}
fn require_doc_field(o: &serde_json::Map<String, Value>, key: &str) -> Result<Document, RpcError> {
match o.get(key) {
Some(v) => serde_json::from_value::<Document>(v.clone())
.map_err(|e| invalid_params(&format!("field {key} is not a valid Document: {e}"))),
None => Err(invalid_params(&format!("missing required field: {key}"))),
}
}
fn resolve_body(
o: &serde_json::Map<String, Value>,
) -> Result<Result<Document, crate::markdown::MarkdownError>, RpcError> {
let doc = o.get("document").filter(|v| !v.is_null());
let md = o.get("markdown").filter(|v| !v.is_null());
match (doc, md) {
(Some(_), Some(_)) => Err(invalid_params("provide exactly one of: document, markdown")),
(None, None) => Err(invalid_params(
"missing required field: provide document or markdown",
)),
(Some(_), None) => Ok(Ok(require_doc_field(o, "document")?)),
(None, Some(Value::String(s))) => Ok(crate::markdown::markdown_to_document(s)),
(None, Some(_)) => Err(invalid_params("field markdown must be a string")),
}
}
fn node_view(id: &str, nf: &NodeFullData) -> Value {
let view = NodeView {
id: crate::ast::NodeId(id.to_string()),
title: crate::ast::Title(nf.title.clone()),
tags: nf.tags.clone(),
document: nf.document.clone(),
created_at: nf.created_at.clone(),
updated_at: nf.updated_at.clone(),
};
serde_json::to_value(&view).unwrap_or(Value::Null)
}
fn summaries_from_pairs(pairs: &[(String, String)]) -> Value {
let arr: Vec<Value> = pairs
.iter()
.map(|(id, title)| json!({ "id": id, "title": title }))
.collect();
Value::Array(arr)
}
fn string_field(desc: &str) -> Value {
json!({ "type": "string", "description": desc })
}
fn schema_object(properties: &Value, required: &[&str]) -> Value {
json!({
"type": "object",
"properties": properties,
"required": required,
})
}
#[must_use]
pub fn mk_tools(state: Arc<ToolsState>) -> Vec<Tool> {
vec![
kb_search(Arc::clone(&state)),
kb_get_node(Arc::clone(&state)),
kb_get_neighborhood(Arc::clone(&state)),
kb_list_by_tag(Arc::clone(&state)),
kb_recent(Arc::clone(&state)),
kb_create_node(Arc::clone(&state)),
kb_update_node(state),
]
}
fn kb_search(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_search".into(),
description: "Full-text search across knowledge base nodes. Accepts FTS5 query syntax \
(phrase quotes, AND/OR, prefix term*, column filters title:term)."
.into(),
input_schema: schema_object(
&json!({
"query": string_field("FTS5 query string. Empty queries return [].")
}),
&["query"],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let q = require_str_field(o, "query")?.to_string();
let query_embedding = compute_query_embedding_blocking(&state, &q);
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
let ranked = match storage::search_hybrid(&conn, &q, query_embedding) {
Ok(r) => r,
Err(e) => {
return Ok(error_content(&format!("search failed: {e}")));
}
};
let ids: Vec<String> = ranked.into_iter().map(|n| n.0).collect();
let titles = match storage::fetch_titles(&conn, &ids) {
Ok(t) => t,
Err(e) => {
return Ok(error_content(&format!("fetch_titles failed: {e}")));
}
};
Ok(success_content(&summaries_from_pairs(&titles)))
}),
}
}
fn kb_get_node(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_get_node".into(),
description: "Retrieve a node by id. Returns the full document, derived title, tag list, \
and storage timestamps."
.into(),
input_schema: schema_object(
&json!({ "id": string_field("Node id (UUID or imported :ID: value).") }),
&["id"],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let id = require_str_field(o, "id")?.to_string();
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
match storage::get_node_full(&conn, &id) {
Ok(Some(nf)) => Ok(success_content(&node_view(&id, &nf))),
Ok(None) => Ok(error_content(&format!("no node with id: {id}"))),
Err(e) => Ok(error_content(&format!("get_node_full failed: {e}"))),
}
}),
}
}
fn kb_get_neighborhood(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_get_neighborhood".into(),
description: "Return the link neighborhood of a node: nodes it links to (outgoing) and \
nodes that link to it (incoming). Only [[id:UUID]] links are tracked. \
Returns an error if the node id is unknown."
.into(),
input_schema: schema_object(
&json!({ "id": string_field("Node id at the center of the neighborhood.") }),
&["id"],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let id = require_str_field(o, "id")?.to_string();
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
let exists = match storage::get_node(&conn, &id) {
Ok(v) => v.is_some(),
Err(e) => {
return Ok(error_content(&format!("get_node failed: {e}")));
}
};
if !exists {
return Ok(error_content(&format!("no node with id: {id}")));
}
match storage::get_neighborhood(&conn, &id) {
Ok(n) => {
let resp = NeighborhoodResponse {
outgoing: n
.outgoing
.into_iter()
.map(|(t, lt)| NeighborEdge {
target: t.0,
link_type: lt,
})
.collect(),
incoming: n
.incoming
.into_iter()
.map(|(t, lt)| NeighborEdge {
target: t.0,
link_type: lt,
})
.collect(),
};
Ok(success_content(
&serde_json::to_value(&resp).unwrap_or(Value::Null),
))
}
Err(e) => Ok(error_content(&format!("get_neighborhood failed: {e}"))),
}
}),
}
}
fn kb_list_by_tag(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_list_by_tag".into(),
description: "List node summaries (id + title) for nodes carrying a given tag.".into(),
input_schema: schema_object(
&json!({ "tag": string_field("Tag name (without surrounding colons).") }),
&["tag"],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let tag = require_str_field(o, "tag")?.to_string();
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
let rows = match storage::list_by_tag(&conn, &tag) {
Ok(r) => r,
Err(e) => return Ok(error_content(&format!("list_by_tag failed: {e}"))),
};
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let titles = match storage::fetch_titles(&conn, &ids) {
Ok(t) => t,
Err(e) => return Ok(error_content(&format!("fetch_titles failed: {e}"))),
};
Ok(success_content(&summaries_from_pairs(&titles)))
}),
}
}
fn kb_recent(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_recent".into(),
description: "Most-recently-updated nodes, newest first.".into(),
input_schema: schema_object(
&json!({
"limit": {
"type": "integer",
"minimum": 1,
"default": 50,
"description": "Maximum number of nodes to return."
}
}),
&[],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let limit: usize = match args {
Value::Object(o) => match o.get("limit") {
Some(Value::Number(n)) => match n.as_i64() {
Some(i) if i >= 1 => i as usize,
_ => return Err(invalid_params("limit must be an integer >= 1")),
},
Some(Value::Null) | None => 50,
Some(_) => return Err(invalid_params("limit must be an integer")),
},
Value::Null => 50,
_ => return Err(invalid_params("expected an object for tool arguments")),
};
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
let rows = match storage::list_recent(&conn, limit) {
Ok(r) => r,
Err(e) => return Ok(error_content(&format!("list_recent failed: {e}"))),
};
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let titles = match storage::fetch_titles(&conn, &ids) {
Ok(t) => t,
Err(e) => return Ok(error_content(&format!("fetch_titles failed: {e}"))),
};
Ok(success_content(&summaries_from_pairs(&titles)))
}),
}
}
fn kb_create_node(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_create_node".into(),
description: "Create a new node. The body is supplied either as 'document' (kb AST JSON) \
or as 'markdown' (GitHub-flavored markdown, converted via pandoc) — provide exactly \
one. If 'id' is supplied, it is used (and the call returns an error if it \
conflicts); otherwise the server mints a UUIDv4."
.into(),
input_schema: schema_object(
&json!({
"id": {
"type": "string",
"description": "Optional caller-provided id (e.g. an org-roam :ID:).",
},
"document": {
"type": "object",
"description": "kb AST (KB.AST.Document) as JSON. Provide this OR markdown.",
},
"markdown": {
"type": "string",
"description": "GitHub-flavored markdown body, converted via pandoc. \
Provide this OR document.",
}
}),
&[],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let supplied: Option<String> = match o.get("id") {
Some(Value::String(s)) => Some(s.clone()),
Some(Value::Null) | None => None,
Some(_) => return Err(invalid_params("field id must be a string")),
};
let doc = match resolve_body(o)? {
Ok(d) => d,
Err(e) => return Ok(error_content(&format!("markdown conversion failed: {e}"))),
};
let nid = match supplied {
Some(s) => s,
None => uuid::Uuid::new_v4().to_string(),
};
{
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
match storage::get_node(&conn, &nid) {
Ok(Some(_)) => {
return Ok(error_content(&format!("id already exists: {nid}")));
}
Ok(None) => {}
Err(e) => return Ok(error_content(&format!("get_node failed: {e}"))),
}
}
let embedding = compute_doc_embedding_blocking(&state, &doc, &nid);
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
if let Err(e) = storage::insert_node_with(
&conn,
&nid,
&doc,
embedding,
state.embedding_model.as_deref(),
) {
return Ok(error_content(&format!("insert_node failed: {e}")));
}
match storage::get_node_full(&conn, &nid) {
Ok(Some(nf)) => Ok(success_content(&node_view(&nid, &nf))),
Ok(None) => Ok(error_content(
"create succeeded but node disappeared before re-read",
)),
Err(e) => Ok(error_content(&format!("get_node_full failed: {e}"))),
}
}),
}
}
fn kb_update_node(state: Arc<ToolsState>) -> Tool {
Tool {
name: "kb_update_node".into(),
description: "Replace an existing node's document. The new body is supplied either as \
'document' (kb AST JSON) or as 'markdown' (GitHub-flavored markdown, converted via \
pandoc) — provide exactly one. Returns the new node view including the refreshed \
updated_at."
.into(),
input_schema: schema_object(
&json!({
"id": string_field("Node id to update."),
"document": {
"type": "object",
"description": "kb AST (KB.AST.Document) as JSON. Provide this OR markdown.",
},
"markdown": {
"type": "string",
"description": "GitHub-flavored markdown body, converted via pandoc. \
Provide this OR document.",
}
}),
&["id"],
),
handler: Box::new(move |args: &Value| -> Result<Value, RpcError> {
let o = require_object(args)?;
let id = require_str_field(o, "id")?.to_string();
let doc = match resolve_body(o)? {
Ok(d) => d,
Err(e) => return Ok(error_content(&format!("markdown conversion failed: {e}"))),
};
let embedding = compute_doc_embedding_blocking(&state, &doc, &id);
let conn = state
.conn
.lock()
.map_err(|_| super::protocol::internal_error("conn poisoned"))?;
match storage::update_node_with(
&conn,
&id,
&doc,
embedding,
state.embedding_model.as_deref(),
) {
Ok(true) => match storage::get_node_full(&conn, &id) {
Ok(Some(nf)) => Ok(success_content(&node_view(&id, &nf))),
Ok(None) => Ok(error_content(
"update succeeded but node disappeared before re-read",
)),
Err(e) => Ok(error_content(&format!("get_node_full failed: {e}"))),
},
Ok(false) => Ok(error_content(&format!("no node with id: {id}"))),
Err(e) => Ok(error_content(&format!("update_node failed: {e}"))),
}
}),
}
}
fn tool_meta(t: &Tool) -> Value {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
}
#[must_use]
pub fn tools_methods(state: Arc<ToolsState>) -> Methods {
let tools = Arc::new(mk_tools(state));
let list_tools = Arc::clone(&tools);
let list_handler: MethodHandler = Box::new(move |_params: Option<&Value>| {
let arr: Vec<Value> = list_tools.iter().map(tool_meta).collect();
Ok(json!({ "tools": arr }))
});
let call_tools = Arc::clone(&tools);
let call_handler: MethodHandler = Box::new(move |params: Option<&Value>| {
let p = params.ok_or_else(|| invalid_params("tools/call requires { name, arguments }"))?;
let Value::Object(obj) = p else {
return Err(invalid_params("tools/call params must be an object"));
};
let name = match obj.get("name") {
Some(Value::String(s)) => s.as_str(),
_ => return Err(invalid_params("tools/call requires a 'name' string")),
};
let args = obj.get("arguments").cloned().unwrap_or(Value::Null);
let tool = call_tools
.iter()
.find(|t| t.name == name)
.ok_or_else(|| invalid_params(&format!("unknown tool: {name}")))?;
(tool.handler)(&args)
});
let mut m: Methods = std::collections::BTreeMap::new();
m.insert("tools/list".into(), list_handler);
m.insert("tools/call".into(), call_handler);
m
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{Block, Document, Inline, Tag, Title};
fn setup() -> Arc<ToolsState> {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap();
storage::init_db(&conn).unwrap();
Arc::new(ToolsState::new(
Arc::new(Mutex::new(conn)),
None,
None,
None,
))
}
fn sample_doc() -> Document {
Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("Hello".into()),
tags: vec![Tag("topic".into())],
children: vec![Block::Paragraph {
inlines: vec![Inline::Plain("body".into())],
}],
}],
}
}
fn unwrap_envelope(v: &Value) -> (String, bool) {
let text = v["content"][0]["text"].as_str().unwrap().to_string();
let is_err = v["isError"].as_bool().unwrap();
(text, is_err)
}
#[test]
fn mcp_tools_list_returns_seven_tools() {
let state = setup();
let m = tools_methods(state);
let h = m.get("tools/list").unwrap();
let v = h(None).unwrap();
let arr = v["tools"].as_array().unwrap();
assert_eq!(arr.len(), 7);
let names: Vec<&str> = arr.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"kb_search",
"kb_get_node",
"kb_get_neighborhood",
"kb_list_by_tag",
"kb_recent",
"kb_create_node",
"kb_update_node",
] {
assert!(names.contains(&expected), "missing {expected}");
}
}
#[test]
fn mcp_tools_list_each_tool_has_required_fields() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/list").unwrap()(None).unwrap();
for t in v["tools"].as_array().unwrap() {
assert!(t["name"].is_string());
assert!(t["description"].is_string());
let schema = &t["inputSchema"];
assert_eq!(schema["type"], "object");
assert!(schema["properties"].is_object());
assert!(schema["required"].is_array());
}
}
#[test]
fn mcp_tools_call_unknown_tool_returns_invalid_params() {
let state = setup();
let m = tools_methods(state);
let err = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_nope",
"arguments": {}
})))
.unwrap_err();
assert_eq!(err.code, -32602);
}
#[test]
fn mcp_tools_call_missing_params_returns_invalid_params() {
let state = setup();
let m = tools_methods(state);
let err = m.get("tools/call").unwrap()(None).unwrap_err();
assert_eq!(err.code, -32602);
}
#[test]
fn mcp_tool_handlers_kb_create_node_mints_uuid_when_id_absent() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "document": sample_doc() }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err, "got error: {text}");
let view: Value = serde_json::from_str(&text).unwrap();
let id = view["id"].as_str().unwrap();
assert!(uuid::Uuid::parse_str(id).is_ok());
}
#[test]
fn mcp_tool_handlers_kb_create_node_uses_supplied_id() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "id": "abc", "document": sample_doc() }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err);
let view: Value = serde_json::from_str(&text).unwrap();
assert_eq!(view["id"].as_str().unwrap(), "abc");
}
#[test]
fn mcp_tool_handlers_kb_create_node_accepts_markdown() {
if std::process::Command::new("pandoc")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: pandoc not on PATH");
return;
}
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "markdown": "# MD Heading\n\nbody\n" }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err, "got error: {text}");
let view: Value = serde_json::from_str(&text).unwrap();
assert_eq!(view["title"].as_str().unwrap(), "MD Heading");
}
#[test]
fn mcp_tool_handlers_kb_create_node_rejects_both_body_forms() {
let state = setup();
let m = tools_methods(state);
let err = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "document": sample_doc(), "markdown": "# x\n" }
})))
.unwrap_err();
assert_eq!(err.code, -32602);
}
#[test]
fn mcp_tool_handlers_kb_create_node_requires_a_body() {
let state = setup();
let m = tools_methods(state);
let err = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": {}
})))
.unwrap_err();
assert_eq!(err.code, -32602);
}
#[test]
fn mcp_tool_handlers_kb_create_node_conflict_returns_iserror_envelope() {
let state = setup();
let m = tools_methods(state);
let _ = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "id": "dup", "document": sample_doc() }
})))
.unwrap();
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_create_node",
"arguments": { "id": "dup", "document": sample_doc() }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(is_err);
assert!(text.contains("already exists"));
}
#[test]
fn mcp_tool_handlers_kb_update_node_unknown_id_returns_iserror() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_update_node",
"arguments": { "id": "nope", "document": sample_doc() }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(is_err);
assert!(text.contains("no node with id"));
}
#[test]
fn mcp_tool_handlers_kb_get_node_missing_returns_iserror() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_get_node",
"arguments": { "id": "absent" }
})))
.unwrap();
let (_, is_err) = unwrap_envelope(&v);
assert!(is_err);
}
#[test]
fn mcp_tool_handlers_kb_recent_default_limit() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_recent",
"arguments": {}
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err);
let arr: Value = serde_json::from_str(&text).unwrap();
assert!(arr.is_array());
}
#[test]
fn mcp_tool_handlers_kb_search_returns_summary_array_envelope() {
let state = setup();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "n1", &sample_doc()).unwrap();
}
let m = tools_methods(Arc::clone(&state));
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_search",
"arguments": { "query": "Hello" }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err);
let arr: Value = serde_json::from_str(&text).unwrap();
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "n1");
}
#[test]
fn mcp_tool_handlers_kb_get_neighborhood_unknown_id_returns_iserror() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_get_neighborhood",
"arguments": { "id": "nope" }
})))
.unwrap();
let (_, is_err) = unwrap_envelope(&v);
assert!(is_err);
}
#[test]
#[allow(non_snake_case)]
fn mcp_tools_call_success_envelope_shape_is_content_text_isError_false() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_recent",
"arguments": {}
})))
.unwrap();
assert_eq!(v["isError"], false);
let content = v["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
let text = content[0]["text"].as_str().unwrap();
let _: Value = serde_json::from_str(text).unwrap();
}
#[test]
#[allow(non_snake_case)]
fn mcp_tools_call_error_envelope_shape_is_content_text_isError_true() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_get_node",
"arguments": { "id": "missing" }
})))
.unwrap();
assert_eq!(v["isError"], true);
assert_eq!(v["content"][0]["type"], "text");
assert!(
v["content"][0]["text"]
.as_str()
.unwrap()
.contains("no node")
);
}
#[test]
fn mcp_tool_handlers_kb_get_neighborhood_returns_neighborhood_response_shape() {
let state = setup();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "centre", &sample_doc()).unwrap();
}
let m = tools_methods(Arc::clone(&state));
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_get_neighborhood",
"arguments": { "id": "centre" }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err, "got error: {text}");
let payload: Value = serde_json::from_str(&text).unwrap();
assert!(payload["outgoing"].is_array());
assert!(payload["incoming"].is_array());
}
#[test]
fn mcp_tools_call_dispatches_by_name_and_passes_arguments() {
let state = setup();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "tagged", &sample_doc()).unwrap();
}
let m = tools_methods(Arc::clone(&state));
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_list_by_tag",
"arguments": { "tag": "topic" }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err);
let arr: Value = serde_json::from_str(&text).unwrap();
let arr = arr.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"], "tagged");
}
#[test]
fn mcp_tools_list_input_schemas_have_expected_required_fields() {
let state = setup();
let m = tools_methods(state);
let v = m.get("tools/list").unwrap()(None).unwrap();
let by_name: std::collections::HashMap<String, Value> = v["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| (t["name"].as_str().unwrap().to_string(), t.clone()))
.collect();
let req = |name: &str| -> Vec<String> {
by_name[name]["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.map(|s| s.as_str().unwrap().to_string())
.collect()
};
assert_eq!(req("kb_search"), vec!["query".to_string()]);
assert_eq!(req("kb_get_node"), vec!["id".to_string()]);
assert_eq!(req("kb_get_neighborhood"), vec!["id".to_string()]);
assert_eq!(req("kb_list_by_tag"), vec!["tag".to_string()]);
assert!(req("kb_recent").is_empty());
assert!(req("kb_create_node").is_empty());
assert_eq!(req("kb_update_node"), vec!["id".to_string()]);
}
#[test]
fn mcp_tool_handlers_kb_update_node_returns_node_view_after_update() {
let state = setup();
{
let conn = state.conn.lock().unwrap();
storage::insert_node(&conn, "edit-me", &sample_doc()).unwrap();
}
let m = tools_methods(Arc::clone(&state));
let new_doc = Document {
blocks: vec![Block::Heading {
level: 1,
title: Title("Replaced".into()),
tags: vec![],
children: vec![],
}],
};
let v = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_update_node",
"arguments": { "id": "edit-me", "document": new_doc }
})))
.unwrap();
let (text, is_err) = unwrap_envelope(&v);
assert!(!is_err);
let view: Value = serde_json::from_str(&text).unwrap();
assert_eq!(view["id"], "edit-me");
assert_eq!(view["title"], "Replaced");
assert!(view["createdAt"].is_string());
assert!(view["updatedAt"].is_string());
}
#[test]
fn mcp_tool_handlers_invalid_arguments_returns_invalid_params() {
let state = setup();
let m = tools_methods(state);
let err = m.get("tools/call").unwrap()(Some(&json!({
"name": "kb_get_node",
"arguments": {}
})))
.unwrap_err();
assert_eq!(err.code, -32602);
}
}