Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ The server communicates over JSON-RPC stdio. All tools return structured JSON wi
| `boruna_template_apply` | Apply a template with variable substitution |
| `boruna_capability_list` | List the frozen 1.0 capability set with `capability_set_hash` |
| `boruna_policy_validate` | Validate a `Policy` JSON document; returns typed `error_kind` on rejection |
| `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity |

## Agent-native CLI surfaces

Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Note: directory paths still use original names (crates/llmbc, crates/llmc, etc.)
- **boruna-framework** (dir: crates/llmfw) — Framework layer enforcing the App protocol (Elm architecture: init/update/view). `AppValidator`, `AppRuntime`, `TestHarness`, `PolicySet`, state machine diffing.
- **boruna-effect** (dir: crates/llm-effect) — Token-optimized LLM integration: prompt building, context management, caching, normalization, capability gating for LLM calls.
- **boruna-cli** (dir: crates/llmvm-cli) — CLI binary (`boruna`). Subcommands: compile, run, trace, replay, inspect, ast, framework, lang, trace2tests, template, workflow, evidence.
- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 12 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16.
- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 13 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16.

### Supporting Crates

Expand Down Expand Up @@ -151,6 +151,7 @@ MCP (Model Context Protocol) server that exposes Boruna's toolchain to AI coding
| `boruna_template_apply` | Apply a template with variable substitution |
| `boruna_capability_list` | Report the capability-set identity hash for `.ax` source |
| `boruna_policy_validate` | Validate a policy definition (strict validator) |
| `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity |

### IDE Configuration

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ This makes Boruna suited for teams building AI workflows that touch regulated da
- **33 built-in functions** — string (12), list (7), and map (7) operations plus type conversions and debug builtins (`__builtin_string_*`, `__builtin_list_*`, `__builtin_map_*`, …) available in every `.ax` file without imports
- **Import resolution** — `import "std-name"` inlines `libs/<name>/src/core.ax` at compile time; all 13 stdlib packages are 1.0-stable
- **Four formal versioned specifications** — `.ax` language 1.0, bytecode 1.0, evidence bundle format 1.0, workflow DAG schema 1.0 (all under [`docs/spec/`](./docs/spec/))
- **MCP server** — exposes 12 tools for AI coding agent integration (Claude Code, Cursor, Codex)
- **MCP server** — exposes 13 tools for AI coding agent integration (Claude Code, Cursor, Codex)

## What Boruna is not

Expand Down
21 changes: 21 additions & 0 deletions crates/boruna-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ struct RunLimitsParams {
max_memory_mb: Option<u64>,
}

#[derive(Serialize, Deserialize, JsonSchema)]
struct SymbolsParams {
/// The .ax source code to extract top-level symbols from
source: String,
}

#[derive(Serialize, Deserialize, JsonSchema)]
struct CheckParams {
/// The .ax source code to check
Expand Down Expand Up @@ -198,6 +204,21 @@ impl BorunaMcpServer {
Ok(CallToolResult::success(vec![Content::text(result)]))
}

#[tool(
description = "Extract the top-level SYMBOLS (functions, records, enums) from .ax source with their EXACT declared signatures. For each function: parameter names+types, return type, declared capability set (the !{...} annotation), and requires/ensures clause arity. For records: field names+types. For enums: variant names+payload types. Use this to ground on the real .ax API surface (exact typed signatures) instead of guessing — Boruna is a new language with little training data. Returns success=true with a `symbols` array; a parse failure returns success=false, error_kind='parse_error' as a successful tool response."
)]
async fn boruna_symbols(
&self,
Parameters(params): Parameters<SymbolsParams>,
) -> Result<CallToolResult, McpError> {
validate_source(&params.source)?;
let source = params.source;
let result = tokio::task::spawn_blocking(move || tools::symbols::extract_symbols(&source))
.await
.map_err(|e| McpError::internal_error(format!("task join error: {e}"), None))?;
Ok(CallToolResult::success(vec![Content::text(result)]))
}

// ── Run Tool ──

#[tool(
Expand Down
15 changes: 15 additions & 0 deletions crates/boruna-mcp/src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod compile;
pub mod framework;
pub mod policy;
pub mod run;
pub mod symbols;
pub mod template;
pub mod workflow;

Expand Down Expand Up @@ -71,6 +72,20 @@ mod protocol_version_tests {
assert_protocol_version(&out, "ast failure");
}

// ── symbols ──

#[test]
fn symbols_success_carries_protocol_version() {
let out = symbols::extract_symbols("fn main() -> Int { 1 }\n");
assert_protocol_version(&out, "symbols success");
}

#[test]
fn symbols_parse_failure_carries_protocol_version() {
let out = symbols::extract_symbols("@@@ not valid");
assert_protocol_version(&out, "symbols parse failure");
}

// ── run ──

#[test]
Expand Down
278 changes: 278 additions & 0 deletions crates/boruna-mcp/src/tools/symbols.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
use boruna_compiler::ast::{FnDef, Item, Program, TypeDef, TypeDefKind, TypeExpr};
use boruna_compiler::CompileError;

use super::TOOL_RESPONSE_PROTOCOL_VERSION;

/// Extract top-level symbols (functions, records, enums) with their exact
/// declared signatures from `.ax` source.
///
/// Grounds LLM agents on real `.ax` APIs instead of hallucinated ones by
/// surfacing the exact parameter names/types, return type, declared
/// capability set, and requires/ensures arity for every top-level symbol.
///
/// Only lex + parse are needed: parameter, return, and field/variant types
/// are all syntactic annotations already present in the parsed AST, so no
/// type-checking pass is required to report exact declared types.
pub fn extract_symbols(source: &str) -> String {
let tokens = match boruna_compiler::lexer::lex(source) {
Ok(t) => t,
Err(e) => return parse_error_json(&e),
};

let program: Program = match boruna_compiler::parser::parse(tokens) {
Ok(p) => p,
Err(e) => return parse_error_json(&e),
};

let symbols: Vec<serde_json::Value> = program
.items
.iter()
.filter_map(|item| match item {
Item::Function(f) => Some(fn_symbol(f)),
Item::TypeDef(t) => Some(type_symbol(t)),
// Imports and re-exports are not symbol *definitions*.
Item::Import(_) | Item::Export(_) => None,
})
.collect();

serde_json::json!({
"success": true,
"protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
"symbols": symbols,
})
.to_string()
}

fn fn_symbol(f: &FnDef) -> serde_json::Value {
let params: Vec<serde_json::Value> = f
.params
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"type": render_type(&p.ty),
})
})
.collect();

let return_type = f
.return_type
.as_ref()
.map(render_type)
.unwrap_or_else(|| "Unit".to_string());

serde_json::json!({
"kind": "fn",
"name": f.name,
"signature": fn_signature(f, &return_type),
"params": params,
"return_type": return_type,
"capabilities": f.capabilities,
"requires": f.requires.len(),
"ensures": f.ensures.len(),
"intent": f.intent,
"exported": f.exported,
})
}

fn fn_signature(f: &FnDef, return_type: &str) -> String {
let params: Vec<String> = f
.params
.iter()
.map(|p| format!("{}: {}", p.name, render_type(&p.ty)))
.collect();
let mut sig = format!("fn {}({}) -> {}", f.name, params.join(", "), return_type);
if !f.capabilities.is_empty() {
sig.push_str(&format!(" !{{{}}}", f.capabilities.join(", ")));
}
sig
}

fn type_symbol(t: &TypeDef) -> serde_json::Value {
match &t.kind {
TypeDefKind::Record(fields) => {
let rendered: Vec<serde_json::Value> = fields
.iter()
.map(|(name, ty)| {
serde_json::json!({
"name": name,
"type": render_type(ty),
})
})
.collect();
let field_sig: Vec<String> = fields
.iter()
.map(|(name, ty)| format!("{}: {}", name, render_type(ty)))
.collect();
serde_json::json!({
"kind": "record",
"name": t.name,
"signature": format!("type {} {{ {} }}", t.name, field_sig.join(", ")),
"fields": rendered,
"capabilities": Vec::<String>::new(),
"exported": t.exported,
})
}
TypeDefKind::Enum(variants) => {
let rendered: Vec<serde_json::Value> = variants
.iter()
.map(|(name, payload)| {
serde_json::json!({
"name": name,
"payload": payload.as_ref().map(render_type),
})
})
.collect();
let variant_sig: Vec<String> = variants
.iter()
.map(|(name, payload)| match payload {
Some(ty) => format!("{}({})", name, render_type(ty)),
None => name.clone(),
})
.collect();
serde_json::json!({
"kind": "enum",
"name": t.name,
"signature": format!("enum {} {{ {} }}", t.name, variant_sig.join(", ")),
"variants": rendered,
"capabilities": Vec::<String>::new(),
"exported": t.exported,
})
}
}
}

/// Render a `TypeExpr` back to its `.ax` surface syntax.
fn render_type(ty: &TypeExpr) -> String {
match ty {
TypeExpr::Named(n) => n.clone(),
TypeExpr::Option(inner) => format!("Option<{}>", render_type(inner)),
TypeExpr::Result(ok, err) => {
format!("Result<{}, {}>", render_type(ok), render_type(err))
}
TypeExpr::List(inner) => format!("List<{}>", render_type(inner)),
TypeExpr::Map(k, v) => format!("Map<{}, {}>", render_type(k), render_type(v)),
TypeExpr::Fn(params, ret) => {
let ps: Vec<String> = params.iter().map(render_type).collect();
format!("({}) -> {}", ps.join(", "), render_type(ret))
}
}
}

fn parse_error_json(err: &CompileError) -> String {
let (message, line, col) = match err {
CompileError::Lexer { line, col, msg } => (msg.clone(), Some(*line), Some(*col)),
CompileError::Parse { line, msg } => (msg.clone(), Some(*line), None),
// extract_symbols only lexes + parses, so Type/Codegen cannot occur.
CompileError::Type(msg) | CompileError::Codegen(msg) => (msg.clone(), None, None),
};

serde_json::json!({
"success": false,
"protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
"error_kind": "parse_error",
"error": message,
"line": line,
"col": col,
})
.to_string()
}

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

fn parse(json: &str) -> Value {
serde_json::from_str(json).expect("valid JSON")
}

#[test]
fn extracts_fn_record_and_enum() {
let source = r#"
type User { name: String, age: Int }

enum Color { Red, Green, Custom(Int) }

fn fetch(url: String, retries: Int) -> Result<String, String> !{net.fetch} {
Ok("body")
}

fn main() -> Int { 0 }
"#;
let out = extract_symbols(source);
let v = parse(&out);
assert_eq!(v["success"], true);
let syms = v["symbols"].as_array().expect("symbols array");
assert_eq!(syms.len(), 4);

// fetch fn
let fetch = syms
.iter()
.find(|s| s["name"] == "fetch")
.expect("fetch symbol");
assert_eq!(fetch["kind"], "fn");
assert_eq!(fetch["return_type"], "Result<String, String>");
assert_eq!(fetch["capabilities"], serde_json::json!(["net.fetch"]));
assert_eq!(fetch["params"][0]["name"], "url");
assert_eq!(fetch["params"][0]["type"], "String");
assert_eq!(fetch["params"][1]["name"], "retries");
assert_eq!(fetch["params"][1]["type"], "Int");
assert_eq!(
fetch["signature"],
"fn fetch(url: String, retries: Int) -> Result<String, String> !{net.fetch}"
);

// User record
let user = syms
.iter()
.find(|s| s["name"] == "User")
.expect("User symbol");
assert_eq!(user["kind"], "record");
assert_eq!(user["fields"][0]["name"], "name");
assert_eq!(user["fields"][0]["type"], "String");
assert_eq!(user["fields"][1]["name"], "age");
assert_eq!(user["fields"][1]["type"], "Int");

// Color enum
let color = syms
.iter()
.find(|s| s["name"] == "Color")
.expect("Color symbol");
assert_eq!(color["kind"], "enum");
let variants = color["variants"].as_array().expect("variants");
assert_eq!(variants.len(), 3);
assert_eq!(variants[0]["name"], "Red");
assert!(variants[0]["payload"].is_null());
assert_eq!(variants[2]["name"], "Custom");
assert_eq!(variants[2]["payload"], "Int");
}

#[test]
fn reports_requires_ensures_arity() {
let source = r#"
fn withdraw(amount: Int) -> Int
requires amount > 0
ensures result >= 0
{
amount
}
"#;
let out = extract_symbols(source);
let v = parse(&out);
assert_eq!(v["success"], true);
let withdraw = &v["symbols"][0];
assert_eq!(withdraw["name"], "withdraw");
assert_eq!(withdraw["requires"], 1);
assert_eq!(withdraw["ensures"], 1);
}

#[test]
fn parse_failure_reports_error_kind() {
let out = extract_symbols("@@@ this is not valid .ax");
let v = parse(&out);
assert_eq!(v["success"], false);
assert_eq!(v["error_kind"], "parse_error");
assert!(v["error"].is_string());
}
}
2 changes: 1 addition & 1 deletion crates/llmbc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ pub use capability::{
CapabilitySetReport, CAPABILITY_REPORT_PROTOCOL_VERSION,
};
pub use module::{BytecodeError, Function, Module};
pub use opcode::Op;
pub use opcode::{ContractKind, Op};
pub use value::Value;

/// Frozen bytecode specification version.
Expand Down
Loading
Loading