use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{Value, json};
use crate::{analysis, docs, symbols};
const VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const MAX_OUTPUT_LINES: usize = 200;
pub fn run() -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let line = line.map_err(|e| format!("reading stdin: {e}"))?;
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<Value>(&line) {
Ok(message) => handle(&message),
Err(e) => Some(error_response(
&Value::Null,
-32700,
&format!("parse error: {e}"),
)),
};
if let Some(response) = response {
writeln!(stdout, "{response}").map_err(|e| format!("writing stdout: {e}"))?;
stdout.flush().map_err(|e| format!("writing stdout: {e}"))?;
}
}
Ok(())
}
pub fn handle(message: &Value) -> Option<Value> {
let id = message.get("id")?.clone();
let method = message["method"].as_str().unwrap_or_default();
let params = &message["params"];
let result = match method {
"initialize" => Ok(initialize(params)),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tools()})),
"tools/call" => Ok(call(
params["name"].as_str().unwrap_or_default(),
¶ms["arguments"],
&app_dir(),
)),
_ => Err((-32601, format!("method not found: {method}"))),
};
Some(match result {
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
Err((code, message)) => error_response(&id, code, &message),
})
}
const API_REFERENCE: &str = include_str!("../templates/api_reference.md");
fn error_response(id: &Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
fn initialize(params: &Value) -> Value {
let asked = params["protocolVersion"].as_str().unwrap_or_default();
let version = VERSIONS
.iter()
.find(|v| **v == asked)
.unwrap_or(&VERSIONS[0]);
json!({
"protocolVersion": version,
"capabilities": {"tools": {}},
"serverInfo": {"name": "glacier", "version": env!("CARGO_PKG_VERSION")},
"instructions": "Glacier is a Rust web framework in the style of Rails and Phoenix. \
App code is mostly `.glacier`, compiled to Rust by the app's build.rs. Start with \
get_syntax_reference and list_conventions; after each change run `check` and \
fix what it reports (errors point at .glacier lines); search_docs answers how-to \
questions from the guides.",
})
}
fn app_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn tool(name: &str, description: &str, properties: Value, required: &[&str]) -> Value {
json!({
"name": name,
"description": description,
"inputSchema": {"type": "object", "properties": properties, "required": required},
})
}
fn tools() -> Vec<Value> {
let guides: Vec<&str> = docs::GUIDES.iter().map(|(name, _)| *name).collect();
vec![
tool(
"search_docs",
"Search Glacier's guides; returns the best-matching sections with their text.",
json!({"query": {"type": "string"}, "limit": {"type": "integer", "description": "Sections to return (default 5)"}}),
&["query"],
),
tool(
"read_guide",
&format!("Read one of Glacier's guides whole: {}.", guides.join(", ")),
json!({"name": {"type": "string", "enum": guides}}),
&["name"],
),
tool(
"get_syntax_reference",
"The .glacier language: every construct, what it compiles to, with examples.",
json!({}),
&[],
),
tool(
"get_api_reference",
"Glacier's framework APIs a .glacier app calls: requests, responses, models and \
queries, time, config, storage, jobs, mail, clients, webhooks, serializers, tests.",
json!({}),
&[],
),
tool(
"list_conventions",
"Where things live in a Glacier app, the commands, and the conventions to follow.",
json!({}),
&[],
),
tool(
"list_routes",
"The app's routes: method, path and the action that answers (builds the app).",
json!({}),
&[],
),
tool(
"list_models",
"The app's models with their fields (name, type, options), table and associations.",
json!({}),
&[],
),
tool(
"list_symbols",
"The app's definitions: models, fields, associations, scopes, callbacks, methods, \
controllers, actions, services, serializers, jobs, mailers, clients, factories, \
tests. Filter by kind or file.",
json!({"kind": {"type": "string"}, "file": {"type": "string"}}),
&[],
),
tool(
"describe_symbol",
"A symbol by name (`Post`, `Post.title`, `Posts#show`, or a bare member name): \
where it's defined, its code, and how many places use it.",
json!({"name": {"type": "string"}}),
&["name"],
),
tool(
"describe_callbacks",
"What runs when a model is saved or destroyed, in order: its callbacks (with \
their code) between the framework's steps (validations, the write, touches, \
dependents, nested attributes). Use it before changing a model's save path.",
json!({"model": {"type": "string"}}),
&["model"],
),
tool(
"get_schema",
"The database's tables and columns (type, nullable, default), from the development \
database (builds and runs the app).",
json!({"table": {"type": "string", "description": "Only this table"}}),
&[],
),
tool(
"find_definition",
"Where a symbol is defined: file, line, column.",
json!({"name": {"type": "string"}}),
&["name"],
),
tool(
"find_references",
"Every place an identifier appears in the app's source (whole words; atoms count).",
json!({"name": {"type": "string"}}),
&["name"],
),
tool(
"rename_symbol",
"Renames an identifier everywhere in the app's source. Reports the changes; pass \
`apply: true` to make them. Check afterwards (a renamed column needs a migration).",
json!({"from": {"type": "string"}, "to": {"type": "string"}, "apply": {"type": "boolean"}}),
&["from", "to"],
),
tool(
"check",
"Compile errors as JSON, located in .glacier source: file, line, column, message, \
the syntax expected and a suggested fix when there is one. Without `build`, only the \
.glacier compiler runs (fast); with it, the whole app is built with cargo check.",
json!({"build": {"type": "boolean", "description": "Also run cargo check (default true)"}}),
&[],
),
tool(
"show_generated_rust",
"The Rust a .glacier file compiles to (line N of the file is line N of the Rust).",
json!({"path": {"type": "string", "description": "e.g. src/controllers/posts_controller.glacier"}}),
&["path"],
),
tool(
"explain_error",
"Explains an error: a rustc code (E0308) from `rustc --explain`, or a .glacier message \
with the guide sections about it.",
json!({"error": {"type": "string", "description": "A rustc code or an error message"}}),
&["error"],
),
tool(
"format",
"Formats .glacier files (all of them without `paths`); with `check`, only lists \
those that need it.",
json!({"paths": {"type": "array", "items": {"type": "string"}}, "check": {"type": "boolean"}}),
&[],
),
tool(
"run_tests",
"Builds and runs the app's tests; failures point at .glacier lines.",
json!({"filter": {"type": "string", "description": "Only tests whose name contains this"}}),
&[],
),
tool(
"audit",
"Looks for security problems in the app's source: raw HTML, SQL built from strings, \
foreign keys a request can set, open CORS, GET routes that change things, and routes \
without a CSRF check. Each finding is a file, a line and what to look at.",
json!({}),
&[],
),
tool(
"generate",
"Runs a generator: scaffold, model, controller, migration, job, mailer, client, \
middleware, webhook, policy, authentication or access, with its arguments as `glacier generate` \
takes them (e.g. kind scaffold, args [\"Post\", \"title:string\", \
\"body:text:optional\"]; kind mailer, args [\"ReportMailer\", \"weekly\"]; \
kind client, args [\"Stripe\", \"https://api.stripe.com/v1\"]; kind webhook, \
args [\"stripe\"]; kind access, args [\"org\"]).",
json!({
"kind": {"type": "string", "enum": ["scaffold", "model", "controller", "migration", "job", "mailer", "client", "middleware", "webhook", "policy", "authentication", "access"]},
"args": {"type": "array", "items": {"type": "string"}},
}),
&["kind"],
),
tool(
"migrate",
"Runs pending migrations on the development database.",
json!({}),
&[],
),
tool(
"query",
"Runs SQL read-only on the development database (the database refuses writes) \
and returns the rows, at most 200.",
json!({"sql": {"type": "string"}}),
&["sql"],
),
tool(
"get_openapi",
"The app's OpenAPI 3.1 document: every route with its parameters, request \
body and responses, and the serializers' JSON schemas. `api_version` \
describes the routes of one API version (`version` scopes).",
json!({"api_version": {"type": "string"}}),
&[],
),
tool(
"inspect_jobs",
"The background job queue: due, scheduled, running and failed counts, and the \
recent failures with their errors.",
json!({}),
&[],
),
tool(
"rollback",
"Rolls back the last migration (or `steps` of them).",
json!({"steps": {"type": "integer"}}),
&[],
),
]
}
pub fn call(name: &str, args: &Value, app: &Path) -> Value {
match run_tool(name, args, app) {
Ok(value) => {
let text = match &value {
Value::String(text) => text.clone(),
other => serde_json::to_string_pretty(other).unwrap_or_default(),
};
let mut result = json!({"content": [{"type": "text", "text": text}], "isError": false});
if value.is_object() {
result["structuredContent"] = value;
}
result
}
Err(message) => json!({"content": [{"type": "text", "text": message}], "isError": true}),
}
}
fn run_tool(name: &str, args: &Value, app: &Path) -> Result<Value, String> {
match name {
"search_docs" => {
let query = args["query"].as_str().ok_or("`query` is required")?;
let limit = args["limit"].as_u64().unwrap_or(5).clamp(1, 20) as usize;
let hits = docs::search(query, limit);
Ok(json!({"results": hits}))
}
"read_guide" => {
let name = args["name"].as_str().ok_or("`name` is required")?;
docs::guide(name)
.map(|text| Value::String(text.to_owned()))
.ok_or_else(|| format!("no guide named `{name}`"))
}
"get_syntax_reference" => Ok(Value::String(
docs::guide("glacier_language")
.unwrap_or_default()
.to_owned(),
)),
"list_conventions" => Ok(Value::String(
include_str!("../templates/AGENTS.md.tmpl").replace("{{name}}", "this app"),
)),
"list_routes" => Ok(json!({"routes": analysis::routes(app)?})),
"list_models" => Ok(json!({"models": analysis::models(app)})),
"list_symbols" => {
let kind = args["kind"].as_str();
let file = args["file"].as_str();
let found: Vec<Value> = symbols::index(app)
.iter()
.filter(|s| kind.is_none_or(|k| s.kind == k))
.filter(|s| file.is_none_or(|f| s.file == f))
.map(symbols::Symbol::to_json)
.collect();
Ok(json!({"symbols": found}))
}
"describe_symbol" | "find_definition" => {
let symbol = args["name"].as_str().ok_or("`name` is required")?;
let index = symbols::index(app);
let found = symbols::lookup(&index, symbol);
if found.is_empty() {
return Err(format!(
"no symbol named `{symbol}`; list_symbols shows them"
));
}
if name == "find_definition" {
let at: Vec<Value> = found.iter().map(|s| s.to_json()).collect();
return Ok(json!({"definitions": at}));
}
let described: Vec<Value> = found
.iter()
.map(|s| {
let mut out = s.to_json();
if let Some(end) = s.end_line
&& let Ok(text) = std::fs::read_to_string(app.join(&s.file))
{
let lines: Vec<&str> = text.lines().collect();
out["code"] = lines[s.line - 1..end.min(lines.len())].join("\n").into();
}
out
})
.collect();
let short = symbol.rsplit(['.', '#']).next().unwrap_or(symbol);
Ok(json!({
"definitions": described,
"references": symbols::references(app, short).len(),
}))
}
"describe_callbacks" => {
let model = args["model"].as_str().ok_or("`model` is required")?;
symbols::callbacks(app, model)
}
"find_references" => {
let name = args["name"].as_str().ok_or("`name` is required")?;
let short = name.rsplit(['.', '#']).next().unwrap_or(name);
Ok(json!({"references": symbols::references(app, short)}))
}
"rename_symbol" => {
let from = args["from"].as_str().ok_or("`from` is required")?;
let to = args["to"].as_str().ok_or("`to` is required")?;
let apply = args["apply"].as_bool() == Some(true);
let changes = symbols::rename(app, from, to, apply)?;
Ok(json!({"applied": apply, "changes": changes}))
}
"get_schema" => {
let mut schema = analysis::app_task(app, "db:schema", &[])?;
if let Some(table) = args["table"].as_str() {
let tables = schema["tables"].as_array().cloned().unwrap_or_default();
let found: Vec<Value> = tables.into_iter().filter(|t| t["name"] == table).collect();
if found.is_empty() {
return Err(format!("no table named `{table}`"));
}
schema["tables"] = found.into();
}
Ok(schema)
}
"query" => {
let sql = args["sql"].as_str().ok_or("`sql` is required")?;
analysis::app_task(app, "db:query", &[("GLACIER_SQL", sql)])
}
"inspect_jobs" => analysis::app_task(app, "jobs", &[]),
"get_openapi" => {
let version = args["api_version"].as_str().unwrap_or("");
analysis::app_task(app, "openapi", &[("GLACIER_API_VERSION", version)])
}
"check" => {
let errors = if args["build"].as_bool().unwrap_or(true) {
analysis::build_errors(app)?
} else {
analysis::glacier_errors(app)
};
let count = errors.iter().filter(|e| e["severity"] == "error").count();
Ok(json!({"ok": count == 0, "errors": count, "diagnostics": errors}))
}
"show_generated_rust" => {
let path = args["path"].as_str().ok_or("`path` is required")?;
let file = app.join(path);
let text =
std::fs::read_to_string(&file).map_err(|e| format!("reading {path}: {e}"))?;
let name = file.file_name().unwrap_or_default().to_string_lossy();
match glacier_dialect::transpile_as(
&text,
&name,
glacier_dialect::Mode::for_path(&file),
) {
Ok(out) => Ok(Value::String(out.rust)),
Err(err) => Err(analysis::glacier_error(path, &err).to_string()),
}
}
"explain_error" => {
let error = args["error"].as_str().ok_or("`error` is required")?.trim();
explain(error)
}
"format" => {
let mut cli = vec!["fmt".to_owned()];
if args["check"].as_bool() == Some(true) {
cli.push("--check".into());
}
for path in args["paths"].as_array().into_iter().flatten() {
cli.extend(path.as_str().map(str::to_owned));
}
glacier(app, &cli)
}
"get_api_reference" => Ok(Value::String(API_REFERENCE.to_owned())),
"audit" => glacier(app, &["audit".to_owned()]),
"run_tests" => {
let mut cli = vec!["test".to_owned()];
if let Some(filter) = args["filter"].as_str() {
cli.push(filter.to_owned());
}
glacier(app, &cli)
}
"generate" => {
let kind = args["kind"].as_str().ok_or("`kind` is required")?;
let mut cli = vec!["generate".to_owned(), kind.to_owned()];
for arg in args["args"].as_array().into_iter().flatten() {
cli.extend(arg.as_str().map(str::to_owned));
}
glacier(app, &cli)
}
"migrate" => glacier(app, &["db".into(), "migrate".into()]),
"rollback" => {
let mut cli = vec!["db".to_owned(), "rollback".to_owned()];
if let Some(steps) = args["steps"].as_u64() {
cli.push(steps.to_string());
}
glacier(app, &cli)
}
_ => Err(format!("no tool named `{name}`")),
}
}
fn explain(error: &str) -> Result<Value, String> {
let code = error.trim_start_matches("error[").trim_end_matches(']');
if code.len() == 5 && code.starts_with('E') && code[1..].chars().all(|c| c.is_ascii_digit()) {
let output = Command::new("rustc")
.args(["--explain", code])
.output()
.map_err(|e| format!("running rustc: {e}"))?;
if output.status.success() {
return Ok(Value::String(
String::from_utf8_lossy(&output.stdout).into_owned(),
));
}
return Err(format!("rustc has no explanation for {code}"));
}
Ok(json!({
"error": error,
"related_docs": docs::search(error, 3),
}))
}
fn glacier(app: &Path, args: &[String]) -> Result<Value, String> {
let exe = std::env::current_exe().map_err(|e| format!("finding glacier: {e}"))?;
let output = Command::new(exe)
.args(args)
.current_dir(app)
.env("NO_COLOR", "1")
.output()
.map_err(|e| format!("running glacier {}: {e}", args.join(" ")))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let text = tail(&text, MAX_OUTPUT_LINES);
if output.status.success() {
Ok(Value::String(text))
} else {
Err(text)
}
}
fn tail(text: &str, lines: usize) -> String {
let all: Vec<&str> = text.lines().collect();
if all.len() <= lines {
return text.to_owned();
}
format!(
"… ({} earlier lines)\n{}",
all.len() - lines,
all[all.len() - lines..].join("\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
fn request(id: u64, method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
#[test]
fn initializes_and_lists_tools() {
let reply = handle(&request(
1,
"initialize",
json!({"protocolVersion": "2025-03-26"}),
))
.unwrap();
assert_eq!(reply["result"]["protocolVersion"], "2025-03-26");
assert_eq!(reply["result"]["serverInfo"]["name"], "glacier");
let reply = handle(&request(
2,
"initialize",
json!({"protocolVersion": "1999-01-01"}),
))
.unwrap();
assert_eq!(
reply["result"]["protocolVersion"], VERSIONS[0],
"ours when we don't know theirs"
);
assert!(
handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none(),
"notifications get no reply"
);
let tools = handle(&request(3, "tools/list", json!({}))).unwrap();
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap())
.collect();
for expected in [
"search_docs",
"get_syntax_reference",
"check",
"list_routes",
"generate",
] {
assert!(names.contains(&expected), "{expected} in {names:?}");
}
let unknown = handle(&request(4, "nope", json!({}))).unwrap();
assert_eq!(unknown["error"]["code"], -32601);
}
#[test]
fn tools_answer_without_an_app() {
let app = Path::new(".");
let found = call("search_docs", &json!({"query": "belongs_to"}), app);
assert_eq!(found["isError"], false);
assert!(
!found["structuredContent"]["results"]
.as_array()
.unwrap()
.is_empty()
);
let syntax = call("get_syntax_reference", &json!({}), app);
assert!(
syntax["content"][0]["text"]
.as_str()
.unwrap()
.contains(".glacier")
);
let missing = call("read_guide", &json!({"name": "nope"}), app);
assert_eq!(missing["isError"], true);
let unknown = call("nope", &json!({}), app);
assert_eq!(unknown["isError"], true);
}
}