Something went wrong. Try again.
The Monad language. Dependent types, functional programming compiled with LLVM. Hobby project. monad-lang.org
dependent-types language compiler programming-language functional-programming
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010//! Minimal stdio MCP server — exposes `check`/`symbols`/`hover`///! `definition`/`organize_imports`/`test` as tools, the same six//! operations the CLI's own `--json` subcommands already provide (`monad//! check/symbols/hover/definition/test --json`, `monad organize-imports`).//! This is the MCP analog of Phase 0 in//! `plans/library-ideas/language-server.md` ("ship structured CLI tools//! first, reuse everywhere") — every tool here is a thin wrapper over the//! same `monad_core` entry points the CLI and [`crate::lsp`] already call,//! so agent clients speaking MCP get identical results to shelling out to//! the CLI or driving the LSP server.//!//! `run` is deliberately NOT exposed as a tool: `monad_core::run` prints//! directly to stdout/stderr and returns `Result<(), String>`, and its//! whole point is a program's side effects/output, not a pass/fail//! report the way `run_tests_for_files` (backing the `test` tool below)//! now is — there's no structured shape to give it that wouldn't just be//! "captured stdout as a string", which needs new output-capture//! plumbing in `core`, not just a new return type. Likewise, MCP//! **resources** and **prompts** (stdlib docs, examples, style-guide//! prompts) aren't implemented — static-content features, not derived//! from `monad_core`, and not needed for "agent calls the compiler".//!//! Transport is MCP's own framing, not LSP's: one JSON-RPC 2.0 object per//! line on stdin/stdout (no `Content-Length` header block), and no//! `shutdown`/`exit` handshake — the loop just ends on stdin EOF.//!//! Tool execution failures (file not found, parse/type errors surfaced as//! zero diagnostics vs. a genuine I/O failure, unknown tool name, missing//! argument) are reported as `isError: true` in the tool result, not as//! JSON-RPC protocol errors — per MCP convention, this keeps failures//! visible to the calling agent as ordinary tool output instead of//! aborting the exchange. Only a genuinely unrecognized top-level//! `method` (not a tool name) gets a JSON-RPC `-32601` error response.
use std::io::{self, BufRead, BufReader, Write};use std::path::PathBuf;
use monad_core::{ check_files, diag::Severity, organize_imports_for_files, run_tests_for_files, symbols_for_files,};use serde_json::Value;
use crate::{ JsonCheckReport, JsonFileReport, JsonHover, JsonLocation, JsonPosition, JsonRange, JsonSummary, JsonSymbol, JsonSymbolFile, build_json_test_report, identifier_at, location_to_json_range, path_to_uri, resolve_symbol, symbol_kind_label, symbols_of_file, to_json_diagnostic,};
/// Server-side ceiling on one `test` tool call's total run time — no/// `timeout`/`jobs` argument is exposed to the MCP client in v1 (see/// `tool_test`'s own doc comment), so this bounds a runaway test suite/// instead of blocking the calling agent indefinitely.const MCP_TEST_TIMEOUT_SECS: u64 = 60;
pub fn run(mote_path: Vec<PathBuf>) -> Result<(), String> { let stdin = io::stdin(); let mut reader = BufReader::new(stdin.lock()); let stdout = io::stdout(); let mut writer = stdout.lock();
loop { let Some(line) = read_line(&mut reader).map_err(|e| format!("transport error: {e}"))? else { break; // stdin closed — client disconnected }; if line.trim().is_empty() { continue; } let msg: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { eprintln!("mcp: dropping malformed message: {e}"); continue; } }; let id = msg.get("id").cloned(); let method = msg.get("method").and_then(|m| m.as_str()); let params = msg.get("params").cloned().unwrap_or(Value::Null);
let Some(method) = method else { // No `method` field: a response to a request we sent — this server // never sends server-initiated requests, so nothing to correlate. continue; };
match method { "initialize" => { if let Some(id) = id { send_response(&mut writer, id, initialize_result(¶ms))?; } } // Notifications this server has nothing to do in response to, but // are a normal part of the protocol. "notifications/initialized" | "notifications/cancelled" => {} "ping" => { if let Some(id) = id { send_response(&mut writer, id, serde_json::json!({}))?; } } "tools/list" => { if let Some(id) = id { send_response( &mut writer, id, serde_json::json!({ "tools": tool_definitions() }), )?; } } "tools/call" => { if let Some(id) = id { let result = handle_tools_call(¶ms, &mote_path); send_response(&mut writer, id, result)?; } } _ => { // A notification with no handler is fine to ignore; a REQUEST // with no handler must get an error response, or a well-behaved // client would hang waiting for one. if let Some(id) = id { send_error( &mut writer, id, -32601, &format!("method not found: {method}"), )?; } } } } Ok(())}
// --- transport ------------------------------------------------------------
fn read_line(reader: &mut impl BufRead) -> io::Result<Option<String>> { let mut line = String::new(); let n = reader.read_line(&mut line)?; if n == 0 { return Ok(None); // EOF } Ok(Some(line.trim_end_matches(['\r', '\n']).to_string()))}
fn write_line(writer: &mut impl Write, body: &Value) -> Result<(), String> { let text = serde_json::to_string(body).map_err(|e| format!("failed to serialize message: {e}"))?; writeln!(writer, "{text}").map_err(|e| format!("{e}"))?; writer.flush().map_err(|e| format!("{e}"))}
fn send_response(writer: &mut impl Write, id: Value, result: Value) -> Result<(), String> { write_line( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result }), )}
fn send_error(writer: &mut impl Write, id: Value, code: i64, message: &str) -> Result<(), String> { write_line( writer, &serde_json::json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }), )}
/// Echoes back the client's requested `protocolVersion` — this server's/// capability surface (a static `tools` list, no resources/prompts//// sampling) hasn't changed across recent MCP protocol revisions, so/// there's no version-specific behavior to gate on. Falls back to a/// known-good version string if the client didn't send one.fn initialize_result(params: &Value) -> Value { let protocol_version = params .get("protocolVersion") .and_then(|v| v.as_str()) .unwrap_or("2024-11-05"); serde_json::json!({ "protocolVersion": protocol_version, "capabilities": { "tools": {} }, "serverInfo": { "name": "monad-mcp", "version": env!("CARGO_PKG_VERSION") } })}
// --- tools/list -------------------------------------------------------------
fn tool_definitions() -> Vec<Value> { vec![ serde_json::json!({ "name": "check", "description": "Parse and type-check the given files (or the current directory if omitted). Returns structured diagnostics — same shape as `monad check --json`.", "inputSchema": { "type": "object", "properties": { "paths": { "type": "array", "items": { "type": "string" }, "description": "Files or directories to check; defaults to the current directory. Ignored if workspace is true." }, "workspace": { "type": "boolean", "description": "Check the whole resolved workspace instead of paths: the project's own src/ plus every dependency mote's src/ (resolved from the discovered mote.toml manifest). Defaults to false." } } } }), serde_json::json!({ "name": "symbols", "description": "List top-level defs/types/classes/instances across the given files (or the current directory if omitted) — same shape as `monad symbols --json`.", "inputSchema": { "type": "object", "properties": { "paths": { "type": "array", "items": { "type": "string" }, "description": "Files or directories to index; defaults to the current directory. Ignored if workspace is true." }, "workspace": { "type": "boolean", "description": "Index the whole resolved workspace instead of paths — see the check tool's workspace argument for exactly what that covers." } } } }), serde_json::json!({ "name": "hover", "description": "Type signature and doc summary of the identifier at a position — same shape as `monad hover --json`. Resolves within the file first; if not found there, falls back to a workspace-wide search across the resolved mote graph (so an identifier imported from another mote resolves too).", "inputSchema": { "type": "object", "properties": { "file": { "type": "string" }, "line": { "type": "integer", "description": "1-indexed" }, "col": { "type": "integer", "description": "1-indexed" } }, "required": ["file", "line", "col"] } }), serde_json::json!({ "name": "definition", "description": "Definition location of the identifier at a position — same shape as `monad definition --json`. Resolves within the file first; if not found there, falls back to a workspace-wide search across the resolved mote graph, returning a location in the defining file.", "inputSchema": { "type": "object", "properties": { "file": { "type": "string" }, "line": { "type": "integer", "description": "1-indexed" }, "col": { "type": "integer", "description": "1-indexed" } }, "required": ["file", "line", "col"] } }), serde_json::json!({ "name": "organize_imports", "description": "Rewrite bare `use`/`open` declarations to explicit name lists and `@[...]` attributes to `#[...]`. Dry-run by default — returns each changed file's new full source without writing it; pass write: true to apply to disk.", "inputSchema": { "type": "object", "properties": { "paths": { "type": "array", "items": { "type": "string" }, "description": "Files or directories to convert; defaults to the current directory. Ignored if workspace is true." }, "workspace": { "type": "boolean", "description": "Convert the whole resolved workspace instead of paths — see the check tool's workspace argument for exactly what that covers." }, "write": { "type": "boolean", "description": "Apply changes to disk. Defaults to false (dry run)." } } } }), serde_json::json!({ "name": "test", "description": "Run every #[test]-attributed def under the given files (or the current directory if omitted). Returns structured pass/fail per file — same shape as `monad test --json`.", "inputSchema": { "type": "object", "properties": { "paths": { "type": "array", "items": { "type": "string" }, "description": "Files or directories to run tests in; defaults to the current directory. Ignored if workspace is true." }, "workspace": { "type": "boolean", "description": "Run tests across the whole resolved workspace instead of paths — see the check tool's workspace argument for exactly what that covers." } } } }), ]}
// --- tools/call ---------------------------------------------------------
fn tool_ok(value: Value) -> Value { let text = serde_json::to_string_pretty(&value).unwrap_or_else(|e| format!("<serialization error: {e}>")); serde_json::json!({ "content": [{ "type": "text", "text": text }], "isError": false })}
fn tool_err(message: impl Into<String>) -> Value { serde_json::json!({ "content": [{ "type": "text", "text": message.into() }], "isError": true })}
fn handle_tools_call(params: &Value, mote_path: &[PathBuf]) -> Value { let Some(name) = params.get("name").and_then(|n| n.as_str()) else { return tool_err("missing required field 'name'"); }; let arguments = params .get("arguments") .cloned() .unwrap_or_else(|| Value::Object(Default::default()));
let outcome = match name { "check" => tool_check(&arguments, mote_path), "symbols" => tool_symbols(&arguments, mote_path), "hover" => tool_hover(&arguments, mote_path), "definition" => tool_definition(&arguments, mote_path), "organize_imports" => tool_organize_imports(&arguments, mote_path), "test" => tool_test(&arguments, mote_path), other => Err(format!("unknown tool: {other}")), }; match outcome { Ok(value) => tool_ok(value), Err(message) => tool_err(message), }}
fn parse_paths(arguments: &Value) -> Vec<PathBuf> { arguments .get("paths") .and_then(|p| p.as_array()) .map(|arr| { arr .iter() .filter_map(|v| v.as_str()) .map(PathBuf::from) .collect() }) .unwrap_or_default()}
/// `paths` argument, or (when `workspace: true`) the server's own/// resolved `mote_path` instead — the project's own `src/` plus every/// dependency mote's `src/`, exactly the directories `--manifest-path`//// manifest discovery already resolved at server startup. `check_files`//// `symbols_for_files`/`organize_imports_for_files` already handle a list/// of directories as `inputs` by recursively walking each one, so this is/// the whole mechanism — no `core` changes needed for workspace mode.fn resolve_inputs(arguments: &Value, mote_path: &[PathBuf]) -> Vec<PathBuf> { let workspace = arguments .get("workspace") .and_then(|v| v.as_bool()) .unwrap_or(false); if workspace { mote_path.to_vec() } else { parse_paths(arguments) }}
/// Shared `file`/`line`/`col` argument parsing for `hover`/`definition` —/// same 1-indexed convention as `monad hover`/`monad definition` and the/// LSP server's own position handling.fn parse_position_args(arguments: &Value) -> Result<(PathBuf, u32, usize), String> { let file = arguments .get("file") .and_then(|v| v.as_str()) .ok_or("missing required argument 'file'")?; let line = arguments .get("line") .and_then(|v| v.as_u64()) .ok_or("missing required argument 'line'")? as u32; let col = arguments .get("col") .and_then(|v| v.as_u64()) .ok_or("missing required argument 'col'")? as usize; Ok((PathBuf::from(file), line, col))}
fn tool_check(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let results = check_files(resolve_inputs(arguments, mote_path), mote_path.to_vec())?;
let mut errors = 0usize; let mut warnings = 0usize; for result in &results { for d in &result.diagnostics { match d.severity { Severity::Error => errors += 1, Severity::Warning => warnings += 1, _ => {} } } }
let report = JsonCheckReport { files: results .iter() .map(|r| JsonFileReport { uri: path_to_uri(&r.path), diagnostics: r.diagnostics.iter().map(to_json_diagnostic).collect(), }) .collect(), summary: JsonSummary { errors, warnings, files_checked: results.len(), }, }; serde_json::to_value(report).map_err(|e| format!("failed to serialize check report: {e}"))}
fn tool_symbols(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let results = symbols_for_files(resolve_inputs(arguments, mote_path), mote_path.to_vec())?;
let files: Vec<JsonSymbolFile> = results .iter() .map(|r| JsonSymbolFile { uri: path_to_uri(&r.path), symbols: r .symbols .iter() .map(|s| JsonSymbol { name: s.name.clone(), kind: s.kind, range: location_to_json_range(s.location.as_ref()), detail: s.detail.clone(), }) .collect(), }) .collect(); serde_json::to_value(files).map_err(|e| format!("failed to serialize symbols: {e}"))}
fn tool_hover(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let (file, line, col) = parse_position_args(arguments)?; let source = std::fs::read_to_string(&file).map_err(|e| format!("{e}"))?; let local_symbols = symbols_of_file(&file, mote_path.to_vec()); let found = identifier_at(&source, line, col).and_then(|(name, start_col, end_col)| { resolve_symbol(&name, &file, &local_symbols, mote_path) .map(|(_, sym)| (sym, start_col, end_col)) });
let hover = found.map(|(sym, start_col, end_col)| { let contents = sym .detail .clone() .unwrap_or_else(|| format!("{} {}", symbol_kind_label(sym.kind), sym.name)); JsonHover { contents, kind: symbol_kind_label(sym.kind).to_string(), range: JsonRange { start: JsonPosition { line: line.saturating_sub(1), character: start_col.saturating_sub(1), }, end: JsonPosition { line: line.saturating_sub(1), character: end_col.saturating_sub(1), }, }, } }); serde_json::to_value(hover).map_err(|e| format!("failed to serialize hover: {e}"))}
fn tool_definition(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let (file, line, col) = parse_position_args(arguments)?; let source = std::fs::read_to_string(&file).map_err(|e| format!("{e}"))?; let local_symbols = symbols_of_file(&file, mote_path.to_vec()); let found = identifier_at(&source, line, col) .and_then(|(name, _, _)| resolve_symbol(&name, &file, &local_symbols, mote_path));
// `def_file` is the *defining* file — for a cross-file match (resolved // via `resolve_symbol`'s workspace fallback) that's no longer `file`, // the queried one. let location = found.map(|(def_file, sym)| JsonLocation { uri: path_to_uri(&def_file), range: location_to_json_range(sym.location.as_ref()), }); serde_json::to_value(location).map_err(|e| format!("failed to serialize location: {e}"))}
fn tool_organize_imports(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let write = arguments .get("write") .and_then(|v| v.as_bool()) .unwrap_or(false); let results = organize_imports_for_files(resolve_inputs(arguments, mote_path), mote_path.to_vec())?;
let mut files = Vec::with_capacity(results.len()); let mut changed = 0usize; let mut errors = 0usize; for r in &results { if let Some(e) = &r.error { errors += 1; files.push(serde_json::json!({ "path": r.path.display().to_string(), "error": e })); continue; } let Some(new_source) = &r.new_source else { continue; // already fully explicit — nothing to report }; changed += 1; if write { std::fs::write(&r.path, new_source) .map_err(|e| format!("failed to write {}: {e}", r.path.display()))?; } files.push(serde_json::json!({ "path": r.path.display().to_string(), "newSource": new_source, "written": write, })); }
Ok(serde_json::json!({ "files": files, "summary": { "changed": changed, "errors": errors, "write": write } }))}
/// Runs every `#[test]` def under `resolve_inputs(arguments, mote_path)`/// and returns the same structured report shape `build_json_test_report`/// gives `monad test --json` — see that function's own doc comment for/// the wire shape. No `timeout`/`jobs` arguments in v1: `MCP_TEST_TIMEOUT_/// SECS` bounds the whole call server-side instead, and thread count/// always uses every available core (`run_tests_for_files`'s single-/// threaded fallback for a 0/1-file batch already keeps small runs cheap).fn tool_test(arguments: &Value, mote_path: &[PathBuf]) -> Result<Value, String> { let num_threads = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(1); let results = run_tests_for_files( resolve_inputs(arguments, mote_path), monad_core::eval::EvalOptions::default(), num_threads, Some(std::time::Duration::from_secs(MCP_TEST_TIMEOUT_SECS)), mote_path.to_vec(), None, )?; serde_json::to_value(build_json_test_report(&results)) .map_err(|e| format!("failed to serialize test report: {e}"))}
#[cfg(test)]mod test { use super::*;
fn call(method: &str, params: Value) -> Value { serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }) }
/// Run one request line through [`run`]'s dispatch by hand (no stdin /// involved) and parse back the single response line written. fn dispatch(method: &str, params: Value, mote_path: &[PathBuf]) -> Value { let mut buf: Vec<u8> = Vec::new(); let msg = call(method, params); let id = msg["id"].clone(); match method { "initialize" => { send_response(&mut buf, id, initialize_result(&msg["params"])).unwrap(); } "tools/list" => { send_response( &mut buf, id, serde_json::json!({ "tools": tool_definitions() }), ) .unwrap(); } "tools/call" => { let result = handle_tools_call(&msg["params"], mote_path); send_response(&mut buf, id, result).unwrap(); } other => panic!("dispatch() helper doesn't know method {other}"), } let line = String::from_utf8(buf).unwrap(); serde_json::from_str(line.trim_end()).unwrap() }
#[test] fn test_initialize_advertises_tools_capability() { let response = dispatch("initialize", serde_json::json!({}), &[]); assert!(response["result"]["capabilities"]["tools"].is_object()); assert_eq!(response["result"]["protocolVersion"], "2024-11-05"); }
#[test] fn test_initialize_echoes_requested_protocol_version() { let response = dispatch( "initialize", serde_json::json!({ "protocolVersion": "2025-03-26" }), &[], ); assert_eq!(response["result"]["protocolVersion"], "2025-03-26"); }
#[test] fn test_tools_list_has_six_tools() { let response = dispatch("tools/list", serde_json::json!({}), &[]); let tools = response["result"]["tools"].as_array().unwrap(); let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); assert_eq!( names, vec![ "check", "symbols", "hover", "definition", "organize_imports", "test" ] ); for tool in tools { assert!(tool["inputSchema"]["type"] == "object"); } }
#[test] fn test_unknown_top_level_method_is_protocol_error() { let mut buf: Vec<u8> = Vec::new(); send_error( &mut buf, serde_json::json!(1), -32601, "method not found: bogus", ) .unwrap(); let line = String::from_utf8(buf).unwrap(); let response: Value = serde_json::from_str(line.trim_end()).unwrap(); assert_eq!(response["error"]["code"], -32601); }
#[test] fn test_check_tool_reports_type_error() { let path = test_tmp("check-err.mo"); std::fs::write(&path, "def x : I64 := \"nope\"\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "check", "arguments": { "paths": [path] } }), &[], ); assert_eq!(response["result"]["isError"], false); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert!(report["summary"]["errors"].as_u64().unwrap() > 0); }
#[test] fn test_check_tool_clean_file_has_no_errors() { let path = test_tmp("check-ok.mo"); std::fs::write(&path, "def x : I64 := 1\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "check", "arguments": { "paths": [path] } }), &[], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["errors"], 0); }
#[test] fn test_symbols_tool_lists_def() { let path = test_tmp("symbols.mo"); std::fs::write(&path, "def add (x : I64) (y : I64) : I64 := x + y\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "symbols", "arguments": { "paths": [path] } }), &[], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let files: Value = serde_json::from_str(text).unwrap(); let names: Vec<&str> = files[0]["symbols"] .as_array() .unwrap() .iter() .map(|s| s["name"].as_str().unwrap()) .collect(); assert!(names.contains(&"add")); }
#[test] fn test_hover_tool_finds_symbol() { let path = test_tmp("hover.mo"); std::fs::write(&path, "def foo : I64 := 42\ndef bar : I64 := foo\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "hover", "arguments": { "file": path, "line": 2, "col": 19 } }), &[], ); assert_eq!(response["result"]["isError"], false); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let hover: Value = serde_json::from_str(text).unwrap(); assert_eq!(hover["kind"], "function"); assert!(hover["contents"].as_str().unwrap().contains("I64")); // foo's type }
#[test] fn test_hover_tool_no_symbol_returns_null_not_error() { let path = test_tmp("hover-empty.mo"); std::fs::write(&path, "def foo : I64 := 42\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "hover", "arguments": { "file": path, "line": 1, "col": 1 } }), &[], ); assert_eq!(response["result"]["isError"], false); let text = response["result"]["content"][0]["text"].as_str().unwrap(); assert_eq!(text, "null"); }
#[test] fn test_definition_tool_finds_location() { let path = test_tmp("definition.mo"); std::fs::write(&path, "def foo : I64 := 42\ndef bar : I64 := foo\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "definition", "arguments": { "file": path, "line": 2, "col": 19 } }), &[], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let loc: Value = serde_json::from_str(text).unwrap(); assert_eq!(loc["range"]["start"]["line"], 0); // `foo` is defined on line 1 (0-indexed: 0) }
#[test] fn test_hover_tool_missing_argument_is_tool_error() { let response = dispatch( "tools/call", serde_json::json!({ "name": "hover", "arguments": { "file": "/tmp/nope.mo" } }), &[], ); assert_eq!(response["result"]["isError"], true); }
#[test] fn test_unknown_tool_name_is_tool_error() { let response = dispatch( "tools/call", serde_json::json!({ "name": "not_a_real_tool", "arguments": {} }), &[], ); assert_eq!(response["result"]["isError"], true); let text = response["result"]["content"][0]["text"].as_str().unwrap(); assert!(text.contains("unknown tool")); }
#[test] fn test_organize_imports_dry_run_does_not_touch_disk() { let path = test_tmp("organize.mo"); std::fs::write(&path, "open IO\n\ndef x : I64 := 1\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "organize_imports", "arguments": { "paths": [path] } }), &[], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["changed"], 1); assert_eq!(report["summary"]["write"], false); let on_disk = std::fs::read_to_string(path).unwrap(); assert_eq!(on_disk, "open IO\n\ndef x : I64 := 1\n"); // untouched }
#[test] fn test_organize_imports_write_applies_to_disk() { let path = test_tmp("organize-write.mo"); std::fs::write(&path, "open IO\n\ndef x : I64 := 1\n").unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "organize_imports", "arguments": { "paths": [path], "write": true } }), &[], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["changed"], 1); let on_disk = std::fs::read_to_string(path).unwrap(); assert_eq!(on_disk, "open IO {}\n\ndef x : I64 := 1\n"); }
// --- workspace mode + cross-mote resolution ------------------------------
/// A 2-mote fixture: `dir_a/wsdep.mo` defines `shared_val`, and /// `dir_b/consumer.mo` imports it via `use wsdep {shared_val}` — same /// dependency shape as the checked-in `motes/example`/ /// `examples/test_mote.mo` pair, just built at test time under unique /// tmp dirs (parallel test threads, so no path can be shared across /// tests). fn write_cross_mote_fixture(dir_a: &str, dir_b: &str) -> (PathBuf, PathBuf) { std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); std::fs::write(format!("{dir_a}/wsdep.mo"), "def shared_val : I64 := 99\n").unwrap(); let consumer = format!("{dir_b}/consumer.mo"); std::fs::write( &consumer, "use wsdep {shared_val}\n\ndef use_it : I64 := shared_val\n", ) .unwrap(); (PathBuf::from(&*dir_a), PathBuf::from(&*dir_b)) }
#[test] fn test_hover_tool_resolves_cross_file_symbol() { let (dir_a, dir_b) = write_cross_mote_fixture( test_tmp("ws-hover-a").as_str(), test_tmp("ws-hover-b").as_str(), ); let mote_path = [dir_a, dir_b.clone()]; let response = dispatch( "tools/call", serde_json::json!({ "name": "hover", "arguments": { "file": dir_b.join("consumer.mo").to_str().unwrap(), "line": 3, "col": 25 } }), &mote_path, ); assert_eq!(response["result"]["isError"], false); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let hover: Value = serde_json::from_str(text).unwrap(); assert!(hover["contents"].as_str().unwrap().contains("I64")); // shared_val's type }
#[test] fn test_definition_tool_resolves_cross_file_symbol() { let (dir_a, dir_b) = write_cross_mote_fixture(test_tmp("ws-def-a").as_str(), test_tmp("ws-def-b").as_str()); let mote_path = [dir_a, dir_b.clone()]; let response = dispatch( "tools/call", serde_json::json!({ "name": "definition", "arguments": { "file": dir_b.join("consumer.mo").to_str().unwrap(), "line": 3, "col": 25 } }), &mote_path, ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let loc: Value = serde_json::from_str(text).unwrap(); let uri = loc["uri"].as_str().unwrap(); assert!( uri.ends_with("wsdep.mo"), "expected definition to point into wsdep.mo (the defining file), got {uri}" ); assert!(!uri.contains("consumer.mo")); }
#[test] fn test_check_tool_workspace_mode_scans_all_mote_dirs() { let dir_a = test_tmp("ws-check-a"); let dir_b = test_tmp("ws-check-b"); std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); std::fs::write(format!("{dir_a}/ok.mo"), "def x : I64 := 1\n").unwrap(); std::fs::write(format!("{dir_b}/bad.mo"), "def y : I64 := \"nope\"\n").unwrap(); let mote_path = [PathBuf::from(&*dir_a), PathBuf::from(&*dir_b)]; let response = dispatch( "tools/call", serde_json::json!({ "name": "check", "arguments": { "workspace": true } }), &mote_path, ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["filesChecked"], 2); assert!(report["summary"]["errors"].as_u64().unwrap() > 0); }
#[test] fn test_symbols_tool_workspace_mode_scans_all_mote_dirs() { let dir_a = test_tmp("ws-symbols-a"); let dir_b = test_tmp("ws-symbols-b"); std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); std::fs::write(format!("{dir_a}/one.mo"), "def alpha : I64 := 1\n").unwrap(); std::fs::write(format!("{dir_b}/two.mo"), "def beta : I64 := 2\n").unwrap(); let mote_path = [PathBuf::from(&*dir_a), PathBuf::from(&*dir_b)]; let response = dispatch( "tools/call", serde_json::json!({ "name": "symbols", "arguments": { "workspace": true } }), &mote_path, ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let files: Value = serde_json::from_str(text).unwrap(); let all_names: Vec<String> = files .as_array() .unwrap() .iter() .flat_map(|f| f["symbols"].as_array().unwrap().iter()) .map(|s| s["name"].as_str().unwrap().to_string()) .collect(); assert!(all_names.contains(&"alpha".to_string())); assert!(all_names.contains(&"beta".to_string())); }
#[test] fn test_organize_imports_tool_workspace_mode_scans_all_mote_dirs() { let dir_a = test_tmp("ws-organize-a"); let dir_b = test_tmp("ws-organize-b"); std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); std::fs::write(format!("{dir_a}/one.mo"), "open IO\n\ndef x : I64 := 1\n").unwrap(); std::fs::write(format!("{dir_b}/two.mo"), "open IO\n\ndef y : I64 := 2\n").unwrap(); let mote_path = [PathBuf::from(&*dir_a), PathBuf::from(&*dir_b)]; let response = dispatch( "tools/call", serde_json::json!({ "name": "organize_imports", "arguments": { "workspace": true } }), &mote_path, ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["changed"], 2); }
// --- `test` tool ---------------------------------------------------- // // `run_tests_for_files` unconditionally loads `std/test.mo` (for // `Test.assert`), same as `run_tests` itself — resolved via the real // workspace root (this crate's `cargo test` cwd is `cli/`, not the // workspace root where `std/` actually lives), mirroring `monad- // core`'s own `run_tests`-adjacent tests' `workspace_root()` helper.
fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .to_path_buf() }
/// Unique per-process tmp path so parallel `cargo test` runs don't /// collide on the same `/tmp/monad-mcp-test-*` files. fn test_tmp(name: &str) -> String { format!("/tmp/monad-mcp-test-{name}-{:x}", std::process::id()) }
#[test] fn test_test_tool_reports_pass_and_fail_counts() { let path = test_tmp("test-pass-fail.mo"); std::fs::write( &path, "#[test]\ndef test_ok : Bool := true\n\n#[test]\ndef test_bad : Bool := false\n", ) .unwrap(); let response = dispatch( "tools/call", serde_json::json!({ "name": "test", "arguments": { "paths": [path] } }), &[workspace_root()], ); assert_eq!(response["result"]["isError"], false); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["passed"], 1); assert_eq!(report["summary"]["failed"], 1); assert_eq!(report["summary"]["filesTested"], 1); }
#[test] fn test_test_tool_reports_file_error_for_broken_file() { let path = test_tmp("test-broken.mo"); std::fs::write(&path, "def x : I64 := \n").unwrap(); // incomplete, doesn't parse let response = dispatch( "tools/call", serde_json::json!({ "name": "test", "arguments": { "paths": [path] } }), &[workspace_root()], ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert!(report["files"][0]["error"].is_string()); assert!(report["files"][0]["tests"].as_array().unwrap().is_empty()); }
#[test] fn test_test_tool_workspace_mode_scans_all_mote_dirs() { let dir_a = test_tmp("ws-test-a"); let dir_b = test_tmp("ws-test-b"); std::fs::create_dir_all(&dir_a).unwrap(); std::fs::create_dir_all(&dir_b).unwrap(); std::fs::write( format!("{dir_a}/one.mo"), "#[test]\ndef test_one : Bool := true\n", ) .unwrap(); std::fs::write( format!("{dir_b}/two.mo"), "#[test]\ndef test_two : Bool := true\n", ) .unwrap(); // `workspace: true` reuses `mote_path` as BOTH the scanned input dirs // and the module-resolution search paths (see `resolve_inputs`'s own // doc comment) -- so `std/test.mo` must be reachable from inside one // of the scanned dirs themselves. A trivial stub (never referenced by // either fixture file, which don't call `Test.assert`) satisfies the // load; it's also picked up as an extra scanned file with zero // `#[test]` defs, accounted for in `filesTested` below. std::fs::create_dir_all(format!("{dir_a}/std")).unwrap(); std::fs::write(format!("{dir_a}/std/test.mo"), "").unwrap();
let mote_path = [PathBuf::from(&*dir_a), PathBuf::from(&*dir_b)]; let response = dispatch( "tools/call", serde_json::json!({ "name": "test", "arguments": { "workspace": true } }), &mote_path, ); let text = response["result"]["content"][0]["text"].as_str().unwrap(); let report: Value = serde_json::from_str(text).unwrap(); assert_eq!(report["summary"]["passed"], 2); assert_eq!(report["summary"]["failed"], 0); assert_eq!(report["summary"]["filesTested"], 3); }}