//! MCP tool registry — definitions and dispatch.
//!
//! **Functional core**: tool definitions are pure data, dispatch is a pure
//! function mapping tool names to handler functions. No IO in this module.
use crate::mcp::protocol::ToolDefinition;
use serde_json::{json, Value};
/// Build the complete list of tool definitions for `tools/list`.
///
/// Each tool has a name, description, and JSON Schema for its parameters.
/// This is a pure function — returns fresh data each call.
pub fn all_tool_definitions() -> Vec<ToolDefinition> {
vec![
// -----------------------------------------------------------------
// Graph CRUD
// -----------------------------------------------------------------
ToolDefinition {
name: "add_node".to_string(),
description: "Add a new node to the decision graph. Node types: goal, decision, option, action, outcome, observation, revisit.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_type": {
"type": "string",
"description": "Node type: goal, decision, option, action, outcome, observation, or revisit",
"enum": ["goal", "decision", "option", "action", "outcome", "observation", "revisit"]
},
"title": {
"type": "string",
"description": "Title of the node"
},
"description": {
"type": "string",
"description": "Optional description with more detail"
},
"confidence": {
"type": "integer",
"description": "Confidence level 0-100",
"minimum": 0,
"maximum": 100
},
"prompt": {
"type": "string",
"description": "The user prompt that triggered this node (capture verbatim for context recovery)"
},
"files": {
"type": "string",
"description": "Comma-separated list of associated files"
},
"branch": {
"type": "string",
"description": "Git branch (auto-detected if omitted)"
},
"commit": {
"type": "string",
"description": "Git commit to link: HEAD, a branch, a tag, origin/main or a hash (prefix); stored as the full hash. A rev this checkout cannot resolve is refused."
}
},
"required": ["node_type", "title"]
}),
},
ToolDefinition {
name: "link_nodes".to_string(),
description: "Create an edge between two nodes in the decision graph.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"from_id": {
"type": ["integer", "string"],
"description": "Source node ID"
},
"to_id": {
"type": ["integer", "string"],
"description": "Target node ID"
},
"rationale": {
"type": "string",
"description": "Reason for this connection"
},
"edge_type": {
"type": "string",
"description": "Edge type (default: leads_to)",
"enum": ["leads_to", "requires", "chosen", "rejected", "blocks", "enables", "took_from"],
"default": "leads_to"
}
},
"required": ["from_id", "to_id"]
}),
},
ToolDefinition {
name: "unlink_nodes".to_string(),
description: "Remove an edge between two nodes. When they are joined by more than one edge type, edge_type names the one to remove; without it the call is refused and nothing is removed.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"from_id": {
"type": ["integer", "string"],
"description": "Source node ID"
},
"to_id": {
"type": ["integer", "string"],
"description": "Target node ID"
},
"edge_type": {
"type": "string",
"description": "Remove only the edge of this type (leads_to, chosen, ...)"
}
},
"required": ["from_id", "to_id"]
}),
},
ToolDefinition {
name: "delete_node".to_string(),
description: "Delete a node and all its connected edges. Use dry_run to preview.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "ID of the node to delete"
},
"dry_run": {
"type": "boolean",
"description": "If true, show what would be deleted without actually deleting",
"default": false
}
},
"required": ["node_id"]
}),
},
ToolDefinition {
name: "update_status".to_string(),
description: "Update the status of a node (active, superseded, abandoned, pending, completed, rejected).".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to update"
},
"status": {
"type": "string",
"description": "New status",
"enum": ["pending", "active", "completed", "rejected", "superseded", "abandoned"]
}
},
"required": ["node_id", "status"]
}),
},
ToolDefinition {
name: "update_prompt".to_string(),
description: "Add or update the verbatim user prompt stored on a node.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to update"
},
"prompt": {
"type": "string",
"description": "The verbatim user prompt text"
}
},
"required": ["node_id", "prompt"]
}),
},
// -----------------------------------------------------------------
// Graph Querying
// -----------------------------------------------------------------
ToolDefinition {
name: "list_nodes".to_string(),
description: "List all nodes in the decision graph with optional filters for branch, type, theme, and status.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"branch": {
"type": "string",
"description": "Filter by git branch"
},
"node_type": {
"type": "string",
"description": "Filter by type: goal, decision, option, action, outcome, observation, revisit"
},
"status": {
"type": "string",
"description": "Filter by status: active, pending, completed, superseded, abandoned"
},
"theme": {
"type": "string",
"description": "Filter by theme name"
}
}
}),
},
ToolDefinition {
name: "list_edges".to_string(),
description: "List all edges in the decision graph.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "show_node".to_string(),
description: "Show detailed information about a single node including its connections, themes, and documents.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to display"
}
},
"required": ["node_id"]
}),
},
ToolDefinition {
name: "get_graph".to_string(),
description: "Export the full decision graph as JSON (all nodes and edges).".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "search_nodes".to_string(),
description: "Search nodes by text across titles, descriptions, and prompts. Returns matching nodes with context.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (matched against title, description, and prompt)"
},
"node_type": {
"type": "string",
"description": "Optional: filter results by node type"
},
"branch": {
"type": "string",
"description": "Optional: filter results by branch"
}
},
"required": ["query"]
}),
},
// -----------------------------------------------------------------
// Analysis / Reporting
// -----------------------------------------------------------------
ToolDefinition {
name: "trace_chain".to_string(),
description: "Trace the full chain of connected nodes from a starting node using BFS. Returns the complete connected subgraph — follow all edges in both directions to find the entire decision tree.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Starting node ID for the trace"
},
"max_depth": {
"type": "integer",
"description": "Maximum traversal depth (0 = unlimited)",
"default": 0
},
"direction": {
"type": "string",
"description": "Traversal direction: both, outgoing, incoming",
"enum": ["both", "outgoing", "incoming"],
"default": "both"
}
},
"required": ["node_id"]
}),
},
ToolDefinition {
name: "get_node_context".to_string(),
description: "Get the full context around a node: the node itself, its parents (incoming edges), children (outgoing edges), sibling nodes, associated themes, and documents. Ideal for understanding a single decision point.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to get context for"
}
},
"required": ["node_id"]
}),
},
ToolDefinition {
name: "get_timeline".to_string(),
description: "Get a chronological timeline of nodes, newest first. Useful for understanding what happened and when.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum number of nodes to return (0 = all)",
"default": 50
},
"node_type": {
"type": "string",
"description": "Filter by node type"
},
"branch": {
"type": "string",
"description": "Filter by git branch"
},
"since": {
"type": "string",
"description": "Only show nodes created after this date (YYYY-MM-DD)"
}
}
}),
},
ToolDefinition {
name: "get_pulse".to_string(),
description: "Get the health and status of the decision graph: summary statistics, active goals, recent activity, orphaned nodes, and connection gaps.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"branch": {
"type": "string",
"description": "Filter by git branch"
}
}
}),
},
ToolDefinition {
name: "find_orphans".to_string(),
description: "Find nodes that violate connection rules: outcomes without parent actions, actions without parent decisions, decisions without parent options, etc. Root goals are excluded (they are valid orphans).".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "get_branch_summary".to_string(),
description: "Get a summary of all decisions and activity on a specific git branch. Shows goals, decisions made, actions taken, and outcomes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"branch": {
"type": "string",
"description": "Git branch name to summarize"
}
},
"required": ["branch"]
}),
},
// -----------------------------------------------------------------
// Documents
// -----------------------------------------------------------------
ToolDefinition {
name: "attach_document".to_string(),
description: "Attach a file to a decision graph node.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to attach the file to"
},
"file_path": {
"type": "string",
"description": "Path to the file to attach"
},
"description": {
"type": "string",
"description": "Description of the document"
}
},
"required": ["node_id", "file_path"]
}),
},
ToolDefinition {
name: "list_documents".to_string(),
description: "List documents attached to a specific node, or all documents if no node ID given.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to list documents for (omit for all)"
}
}
}),
},
// -----------------------------------------------------------------
// Themes
// -----------------------------------------------------------------
ToolDefinition {
name: "list_themes".to_string(),
description: "List all defined themes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "create_theme".to_string(),
description: "Create a new theme for tagging nodes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Theme name (lowercase, dash-separated)"
},
"color": {
"type": "string",
"description": "Hex color code (e.g., '#ef4444')",
"default": "#6b7280"
},
"description": {
"type": "string",
"description": "Theme description"
}
},
"required": ["name"]
}),
},
ToolDefinition {
name: "tag_node".to_string(),
description: "Add a theme tag to a node.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to tag"
},
"theme": {
"type": "string",
"description": "Theme name to apply"
}
},
"required": ["node_id", "theme"]
}),
},
ToolDefinition {
name: "untag_node".to_string(),
description: "Remove a theme tag from a node.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"node_id": {
"type": ["integer", "string"],
"description": "Node ID to untag"
},
"theme": {
"type": "string",
"description": "Theme name to remove"
}
},
"required": ["node_id", "theme"]
}),
},
// -----------------------------------------------------------------
// Export
// -----------------------------------------------------------------
ToolDefinition {
name: "export_dot".to_string(),
description: "Export the decision graph (or a subset) as DOT format for visualization.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"roots": {
"type": "string",
"description": "Root node IDs (comma-separated) — traverses children"
},
"nodes": {
"type": "string",
"description": "Specific node IDs or ranges (e.g., '1-11' or '1,3,5-10')"
},
"title": {
"type": "string",
"description": "Graph title"
},
"rankdir": {
"type": "string",
"description": "Graph direction: TB (top-bottom) or LR (left-right)",
"enum": ["TB", "LR"],
"default": "TB"
}
}
}),
},
ToolDefinition {
name: "generate_writeup".to_string(),
description: "Generate a PR writeup from the decision graph. Returns markdown text summarizing goals, decisions, and outcomes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "PR title"
},
"roots": {
"type": "string",
"description": "Root node IDs (comma-separated)"
},
"nodes": {
"type": "string",
"description": "Specific node IDs or ranges"
},
"no_dot": {
"type": "boolean",
"description": "Skip DOT graph section",
"default": false
},
"no_test_plan": {
"type": "boolean",
"description": "Skip test plan section",
"default": false
}
}
}),
},
// -----------------------------------------------------------------
// Sessions (conversation-scoped trees)
// -----------------------------------------------------------------
ToolDefinition {
name: "start_session".to_string(),
description: "Start a new conversation session. Creates a root goal node for this conversation and returns a session ID. All nodes created during this session are automatically associated with it, giving each conversation its own decision tree.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Session name (e.g., 'cowork: auth refactor' or 'chat: debugging memory leak')"
},
"goal_title": {
"type": "string",
"description": "Title for the root goal node of this session"
},
"goal_prompt": {
"type": "string",
"description": "The initial user prompt/request that started this conversation (verbatim)"
}
},
"required": ["name", "goal_title"]
}),
},
ToolDefinition {
name: "end_session".to_string(),
description: "End the current conversation session. Marks it as complete with an optional summary.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Brief summary of what was accomplished in this session"
}
}
}),
},
ToolDefinition {
name: "get_session".to_string(),
description: "Get details about the current session or a specific session, including all its nodes.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"session_id": {
"type": "integer",
"description": "Session ID to query (omit for current session)"
}
}
}),
},
ToolDefinition {
name: "resume_session".to_string(),
description: "Resume a previously started session by ID. Use this to reconnect to a long-running conversation's decision tree after a break or server restart.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"session_id": {
"type": "integer",
"description": "Session ID to resume"
}
},
"required": ["session_id"]
}),
},
ToolDefinition {
name: "list_sessions".to_string(),
description: "List all conversation sessions (decision trees), optionally filtering to only active ones.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"active_only": {
"type": "boolean",
"description": "If true, only show sessions that haven't ended",
"default": false
}
}
}),
},
ToolDefinition {
name: "sync_status".to_string(),
description: "Show multi-user sync status: whether .deciduous/graph.json exists, how many records it holds, and how many changes are pending in each direction. Node ids are local; records are shared by change_id.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
ToolDefinition {
name: "sync".to_string(),
description: "Reconcile the local database with .deciduous/graph.json in both directions. Run after `git pull` to receive teammates' decisions (they get local ids here) and before `git push` so your decisions are written out. Creates the file on first use.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"dry_run": {
"type": "boolean",
"description": "Only report what would change",
"default": false
}
}
}),
},
// -----------------------------------------------------------------
// Message board: agents working at the same time
// -----------------------------------------------------------------
ToolDefinition {
name: "post_message".to_string(),
description: format!(
"Post to the agent message board. When more than one agent works at once, interface changes, questions and answers go here, never into a scratch or markdown file. Address agents with @label in subject or body; answer a message with reply_to its id. Messages are not graph nodes: not in graph.json, not exported, not synced. Local projects keep one board for all git worktrees; a project with a [remote] posts to the server (which needs {}+).",
crate::board::SERVER_VERSION_NEEDED
),
input_schema: json!({
"type": "object",
"properties": {
"workspace": {"type": "string", "maxLength": 262144, "description": BOARD_WORKSPACE_DESC},
"branch": {"type": "string", "maxLength": 512, "description": "Branch the message is about (default: the current one)"},
"author": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Your label, e.g. \"lead\" or \"A-retrieval\" (at most 100 characters)"},
"subject": {"type": "string", "minLength": 1, "maxLength": 300, "description": "Subject line (at most 300 characters)"},
"body": {"type": "string", "minLength": 1, "maxLength": 65536, "description": "The message (at most 64 KiB). @label anywhere addresses it."},
"reply_to": {"type": "integer", "minimum": 1, "maximum": i64::MAX, "description": "Id of the message this answers; must exist on this board"}
},
"required": ["author", "subject", "body"],
"additionalProperties": false
}),
},
ToolDefinition {
name: "read_messages".to_string(),
description: "Read the agent message board, oldest first. At start, before touching shared files, and before finishing, call it with unanswered_for set to your own label: it returns messages that mention you and that you have not answered with reply_to. Poll with since_id = the latest_id of the previous call.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"workspace": {"type": "string", "maxLength": 262144, "description": BOARD_WORKSPACE_DESC},
"branch": {"type": "string", "maxLength": 512, "description": "Only messages posted with this branch"},
"since_id": {"type": "integer", "minimum": 0, "maximum": i64::MAX, "description": "Only messages with an id greater than this"},
"author": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Only messages by this label"},
"to": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Only messages mentioning this label"},
"unanswered_for": {"type": "string", "minLength": 1, "maxLength": 100, "description": "Messages mentioning this label that no message by this label replies to"},
"query": {"type": "string", "minLength": 1, "maxLength": 1000, "description": "Text to find in subject or body"},
"id": {"type": "integer", "minimum": 1, "maximum": i64::MAX, "description": "One message by id (an unknown id is an empty list)"},
"limit": {"type": "integer", "description": "At most this many (default 50, max 200); truncated says whether more match", "minimum": 1, "maximum": 200}
},
"additionalProperties": false
}),
},
]
}
/// The `workspace` argument of the board tools. It is there so the tools
/// read the same here as on the server; a local board has one workspace.
const BOARD_WORKSPACE_DESC: &str = "Project this call belongs to: `workspace` under [remote] in the repository's .deciduous/config.toml when it has one, otherwise the basename of the git repo root. Optional here; a workspace other than this project's is refused.";
/// Look up a tool definition by name. Returns None if not found.
pub fn find_tool(name: &str) -> Option<ToolDefinition> {
all_tool_definitions().into_iter().find(|t| t.name == name)
}
/// Get all tool names as a sorted list.
pub fn tool_names() -> Vec<String> {
let mut names: Vec<String> = all_tool_definitions().into_iter().map(|t| t.name).collect();
names.sort();
names
}
/// Validate that a tool name exists in the registry.
pub fn is_valid_tool(name: &str) -> bool {
all_tool_definitions().iter().any(|t| t.name == name)
}
/// Validate tool arguments against the tool's input schema.
/// Returns Ok(()) if valid, Err(message) if invalid.
pub fn validate_tool_args(tool_name: &str, args: &Value) -> Result<(), String> {
let tool = find_tool(tool_name).ok_or_else(|| format!("Unknown tool: {tool_name}"))?;
let schema = &tool.input_schema;
// Check required fields
if let Some(required) = schema.get("required").and_then(Value::as_array) {
for field in required {
if let Some(field_name) = field.as_str() {
let empty = serde_json::Map::new();
let args_obj = args.as_object().unwrap_or(&empty);
if !args_obj.contains_key(field_name) {
return Err(format!("Missing required parameter: {field_name}"));
}
}
}
}
// An argument the tool does not declare. Every handler reads only the
// names its schema declares, so an unknown one used to vanish and the
// call succeeded doing something else: a misspelt `stauts` left the
// status as it was and answered success.
let declared: Vec<&str> = schema
.get("properties")
.and_then(Value::as_object)
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
if let Some(obj) = args.as_object() {
let mut unknown: Vec<&str> = obj
.keys()
.map(String::as_str)
.filter(|k| !declared.contains(k))
.collect();
if !unknown.is_empty() {
unknown.sort_unstable();
let named: Vec<String> = unknown
.iter()
.map(|k| match suggest(k, &declared) {
Some(s) => format!("\"{k}\" (did you mean {s}?)"),
None => format!("\"{k}\""),
})
.collect();
let mut all = declared.clone();
all.sort_unstable();
return Err(format!(
"{tool_name} has no argument{} {}. Its arguments are: {}",
if unknown.len() == 1 { "" } else { "s" },
named.join("; "),
all.join(", ")
));
}
}
Ok(())
}
/// The declared argument a caller most likely meant by `key`.
fn suggest<'a>(key: &str, declared: &[&'a str]) -> Option<&'a str> {
const ALIASES: &[(&str, &[&str])] = &[
("type", &["node_type", "edge_type"]),
("from", &["from_id", "author"]),
("to", &["to_id"]),
("from_node_id", &["from_id"]),
("to_node_id", &["to_id"]),
("id", &["node_id"]),
("parent_id", &["from_id", "node_id"]),
("name", &["title"]),
("text", &["description", "title", "body"]),
("search", &["query"]),
("q", &["query"]),
("reason", &["rationale"]),
// The board tools, as the CLI spells them.
("unanswered", &["unanswered_for"]),
("since", &["since_id"]),
("message", &["body"]),
("as", &["author"]),
("reply", &["reply_to"]),
("in_reply_to", &["reply_to"]),
];
if let Some((_, targets)) = ALIASES.iter().find(|(k, _)| *k == key) {
if let Some(t) = targets.iter().find(|t| declared.contains(t)) {
return declared.iter().copied().find(|d| d == t);
}
}
declared
.iter()
.copied()
.map(|d| (d, edit_distance(key, d)))
.filter(|(d, dist)| *dist <= 2.max(d.len() / 4))
.min_by_key(|(_, dist)| *dist)
.map(|(d, _)| d)
}
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut cur = vec![i + 1; b.len() + 1];
for (j, cb) in b.iter().enumerate() {
let sub = prev[j] + usize::from(ca != *cb);
cur[j + 1] = sub.min(prev[j + 1] + 1).min(cur[j] + 1);
}
prev = cur;
}
prev[b.len()]
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// validate_tool_args checks the top level only. That is complete while
/// no argument is an object or an array: the server's MCP had nested
/// arguments, checked only at the top, and a misspelt key inside one
/// vanished (chapter 30 verification, T7/T11 nested). A tool that adds
/// one here must check its keys too, and this fails until it does.
#[test]
fn t7_t11_nested_no_tool_takes_an_object_or_array_argument() {
for tool in all_tool_definitions() {
let props = tool.input_schema["properties"].as_object().unwrap();
for (name, spec) in props {
let types: Vec<&str> = match &spec["type"] {
Value::String(t) => vec![t.as_str()],
Value::Array(ts) => ts.iter().filter_map(Value::as_str).collect(),
_ => vec![],
};
assert!(
!types.iter().any(|t| *t == "object" || *t == "array"),
"{}.{name} is {types:?}; validate_tool_args would not see a misspelt key inside it",
tool.name
);
}
}
}
#[test]
fn test_all_tools_have_valid_schemas() {
let tools = all_tool_definitions();
for tool in &tools {
assert!(!tool.name.is_empty(), "Tool name must not be empty");
assert!(
!tool.description.is_empty(),
"Tool {} must have a description",
tool.name
);
assert!(
tool.input_schema.is_object(),
"Tool {} input_schema must be an object",
tool.name
);
assert_eq!(
tool.input_schema["type"], "object",
"Tool {} input_schema type must be 'object'",
tool.name
);
}
}
#[test]
fn test_no_duplicate_tool_names() {
let tools = all_tool_definitions();
let mut seen = std::collections::HashSet::new();
for tool in &tools {
assert!(
seen.insert(&tool.name),
"Duplicate tool name: {}",
tool.name
);
}
}
#[test]
fn test_tool_count() {
let tools = all_tool_definitions();
// 6 CRUD + 5 Query + 6 Analysis + 2 Docs + 4 Themes + 5 Sessions + 2 Export + 2 Sync
// + 2 Board = 34
assert_eq!(tools.len(), 34, "Expected 34 tools, got {}", tools.len());
}
/// The board tools take exactly the arguments the 1.0.9 interface gives
/// them on the server (board post 16), with the same types, so an agent
/// moving between a local and a remote project calls them the same way.
#[test]
fn board_tool_schemas_match_the_interface() {
// The server's tools/list inputSchema for both tools, descriptions
// dropped, as board-server captured it from the Elixir router at
// 30f99cb (board post 19). Everything but the descriptions has to
// be identical: names, types, bounds, required, closed objects.
let server = json!({
"post_message": {"additionalProperties":false,"properties":{"author":{"maxLength":100,"minLength":1,"type":"string"},"body":{"maxLength":65536,"minLength":1,"type":"string"},"branch":{"maxLength":512,"type":"string"},"reply_to":{"maximum":9223372036854775807_i64,"minimum":1,"type":"integer"},"subject":{"maxLength":300,"minLength":1,"type":"string"},"workspace":{"maxLength":262144,"type":"string"}},"required":["author","subject","body"],"type":"object"},
"read_messages": {"additionalProperties":false,"properties":{"author":{"maxLength":100,"minLength":1,"type":"string"},"branch":{"maxLength":512,"type":"string"},"id":{"maximum":9223372036854775807_i64,"minimum":1,"type":"integer"},"limit":{"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":1000,"minLength":1,"type":"string"},"since_id":{"maximum":9223372036854775807_i64,"minimum":0,"type":"integer"},"to":{"maxLength":100,"minLength":1,"type":"string"},"unanswered_for":{"maxLength":100,"minLength":1,"type":"string"},"workspace":{"maxLength":262144,"type":"string"}},"type":"object"}
});
fn strip(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(
m.iter()
.filter(|(k, _)| k.as_str() != "description")
.map(|(k, v)| (k.clone(), strip(v)))
.collect(),
),
other => other.clone(),
}
}
for name in ["post_message", "read_messages"] {
assert_eq!(
strip(&find_tool(name).unwrap().input_schema),
server[name],
"{name} differs from the server's schema"
);
}
let e = validate_tool_args("read_messages", &json!({"unanswered": "lead"})).unwrap_err();
assert!(
e.contains("\"unanswered\" (did you mean unanswered_for?)"),
"{e}"
);
let e = validate_tool_args("read_messages", &json!({"from": "lead"})).unwrap_err();
assert!(e.contains("did you mean author?"), "{e}");
let e = validate_tool_args(
"post_message",
&json!({"author": "a", "subject": "s", "body": "b", "message": "m"}),
)
.unwrap_err();
assert!(e.contains("did you mean body?"), "{e}");
}
#[test]
fn test_find_tool_exists() {
assert!(find_tool("add_node").is_some());
assert!(find_tool("link_nodes").is_some());
assert!(find_tool("trace_chain").is_some());
assert!(find_tool("get_pulse").is_some());
}
#[test]
fn test_find_tool_not_exists() {
assert!(find_tool("nonexistent_tool").is_none());
assert!(find_tool("").is_none());
}
#[test]
fn test_is_valid_tool() {
assert!(is_valid_tool("add_node"));
assert!(is_valid_tool("list_nodes"));
assert!(!is_valid_tool("fake_tool"));
}
#[test]
fn test_tool_names_sorted() {
let names = tool_names();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "tool_names() must return sorted names");
}
#[test]
fn test_validate_tool_args_valid() {
let args = serde_json::json!({"node_type": "goal", "title": "Test"});
assert!(validate_tool_args("add_node", &args).is_ok());
}
#[test]
fn test_validate_tool_args_missing_required() {
// add_node requires node_type and title
let args = serde_json::json!({"node_type": "goal"});
let result = validate_tool_args("add_node", &args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("title"));
}
#[test]
fn test_validate_tool_args_no_required_fields() {
// list_nodes has no required fields
let args = serde_json::json!({});
assert!(validate_tool_args("list_nodes", &args).is_ok());
}
/// T7/T11: an argument the tool does not declare used to be ignored,
/// so a misspelt name did something other than what was asked.
#[test]
fn t7_unknown_argument_is_refused_naming_the_right_one() {
let args = serde_json::json!({"node_id": 1, "stauts": "completed", "status": "completed"});
let err = validate_tool_args("update_status", &args).unwrap_err();
assert!(err.contains("\"stauts\""), "{err}");
assert!(err.contains("did you mean status?"), "{err}");
let args = serde_json::json!({"node_type": "goal", "title": "t", "type": "goal"});
let err = validate_tool_args("add_node", &args).unwrap_err();
assert!(err.contains("did you mean node_type?"), "{err}");
let args = serde_json::json!({"from": 1, "to_id": 2, "from_id": 1});
let err = validate_tool_args("link_nodes", &args).unwrap_err();
assert!(err.contains("\"from\" (did you mean from_id?)"), "{err}");
let args = serde_json::json!({"node_type": "goal", "title": "t", "qqq": 1});
let err = validate_tool_args("add_node", &args).unwrap_err();
assert!(
err.contains("\"qqq\"") && err.contains("Its arguments are:"),
"{err}"
);
}
/// T12: every argument a handler refuses the call without is marked
/// required in the schema, so a client learns it from tools/list
/// rather than from the refusal.
#[test]
fn t12_every_argument_a_handler_requires_is_marked_required() {
let dir = tempfile::tempdir().unwrap();
let db = crate::db::Database::open_at(dir.path().join("t.db")).unwrap();
for tool in all_tool_definitions() {
let result = crate::mcp::handlers::dispatch(&db, &tool.name, serde_json::json!({}));
let text = result
.content
.first()
.map(|c| c.text.clone())
.unwrap_or_default();
if let Some(rest) = text.split("Missing required parameter: ").nth(1) {
let field = rest
.split(|c: char| !(c.is_alphanumeric() || c == '_'))
.next()
.unwrap();
let required: Vec<&str> = tool.input_schema["required"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
assert!(
required.contains(&field),
"{} refuses a call without {field} but does not mark it required",
tool.name
);
}
}
}
#[test]
fn test_validate_tool_args_unknown_tool() {
let args = serde_json::json!({});
let result = validate_tool_args("fake_tool", &args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown tool"));
}
#[test]
fn test_crud_tools_present() {
let names = tool_names();
for name in &[
"add_node",
"link_nodes",
"unlink_nodes",
"delete_node",
"update_status",
"update_prompt",
] {
assert!(
names.contains(&name.to_string()),
"Missing CRUD tool: {name}"
);
}
}
#[test]
fn test_query_tools_present() {
let names = tool_names();
for name in &[
"list_nodes",
"list_edges",
"show_node",
"get_graph",
"search_nodes",
] {
assert!(
names.contains(&name.to_string()),
"Missing query tool: {name}"
);
}
}
#[test]
fn test_analysis_tools_present() {
let names = tool_names();
for name in &[
"trace_chain",
"get_node_context",
"get_timeline",
"get_pulse",
"find_orphans",
"get_branch_summary",
] {
assert!(
names.contains(&name.to_string()),
"Missing analysis tool: {name}"
);
}
}
#[test]
fn test_add_node_schema_has_enum() {
let tool = find_tool("add_node").unwrap();
let node_type_enum = &tool.input_schema["properties"]["node_type"]["enum"];
assert!(node_type_enum.is_array());
let types: Vec<&str> = node_type_enum
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(types.contains(&"goal"));
assert!(types.contains(&"decision"));
assert!(types.contains(&"observation"));
assert!(types.contains(&"revisit"));
}
#[test]
fn test_link_nodes_schema_has_edge_types() {
let tool = find_tool("link_nodes").unwrap();
let edge_enum = &tool.input_schema["properties"]["edge_type"]["enum"];
assert!(edge_enum.is_array());
let types: Vec<&str> = edge_enum
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(types.contains(&"leads_to"));
assert!(types.contains(&"rejected"));
}
#[test]
fn test_all_tools_serialize_cleanly() {
let tools = all_tool_definitions();
for tool in &tools {
let serialized = serde_json::to_string(tool);
assert!(
serialized.is_ok(),
"Tool {} failed to serialize: {:?}",
tool.name,
serialized.err()
);
}
}
}