AkurAI Build
Menu

AkurAI-Build

public

Latest change 1a4c18b55f4044c22bdbd9803ef7f42e54f3cf70 - feat: akurai_release MCP tool — one release path for every product by Ólafur Búi Ólafsson

use std::{
    fs,
    io::{self, BufRead, Write},
    path::PathBuf,
    process::Command,
    thread,
    time::{Duration, Instant},
};

use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::{
    config,
    db::{Database, RepositoryQuery, RunQuery},
    hosted_git, release,
    runner::Runner,
    tree::RepoTree,
};

const PROTOCOL_VERSION: &str = "2025-06-18";

/// Fixed context every tool call runs against: the encrypted database, the
/// data root used to resolve hosted mirrors/workspaces/artifacts, and
/// whether native (non-Docker) job execution is permitted.
struct Ctx {
    database: Database,
    root: PathBuf,
    allow_native: bool,
    tree: RepoTree,
}

impl Ctx {
    fn runner(&self) -> Result<Runner> {
        Runner::new(self.database.clone(), self.root.clone(), self.allow_native)
    }
}

pub fn serve(database: Database, root: PathBuf, allow_native: bool) -> Result<()> {
    let tree = RepoTree::new(&root)?;
    let context = Ctx {
        database,
        root,
        allow_native,
        tree,
    };
    let stdin = io::stdin();
    let mut stdout = io::stdout().lock();
    for line in stdin.lock().lines() {
        let line = line.context("read MCP request")?;
        if line.trim().is_empty() {
            continue;
        }
        let response = match serde_json::from_str::<Value>(&line) {
            Ok(request) => handle(&context, &request),
            Err(error) => Some(json_rpc_error(
                Value::Null,
                -32700,
                format!("invalid JSON: {error}"),
            )),
        };
        if let Some(response) = response {
            serde_json::to_writer(&mut stdout, &response)?;
            stdout.write_all(b"\n")?;
            stdout.flush()?;
        }
    }
    Ok(())
}

fn handle(context: &Ctx, request: &Value) -> Option<Value> {
    let id = request.get("id")?.clone();
    let method = request.get("method").and_then(Value::as_str);
    let Some(method) = method else {
        return Some(json_rpc_error(id, -32600, "missing method"));
    };
    let response = match method {
        "initialize" => json_rpc_result(
            id,
            json!({
                "protocolVersion": PROTOCOL_VERSION,
                "capabilities": {"tools": {"listChanged": false}},
                "serverInfo": {"name": "akurai-build", "version": env!("CARGO_PKG_VERSION")},
                "instructions": "Full read/write access to AkurAI Build repositories, pipeline runs, artifacts, protected-environment promotions, and Titan workers. Mutating tools (repo_add, repo_host, repo_sync, repo_rename, repo_remove, repo_visibility, run_queue, run_retry, run_promote) change production CI/CD state; repo_remove and run_promote are consequential."
            }),
        ),
        "ping" => json_rpc_result(id, json!({})),
        "tools/list" => json_rpc_result(id, json!({"tools": tools()})),
        "tools/call" => {
            let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
            let name = params.get("name").and_then(Value::as_str);
            let arguments = params
                .get("arguments")
                .cloned()
                .unwrap_or_else(|| json!({}));
            match name {
                Some(name) => match call_tool(context, name, arguments) {
                    Ok(data) => json_rpc_result(id, tool_result(data, false)),
                    Err(error) => json_rpc_result(id, tool_result(json!(error.to_string()), true)),
                },
                None => json_rpc_error(id, -32602, "tool name is required"),
            }
        }
        _ => json_rpc_error(id, -32601, format!("unknown method: {method}")),
    };
    Some(response)
}

fn call_tool(context: &Ctx, name: &str, arguments: Value) -> Result<Value> {
    let database = &context.database;
    match name {
        "akurai_doctor" => Ok(doctor(context)),
        "akurai_repo_list" => {
            let arguments: RepositoryArguments = parse(arguments)?;
            Ok(to_value(database.query_repositories(
                &RepositoryQuery {
                    search: arguments.search,
                    visibility: arguments.visibility,
                    limit: arguments.limit,
                    offset: arguments.offset,
                },
            )?)?)
        }
        "akurai_repo_add" => {
            let arguments: RepoAddArguments = parse(arguments)?;
            config::validate_repository(&arguments.name, &arguments.url, &arguments.branch)?;
            Ok(to_value(database.add_repository(
                &arguments.name,
                &arguments.url,
                &arguments.branch,
            )?)?)
        }
        "akurai_repo_host" => {
            let arguments: RepoHostArguments = parse(arguments)?;
            let path = hosted_git::host(&context.root, &arguments.name, &arguments.source)?;
            match database.add_repository(
                &arguments.name,
                path.to_str()
                    .context("hosted repository path is not UTF-8")?,
                &arguments.branch,
            ) {
                Ok(repository) => Ok(json!({
                    "repository": repository,
                    "clone_url": format!("https://akurai-build.olibuijr.com/git/{}.git", arguments.name)
                })),
                Err(error) => {
                    let _ = fs::remove_dir_all(path);
                    Err(error)
                }
            }
        }
        "akurai_repo_sync" => {
            let arguments: RepoSyncArguments = parse(arguments)?;
            let path = hosted_git::sync(&context.root, &arguments.name, &arguments.source)?;
            Ok(json!({"name": arguments.name, "path": path, "synchronized": true}))
        }
        "akurai_release" => {
            let arguments: ReleaseArguments = parse(arguments)?;
            // The repository must be registered before a release may target it.
            database.repository(&arguments.repository)?;
            let outcome = release::release(
                &arguments.source,
                &arguments.bump,
                arguments.notes.as_deref(),
            )?;
            let mirror = hosted_git::sync(&context.root, &arguments.repository, &arguments.source)?;
            let runner = context.runner()?;
            let id = runner.queue(&arguments.repository, None, Some(&outcome.commit), "manual")?;
            let run = if arguments.wait {
                to_value(runner.process(id)?)?
            } else {
                json!({"id": id, "status": "queued"})
            };
            Ok(json!({
                "release": to_value(outcome)?,
                "mirror": mirror,
                "run": run,
            }))
        }
        "akurai_repo_rename" => {
            let arguments: RepoRenameArguments = parse(arguments)?;
            Ok(to_value(
                database.rename_repository(&arguments.old, &arguments.new)?,
            )?)
        }
        "akurai_repo_remove" => {
            let arguments: RepoNameArguments = parse(arguments)?;
            let repository = database.remove_repository(&arguments.name)?;
            let mirror = hosted_git::remove(&context.root, &arguments.name)?;
            Ok(json!({
                "repository": repository,
                "removed": true,
                "mirror_deleted": mirror,
            }))
        }
        "akurai_repo_visibility" => {
            let arguments: RepoVisibilityArguments = parse(arguments)?;
            Ok(to_value(database.set_repository_visibility(
                &arguments.repository,
                &arguments.visibility,
            )?)?)
        }
        "akurai_repo_branches" => {
            let arguments: RepoNameArguments = parse(arguments)?;
            Ok(to_value(repo_branches(database, &arguments.name)?)?)
        }
        "akurai_repo_tree" => {
            let arguments: RepoTreeArguments = parse(arguments)?;
            let repository = database.repository(&arguments.repository)?;
            let sub_path = arguments.path.unwrap_or_default();
            Ok(to_value(context.tree.tree(
                &repository.name,
                &repository.url,
                arguments.reference.as_deref(),
                &sub_path,
            )?)?)
        }
        "akurai_init" => {
            let arguments: InitArguments = parse(arguments)?;
            ensure!(
                arguments.force || !arguments.out.exists(),
                "{} already exists; use force",
                arguments.out.display()
            );
            fs::write(&arguments.out, include_str!("../akurai.example.yml"))?;
            Ok(json!({"path": arguments.out, "created": true}))
        }
        "akurai_run_queue" => {
            let arguments: RunQueueArguments = parse(arguments)?;
            let runner = context.runner()?;
            let id = runner.queue(
                &arguments.repository,
                arguments.git_ref.as_deref(),
                arguments.commit.as_deref(),
                "manual",
            )?;
            if arguments.wait {
                Ok(to_value(runner.process(id)?)?)
            } else {
                Ok(json!({"id": id, "status": "queued"}))
            }
        }
        "akurai_runs" => {
            let arguments: RunArguments = parse(arguments)?;
            Ok(to_value(
                database.query_runs(&RunQuery {
                    repository: arguments.repo.map(|value| value.values().join(",")),
                    statuses: arguments.status.map(StringList::values).unwrap_or_default(),
                    git_ref: arguments.git_ref,
                    triggers: arguments
                        .trigger
                        .map(StringList::values)
                        .unwrap_or_default(),
                    search: arguments.search,
                    limit: arguments.limit,
                    offset: arguments.offset,
                })?,
            )?)
        }
        "akurai_run_show" => {
            let arguments: RunIdArguments = parse(arguments)?;
            Ok(to_value(database.detail(arguments.id)?)?)
        }
        "akurai_run_wait" => {
            let arguments: RunWaitArguments = parse(arguments)?;
            ensure!(
                (1..=14_400).contains(&arguments.timeout),
                "timeout must be 1..=14400 seconds"
            );
            ensure!(
                (1..=60).contains(&arguments.interval),
                "interval must be 1..=60 seconds"
            );
            let deadline = Instant::now() + Duration::from_secs(arguments.timeout);
            loop {
                let detail = database.detail(arguments.id)?;
                if matches!(
                    detail.run.status.as_str(),
                    "waiting" | "succeeded" | "failed" | "canceled" | "interrupted"
                ) {
                    return to_value(detail);
                }
                ensure!(
                    Instant::now() < deadline,
                    "timed out waiting for run {}",
                    arguments.id
                );
                thread::sleep(Duration::from_secs(arguments.interval));
            }
        }
        "akurai_run_logs" => {
            let arguments: RunLogsArguments = parse(arguments)?;
            let mut jobs = database.jobs(arguments.id)?;
            if arguments.failed {
                jobs.retain(|job| {
                    matches!(job.status.as_str(), "failed" | "interrupted" | "canceled")
                });
            }
            Ok(to_value(
                jobs.into_iter()
                    .map(|job| {
                        json!({"id": job.id, "name": job.name, "status": job.status, "logs": job.logs})
                    })
                    .collect::<Vec<_>>(),
            )?)
        }
        "akurai_run_retry" => {
            let arguments: RunRetryArguments = parse(arguments)?;
            let id = database.retry(arguments.id)?;
            if arguments.wait {
                Ok(to_value(context.runner()?.process(id)?)?)
            } else {
                Ok(json!({"id": id, "status": "queued"}))
            }
        }
        "akurai_run_promote" => {
            let arguments: RunPromoteArguments = parse(arguments)?;
            let jobs = database.approve_environment(arguments.id, &arguments.environment)?;
            if arguments.wait {
                Ok(to_value(context.runner()?.process(arguments.id)?)?)
            } else {
                Ok(json!({
                    "id": arguments.id,
                    "environment": arguments.environment,
                    "approved_jobs": jobs,
                    "status": "queued"
                }))
            }
        }
        "akurai_artifact_get" => {
            let arguments: ArtifactGetArguments = parse(arguments)?;
            ensure!(
                arguments.force || !arguments.output.exists(),
                "{} exists; use force",
                arguments.output.display()
            );
            let runner = context.runner()?;
            let artifact = database.artifact(arguments.id)?;
            let source = runner.artifact_path(&artifact)?;
            if let Some(parent) = arguments
                .output
                .parent()
                .filter(|path| !path.as_os_str().is_empty())
            {
                fs::create_dir_all(parent)?;
            }
            fs::copy(source, &arguments.output)?;
            Ok(json!({
                "id": arguments.id,
                "output": arguments.output,
                "sha256": artifact.sha256,
                "bytes": artifact.bytes
            }))
        }
        "akurai_workers" => Ok(to_value(database.workers()?)?),
        _ => bail!("unknown tool: {name}"),
    }
}

fn doctor(context: &Ctx) -> Value {
    let git = version("git", &["--version"]);
    let docker = version("docker", &["info", "--format", "{{.ServerVersion}}"]);
    let database = context
        .database
        .check_ready()
        .map(|()| "ready".to_owned())
        .unwrap_or_else(|error| format!("unavailable: {error}"));
    json!({"database": database, "git": git, "docker": docker, "data": context.root})
}

fn version(program: &str, arguments: &[&str]) -> Value {
    match Command::new(program).args(arguments).output() {
        Ok(output) => json!({
            "ok": output.status.success(),
            "output": String::from_utf8_lossy(if output.status.success() { &output.stdout } else { &output.stderr }).trim()
        }),
        Err(error) => json!({"ok": false, "output": error.to_string()}),
    }
}

#[derive(Serialize)]
struct BranchInfo {
    name: String,
    is_default: bool,
}

fn repo_branches(database: &Database, name: &str) -> Result<Vec<BranchInfo>> {
    let repository = database.repository(name)?;
    let output = Command::new("git")
        .args(["ls-remote", "--heads", &repository.url])
        .env("GIT_TERMINAL_PROMPT", "0")
        .output()
        .context("failed to list branches")?;
    ensure!(
        output.status.success(),
        "git ls-remote failed: {}",
        String::from_utf8_lossy(&output.stderr)
            .lines()
            .next()
            .unwrap_or("unknown error")
    );
    let mut names: Vec<String> = String::from_utf8(output.stdout)
        .context("invalid git output")?
        .lines()
        .filter_map(|line| line.split('\t').nth(1))
        .filter_map(|reference| reference.strip_prefix("refs/heads/"))
        .map(str::to_owned)
        .collect();
    names.sort();
    Ok(names
        .into_iter()
        .map(|name| {
            let is_default = name == repository.default_branch;
            BranchInfo { name, is_default }
        })
        .collect())
}

fn parse<T: for<'de> Deserialize<'de>>(arguments: Value) -> Result<T> {
    Ok(serde_json::from_value(arguments)?)
}

fn to_value<T: Serialize>(data: T) -> Result<Value> {
    Ok(serde_json::to_value(data)?)
}

#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RepositoryArguments {
    #[serde(alias = "q")]
    search: Option<String>,
    visibility: Option<String>,
    #[serde(default = "default_repository_limit")]
    limit: usize,
    #[serde(default)]
    offset: usize,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoAddArguments {
    name: String,
    url: String,
    #[serde(default = "default_branch")]
    branch: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoHostArguments {
    name: String,
    source: PathBuf,
    #[serde(default = "default_branch")]
    branch: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoSyncArguments {
    name: String,
    source: PathBuf,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReleaseArguments {
    repository: String,
    source: PathBuf,
    #[serde(default = "default_bump")]
    bump: String,
    #[serde(default)]
    notes: Option<String>,
    #[serde(default)]
    wait: bool,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoRenameArguments {
    old: String,
    new: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoNameArguments {
    name: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoVisibilityArguments {
    repository: String,
    visibility: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RepoTreeArguments {
    repository: String,
    #[serde(rename = "ref")]
    reference: Option<String>,
    path: Option<String>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InitArguments {
    out: PathBuf,
    #[serde(default)]
    force: bool,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunQueueArguments {
    repository: String,
    git_ref: Option<String>,
    commit: Option<String>,
    #[serde(default)]
    wait: bool,
}

#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RunArguments {
    repo: Option<StringList>,
    status: Option<StringList>,
    git_ref: Option<String>,
    trigger: Option<StringList>,
    #[serde(alias = "q")]
    search: Option<String>,
    #[serde(default = "default_run_limit")]
    limit: usize,
    #[serde(default)]
    offset: usize,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunIdArguments {
    id: i64,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunWaitArguments {
    id: i64,
    #[serde(default = "default_wait_timeout")]
    timeout: u64,
    #[serde(default = "default_wait_interval")]
    interval: u64,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunLogsArguments {
    id: i64,
    #[serde(default)]
    failed: bool,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunRetryArguments {
    id: i64,
    #[serde(default)]
    wait: bool,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunPromoteArguments {
    id: i64,
    environment: String,
    #[serde(default)]
    wait: bool,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ArtifactGetArguments {
    id: i64,
    output: PathBuf,
    #[serde(default)]
    force: bool,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum StringList {
    One(String),
    Many(Vec<String>),
}

impl StringList {
    fn values(self) -> Vec<String> {
        let values = match self {
            Self::One(value) => vec![value],
            Self::Many(values) => values,
        };
        values
            .into_iter()
            .flat_map(|value| {
                value
                    .split(',')
                    .map(str::trim)
                    .filter(|item| !item.is_empty())
                    .map(str::to_owned)
                    .collect::<Vec<_>>()
            })
            .collect()
    }
}

const fn default_repository_limit() -> usize {
    50
}

const fn default_run_limit() -> usize {
    20
}

const fn default_wait_timeout() -> u64 {
    3600
}

const fn default_wait_interval() -> u64 {
    2
}

fn default_branch() -> String {
    "main".to_owned()
}

fn default_bump() -> String {
    "patch".to_owned()
}

fn tools() -> Vec<Value> {
    vec![
        json!({
            "name": "akurai_doctor",
            "description": "Run controller diagnostics (database readiness, git, docker) without changing state.",
            "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}
        }),
        json!({
            "name": "akurai_repo_list",
            "description": "Query registered Git repositories by text and visibility.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "search": {"type": "string", "description": "LIKE search across name, URL, and default branch."},
                    "visibility": {"type": "string", "enum": ["private", "public"]},
                    "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
                    "offset": {"type": "integer", "minimum": 0, "maximum": 10000, "default": 0}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_add",
            "description": "Register an external Git repository by URL for CI.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "url": {"type": "string"},
                    "branch": {"type": "string", "default": "main"}
                },
                "required": ["name", "url"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_host",
            "description": "Create a bare mirror owned by AkurAI Build from a trusted Titan checkout and register it for CI.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "source": {"type": "string", "description": "Absolute path to the trusted Titan checkout."},
                    "branch": {"type": "string", "default": "main"}
                },
                "required": ["name", "source"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_sync",
            "description": "Synchronize an existing AkurAI Build mirror from its trusted Titan checkout.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "source": {"type": "string", "description": "Absolute path to the trusted Titan checkout."}
                },
                "required": ["name", "source"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_release",
            "description": "Release a product: bump the version, cut the changelog, commit + tag in the trusted checkout, sync the mirror, and queue a pipeline run for the release commit. The single maintained release path for every product.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "repository": {"type": "string", "description": "Registered repository name."},
                    "source": {"type": "string", "description": "Absolute path to the trusted Titan checkout."},
                    "bump": {"type": "string", "enum": ["patch", "minor", "major"], "default": "patch"},
                    "notes": {"type": "string", "description": "Changelog body for the cut section; defaults to the changelog's Unreleased content."},
                    "wait": {"type": "boolean", "default": false, "description": "Block until the queued run reaches a terminal or approval-required state."}
                },
                "required": ["repository", "source"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_rename",
            "description": "Rename a registered repository.",
            "inputSchema": {
                "type": "object",
                "properties": {"old": {"type": "string"}, "new": {"type": "string"}},
                "required": ["old", "new"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_remove",
            "description": "Unregister a repository and delete its hosted mirror. Cascades runs and artifacts. Destructive.",
            "inputSchema": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_visibility",
            "description": "Set a repository's dashboard visibility.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "repository": {"type": "string"},
                    "visibility": {"type": "string", "enum": ["private", "public"]}
                },
                "required": ["repository", "visibility"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_branches",
            "description": "List remote branch heads for a registered repository via git ls-remote.",
            "inputSchema": {
                "type": "object",
                "properties": {"name": {"type": "string"}},
                "required": ["name"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_repo_tree",
            "description": "Browse a repository's file tree at a Git ref and sub-path.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "repository": {"type": "string"},
                    "ref": {"type": "string", "description": "Git ref; defaults to the repository's HEAD."},
                    "path": {"type": "string", "description": "Sub-path within the tree; defaults to the root."}
                },
                "required": ["repository"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_init",
            "description": "Write a minimal .akurai.yml pipeline template to a path on the Titan filesystem.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "out": {"type": "string"},
                    "force": {"type": "boolean", "default": false}
                },
                "required": ["out"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_queue",
            "description": "Queue a pipeline run for a registered repository, optionally blocking until it reaches a terminal or approval-required state.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "repository": {"type": "string"},
                    "git_ref": {"type": "string"},
                    "commit": {"type": "string", "description": "Full hexadecimal commit SHA to pin."},
                    "wait": {"type": "boolean", "default": false}
                },
                "required": ["repository"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_runs",
            "description": "Query runs across one, many, or all repositories with composable filters.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "repo": string_or_array("Repository name, comma-separated names, or an array."),
                    "status": string_or_array("Status or statuses: queued, running, waiting, succeeded, failed, canceled, interrupted."),
                    "git_ref": {"type": "string", "description": "Exact Git ref."},
                    "trigger": string_or_array("Trigger or triggers: manual, webhook, retry."),
                    "search": {"type": "string", "description": "LIKE search across repository, ref, commit SHA, and retained error."},
                    "limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
                    "offset": {"type": "integer", "minimum": 0, "maximum": 10000, "default": 0}
                },
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_show",
            "description": "Read one run with jobs, bounded logs, artifacts, and deployments.",
            "inputSchema": {
                "type": "object",
                "properties": {"id": {"type": "integer", "minimum": 1}},
                "required": ["id"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_wait",
            "description": "Poll a run until it reaches a persisted terminal or approval-required state.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer", "minimum": 1},
                    "timeout": {"type": "integer", "minimum": 1, "maximum": 14400, "default": 3600},
                    "interval": {"type": "integer", "minimum": 1, "maximum": 60, "default": 2}
                },
                "required": ["id"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_logs",
            "description": "Return job logs for a run, optionally filtered to failed/canceled/interrupted jobs.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer", "minimum": 1},
                    "failed": {"type": "boolean", "default": false}
                },
                "required": ["id"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_retry",
            "description": "Queue the same immutable revision as a prior run, optionally waiting.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer", "minimum": 1},
                    "wait": {"type": "boolean", "default": false}
                },
                "required": ["id"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_run_promote",
            "description": "Approve and resume a protected environment on a run, optionally waiting. Production-impacting.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer", "minimum": 1},
                    "environment": {"type": "string"},
                    "wait": {"type": "boolean", "default": false}
                },
                "required": ["id", "environment"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_artifact_get",
            "description": "Copy a build artifact to a path on the Titan filesystem.",
            "inputSchema": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer", "minimum": 1},
                    "output": {"type": "string"},
                    "force": {"type": "boolean", "default": false}
                },
                "required": ["id", "output"],
                "additionalProperties": false
            }
        }),
        json!({
            "name": "akurai_workers",
            "description": "List Titan worker status, capacity, current run, heartbeat, and completed-run count.",
            "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}
        }),
    ]
}

fn string_or_array(description: &str) -> Value {
    json!({
        "description": description,
        "oneOf": [
            {"type": "string"},
            {"type": "array", "items": {"type": "string"}, "maxItems": 32}
        ]
    })
}

fn tool_result(data: Value, is_error: bool) -> Value {
    let text = if is_error {
        data.as_str().unwrap_or("tool failed").to_owned()
    } else {
        serde_json::to_string_pretty(&data).unwrap_or_else(|_| "null".into())
    };
    json!({"content": [{"type": "text", "text": text}], "isError": is_error})
}

fn json_rpc_result(id: Value, result: Value) -> Value {
    json!({"jsonrpc": "2.0", "id": id, "result": result})
}

fn json_rpc_error(id: Value, code: i64, message: impl Into<String>) -> Value {
    json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}

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

    const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";

    fn context() -> Ctx {
        let database = Database::memory(KEY).expect("open in-memory database");
        database.migrate().expect("migrate schema");
        let root = std::env::temp_dir().join(format!("akurai-mcp-test-{}", std::process::id()));
        Ctx {
            database,
            root: root.clone(),
            allow_native: false,
            tree: RepoTree::new(&root).expect("create tree root"),
        }
    }

    #[test]
    fn lists_tools() {
        let tools = tools();
        let names: Vec<&str> = tools
            .iter()
            .map(|tool| tool["name"].as_str().expect("tool name"))
            .collect();
        assert!(names.contains(&"akurai_repo_add"));
        assert!(names.contains(&"akurai_run_queue"));
        assert!(names.contains(&"akurai_run_promote"));
        assert!(names.contains(&"akurai_repo_visibility"));
        assert!(names.contains(&"akurai_repo_branches"));
        assert!(names.contains(&"akurai_repo_tree"));
    }

    #[test]
    fn repo_lifecycle_through_tools() {
        let context = context();
        let added = call_tool(
            &context,
            "akurai_repo_add",
            json!({"name": "demo", "url": "https://example.com/demo.git", "branch": "main"}),
        )
        .expect("add repository");
        assert_eq!(added["name"], "demo");

        let listed = call_tool(&context, "akurai_repo_list", json!({"search": "demo"}))
            .expect("list repositories");
        assert_eq!(listed.as_array().expect("array").len(), 1);

        let renamed = call_tool(
            &context,
            "akurai_repo_rename",
            json!({"old": "demo", "new": "demo2"}),
        )
        .expect("rename repository");
        assert_eq!(renamed["name"], "demo2");

        let visibility = call_tool(
            &context,
            "akurai_repo_visibility",
            json!({"repository": "demo2", "visibility": "public"}),
        )
        .expect("set visibility");
        assert_eq!(visibility["visibility"], "public");

        let removed = call_tool(&context, "akurai_repo_remove", json!({"name": "demo2"}))
            .expect("remove repository");
        assert_eq!(removed["removed"], true);
    }

    #[test]
    fn unknown_tool_errors() {
        let context = context();
        let error = call_tool(&context, "akurai_nope", json!({})).expect_err("should fail");
        assert!(error.to_string().contains("unknown tool"));
    }

    #[test]
    fn initialize_and_tools_list_round_trip() {
        let context = context();
        let init = handle(
            &context,
            &json!({"jsonrpc":"2.0","id":1,"method":"initialize"}),
        )
        .expect("initialize response");
        assert_eq!(init["result"]["serverInfo"]["name"], "akurai-build");
        let list = handle(
            &context,
            &json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
        )
        .expect("tools/list response");
        assert!(
            !list["result"]["tools"]
                .as_array()
                .expect("array")
                .is_empty()
        );
    }
}