Menu
AkurAI-Build
publicLatest change 30e6b8c347048d64c2c048dfd05a8ded219d37f6 - Host authenticated Git repositories over Smart HTTP with repo host/sync CLI by Ólafur Búi Ólafsson
use std::io::{self, BufRead, Write};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::db::{Database, RepositoryQuery, RunQuery};
const PROTOCOL_VERSION: &str = "2025-06-18";
#[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, 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 RunDetailArguments {
id: i64,
}
#[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
}
pub fn serve(database: Database) -> Result<()> {
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(&database, &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(database: &Database, 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": "Read-only access to AkurAI Build repositories, runs, run evidence, and Titan workers."
}),
),
"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(database, 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(database: &Database, name: &str, arguments: Value) -> Result<Value> {
match name {
"akurai_repositories" => {
let arguments: RepositoryArguments = serde_json::from_value(arguments)?;
Ok(serde_json::to_value(database.query_repositories(
&RepositoryQuery {
search: arguments.search,
visibility: arguments.visibility,
limit: arguments.limit,
offset: arguments.offset,
},
)?)?)
}
"akurai_runs" => {
let arguments: RunArguments = serde_json::from_value(arguments)?;
Ok(serde_json::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" => {
let arguments: RunDetailArguments = serde_json::from_value(arguments)?;
Ok(serde_json::to_value(database.detail(arguments.id)?)?)
}
"akurai_workers" => Ok(serde_json::to_value(database.workers()?)?),
_ => bail!("unknown tool: {name}"),
}
}
fn tools() -> Vec<Value> {
vec![
json!({
"name": "akurai_repositories",
"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_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",
"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_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";
#[test]
fn lists_and_queries_read_only_tools() -> Result<()> {
let database = Database::memory(KEY)?;
let alpha = database.add_repository("alpha", "https://example.com/alpha.git", "main")?;
let beta = database.add_repository("beta", "https://example.com/beta.git", "main")?;
let first = database.create_run(alpha.id, "main", None, "manual")?;
database.finish_run(first, "failed", Some("compiler error"))?;
let second = database.create_run(beta.id, "main", None, "webhook")?;
database.finish_run(second, "succeeded", None)?;
database.register_worker("titan-1", "titan", "docker", 1)?;
let listed = handle(
&database,
&json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
)
.context("tools/list returned no response")?;
assert_eq!(listed["result"]["tools"].as_array().map(Vec::len), Some(4));
let queried = handle(
&database,
&json!({
"jsonrpc":"2.0",
"id":2,
"method":"tools/call",
"params":{"name":"akurai_runs","arguments":{"repo":["alpha","beta"],"status":["failed","succeeded"],"search":"main"}}
}),
)
.context("tools/call returned no response")?;
assert_eq!(queried["result"]["isError"], false);
let text = queried["result"]["content"][0]["text"]
.as_str()
.context("tool response has no text")?;
let runs: Vec<Value> = serde_json::from_str(text)?;
assert_eq!(runs.len(), 2);
Ok(())
}
}