//! Cairn MCP server — a thin JSON-RPC transport over `cairn_core::edit`.
//!
//! It holds no logic of its own (`docs/design.md` Section 7): every tool call
//! dispatches to the one `Editor` in Core, and every commit returns the one
//! Core `Report`. The MCP subset implemented is the minimum to drive
//! authoring: `initialize`, `tools/list`, `tools/call`. Resources, prompts,
//! and capability negotiation beyond a tools declaration are out of v0.1.
//!
//! Tools exposed are exactly those that work: the Section 6
//! worked-session loop, `run`/`run_module`, the version-control surface,
//! the stored-tree tools `query_type`, `find_references`,
//! `replace_node`, `fill_hole`, and the v0.2 editor substrate
//! `render_addressed`/`node_at`/`put_expr` (projection + hash↔span
//! index + the node-construction primitive, so an out-of-process
//! editor turns a cursor into a node hash and mints replacements).
//! `tools/list` reflects exactly the working surface.
use cairn_core::{
node_at, render_addressed, Confidence, Editor, ExprSpec, ModuleSpec, NodeHash,
Store, Type, TypeDefSpec,
};
use serde_json::{json, Value};
use std::collections::BTreeSet;
/// One authoring session: a store, the single Core editor over it,
/// and a per-session SQLite file `run_handler` drives so a web
/// handler's load/persist round-trip persists across calls (POST in
/// one call, GET it back in the next — the verification an `i64`
/// `run_module` cannot give).
pub struct Session {
editor: Editor,
handler_db: String,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
/// A unique temp path for this session's `run_handler` SQLite file.
/// (Unique per process + a monotonic counter, so concurrent sessions
/// — and the test suite — never share one.)
fn session_db_path() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let mut p = std::env::temp_dir();
p.push(format!(
"cairn-mcp-handler-{}-{}.sqlite",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
p.to_string_lossy().into_owned()
}
impl Session {
pub fn new() -> Self {
let store = Store::open_in_memory().expect("in-memory store");
Self {
editor: Editor::new(store),
handler_db: session_db_path(),
}
}
/// Open a file-backed session so an agent's work persists across
/// sessions (the project story). The store validates its
/// `FORMAT_VERSION` on open and refuses an incompatible one.
pub fn open(path: &str) -> Result<Self, String> {
let store = Store::open(path).map_err(|e| e.to_string())?;
Ok(Self {
editor: Editor::new(store),
handler_db: session_db_path(),
})
}
/// Handle one JSON-RPC message and return the response value. A
/// notification (no `id`) returns `Value::Null` and is not written back.
pub fn handle(&mut self, req: Value) -> Value {
let id = req.get("id").cloned();
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
let params = req.get("params").cloned().unwrap_or(Value::Null);
let outcome = match method {
"initialize" => Ok(json!({
"protocolVersion": "2024-11-05",
"serverInfo": { "name": "cairn-mcp-server", "version": env!("CARGO_PKG_VERSION") },
"capabilities": { "tools": {} }
})),
"tools/list" => Ok(json!({ "tools": tool_list() })),
"tools/call" => self.call(¶ms),
_ if id.is_none() => return Value::Null, // notification
_ => Err(Rpc::method_not_found(method)),
};
match (id, outcome) {
(None, _) => Value::Null,
(Some(id), Ok(result)) => json!({"jsonrpc":"2.0","id":id,"result":result}),
(Some(id), Err(e)) => {
json!({"jsonrpc":"2.0","id":id,"error":{"code":e.0,"message":e.1}})
}
}
}
fn call(&mut self, params: &Value) -> Result<Value, Rpc> {
let name = params
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| Rpc::invalid("missing tool name"))?;
let args = params.get("arguments").cloned().unwrap_or(json!({}));
// A tool result is MCP-shaped: a text content block carrying JSON,
// with `isError` set when the editor rejected the call.
match self.dispatch(name, &args) {
Ok(payload) => Ok(json!({
"content": [{ "type": "text", "text": payload.to_string() }],
"isError": false
})),
Err(msg) => Ok(json!({
"content": [{ "type": "text", "text": msg }],
"isError": true
})),
}
}
/// Map a tool name + arguments onto the Core editor. Returns the tool's
/// structured JSON result, or a human-readable error string.
fn dispatch(&mut self, name: &str, a: &Value) -> Result<Value, String> {
let ok = || json!({ "ok": true });
match name {
"begin_edit" => self.editor.begin_edit().map(|_| ok()).map_err(es),
"abort_edit" => {
self.editor.abort_edit();
Ok(ok())
}
"create_function" => self
.editor
.create_function(str_arg(a, "name")?)
.map(|_| ok())
.map_err(es),
"add_param" => {
let ty: Type = de(a, "type")?;
let mc: Confidence = de(a, "min_confidence")?;
self.editor
.add_param(str_arg(a, "name")?, ty, mc)
.map(|_| ok())
.map_err(es)
}
"set_produces" => {
let ty: Type = de(a, "type")?;
let c: Confidence = de(a, "confidence")?;
self.editor.set_produces(ty, c).map(|_| ok()).map_err(es)
}
"set_effects" => {
let effects: BTreeSet<cairn_core::Effect> = de(a, "effects")?;
self.editor.set_effects(effects).map(|_| ok()).map_err(es)
}
"set_on_failure" => {
let f: Vec<String> = de(a, "failures")?;
self.editor.set_on_failure(f).map(|_| ok()).map_err(es)
}
"add_step" => {
let value: ExprSpec = de(a, "value")?;
self.editor
.add_step(str_arg(a, "binding")?, value)
.map(|_| ok())
.map_err(es)
}
"set_yield" => {
let value: ExprSpec = de(a, "value")?;
self.editor.set_yield(value).map(|_| ok()).map_err(es)
}
"describe_hole" => self
.editor
.describe_hole()
.map(|h| serde_json::to_value(h).unwrap())
.map_err(es),
// Module-level authoring: the same Core API the framework and
// stdlib are built with, now reachable by an agent. Closes
// the single-function ceiling — typed, multi-function
// programs (records/variants, cross-function calls) can be
// authored over JSON-RPC, not only via Rust spec builders.
"define_type" => {
let spec: TypeDefSpec = de(a, "type")?;
self.editor
.define_type(&spec)
.map(|h| json!({ "hash": h.to_string() }))
.map_err(es)
}
"apply_module" => {
let spec: ModuleSpec = de(a, "module")?;
self.editor
.apply_module(&spec)
.map(|(hash, report)| {
json!({ "hash": hash.to_string(), "report": report })
})
.map_err(es)
}
// The structural scaffolder over the wire.
// `cairn_core::scaffold` generates the mechanical CRUD
// *shell* AST (`*_from_rows`, the `save`/`save_step`
// persist pair) plus the table DDL and SELECT from one
// declarative `EntitySpec`. The thinnest possible
// transport: deserialize the spec, call the same
// generator the Rust API calls, return the `FunctionSpec`s
// + SQL as JSON. It does not apply — the shell is not
// standalone (`*_from_rows` yields `List<Named(record)>`;
// a `Variant` field calls app-named decode/encode fns), so
// it only checks once the caller composes the returned
// functions into one `ModuleSpec` with the record
// `define_type` and the business logic and calls
// `apply_module`. The MCP server holds no logic of its own
// (design.md §7).
"scaffold_entity" => {
let spec: cairn_core::EntitySpec = de(a, "entity")?;
// Fail closed on a malformed spec: a bad `EntitySpec`
// must error at the seam, never silently emit corrupt
// SQL or self-shadowing code.
spec.validate()?;
let from_rows = cairn_core::scaffold::from_rows(&spec);
let (save, step) = cairn_core::scaffold::save_pair(&spec);
Ok(json!({
"functions": [from_rows, save, step],
"create_table": cairn_core::scaffold::create_table(&spec),
"select_all": cairn_core::scaffold::select_all(&spec),
}))
}
// The Tier-2 web framework over the wire. An app composes
// the framework by *including its specs* in its own
// `ModuleSpec` — the content-addressed store is the linker
// (design.md §7), so identical specs hash once and
// "including" *is* "reusing". The Rust API gets this by
// calling `cairn_core::web::{types,functions}()`; this is
// the same surface for a client that has only MCP, so the
// scaffolded shell (which calls the framework `field`
// accessor) can be composed into a checkable module. Same
// thin-transport contract as `scaffold_entity`: it returns
// the same specs the Rust API splices, as JSON, holding no
// logic of its own. `web::functions()` is self-contained
// (it already includes the Cairn stdlib it layers on), so
// the returned splice checks as a unit once the caller
// merges it with its record `define_type`s + scaffolded
// shell + business logic and calls `apply_module`.
"framework" => Ok(json!({
"types": cairn_core::web::types(),
"functions": cairn_core::web::functions(),
})),
// The canonical app skeleton over the wire — Cairn's
// answer to `rails new`, but explicit not magical: it
// returns the *documented* TEA shape (model + `<app>Msg`
// + the five `run_app` functions + the `route` entry),
// not a runtime convention. Same Principle-9 transport as
// `scaffold_entity`/`framework`: it calls the one
// `cairn_core::scaffold::app_skeleton` generator and
// returns `{ types, functions }` as JSON. Not standalone
// (`Request`/`Response`/`Element`/`run_app` resolve from
// the framework splice), so the agent merges it with
// `framework` + any `scaffold_entity` shells + the
// business logic into one `ModuleSpec` and calls
// `apply_module` — the canonical shape, generated so it
// cannot drift, never reverse-engineered.
"app_skeleton" => {
let spec: cairn_core::AppSpec = de(a, "spec")?;
let (types, functions) =
cairn_core::scaffold::app_skeleton(&spec);
Ok(json!({ "types": types, "functions": functions }))
}
"commit_edit" => self
.editor
.commit_edit()
.map(|(hash, report)| {
json!({ "hash": hash.to_string(), "report": report })
})
.map_err(es),
"run" => {
let func = NodeHash::parse(str_arg(a, "func")?);
let fname = str_arg(a, "name")?;
let args: Vec<i64> = de(a, "args")?;
self.editor
.run(&func, fname, &args)
.map(|v| json!({ "result": v }))
.map_err(es)
}
// The v0.1 version-control surface: name a root so it
// survives the session, and resolve it back later.
"checkpoint" => {
let root = NodeHash::parse(str_arg(a, "root")?);
self.editor
.store()
.checkpoint(str_arg(a, "name")?, &root)
.map(|_| ok())
.map_err(es)
}
"branch" => {
let root = NodeHash::parse(str_arg(a, "root")?);
self.editor
.store()
.branch(str_arg(a, "name")?, &root)
.map(|_| ok())
.map_err(es)
}
"resolve" => self
.editor
.store()
.resolve(str_arg(a, "name")?)
.map(|o| json!({ "hash": o.map(|h| h.to_string()) }))
.map_err(es),
"query_type" => {
let m = NodeHash::parse(str_arg(a, "module")?);
self.editor
.query_type(&m, str_arg(a, "name")?)
.map(|o| serde_json::to_value(o).unwrap())
.map_err(es)
}
// v0.2 editor substrate: the projection + its hash↔span
// index, and the deepest node at a cursor offset. Thin
// transports over Core, like every other tool — they let an
// out-of-process editor turn a click into a node hash the
// structural tools already act on.
"render_addressed" => {
let root = NodeHash::parse(str_arg(a, "root")?);
render_addressed(self.editor.store(), &root)
.map(|ad| serde_json::to_value(ad).unwrap())
.map_err(es)
}
"node_at" => {
let root = NodeHash::parse(str_arg(a, "root")?);
let offset: usize = de(a, "offset")?;
let ad = render_addressed(self.editor.store(), &root)
.map_err(es)?;
Ok(json!({
"hash": node_at(&ad, offset).map(|h| h.to_string())
}))
}
// The editor's construction primitive: materialize an
// ExprSpec and return its hash, to feed `replace_node`/
// `fill_hole`. The missing half of structural editing.
"put_expr" => {
let spec: ExprSpec = de(a, "expr")?;
self.editor
.put_expr(&spec)
.map(|h| json!({ "hash": h.to_string() }))
.map_err(es)
}
"fill_hole" => {
let root = NodeHash::parse(str_arg(a, "root")?);
let hole = NodeHash::parse(str_arg(a, "hole")?);
let replacement = NodeHash::parse(str_arg(a, "replacement")?);
self.editor
.fill_hole(&root, &hole, &replacement)
.map(|(hash, report)| {
json!({ "root": hash.to_string(), "report": report })
})
.map_err(es)
}
"replace_node" => {
let root = NodeHash::parse(str_arg(a, "root")?);
let target = NodeHash::parse(str_arg(a, "target")?);
let replacement = NodeHash::parse(str_arg(a, "replacement")?);
self.editor
.replace_node(&root, &target, &replacement)
.map(|(hash, report)| {
json!({ "root": hash.to_string(), "report": report })
})
.map_err(es)
}
"find_references" => {
let root = NodeHash::parse(str_arg(a, "root")?);
let target = NodeHash::parse(str_arg(a, "target")?);
self.editor
.find_references(&root, &target)
.map(|refs| json!({ "references": refs }))
.map_err(es)
}
"run_module" => {
let module = NodeHash::parse(str_arg(a, "module")?);
let fname = str_arg(a, "name")?;
let args: Vec<i64> = de(a, "args")?;
self.editor
.run_module(&module, fname, &args)
.map(|v| json!({ "result": v }))
.map_err(es)
}
// Execute a web handler `handler(Request)->Response` once
// against this session's persistent SQLite file — so an
// MCP-only client can *run and verify* a Tier-3 app end
// to end (POST in one call, GET it back in the next), not
// merely type-check it (`run`/`run_module` take only i64
// args and cannot pass a `Request`). A thin transport
// over the same `serve_request` path the CLI/HTTP servers
// use (design.md §7). `body`/`headers` default to empty.
"run_handler" => {
let module = NodeHash::parse(str_arg(a, "module")?);
let handler = str_arg(a, "handler")?;
let method = str_arg(a, "method")?;
let path = str_arg(a, "path")?;
let opt = |k| a.get(k).and_then(Value::as_str).unwrap_or("");
self.editor
.run_handler(
&module,
handler,
&self.handler_db,
method,
path,
opt("body"),
opt("headers"),
)
.map(|r| json!({ "status": r.status, "body": r.body }))
.map_err(es)
}
other => Err(format!("unknown tool: {other}")),
}
}
}
fn es<E: std::fmt::Display>(e: E) -> String {
e.to_string()
}
fn str_arg<'v>(a: &'v Value, key: &str) -> Result<&'v str, String> {
a.get(key)
.and_then(Value::as_str)
.ok_or_else(|| format!("missing or non-string argument `{key}`"))
}
fn de<T: serde::de::DeserializeOwned>(a: &Value, key: &str) -> Result<T, String> {
let v = a
.get(key)
.ok_or_else(|| format!("missing argument `{key}`"))?;
serde_json::from_value(v.clone()).map_err(|e| format!("bad argument `{key}`: {e}"))
}
/// JSON-RPC error `(code, message)`.
struct Rpc(i64, String);
impl Rpc {
fn method_not_found(m: &str) -> Self {
Rpc(-32601, format!("method not found: {m}"))
}
fn invalid(m: &str) -> Self {
Rpc(-32602, m.to_string())
}
}
fn tool_list() -> Value {
let obj = || json!({ "type": "object" });
json!([
{ "name": "begin_edit", "description": "Open an edit transaction (non-nesting).", "inputSchema": obj() },
{ "name": "abort_edit", "description": "Discard the open draft.", "inputSchema": obj() },
{ "name": "create_function", "description": "Start a function draft.",
"inputSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } },
{ "name": "add_param", "description": "Add a parameter (name, type, min_confidence).", "inputSchema": obj() },
{ "name": "set_produces", "description": "Set the produced type and confidence.", "inputSchema": obj() },
{ "name": "set_effects", "description": "Set the required effect set.", "inputSchema": obj() },
{ "name": "set_on_failure", "description": "Set declared failure variants.", "inputSchema": obj() },
{ "name": "add_step", "description": "Append a chain step (binding, value).", "inputSchema": obj() },
{ "name": "set_yield", "description": "Set the result expression.", "inputSchema": obj() },
{ "name": "describe_hole", "description": "What the body expects and what is in scope.", "inputSchema": obj() },
{ "name": "define_type", "description": "Define a record/variant type from a TypeDefSpec.", "inputSchema": obj() },
{ "name": "apply_module", "description": "Author a whole typed, multi-function module (ModuleSpec); returns the assembled-module check report.", "inputSchema": obj() },
{ "name": "scaffold_entity", "description": "Generate the mechanical CRUD shell (the `*_from_rows` load parser, the save/save_step persist pair) plus the CREATE TABLE DDL and SELECT, from one declarative EntitySpec. Returns { functions:[from_rows,save,save_step], create_table, select_all } — drop the functions into a ModuleSpec alongside the record define_type and the hand-authored business logic, then apply_module. `fields` is a list of [field_name, sql_column_name, kind] triples; the middle element is the SQL column name (NOT a Cairn type); kind ∈ Num|Text|Decimal|{\"Variant\":{\"decode\":fn,\"encode\":fn}}. The first field is the `id` INTEGER PRIMARY KEY. `save_param` must not be `i`/`n`/`ins`. A malformed spec is rejected, not silently mis-generated.",
"inputSchema": { "type": "object", "properties": { "entity": { "type": "object",
"properties": {
"record": { "type": "string" }, "table": { "type": "string" },
"rows_fn": { "type": "string" }, "save_fn": { "type": "string" },
"save_param": { "type": "string" },
"fields": { "type": "array", "items": {
"type": "array", "minItems": 3, "maxItems": 3 } } },
"required": ["record","table","rows_fn","save_fn","save_param","fields"] } },
"required": ["entity"] } },
{ "name": "framework", "description": "The Tier-2 web framework splice: { types, functions } as JSON (Request/Response/Element + render_html/field/split/form_value/run_app/…, the Cairn stdlib included). Merge into a ModuleSpec alongside your record define_types, the scaffolded shell, and the business logic, then apply_module — the content-addressed store dedups identical specs, so including is reusing. The pure-MCP analogue of the Rust web::types()/functions() splice.",
"inputSchema": obj() },
{ "name": "app_skeleton", "description": "Cairn's `rails new`: generate the canonical TEA app shell from { app } — the model record, the <app>Msg variant, the five functions run_app composes (route_msg/load/update/view/persist), and the `route` served entry wired by FuncRef. Returns { types, functions }. Explicit, not magical: it is the documented conventional shape, not a runtime convention. Not standalone — merge with `framework` (+ any `scaffold_entity` shells) and your business logic into one ModuleSpec, then apply_module. The placeholders type-check and run; filling them in is the shell→logic seam.",
"inputSchema": { "type": "object", "properties": { "spec": { "type": "object", "properties": { "app": { "type": "string" } }, "required": ["app"] } }, "required": ["spec"] } },
{ "name": "commit_edit", "description": "Materialize, check, and return the report.", "inputSchema": obj() },
{ "name": "checkpoint", "description": "Name a root (immutable) so it survives the session.", "inputSchema": obj() },
{ "name": "branch", "description": "Name a root as a movable branch.", "inputSchema": obj() },
{ "name": "resolve", "description": "Resolve a checkpoint/branch name to its root hash.", "inputSchema": obj() },
{ "name": "query_type", "description": "The signature of a function in an applied module (params, produces, effects, failures, rendered). Args: `module` (the applied module's root hash) and `name` (the function).", "inputSchema": { "type": "object", "properties": { "module": { "type": "string" }, "name": { "type": "string" } }, "required": ["module","name"] } },
{ "name": "find_references", "description": "Every node within a root's subtree that references a target node by hash (the basis for rename-as-label).", "inputSchema": obj() },
{ "name": "replace_node", "description": "Structurally replace every occurrence of a target node within a root; returns the new root hash and its check report.", "inputSchema": obj() },
{ "name": "fill_hole", "description": "Fill a typed hole with a node; accepted only if the result checks, else the hole remains and the report names the violated principle.", "inputSchema": obj() },
{ "name": "render_addressed", "description": "The Section-5 projection plus the hash<->span index (nested byte ranges) — the editor substrate.", "inputSchema": obj() },
{ "name": "node_at", "description": "The deepest node hash whose rendered span contains a byte offset (a cursor → the node to edit).", "inputSchema": obj() },
{ "name": "put_expr", "description": "Materialize an ExprSpec and return its node hash — the construction primitive feeding replace_node/fill_hole.", "inputSchema": obj() },
{ "name": "run", "description": "Lower a committed function and run it under wasmtime.", "inputSchema": obj() },
{ "name": "run_module", "description": "Lower an applied module and run one of its functions under wasmtime (i64 args/result).", "inputSchema": obj() },
{ "name": "run_handler", "description": "Execute a web handler `handler(Request)->Response` once against this session's persistent SQLite file (state survives across calls — POST then GET it back). Returns { status, body }. This is how you actually run and verify a Tier-3 app over MCP, not just type-check it. Args: module (applied root hash), handler (function name, e.g. `route`), method, path; optional body, headers (Name: Value lines, \\n-joined).",
"inputSchema": { "type": "object", "properties": {
"module": { "type": "string" }, "handler": { "type": "string" },
"method": { "type": "string" }, "path": { "type": "string" },
"body": { "type": "string" }, "headers": { "type": "string" } },
"required": ["module","handler","method","path"] } }
])
}
/// Read JSON-RPC messages line-by-line from stdin, write responses to stdout.
pub fn serve_stdio() {
use std::io::{BufRead, Write};
// A persistent project store if `CAIRN_STORE` points at a file;
// otherwise an ephemeral in-memory session. A store that fails to
// open (e.g. an incompatible FORMAT_VERSION) is reported, not
// silently swapped for a different one.
let mut session = match std::env::var("CAIRN_STORE") {
Ok(path) => Session::open(&path).unwrap_or_else(|e| {
eprintln!("cairn-mcp: cannot open store `{path}`: {e}");
std::process::exit(1);
}),
Err(_) => Session::new(),
};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let req: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(
stdout,
"{}",
json!({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":e.to_string()}})
);
continue;
}
};
let resp = session.handle(req);
if !resp.is_null() {
let _ = writeln!(stdout, "{resp}");
let _ = stdout.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn call(s: &mut Session, id: i64, name: &str, args: Value) -> Value {
let resp = s.handle(json!({
"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": { "name": name, "arguments": args }
}));
// Unwrap the MCP envelope to the inner tool JSON (or error string).
let content = &resp["result"]["content"][0]["text"];
let text = content.as_str().expect("text content");
let is_error = resp["result"]["isError"].as_bool().unwrap_or(false);
if is_error {
json!({ "isError": true, "message": text })
} else {
serde_json::from_str(text).expect("tool result is JSON")
}
}
#[test]
fn initialize_and_tools_list() {
let mut s = Session::new();
let init = s.handle(json!({"jsonrpc":"2.0","id":1,"method":"initialize"}));
assert_eq!(init["result"]["serverInfo"]["name"], "cairn-mcp-server");
let list = s.handle(json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}));
let names: Vec<&str> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
for expected in [
"create_function",
"commit_edit",
"run",
"query_type",
"find_references",
"replace_node",
"fill_hole",
"render_addressed",
"node_at",
"put_expr",
] {
assert!(names.contains(&expected), "missing tool {expected}");
}
// The Section 6 stored-tree set is complete; `tools/list`
// reflects exactly the working surface.
}
#[test]
fn authors_checks_and_runs_a_function_over_jsonrpc() {
let mut s = Session::new();
assert_eq!(call(&mut s, 1, "begin_edit", json!({}))["ok"], true);
call(&mut s, 2, "create_function", json!({ "name": "id" }));
call(
&mut s,
3,
"add_param",
json!({ "name": "n", "type": "Number", "min_confidence": "External" }),
);
call(
&mut s,
4,
"set_produces",
json!({ "type": "Number", "confidence": "External" }),
);
call(&mut s, 5, "set_effects", json!({ "effects": [] }));
call(&mut s, 6, "set_yield", json!({ "value": { "Ref": "n" } }));
let committed = call(&mut s, 7, "commit_edit", json!({}));
assert_eq!(committed["report"]["status"], "Complete");
assert!(committed["report"]["violations"].as_array().unwrap().is_empty());
let hash = committed["hash"].as_str().unwrap().to_string();
let ran = call(
&mut s,
8,
"run",
json!({ "func": hash, "name": "id", "args": [7] }),
);
assert_eq!(ran["result"], 7);
}
/// The agent-facing surface can author a real v0.4 program — a
/// function whose body builds and applies a *closure* — entirely as
/// JSON over JSON-RPC, with no Rust spec builders. This is the
/// keystone reached through the actual product, not the test harness.
#[test]
fn authors_a_v04_closure_function_over_mcp() {
let mut s = Session::new();
call(&mut s, 1, "begin_edit", json!({}));
call(&mut s, 2, "create_function", json!({ "name": "twice" }));
call(
&mut s,
3,
"add_param",
json!({ "name": "n", "type": "Number", "min_confidence": "External" }),
);
call(
&mut s,
4,
"set_produces",
json!({ "type": "Number", "confidence": "External" }),
);
call(&mut s, 5, "set_effects", json!({ "effects": [] }));
// yield = (|x| x * 2)(n) — a closure, built and applied inline.
call(
&mut s,
6,
"set_yield",
json!({ "value": {
"CallValue": {
"callee": { "Lambda": {
"params": [["x", "Number"]],
"body": { "BinOp": {
"op": "Mul",
"lhs": { "Ref": "x" },
"rhs": { "Lit": 2 }
}}
}},
"args": [ { "Ref": "n" } ]
}
}}),
);
let committed = call(&mut s, 7, "commit_edit", json!({}));
assert_eq!(
committed["report"]["status"], "Complete",
"v0.4 closure must author cleanly over MCP: {committed:?}"
);
assert!(committed["report"]["violations"]
.as_array()
.unwrap()
.is_empty());
let hash = committed["hash"].as_str().unwrap().to_string();
let ran = call(
&mut s,
8,
"run",
json!({ "func": hash, "name": "twice", "args": [21] }),
);
assert_eq!(ran["result"], 42, "(|x| x*2)(21) over MCP");
}
/// The single-function ceiling is closed: the same finding that the
/// surface couldn't author a typed, multi-function program now drives
/// its fix. A record type plus three functions (one doing field
/// access, one calling the other two) are authored as a single
/// `apply_module` JSON call — the parity the design doc Section 6
/// claimed but the per-function tools could not deliver — then run.
#[test]
fn the_mcp_surface_authors_a_typed_multi_function_module() {
let mut s = Session::new();
let list = s.handle(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
for present in ["define_type", "apply_module"] {
assert!(
names.contains(&present.to_string()),
"module-level authoring tool `{present}` must be advertised"
);
}
// type Pair { a: Number, b: Number }
// mk(x) -> Pair = Pair { a: x, b: x * 2 }
// sum(p) -> Number = p.a + p.b
// run_it(n) -> Number = sum(mk(n))
let prod = json!({ "ty": "Number", "confidence": "External" });
let pnum = |n: &str| {
json!({ "name": n, "ty": "Number", "min_confidence": "External" })
};
let fnspec = |name: &str, params: Value, ty: Value, result: Value| {
json!({
"name": name, "type_params": [], "params": params,
"produces": ty, "requires": [], "on_failure": [],
"steps": [], "result": result
})
};
let module = json!({ "module": {
"name": "pairs",
"types": [ { "Record": {
"name": "Pair",
"fields": [ ["a", "Number"], ["b", "Number"] ]
}}],
"functions": [
fnspec("mk",
json!([ pnum("x") ]),
json!({ "ty": { "Named": "Pair" }, "confidence": "External" }),
json!({ "Record": { "type_name": "Pair", "fields": [
["a", { "Ref": "x" }],
["b", { "BinOp": { "op": "Mul",
"lhs": { "Ref": "x" }, "rhs": { "Lit": 2 } } }]
]}})),
fnspec("sum",
json!([ { "name": "p", "ty": { "Named": "Pair" },
"min_confidence": "External" } ]),
prod.clone(),
json!({ "BinOp": { "op": "Add",
"lhs": { "Field": { "base": { "Ref": "p" },
"type_name": "Pair", "field": "a" } },
"rhs": { "Field": { "base": { "Ref": "p" },
"type_name": "Pair", "field": "b" } } } })),
fnspec("run_it",
json!([ pnum("n") ]),
prod.clone(),
json!({ "Call": { "func": "sum", "args": [
{ "Call": { "func": "mk", "args": [ { "Ref": "n" } ] } }
]}})),
]
}});
let applied = call(&mut s, 2, "apply_module", module);
assert_eq!(
applied["report"]["status"], "Complete",
"typed multi-function module must verify over MCP: {applied:?}"
);
assert!(applied["report"]["violations"]
.as_array()
.unwrap()
.is_empty());
let hash = applied["hash"].as_str().unwrap().to_string();
let ran = call(
&mut s,
3,
"run_module",
json!({ "module": hash, "name": "run_it", "args": [5] }),
);
// mk(5) = {a:5, b:10}; sum = 15
assert_eq!(ran["result"], 15, "sum(mk(5)) over MCP: ran={ran:?}");
}
/// The structural scaffolder reachable over MCP. A spec sent as
/// JSON over the wire must yield the *same* shell the Rust
/// generator yields, and that shell must compose: dropped into a
/// `ModuleSpec` next to the record `define_type`, it
/// type-/effect-checks. This is the over-the-wire analogue of
/// `scaffold.rs`'s byte-identical unit
/// tests — proof the generator is genuinely reachable by an agent
/// that has only the MCP surface, not the `cairn_core` Rust lib.
#[test]
fn the_mcp_surface_scaffolds_an_entity_shell_that_composes() {
let mut s = Session::new();
// `scaffold_entity` must be on the advertised surface.
let list = s.handle(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.contains(&"scaffold_entity".to_string()),
"scaffold_entity must be advertised: {names:?}"
);
// The Contact entity, declared as JSON (externally-tagged
// FieldKind: "Num"/"Text", column == field).
let f = |name: &str, kind: &str| {
json!({ "field": name, "column": name, "kind": kind })
};
let entity = json!({
"record": "Contact",
"table": "contacts",
"rows_fn": "contacts_from_rows",
"save_fn": "crm_save_contacts",
"save_param": "cs",
"fields": [
f("id", "Num"), f("name", "Text"),
f("phone", "Text"), f("kind", "Text")
]
});
let sc = call(&mut s, 2, "scaffold_entity", json!({ "entity": entity }));
// The SQL is byte-identical to the hand constants (the same
// wall the Rust unit test asserts, now over JSON-RPC).
assert_eq!(
sc["create_table"],
"CREATE TABLE IF NOT EXISTS contacts \
(id INTEGER PRIMARY KEY, name TEXT, phone TEXT, kind TEXT)"
);
assert_eq!(
sc["select_all"],
"SELECT id, name, phone, kind FROM contacts ORDER BY id"
);
let funcs = sc["functions"].as_array().unwrap().clone();
assert_eq!(
funcs.len(),
3,
"from_rows + save + save_step: {sc:?}"
);
// The returned shell composes in its real module context —
// the same assembly the Rust API performs: the Tier-2 `web`
// framework (which supplies the `field` column accessor
// `*_from_rows` calls) spliced with the app's record type and
// the scaffolded
// functions. `apply_module` then verifies the whole thing.
let mut types: Vec<Value> = cairn_core::web::types()
.iter()
.map(|t| serde_json::to_value(t).unwrap())
.collect();
types.push(json!({ "Record": {
"name": "Contact",
"fields": [
["id", "Number"], ["name", "String"],
["phone", "String"], ["kind", "String"]
]
}}));
let mut functions: Vec<Value> = cairn_core::web::functions()
.iter()
.map(|f| serde_json::to_value(f).unwrap())
.collect();
functions.extend(funcs);
let module = json!({ "module": {
"name": "contacts_shell",
"types": types,
"functions": functions
}});
let applied = call(&mut s, 3, "apply_module", module);
assert_eq!(
applied["report"]["status"], "Complete",
"MCP-scaffolded shell must type-/effect-check: {applied:?}"
);
assert!(
applied["report"]["violations"]
.as_array()
.unwrap()
.is_empty(),
"violations: {:#?}",
applied["report"]["violations"]
);
// The shell's signatures are real: query_type sees the
// recursive load parser the agent never hand-wrote.
let m = applied["hash"].as_str().unwrap().to_string();
let qt = call(
&mut s,
4,
"query_type",
json!({ "module": m, "name": "contacts_from_rows" }),
);
assert!(
!qt["isError"].as_bool().unwrap_or(false),
"contacts_from_rows must be queryable: {qt:?}"
);
}
/// `scaffold_entity` must fail closed at the seam on a malformed
/// spec (a message that names the fix), never silently emit
/// corrupt SQL — verified over the wire, not just in the Core
/// unit test.
#[test]
fn scaffold_entity_fails_closed_on_a_malformed_spec() {
let mut s = Session::new();
// First column not `id` (the FieldSpec-middle-is-column,
// not-a-Type confusion the cold agent hit).
let bad = call(
&mut s,
1,
"scaffold_entity",
json!({ "entity": {
"record": "Note", "table": "notes",
"rows_fn": "note_from_rows", "save_fn": "note_save",
"save_param": "notes",
"fields": [
["id", "Number", "Num"],
["body", "String", "Text"]
]
}}),
);
assert!(
bad["isError"].as_bool().unwrap_or(false),
"a first column != id must be rejected: {bad:?}"
);
assert!(
bad["message"].as_str().unwrap().contains("must be `id`"),
"the error names the fix: {bad:?}"
);
// save_param colliding with the generator's internal `n`.
let collide = call(
&mut s,
2,
"scaffold_entity",
json!({ "entity": {
"record": "Note", "table": "notes",
"rows_fn": "note_from_rows", "save_fn": "note_save",
"save_param": "n",
"fields": [["id", "id", "Num"], ["body", "body", "Text"]]
}}),
);
assert!(
collide["isError"].as_bool().unwrap_or(false)
&& collide["message"]
.as_str()
.unwrap()
.contains("collides"),
"a reserved save_param must be rejected: {collide:?}"
);
// The corrected spec scaffolds fine.
let ok = call(
&mut s,
3,
"scaffold_entity",
json!({ "entity": {
"record": "Note", "table": "notes",
"rows_fn": "note_from_rows", "save_fn": "note_save",
"save_param": "notes",
"fields": [["id", "id", "Num"], ["body", "body", "Text"]]
}}),
);
assert_eq!(
ok["create_table"],
"CREATE TABLE IF NOT EXISTS notes \
(id INTEGER PRIMARY KEY, body TEXT)"
);
}
/// The pure-MCP Tier-3 path — the gap `scaffold_entity` surfaced,
/// now closed. An agent with **only** the MCP surface (no
/// `cairn_core` Rust) builds a checkable entity module entirely
/// over the wire: `framework` returns the Tier-2 splice,
/// `scaffold_entity` returns the CRUD shell, the record type is a
/// `define_type`-shaped JSON value — merged into one `ModuleSpec`,
/// `apply_module` verifies the whole thing. The distinguishing
/// point vs the scaffolder test above: this one calls **no**
/// `cairn_core::web::*` from Rust — the framework arrives over
/// JSON-RPC, proving the path is genuinely reachable by an
/// MCP-only agent.
#[test]
fn the_mcp_surface_serves_the_framework_for_pure_mcp_composition() {
let mut s = Session::new();
// `framework` must be advertised.
let list = s.handle(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.contains(&"framework".to_string()),
"framework must be advertised: {names:?}"
);
// The framework, fetched over the wire — not a Rust call.
let fw = call(&mut s, 2, "framework", json!({}));
let mut types = fw["types"].as_array().unwrap().clone();
let mut functions = fw["functions"].as_array().unwrap().clone();
assert!(
!types.is_empty() && !functions.is_empty(),
"framework splice must be non-empty: {fw:?}"
);
// The scaffolded Contact shell, also over the wire.
let f = |name: &str, kind: &str| {
json!({ "field": name, "column": name, "kind": kind })
};
let sc = call(
&mut s,
3,
"scaffold_entity",
json!({ "entity": {
"record": "Contact", "table": "contacts",
"rows_fn": "contacts_from_rows",
"save_fn": "crm_save_contacts", "save_param": "cs",
"fields": [
f("id", "Num"), f("name", "Text"),
f("phone", "Text"), f("kind", "Text")
]
}}),
);
// Compose: framework splice + the Contact record + the shell.
types.push(json!({ "Record": {
"name": "Contact",
"fields": [
["id", "Number"], ["name", "String"],
["phone", "String"], ["kind", "String"]
]
}}));
functions.extend(sc["functions"].as_array().unwrap().clone());
let applied = call(
&mut s,
4,
"apply_module",
json!({ "module": {
"name": "contacts_pure_mcp",
"types": types,
"functions": functions
}}),
);
assert_eq!(
applied["report"]["status"], "Complete",
"pure-MCP framework+shell module must verify: {applied:?}"
);
assert!(
applied["report"]["violations"]
.as_array()
.unwrap()
.is_empty(),
"violations: {:#?}",
applied["report"]["violations"]
);
}
/// Cairn's `rails new` over pure MCP: `app_skeleton` returns the
/// canonical TEA shape (its own model + `Msg` triad + the five
/// `run_app` functions + `route`), which — composed with the
/// `framework` splice and nothing else — type-/effect-checks as a
/// whole. The conventional app shape is *generated and verified*,
/// not reconstructed by hand; a client with only the MCP surface
/// gets a working starting point.
#[test]
fn the_mcp_surface_skeletons_a_canonical_app_that_composes() {
let mut s = Session::new();
let list = s.handle(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.contains(&"app_skeleton".to_string()),
"app_skeleton must be advertised: {names:?}"
);
// framework splice + the canonical skeleton, both over JSON.
let fw = call(&mut s, 2, "framework", json!({}));
let sk = call(
&mut s,
3,
"app_skeleton",
json!({ "spec": { "app": "Tally" } }),
);
let mut types = fw["types"].as_array().unwrap().clone();
types.extend(sk["types"].as_array().unwrap().clone());
let mut functions = fw["functions"].as_array().unwrap().clone();
functions.extend(sk["functions"].as_array().unwrap().clone());
let applied = call(
&mut s,
4,
"apply_module",
json!({ "module": {
"name": "tally_app",
"types": types,
"functions": functions
}}),
);
assert_eq!(
applied["report"]["status"], "Complete",
"the canonical skeleton must verify composed with the \
framework: {applied:?}"
);
assert!(
applied["report"]["violations"]
.as_array()
.unwrap()
.is_empty(),
"violations: {:#?}",
applied["report"]["violations"]
);
// The served entry exists with the conventional name.
let m = applied["hash"].as_str().unwrap().to_string();
let qt = call(
&mut s,
5,
"query_type",
json!({ "module": m, "name": "route" }),
);
assert!(
!qt["isError"].as_bool().unwrap_or(false),
"the `route` entry must be queryable: {qt:?}"
);
}
/// An MCP-only client can *execute and verify* a Tier-3 handler
/// end to end — not just type-check it. A handler that writes its
/// request body on POST and reads it back on GET, exercised via
/// `run_handler`, proves the Request→Response path AND that the
/// Db load/persist round-trip survives across calls on the
/// session's file (exactly what the i64-only `run_module` could
/// never show).
#[test]
fn run_handler_executes_a_tier3_handler_with_db_roundtrip() {
let mut s = Session::new();
// Minimal Tier-3 module: the serve ABI's Request/Response
// records + a `route` that upserts req.body on POST and
// always returns the stored value (CREATE is idempotent).
let dbq = |sql: &str, params: Value| {
json!({ "DbQuery": { "sql": { "Str": sql }, "params": params } })
};
let module = json!({ "module": {
"name": "kv_app",
"types": [
{ "Record": { "name": "Request", "fields": [
["method","String"],["path","String"],
["body","String"],["headers","String"] ] } },
{ "Record": { "name": "Response", "fields": [
["status","Number"],["body","String"] ] } }
],
"functions": [{
"name": "route", "type_params": [],
"params": [{ "name":"req","ty":{"Named":"Request"},
"min_confidence":"External" }],
"produces": { "ty":{"Named":"Response"},
"confidence":"External" },
"requires": ["Db"], "on_failure": [],
"steps": [
{ "binding":"_mk", "value": dbq(
"CREATE TABLE IF NOT EXISTS kv(id INTEGER PRIMARY KEY, v TEXT)",
json!({"ListEmpty":{"elem":"String"}})) },
{ "binding":"_w", "value": { "If": {
"cond": { "StrEq": [
{ "Field": { "base": {"Ref":"req"},
"type_name":"Request","field":"method" } },
{ "Str": "POST" } ] },
"then_branch": dbq(
"INSERT OR REPLACE INTO kv(id,v) VALUES(1, ?)",
json!({"List":[{ "Field": { "base":{"Ref":"req"},
"type_name":"Request","field":"body" } }]})),
"else_branch": { "Str": "" } } } },
{ "binding":"read", "value": dbq(
"SELECT COALESCE((SELECT v FROM kv WHERE id=1),'')",
json!({"ListEmpty":{"elem":"String"}})) }
],
"result": { "Record": { "type_name":"Response",
"fields": [ ["status",{"Lit":200}],
["body",{"Ref":"read"}] ] } }
}]
}});
let applied = call(&mut s, 1, "apply_module", module);
assert_eq!(
applied["report"]["status"], "Complete",
"the kv handler must verify: {applied:?}"
);
let m = applied["hash"].as_str().unwrap().to_string();
// POST writes "hello".
let post = call(
&mut s, 2, "run_handler",
json!({ "module": m, "handler": "route",
"method": "POST", "path": "/", "body": "hello" }),
);
assert_eq!(post["status"], 200, "POST runs: {post:?}");
assert_eq!(post["body"], "hello", "POST echoes stored value");
// A SEPARATE call (fresh wasm instance) reads it back — the
// round-trip the i64 surface could never verify.
let get = call(
&mut s, 3, "run_handler",
json!({ "module": m, "handler": "route",
"method": "GET", "path": "/" }),
);
assert_eq!(get["status"], 200, "GET runs: {get:?}");
assert_eq!(
get["body"], "hello",
"the Db write persisted across run_handler calls: {get:?}"
);
// A brand-new session has its own file — no leakage.
let mut s2 = Session::new();
let a2 = call(&mut s2, 1, "apply_module",
json!({ "module": {
"name":"kv_app","types":[
{"Record":{"name":"Request","fields":[["method","String"],
["path","String"],["body","String"],["headers","String"]]}},
{"Record":{"name":"Response","fields":[["status","Number"],
["body","String"]]}}],
"functions":[{ "name":"route","type_params":[],
"params":[{"name":"req","ty":{"Named":"Request"},
"min_confidence":"External"}],
"produces":{"ty":{"Named":"Response"},"confidence":"External"},
"requires":["Db"],"on_failure":[],
"steps":[{ "binding":"read","value": dbq(
"SELECT COALESCE((SELECT v FROM kv WHERE id=1),'')",
json!({"ListEmpty":{"elem":"String"}})) }],
"result":{"Record":{"type_name":"Response","fields":[
["status",{"Lit":200}],["body",{"Ref":"read"}]]}} }] }}));
let m2 = a2["hash"].as_str().unwrap().to_string();
let g2 = call(&mut s2, 2, "run_handler",
json!({ "module": m2, "handler":"route",
"method":"GET","path":"/" }));
assert_eq!(
g2["body"], "",
"a new session's handler DB is isolated: {g2:?}"
);
}
/// The styling mechanism, end to end over MCP: the framework
/// ships `base_css()` (an unopinionated base), and the CSS-route
/// pattern serves it as a real stylesheet — proven by composing
/// `framework` + a one-line `styles` handler and fetching it via
/// `run_handler`. Pins the load-bearing invariant the styling
/// decision rests on: the base is non-trivial AND
/// metacharacter-free (so it survives both the route and an
/// escaped inline `<style>`).
#[test]
fn the_css_route_pattern_serves_the_unopinionated_base() {
let mut s = Session::new();
let fw = call(&mut s, 1, "framework", json!({}));
let types = fw["types"].clone();
let mut functions = fw["functions"].as_array().unwrap().clone();
// styles(req: Request) -> Response = { 200, base_css() }
functions.push(json!({
"name": "styles", "type_params": [],
"params": [{ "name":"req","ty":{"Named":"Request"},
"min_confidence":"External" }],
"produces": { "ty":{"Named":"Response"},
"confidence":"External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "Record": { "type_name":"Response", "fields": [
["status", { "Lit": 200 }],
["body", { "Call": { "func":"base_css", "args":[] } }] ] } }
}));
let applied = call(
&mut s, 2, "apply_module",
json!({ "module": {
"name":"styled","types":types,"functions":functions }}),
);
assert_eq!(
applied["report"]["status"], "Complete",
"framework + a base_css route must verify: {applied:?}"
);
let m = applied["hash"].as_str().unwrap().to_string();
let css = call(
&mut s, 3, "run_handler",
json!({ "module": m, "handler":"styles",
"method":"GET", "path":"/styles.css" }),
);
assert_eq!(css["status"], 200, "the CSS route runs: {css:?}");
let body = css["body"].as_str().unwrap();
assert!(
body.contains("box-sizing:border-box")
&& body.contains("max-width:42rem"),
"serves the real unopinionated base: {body}"
);
// The invariant the styling decision rests on: metachar-free,
// so the same base survives an escaped inline <style> too.
for mc in ['&', '<', '>', '"'] {
assert!(
!body.contains(mc),
"base_css must be metacharacter-free (found {mc:?})"
);
}
}
/// The project story: author + checkpoint in one session, then a
/// brand-new session on the same file-backed store resolves the
/// checkpoint and runs it. This is what makes the agent surface
/// usable for real work rather than a scratchpad that vanishes.
#[test]
fn a_project_persists_across_sessions() {
let mut path = std::env::temp_dir();
path.push(format!("cairn-proj-{}.sqlite", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_str().unwrap().to_string();
let hash = {
let mut s = Session::open(&p).expect("open file-backed session");
call(&mut s, 1, "begin_edit", json!({}));
call(&mut s, 2, "create_function", json!({ "name": "answer" }));
call(
&mut s,
3,
"set_produces",
json!({ "type": "Number", "confidence": "External" }),
);
call(&mut s, 4, "set_effects", json!({ "effects": [] }));
call(&mut s, 5, "set_yield", json!({ "value": { "Lit": 42 } }));
let committed = call(&mut s, 6, "commit_edit", json!({}));
let hash = committed["hash"].as_str().unwrap().to_string();
let cp = call(
&mut s,
7,
"checkpoint",
json!({ "name": "release", "root": hash }),
);
assert_eq!(cp["ok"], true);
hash
}; // session dropped — store is on disk
let mut s2 = Session::open(&p).expect("reopen the same store");
let resolved = call(&mut s2, 8, "resolve", json!({ "name": "release" }));
assert_eq!(
resolved["hash"].as_str().unwrap(),
hash,
"checkpoint must resolve to the same root in a new session"
);
let ran = call(
&mut s2,
9,
"run",
json!({ "func": hash, "name": "answer", "args": [] }),
);
assert_eq!(ran["result"], 42, "the persisted function still runs");
std::fs::remove_file(&path).unwrap();
}
/// query_type gives an agent the structural signature it needs to
/// call a function correctly — the keystone of authoring at scale
/// against the stdlib/framework.
#[test]
fn query_type_reports_a_signature_over_mcp() {
let mut s = Session::new();
let module = json!({ "module": {
"name": "m",
"types": [],
"functions": [{
"name": "add", "type_params": [],
"params": [
{ "name": "a", "ty": "Number", "min_confidence": "External" },
{ "name": "b", "ty": "Number", "min_confidence": "External" }
],
"produces": { "ty": "Number", "confidence": "External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "BinOp": { "op": "Add",
"lhs": { "Ref": "a" }, "rhs": { "Ref": "b" } } }
}]
}});
let applied = call(&mut s, 1, "apply_module", module);
assert_eq!(applied["report"]["status"], "Complete");
let h = applied["hash"].as_str().unwrap().to_string();
let sig = call(
&mut s,
2,
"query_type",
json!({ "module": h, "name": "add" }),
);
assert_eq!(sig["name"], "add");
assert_eq!(sig["params"].as_array().unwrap().len(), 2);
assert_eq!(sig["params"][0]["name"], "a");
assert_eq!(sig["params"][0]["ty"], "Number");
assert_eq!(sig["produces"]["ty"], "Number");
assert!(
sig["rendered"].as_str().unwrap().contains("function add"),
"rendered projection for review: {}",
sig["rendered"]
);
// An absent name resolves to null, not an error.
let none = call(
&mut s,
3,
"query_type",
json!({ "module": h, "name": "nope" }),
);
assert!(none.is_null(), "missing function → null: {none:?}");
}
#[test]
fn find_references_round_trips_over_mcp() {
let mut s = Session::new();
let module = json!({ "module": {
"name": "m",
"types": [],
"functions": [{
"name": "add", "type_params": [],
"params": [
{ "name": "a", "ty": "Number", "min_confidence": "External" },
{ "name": "b", "ty": "Number", "min_confidence": "External" }
],
"produces": { "ty": "Number", "confidence": "External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "BinOp": { "op": "Add",
"lhs": { "Ref": "a" }, "rhs": { "Ref": "b" } } }
}]
}});
let applied = call(&mut s, 1, "apply_module", module);
let h = applied["hash"].as_str().unwrap().to_string();
// The transport returns the documented shape; the root is never
// self-reported (deep semantics are covered in edit.rs).
let r = call(
&mut s,
2,
"find_references",
json!({ "root": h, "target": h }),
);
assert_eq!(r["references"].as_array().unwrap().len(), 0);
}
#[test]
fn replace_node_round_trips_over_mcp() {
let mut s = Session::new();
let module = json!({ "module": {
"name": "m",
"types": [],
"functions": [{
"name": "k", "type_params": [],
"params": [],
"produces": { "ty": "Number", "confidence": "External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "Lit": 1 }
}]
}});
let applied = call(&mut s, 1, "apply_module", module);
let h = applied["hash"].as_str().unwrap().to_string();
// Transport + report shape (deep semantics are covered in
// edit.rs). root==target==replacement re-checks to the same
// root, still Complete.
let r = call(
&mut s,
2,
"replace_node",
json!({ "root": h, "target": h, "replacement": h }),
);
assert_eq!(r["root"], h);
assert_eq!(r["report"]["status"], "Complete");
}
/// v0.2 editor loop, pure MCP: render_addressed → node_at (cursor →
/// hash) → replace_node at that hash → re-render reflects the edit.
/// `j`'s literal `2` supplies the replacement hash (no Core access).
#[test]
fn the_editor_substrate_round_trips_over_mcp() {
let mut s = Session::new();
let nf = |name: &str, lit: i64| {
json!({
"name": name, "type_params": [], "params": [],
"produces": { "ty": "Number", "confidence": "External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "Lit": lit }
})
};
let applied = call(
&mut s,
1,
"apply_module",
json!({ "module": {
"name": "m", "types": [],
"functions": [nf("k", 1), nf("j", 2)]
}}),
);
let root = applied["hash"].as_str().unwrap().to_string();
// Render with the span index; text is the projection.
let a = call(&mut s, 2, "render_addressed", json!({ "root": root }));
let text = a["text"].as_str().unwrap().to_string();
assert!(!text.contains('\u{1}'), "no sentinel leaks: {text:?}");
assert!(text.contains("yield 1") && text.contains("yield 2"));
// Cursor on k's `1` and on j's `2` → the two literal hashes.
let off1 = text.find("yield 1").unwrap() + "yield ".len();
let off2 = text.find("yield 2").unwrap() + "yield ".len();
let at = |s: &mut Session, id, off: usize| {
call(s, id, "node_at", json!({ "root": root, "offset": off }))
["hash"]
.as_str()
.unwrap()
.to_string()
};
let lit1 = at(&mut s, 3, off1);
let lit2 = at(&mut s, 4, off2);
assert_ne!(lit1, lit2);
// Replace every `1` with `2` structurally, then re-render: the
// edit a click would drive, end to end over MCP.
let rep = call(
&mut s,
5,
"replace_node",
json!({ "root": root, "target": lit1, "replacement": lit2 }),
);
assert_eq!(rep["report"]["status"], "Complete");
let root2 = rep["root"].as_str().unwrap().to_string();
let a2 = call(&mut s, 6, "render_addressed", json!({ "root": root2 }));
let text2 = a2["text"].as_str().unwrap();
assert!(
!text2.contains("yield 1") && text2.matches("yield 2").count() == 2,
"both functions now yield 2: {text2:?}"
);
// Out-of-range cursor addresses nothing.
let none = call(
&mut s,
7,
"node_at",
json!({ "root": root, "offset": 100_000 }),
);
assert!(none["hash"].is_null());
}
#[test]
fn fill_hole_rejects_a_non_hole_over_mcp() {
let mut s = Session::new();
let module = json!({ "module": {
"name": "m", "types": [],
"functions": [{
"name": "k", "type_params": [], "params": [],
"produces": { "ty": "Number", "confidence": "External" },
"requires": [], "on_failure": [], "steps": [],
"result": { "Lit": 1 }
}]
}});
let h = call(&mut s, 1, "apply_module", module)["hash"]
.as_str()
.unwrap()
.to_string();
// The module hash is not a Hole → the typed NotAHole error
// surfaces through the MCP envelope (transport check; the
// accept/reject semantics are covered in edit.rs).
let r = call(
&mut s,
2,
"fill_hole",
json!({ "root": h, "hole": h, "replacement": h }),
);
assert_eq!(r["isError"], true);
assert!(
r["message"].as_str().unwrap().contains("not a hole"),
"message: {}",
r["message"]
);
}
#[test]
fn a_tool_error_is_reported_not_panicked() {
let mut s = Session::new();
// create_function before begin_edit → editor error, surfaced as isError.
let r = call(&mut s, 1, "create_function", json!({ "name": "x" }));
assert_eq!(r["isError"], true);
}
#[test]
fn unknown_method_is_a_jsonrpc_error() {
let mut s = Session::new();
let r = s.handle(json!({"jsonrpc":"2.0","id":9,"method":"no/such"}));
assert_eq!(r["error"]["code"], -32601);
}
}