use serde_json::{json, Value};
use crate::commands::describe::{self, ArgDescriptor, CommandDescriptor};
use crate::extensions;
use crate::mcp::protocol::Tool;
/// (mcp_command_name, full_cli_command_path)
pub(crate) type CommandPath = (&'static str, &'static [&'static str]);
pub(crate) struct ToolDef {
pub(crate) name: &'static str,
pub(crate) description: &'static str,
pub(crate) commands: &'static [CommandPath],
/// Arg names to rename in the MCP schema: (from, to).
pub(crate) renames: &'static [(&'static str, &'static str)],
/// CLI arg names to omit from the auto-generated MCP schema.
pub(crate) exclude: &'static [&'static str],
/// Extra properties to inject into the MCP schema after auto-generation: (name, JSON schema).
pub(crate) extra_props: &'static [(&'static str, &'static str)],
}
const RUNTIME_REQUEST_PROP: (&str, &str) = (
"runtime_request",
r#"{"type":"object","description":"Optional shared runtime envelope metadata echoed in machine responses.","properties":{"api_family":{"type":"string","enum":["ccd-runtime"]},"api_version":{"type":"integer","enum":[1]},"request_id":{"type":"string"},"actor_id":{"type":"string"},"supervisor_id":{"type":"string"},"host_session_id":{"type":"string"},"host_run_id":{"type":"string"},"host_task_id":{"type":"string"},"approval_request_id":{"type":"string"},"idempotency_key":{"type":"string"}},"required":["api_family","api_version","request_id"]}"#,
);
const APPEND_PROTECTED_WRITE_PROP: (&str, &str) = (
"protected_write",
r#"{"type":"object","description":"Protected-write envelope for append-oriented autonomous surfaces. When the workspace is under an active autonomous lease, provide actor_id and session_id matching the active owner (or explicitly authorized supervisor for clear-by-id). expected_revision is optional and ignored for append semantics.","properties":{"actor_id":{"type":"string"},"session_id":{"type":"string"},"expected_revision":{"type":"integer","minimum":0}}}"#,
);
const CORE_TOOLS: &[ToolDef] = &[
ToolDef {
name: "ccd_repo",
description: "Repo lifecycle: attach scaffold link unlink gc skills-install",
commands: &[
("attach", &["attach"]),
("scaffold", &["scaffold"]),
("link", &["link"]),
("unlink", &["unlink"]),
("gc", &["gc"]),
("skills-install", &["skills", "install"]),
],
renames: &[],
exclude: &[],
extra_props: &[],
},
ToolDef {
name: "ccd_health",
description: "Validate: doctor check drift sync preflight hooks",
commands: &[
("check", &["check"]),
("preflight", &["preflight"]),
("doctor", &["doctor"]),
("drift", &["drift"]),
("sync", &["sync"]),
("hooks-install", &["hooks", "install"]),
("hooks-check", &["hooks", "check"]),
],
renames: &[],
exclude: &[],
extra_props: &[],
},
ToolDef {
name: "ccd_session",
description:
"Startup context and shared-checkout helpers: start open state-start state-clear",
commands: &[
("start", &["start"]),
("session-open", &["session", "open"]),
("session-state-start", &["session-state", "start"]),
("session-state-clear", &["session-state", "clear"]),
],
renames: &[("from_ref", "from")],
// The MCP surface stays compact and capability-oriented: `fields` is
// replaced by `full`, `check` is a CLI exit-code mode, and wrapper-
// first startup stays on the host-hook CLI surface rather than as an
// MCP macro.
exclude: &[
"fields",
"memory_depth",
"check",
"lifecycle",
"owner_kind",
"actor_id",
"supervisor_id",
"lease_seconds",
"reason",
"activate",
],
extra_props: &[],
},
ToolDef {
name: "ccd_session_lifecycle",
description: "Runtime session lifecycle: start heartbeat clear takeover",
commands: &[
("start-session", &["session-state", "start"]),
("heartbeat-session", &["session-state", "heartbeat"]),
("clear-session", &["session-state", "clear"]),
("takeover-session", &["session-state", "takeover"]),
],
renames: &[],
exclude: &[
"lifecycle",
"owner_kind",
"actor_id",
"supervisor_id",
"lease_seconds",
"reason",
"activity",
"full",
],
extra_props: &[
(
"session_owner",
r#"{"type":"object","description":"Session ownership and lifecycle options. `start-session` defaults to interactive when omitted. Autonomous lifecycle flows require actor_id and lease_seconds; `owner_kind` defaults to `runtime_worker` when omitted and may be set to `runtime_supervisor` for an explicit supervisor-owned session path. `takeover-session` requires actor_id and may carry supervisor_id.","properties":{"lifecycle":{"type":"string","enum":["interactive","autonomous"]},"owner_kind":{"type":"string","enum":["runtime_worker","runtime_supervisor"]},"actor_id":{"type":"string"},"supervisor_id":{"type":"string"},"lease_seconds":{"type":"integer","minimum":1}}}"#,
),
RUNTIME_REQUEST_PROP,
],
},
ToolDef {
name: "ccd_session_gates",
description: "Execution gates: list replace seed set-status advance clear",
commands: &[
("list-gates", &["session-state", "gates", "list"]),
("replace-gates", &["session-state", "gates", "replace"]),
("seed-gates", &["session-state", "gates", "seed"]),
("set-gate-status", &["session-state", "gates", "set-status"]),
("advance-gates", &["session-state", "gates", "advance"]),
("clear-gates", &["session-state", "gates", "clear"]),
],
renames: &[],
exclude: &[
"actor_id",
"session_id",
"expected_revision",
"gates",
"full",
],
extra_props: &[
(
"protected_write",
r#"{"type":"object","description":"Protected-write envelope for autonomous owner surfaces. When the workspace is under an active autonomous lease, provide actor_id, session_id, and expected_revision matching the active owner.","properties":{"actor_id":{"type":"string"},"session_id":{"type":"string"},"expected_revision":{"type":"integer","minimum":0}}}"#,
),
(
"gates",
r#"{"type":"array","description":"Execution gate texts (required for: replace-gates).","items":{"type":"string"}}"#,
),
RUNTIME_REQUEST_PROP,
],
},
ToolDef {
name: "ccd_escalation",
description: "Escalation state: list set clear",
commands: &[
("list-escalations", &["escalation-state", "list"]),
("set-escalation", &["escalation-state", "set"]),
("clear-escalation", &["escalation-state", "clear"]),
],
renames: &[],
exclude: &["full", "actor_id", "session_id", "expected_revision"],
extra_props: &[APPEND_PROTECTED_WRITE_PROP, RUNTIME_REQUEST_PROP],
},
ToolDef {
name: "ccd_recovery",
description: "Recovery artifacts: write checkpoint or working buffer",
commands: &[("write-recovery", &["recovery", "write"])],
renames: &[],
exclude: &["actor_id", "session_id", "expected_revision"],
extra_props: &[
(
"origin",
r#"{"type":"string","enum":["manual","compaction","risky_pause"]}"#,
),
(
"checkpoint",
r#"{"type":"object","description":"Checkpoint payload to persist.","properties":{"summary":{"type":"string"},"immediate_actions":{"type":"array","items":{"type":"string"}},"key_files":{"type":"array","items":{"type":"string"}}},"required":["summary"]}"#,
),
(
"working_buffer",
r#"{"type":"object","description":"Working-buffer payload to persist.","properties":{"summary_lines":{"type":"array","items":{"type":"string"}}},"required":["summary_lines"]}"#,
),
APPEND_PROTECTED_WRITE_PROP,
RUNTIME_REQUEST_PROP,
],
},
ToolDef {
name: "ccd_state",
description: "State and governance: export radar checkpoint handoff policy escalation",
commands: &[
("runtime-state-export", &["runtime-state", "export"]),
("radar-state", &["radar-state"]),
("checkpoint", &["checkpoint"]),
("handoff-refresh", &["handoff", "refresh"]),
("policy-check", &["policy-check"]),
("escalation-state-list", &["escalation-state", "list"]),
("escalation-state-set", &["escalation-state", "set"]),
],
renames: &[],
exclude: &[
"fields",
"memory_depth",
"actor_id",
"session_id",
"expected_revision",
"id",
"commit",
"since_session",
],
extra_props: &[],
},
ToolDef {
name: "ccd_delegation",
description: "Delegation bootstrap: bounded child context for host-driven sub-agents",
commands: &[("child-bootstrap", &["runtime-state", "child-bootstrap"])],
renames: &[],
exclude: &[],
extra_props: &[],
},
ToolDef {
name: "ccd_context",
description: "Mid-session refresh evaluation: context-check",
commands: &[("context-check", &["context-check"])],
renames: &[],
exclude: &["fields", "memory_depth"],
extra_props: &[RUNTIME_REQUEST_PROP],
},
ToolDef {
name: "ccd_memory_capture",
description: "Bounded evidence capture: evidence-submit candidate-extract",
commands: &[
("memory-evidence-submit", &["memory", "evidence", "submit"]),
(
"memory-candidate-extract",
&["memory", "candidate", "extract"],
),
],
renames: &[],
exclude: &[
"scope",
"entry_type",
"source_kind",
"source_ref",
"host_hook",
"host",
"provider",
"provider_ref",
"summary",
"evidence_id",
"limit",
"actor_id",
"session_id",
"expected_revision",
"full",
],
extra_props: &[
(
"evidence_envelope",
r#"{"type":"object","description":"Bounded evidence summary payload (required for: memory-evidence-submit). Scope is canonical CCD scope: workspace, work-stream, project, profile, pod, or project-truth. Optional host/provider references preserve attribution without storing raw transcript bodies.","properties":{"scope":{"type":"string","enum":["workspace","work-stream","project","profile","pod","project-truth"]},"type":{"type":"string","enum":["rule","constraint","heuristic","observation","attempt"]},"source_kind":{"type":"string","enum":["transcript","session","event-stream","hook-output","log","document"]},"summary":{"type":"string"},"source_ref":{"type":"string"},"host":{"type":"string"},"host_hook":{"type":"string","enum":["on-session-start","before-prompt-build","on-compaction-notice","on-agent-end","on-session-end","supervisor-tick"]},"provider":{"type":"string"},"provider_ref":{"type":"string"}},"required":["scope","type","source_kind","summary"]}"#,
),
(
"extract_options",
r#"{"type":"object","description":"Candidate extraction selection (optional for: memory-candidate-extract). Omit to scan pending evidence oldest-first.","properties":{"evidence_id":{"type":"string"},"limit":{"type":"integer","minimum":1}}}"#,
),
APPEND_PROTECTED_WRITE_PROP,
RUNTIME_REQUEST_PROP,
],
},
ToolDef {
name: "ccd_memory",
description: "Memory follow-through: candidate-admit compact promote",
commands: &[
("memory-candidate-admit", &["memory", "candidate", "admit"]),
("memory-compact", &["memory", "compact"]),
("memory-promote", &["memory", "promote"]),
],
renames: &[],
// Compact and promote each need command-specific parameters. Keep both within the
// 10-property MCP budget by collapsing command-specific options into objects.
exclude: &[
"destination",
"source_scope",
"target_file",
"source_outcome",
"scope",
"entry_type",
"source_kind",
"source_ref",
"host_hook",
"host",
"provider",
"provider_ref",
"summary",
"evidence_id",
"limit",
"actor_id",
"session_id",
"expected_revision",
"keep",
"decay_class",
"review",
"remove",
"full",
],
extra_props: &[
(
"compact_options",
r#"{"type":"object","description":"Compaction options (required for: memory-compact). Include scope: profile, project, work-stream, or workspace. Then use {\"keep\":\"<id>\"}, {\"decay_class\":\"stable\"}, or {\"review\":\"expired\"}; optional remove: true when reviewing a selected entry.","properties":{"scope":{"type":"string","enum":["profile","project","work-stream","workspace"]},"keep":{"type":"string"},"decay_class":{"type":"string","enum":["permanent","stable","active"]},"review":{"type":"string","enum":["expired","superseded","promotion-candidate"]},"remove":{"type":"boolean"}}}"#,
),
(
"candidate_route",
r#"{"type":"object","description":"Candidate admission route (required for: memory-candidate-admit).","properties":{"source_scope":{"type":"string","enum":["clone-memory","branch-memory","pod-memory"]},"destination":{"type":"string","enum":["branch-memory","repo-memory"]}},"required":["source_scope","destination"]}"#,
),
(
"promote_destination",
r#"{"type":"object","description":"Promotion options (required for: memory-promote). Use {\"type\":\"work-stream-memory\"} for workspace->work-stream promotion, {\"type\":\"project-memory\"} for work-stream/workspace->project promotion, {\"type\":\"profile-memory\"} for project->profile promotion, or {\"type\":\"project-truth\",\"target_file\":\"/path/to/file\"}; optional source_outcome: active, superseded, or link-only.","properties":{"type":{"type":"string","enum":["work-stream-memory","project-memory","profile-memory","project-truth"]},"target_file":{"type":"string"},"source_outcome":{"type":"string","enum":["active","superseded","link-only"]}},"required":["type"]}"#,
),
APPEND_PROTECTED_WRITE_PROP,
RUNTIME_REQUEST_PROP,
],
},
ToolDef {
name: "ccd_memory_recall",
description: "Memory recall and ingest reporting",
commands: &[
("memory-search", &["memory", "search"]),
("memory-describe", &["memory", "describe"]),
("memory-expand", &["memory", "expand"]),
("memory-sync-status", &["memory", "sync-status"]),
("memory-source-map", &["memory", "source-map"]),
],
renames: &[],
exclude: &["full"],
extra_props: &[RUNTIME_REQUEST_PROP],
},
];
/// Build the available MCP tool definitions from the CLI describe schema.
pub fn build_tools() -> Vec<Tool> {
let schema = describe::run();
let mut tools = Vec::new();
for def in CORE_TOOLS {
if def.name == "ccd_memory" {
tools.extend(extensions::build_mcp_tools(&schema.commands));
}
tools.push(build_tool(def, &schema.commands));
}
tools
}
pub(crate) fn build_tool(def: &ToolDef, commands: &[CommandDescriptor]) -> Tool {
let mut command_names: Vec<&str> = Vec::new();
// Track (prop_name, ArgDescriptor ref, set of mcp command names that carry this arg,
// set of mcp command names where this arg is required)
let mut arg_info: Vec<(String, &ArgDescriptor, Vec<&str>, Vec<&str>)> = Vec::new();
for &(mcp_name, path) in def.commands {
command_names.push(mcp_name);
let cmd_desc = find_command(commands, path)
.unwrap_or_else(|| panic!("command not found in describe tree: {}", path.join(" ")));
for arg in &cmd_desc.args {
let prop_name = rename_arg(&arg.name, def.renames);
if let Some(entry) = arg_info.iter_mut().find(|(n, _, _, _)| *n == prop_name) {
entry.2.push(mcp_name);
if arg.required {
entry.3.push(mcp_name);
}
} else {
let required_by = if arg.required { vec![mcp_name] } else { vec![] };
arg_info.push((prop_name, arg, vec![mcp_name], required_by));
}
}
}
let total_cmds = command_names.len();
// Build inputSchema
let mut props = serde_json::Map::new();
props.insert(
"command".to_owned(),
json!({
"type": "string",
"enum": command_names,
"description": "Command to execute"
}),
);
if !def.exclude.contains(&"full") {
props.insert(
"full".to_owned(),
json!({
"type": "boolean",
"default": false,
"description": "Return the full report. Default false uses compact MCP output for verbose commands."
}),
);
}
let mut required: Vec<String> = vec!["command".to_owned()];
for (name, arg, _present_in, required_by) in &arg_info {
if def.exclude.contains(&name.as_str()) {
continue;
}
let mut schema = arg_to_schema(arg);
if !required_by.is_empty() {
if required_by.len() == total_cmds {
// Required by ALL commands — add to schema required array
required.push(name.clone());
} else {
// Required by only some commands — annotate description
let suffix = format!(" (required for: {})", required_by.join(", "));
if let Some(obj) = schema.as_object_mut() {
let desc = obj
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
obj.insert("description".to_owned(), json!(format!("{desc}{suffix}")));
}
}
}
props.insert(name.clone(), schema);
}
for (name, schema_str) in def.extra_props {
let schema: Value = serde_json::from_str(schema_str)
.unwrap_or_else(|e| panic!("invalid extra_props schema for {}: {e}", def.name));
props.insert((*name).to_owned(), schema);
}
let input_schema = json!({
"type": "object",
"properties": props,
"required": required
});
// Budget assertions (enforced in debug builds)
let prop_count = props.len();
let cmd_count = command_names.len();
debug_assert!(
prop_count <= 10,
"tool {} has {} properties (budget: 10)",
def.name,
prop_count
);
debug_assert!(
cmd_count <= 7,
"tool {} has {} command enum values (budget: 7)",
def.name,
cmd_count
);
debug_assert!(
def.description.len() <= 80,
"tool {} description is {} chars (budget: 80)",
def.name,
def.description.len()
);
Tool {
name: def.name.to_owned(),
description: def.description.to_owned(),
input_schema,
}
}
fn find_command<'a>(
commands: &'a [CommandDescriptor],
path: &[&str],
) -> Option<&'a CommandDescriptor> {
let (head, tail) = path.split_first()?;
let command = commands.iter().find(|c| c.name == *head)?;
if tail.is_empty() {
Some(command)
} else {
find_command(&command.subcommands, tail)
}
}
fn rename_arg(name: &str, renames: &[(&str, &str)]) -> String {
for &(from, to) in renames {
if name == from {
return to.to_owned();
}
}
name.to_owned()
}
fn arg_to_schema(arg: &ArgDescriptor) -> Value {
let is_bool = arg.possible_values.as_ref().is_some_and(|pv| {
pv.len() == 2 && pv.contains(&"true".to_owned()) && pv.contains(&"false".to_owned())
});
let mut schema = serde_json::Map::new();
if is_bool {
schema.insert("type".to_owned(), json!("boolean"));
} else {
schema.insert("type".to_owned(), json!("string"));
if let Some(ref pv) = arg.possible_values {
schema.insert("enum".to_owned(), json!(pv));
}
}
if let Some(ref help) = arg.help {
let desc = truncate(help, 60);
schema.insert("description".to_owned(), json!(desc));
}
if let Some(ref default) = arg.default {
if is_bool {
schema.insert("default".to_owned(), json!(default == "true"));
} else {
schema.insert("default".to_owned(), json!(default));
}
}
Value::Object(schema)
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_owned()
} else {
format!("{}...", &s[..max - 3])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_tools_returns_expected_count() {
let tools = build_tools();
let base = 13;
let backlog = if cfg!(feature = "extension-backlog") {
1
} else {
0
};
let codemap = if cfg!(feature = "extension-codemap") {
1
} else {
0
};
assert_eq!(tools.len(), base + backlog + codemap);
}
#[test]
fn tool_names_are_correct() {
let tools = build_tools();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
let mut expected = vec![
"ccd_repo",
"ccd_health",
"ccd_session",
"ccd_session_lifecycle",
"ccd_session_gates",
"ccd_escalation",
"ccd_recovery",
"ccd_state",
"ccd_delegation",
"ccd_context",
];
expected.push("ccd_memory_capture");
// Extension tools are inserted between ccd_memory_capture and ccd_memory.
#[cfg(feature = "extension-backlog")]
expected.push("ccd_backlog");
#[cfg(feature = "extension-codemap")]
expected.push("ccd_codemap");
expected.push("ccd_memory");
expected.push("ccd_memory_recall");
assert_eq!(names, expected);
}
#[test]
fn budget_limits_hold() {
let tools = build_tools();
for tool in &tools {
let props = tool.input_schema["properties"].as_object().unwrap();
assert!(
props.len() <= 10,
"{} has {} properties",
tool.name,
props.len()
);
let cmd_enum = tool.input_schema["properties"]["command"]["enum"]
.as_array()
.unwrap();
assert!(
cmd_enum.len() <= 7,
"{} has {} commands",
tool.name,
cmd_enum.len()
);
assert!(
tool.description.len() <= 80,
"{} description is {} chars",
tool.name,
tool.description.len()
);
}
}
#[test]
fn command_is_required() {
let tools = build_tools();
for tool in &tools {
let required = tool.input_schema["required"].as_array().unwrap();
assert!(
required.contains(&json!("command")),
"{} missing required command",
tool.name
);
}
}
#[test]
fn from_ref_renamed_to_from_in_session() {
let tools = build_tools();
let session = tools.iter().find(|t| t.name == "ccd_session").unwrap();
let props = session.input_schema["properties"].as_object().unwrap();
assert!(props.contains_key("from"), "expected 'from' property");
assert!(
!props.contains_key("from_ref"),
"unexpected 'from_ref' property"
);
}
#[test]
fn session_mode_is_exposed_but_check_is_omitted() {
let tools = build_tools();
let session = tools.iter().find(|t| t.name == "ccd_session").unwrap();
let props = session.input_schema["properties"].as_object().unwrap();
assert_eq!(
session.description,
"Startup context and shared-checkout helpers: start open state-start state-clear"
);
assert!(
props.contains_key("mode"),
"mode should be exposed for session-state start"
);
assert!(
!props.contains_key("activate"),
"activate is CLI-only coalescing flag"
);
assert!(
!props.contains_key("check"),
"check is CLI-only exit-code mode"
);
}
#[test]
fn session_lifecycle_tool_bundles_owner_fields() {
let tools = build_tools();
let lifecycle = tools
.iter()
.find(|t| t.name == "ccd_session_lifecycle")
.unwrap();
let props = lifecycle.input_schema["properties"].as_object().unwrap();
assert!(
props.contains_key("session_owner"),
"expected bundled session_owner property"
);
assert!(
!props.contains_key("actor_id"),
"unexpected top-level actor_id property"
);
assert!(
!props.contains_key("supervisor_id"),
"unexpected top-level supervisor_id property"
);
assert!(
!props.contains_key("lease_seconds"),
"unexpected top-level lease_seconds property"
);
}
#[test]
fn session_gates_tool_bundles_protected_write_fields() {
let tools = build_tools();
let gates = tools
.iter()
.find(|t| t.name == "ccd_session_gates")
.unwrap();
let props = gates.input_schema["properties"].as_object().unwrap();
assert!(
props.contains_key("protected_write"),
"expected bundled protected_write property"
);
assert!(
!props.contains_key("actor_id"),
"unexpected top-level actor_id property"
);
assert!(
!props.contains_key("session_id"),
"unexpected top-level session_id property"
);
assert!(
!props.contains_key("expected_revision"),
"unexpected top-level expected_revision property"
);
assert_eq!(props["gates"]["type"], "array");
}
#[test]
fn universally_required_args_in_required_array() {
let tools = build_tools();
let memory = tools.iter().find(|t| t.name == "ccd_memory").unwrap();
let required = memory.input_schema["required"].as_array().unwrap();
assert!(
!required.contains(&json!("entry")),
"entry should not be universally required now that review mode can list candidates"
);
// scope is only required by memory-compact, not memory-promote
assert!(
!required.contains(&json!("scope")),
"scope should not be in required"
);
assert!(
!required.contains(&json!("candidate_route")),
"candidate_route should not be universally required"
);
}
#[test]
fn per_command_required_args_annotated_in_description() {
let tools = build_tools();
if !cfg!(feature = "extension-backlog") {
return;
}
let backlog = tools.iter().find(|t| t.name == "ccd_backlog").unwrap();
let github_repo = &backlog.input_schema["properties"]["github_repo"];
let desc = github_repo["description"].as_str().unwrap_or("");
assert!(
desc.contains("required for:"),
"github_repo description should annotate requiring commands, got: {desc}"
);
}
}