use std::{
collections::{BTreeMap, BTreeSet},
fmt::Write,
fs::create_dir_all,
io,
path::{Path, PathBuf},
};
use brk_types::TreeNode;
use oas3::Spec;
use serde_json::Value;
use crate::{ClientMetadata, Endpoint, ResponseKind, TypeSchemas};
use super::write_if_changed;
mod manifest;
const BASE_URL: &str = "https://bitview.space";
const MCP_URL: &str = "https://mcp.bitview.space/";
pub fn generate_llm_clients(
metadata: &ClientMetadata,
spec: &Spec,
endpoints: &[Endpoint],
schemas: &TypeSchemas,
roots: &[PathBuf],
manifest_path: Option<&Path>,
) -> io::Result<()> {
let metric_count = count_metrics(&metadata.catalog);
let generated = endpoints
.iter()
.filter(|endpoint| endpoint.should_generate())
.collect::<Vec<_>>();
let llms = render_llms(
&spec.info.title,
&spec.info.version,
metric_count,
&generated,
);
let llms_full = render_llms_full(
&spec.info.title,
&spec.info.version,
metric_count,
&generated,
schemas,
);
for root in roots {
create_dir_all(root)?;
write_output(&root.join("llms.txt"), &llms)?;
write_output(&root.join("llms-full.txt"), &llms_full)?;
}
if let Some(path) = manifest_path {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
manifest::generate_tool_manifest(endpoints, schemas, path)?;
}
Ok(())
}
fn write_output(path: &Path, content: &str) -> io::Result<()> {
if let Some(parent) = path.parent() {
create_dir_all(parent)?;
}
write_if_changed(path, content)
}
fn count_metrics(node: &TreeNode) -> usize {
match node {
TreeNode::Leaf(_) => 1,
TreeNode::Branch(children) => children.values().map(count_metrics).sum(),
}
}
fn render_llms(title: &str, version: &str, metric_count: usize, endpoints: &[&Endpoint]) -> String {
format!(
"# {title} (BRK)\n\n\
> Free, open-source Bitcoin analytics API and block explorer. {metric_count} on-chain time-series and {} API operations. No authentication required.\n\n\
## API\n\n\
- Version: `{version}`\n\
- Base URL: {BASE_URL}\n\
- [Full plain-text reference]({BASE_URL}/llms-full.txt)\n\
- [Compact OpenAPI]({BASE_URL}/api.json)\n\
- [Full OpenAPI]({BASE_URL}/openapi.json)\n\
- [Series catalog]({BASE_URL}/api/series)\n\
- [Interactive documentation]({BASE_URL}/api)\n\n\
Use OpenAPI for tool construction, `/api/series` for complete series metadata, and `llms-full.txt` for a readable reference.\n\n\
## MCP\n\n\
- Endpoint: {MCP_URL}\n\
- Transport: Streamable HTTP\n\
- Authentication: None\n\n\
The MCP server is stateless and read-only. Its tools are generated from these OpenAPI operations.\n\n\
## Clients\n\n\
- [JavaScript](https://www.npmjs.com/package/brk-client)\n\
- [Python](https://pypi.org/project/brk-client/)\n\
- [Rust](https://crates.io/crates/brk_client)\n\n\
## Source\n\n\
- [GitHub](https://github.com/bitcoinresearchkit/brk)\n\
- MIT licensed\n",
endpoints.len(),
)
}
fn render_llms_full(
title: &str,
version: &str,
metric_count: usize,
endpoints: &[&Endpoint],
schemas: &TypeSchemas,
) -> String {
let mut output = String::new();
writeln!(output, "# {title} (BRK) — Full API Reference\n").unwrap();
writeln!(
output,
"> Generated from BRK's OpenAPI specification and metric tree. Do not edit this file manually.\n"
)
.unwrap();
writeln!(output, "- Version: `{version}`").unwrap();
writeln!(output, "- Base URL: {BASE_URL}").unwrap();
writeln!(output, "- MCP endpoint: {MCP_URL}").unwrap();
writeln!(output, "- Metrics: {metric_count}").unwrap();
writeln!(output, "- Operations: {}\n", endpoints.len()).unwrap();
writeln!(
output,
"For machine-readable tool construction, use [{BASE_URL}/openapi.json]({BASE_URL}/openapi.json). For the complete source-derived series tree, use [{BASE_URL}/api/series]({BASE_URL}/api/series).\n"
)
.unwrap();
let mut groups = BTreeMap::<String, Vec<&Endpoint>>::new();
for endpoint in endpoints {
groups
.entry(endpoint_group(&endpoint.path))
.or_default()
.push(endpoint);
}
for group in groups.values_mut() {
group.sort_unstable_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.method.cmp(&right.method))
});
}
writeln!(output, "## Operations\n").unwrap();
for (group, operations) in groups {
writeln!(output, "### {group}\n").unwrap();
for endpoint in operations {
render_endpoint(&mut output, endpoint);
}
}
let referenced = referenced_schemas(endpoints, schemas);
if !referenced.is_empty() {
writeln!(output, "## Schemas\n").unwrap();
for name in referenced {
let Some(schema) = schemas.get(&name) else {
continue;
};
render_schema(&mut output, &name, schema);
}
}
output
}
fn endpoint_group(path: &str) -> String {
let segment = path
.split('/')
.find(|part| !part.is_empty() && *part != "api" && *part != "v1")
.unwrap_or("server");
title_case(segment)
}
fn title_case(value: &str) -> String {
value
.split(['-', '_'])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
})
.collect::<Vec<_>>()
.join(" ")
}
fn render_endpoint(output: &mut String, endpoint: &Endpoint) {
writeln!(output, "#### {} `{}`\n", endpoint.method, endpoint.path).unwrap();
if let Some(summary) = endpoint
.summary
.as_deref()
.filter(|value| !value.trim().is_empty())
{
writeln!(output, "{}\n", one_line(summary)).unwrap();
}
if let Some(description) = endpoint
.description
.as_deref()
.filter(|value| !value.trim().is_empty())
{
let description = one_line(description);
if endpoint.summary.as_deref().map(one_line).as_deref() != Some(description.as_str()) {
writeln!(output, "{description}\n").unwrap();
}
}
let parameters = endpoint
.path_params
.iter()
.map(|parameter| ("path", parameter))
.chain(
endpoint
.query_params
.iter()
.map(|parameter| ("query", parameter)),
)
.collect::<Vec<_>>();
if !parameters.is_empty() {
writeln!(output, "Parameters:").unwrap();
for (location, parameter) in parameters {
let requirement = if parameter.required {
"required"
} else {
"optional"
};
write!(
output,
"- `{}` ({location}, {}, {requirement})",
parameter.name, parameter.param_type
)
.unwrap();
if let Some(description) = parameter
.description
.as_deref()
.filter(|value| !value.trim().is_empty())
{
write!(output, ": {}", one_line(description)).unwrap();
}
writeln!(output).unwrap();
}
writeln!(output).unwrap();
}
if let Some(body) = &endpoint.request_body {
writeln!(
output,
"Request body: `{}` ({})\n",
body.body_type,
if body.required {
"required"
} else {
"optional"
}
)
.unwrap();
}
writeln!(
output,
"Returns: {}\n",
response_label(&endpoint.response_kind)
)
.unwrap();
writeln!(output, "```bash").unwrap();
writeln!(output, "{}", curl_example(endpoint)).unwrap();
writeln!(output, "```\n").unwrap();
}
fn response_label(response: &ResponseKind) -> String {
match response {
ResponseKind::Json(name) => format!("JSON `{name}`"),
ResponseKind::Text(Some(schema)) => format!("text `{}`", schema.name),
ResponseKind::Text(None) => "text".to_owned(),
ResponseKind::Binary => "binary data".to_owned(),
}
}
fn curl_example(endpoint: &Endpoint) -> String {
let mut path = endpoint.path.clone();
for parameter in &endpoint.path_params {
path = path.replace(
&format!("{{{}}}", parameter.name),
&format!("<{}>", parameter.name),
);
}
if !endpoint.query_params.is_empty() {
path.push('?');
path.push_str(
&endpoint
.query_params
.iter()
.map(|parameter| format!("{}=<{}>", parameter.name, parameter.name))
.collect::<Vec<_>>()
.join("&"),
);
}
if let Some(body) = &endpoint.request_body {
format!(
"curl -s -X {} --data '<{}>' \"{BASE_URL}{path}\"",
endpoint.method, body.body_type
)
} else {
format!("curl -s \"{BASE_URL}{path}\"")
}
}
fn referenced_schemas(endpoints: &[&Endpoint], schemas: &TypeSchemas) -> BTreeSet<String> {
let mut names = BTreeSet::new();
for endpoint in endpoints {
if let Some(name) = endpoint.schema_name().filter(|name| *name != "*") {
names.insert(name.to_owned());
}
if let Some(body) = &endpoint.request_body
&& schemas.contains_key(&body.body_type)
{
names.insert(body.body_type.clone());
}
}
let mut pending = names.iter().cloned().collect::<Vec<_>>();
while let Some(name) = pending.pop() {
let Some(schema) = schemas.get(&name) else {
continue;
};
let mut refs = BTreeSet::new();
collect_refs(schema, &mut refs);
for referenced in refs {
if schemas.contains_key(&referenced) && names.insert(referenced.clone()) {
pending.push(referenced);
}
}
}
names
}
fn collect_refs(value: &Value, refs: &mut BTreeSet<String>) {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
&& let Some(name) = reference.rsplit('/').next()
{
refs.insert(name.to_owned());
}
for child in object.values() {
collect_refs(child, refs);
}
}
Value::Array(values) => {
for child in values {
collect_refs(child, refs);
}
}
_ => {}
}
}
fn render_schema(output: &mut String, name: &str, schema: &Value) {
writeln!(output, "### `{name}`\n").unwrap();
let required = schema
.get("required")
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_str)
.collect::<BTreeSet<_>>()
})
.unwrap_or_default();
if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
for (property, shape) in properties {
write!(
output,
"- `{property}`: `{}`{}",
schema_type(shape),
if required.contains(property.as_str()) {
" (required)"
} else {
""
}
)
.unwrap();
if let Some(description) = shape
.get("description")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
write!(output, " — {}", one_line(description)).unwrap();
}
writeln!(output).unwrap();
}
} else {
writeln!(output, "`{}`", schema_type(schema)).unwrap();
}
writeln!(output).unwrap();
}
fn schema_type(schema: &Value) -> String {
if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
return reference.rsplit('/').next().unwrap_or(reference).to_owned();
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return values
.iter()
.map(|value| {
value
.as_str()
.map_or_else(|| value.to_string(), str::to_owned)
})
.collect::<Vec<_>>()
.join(" | ");
}
if let Some(kind) = schema.get("type").and_then(Value::as_str) {
if kind == "array" {
return format!(
"{}[]",
schema
.get("items")
.map(schema_type)
.unwrap_or_else(|| "value".to_owned())
);
}
return kind.to_owned();
}
for key in ["oneOf", "anyOf", "allOf"] {
if let Some(values) = schema.get(key).and_then(Value::as_array) {
return values
.iter()
.map(schema_type)
.collect::<Vec<_>>()
.join(" | ");
}
}
"object".to_owned()
}
fn one_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Parameter;
fn endpoint(path: &str, method: &str) -> Endpoint {
Endpoint {
method: method.to_owned(),
path: path.to_owned(),
operation_id: None,
summary: Some("Read a thing".to_owned()),
description: None,
path_params: vec![Parameter {
name: "id".to_owned(),
required: true,
param_type: "string".to_owned(),
description: Some("Thing identifier".to_owned()),
schema: serde_json::json!({ "type": "string" }),
}],
query_params: Vec::new(),
request_body: None,
response_kind: ResponseKind::Json("Thing".to_owned()),
json_response_schema: Some(serde_json::json!({
"$ref": "#/components/schemas/Thing"
})),
deprecated: false,
supports_csv: false,
}
}
#[test]
fn full_reference_lists_every_operation_once() {
let first = endpoint("/api/thing/{id}", "GET");
let second = endpoint("/api/thing/{id}/status", "GET");
let endpoints = [&first, &second];
let output = render_llms_full("BRK", "v1", 12, &endpoints, &BTreeMap::new());
assert_eq!(output.matches("#### GET `/api/thing/{id}`\n").count(), 1);
assert_eq!(
output
.matches("#### GET `/api/thing/{id}/status`\n")
.count(),
1
);
assert!(output.contains("- MCP endpoint: https://mcp.bitview.space/"));
assert!(output.contains("curl -s \"https://bitview.space/api/thing/<id>\""));
}
#[test]
fn discovery_lists_the_official_mcp_endpoint() {
let first = endpoint("/api/thing/{id}", "GET");
let endpoints = [&first];
let output = render_llms("BRK", "v1", 12, &endpoints);
assert!(output.contains("- Endpoint: https://mcp.bitview.space/"));
assert!(output.contains("- Transport: Streamable HTTP"));
assert!(output.contains("- Authentication: None"));
}
#[test]
fn schema_renderer_keeps_required_fields_and_references() {
let schema = serde_json::json!({
"type": "object",
"required": ["tx"],
"properties": {
"tx": { "$ref": "#/components/schemas/Transaction" },
"height": { "type": "integer" }
}
});
let mut output = String::new();
render_schema(&mut output, "Result", &schema);
assert!(output.contains("`tx`: `Transaction` (required)"));
assert!(output.contains("`height`: `integer`"));
}
}