///|
/// MCP stdio 服务器(jumbit 扩展,上游无)。传输形态对齐 MCP 规范
/// 2025-06-18 的 stdio transport:newline-delimited JSON-RPC 2.0,UTF-8,
/// stdout 只承载 MCP 消息(规范硬性要求,任何日志只能走 stderr)。
/// 零新语义薄适配:工具执行层与 CLI 同源(query_exec 的 --json 形态 /
/// export_exec),进程退出码 0/1 直接映射 isError;协议层新增决策仅限
/// JSON-RPC 信封、错误码与 content 包装(协议必需品)。参考实现
/// atuinsh/atuin crates/atuin-ai/src/mcp.rs(rmcp SDK)的工具面划分。
async fn run_mcp(getenv : (String) -> String?) -> Int {
while true {
match @stdio.stdin.read_until("\n") {
// EOF:客户端关闭 stdin 即规范 shutdown(关流→等进程退出),静默退出 0
None => break
Some(raw) => {
let line = strip_eol(raw)
if line == "" {
continue
}
match mcp_handle_line(line, getenv) {
Some(out) => @stdio.stdout.write(out + "\n") catch { _ => () }
None => ()
}
}
}
}
0
}
///|
/// 剥行尾换行(\n 与 \r\n 两形态;行内空白按 JSON 语义本就非法,不碰)
fn strip_eol(s : String) -> String {
let s = if s.has_suffix("\n") { s[0:s.length() - 1].to_owned() } else { s }
if s.has_suffix("\r") {
s[0:s.length() - 1].to_owned()
} else {
s
}
}
///|
/// 单条消息处理:返回响应行(None=不响应)。纯信封层,工具体走执行层。
async fn mcp_handle_line(
line : String,
getenv : (String) -> String?,
) -> String? {
let msg : Json = @json.parse(line) catch {
_ => return Some(mcp_error(Json::null(), -32700, "Parse error"))
}
let entries : Map[String, Json] = match msg {
Json::Object(map) => map
// MCP 2025-06-18 已移除 JSON-RPC batch;数组与其余非对象形态一律 Invalid Request
_ => return Some(mcp_error(Json::null(), -32600, "Invalid Request"))
}
let id : Json? = entries.get("id")
let method_name : String? = match entries.get("method") {
Some(Json::String(m)) => Some(m)
_ => None
}
// jsonrpc 版本字段:请求缺字段/非 "2.0" → Invalid Request(JSON-RPC 2.0 语义);
// 通知形态一律静默忽略(含 notifications/initialized 与无法识别的通知)
let version_ok = match entries.get("jsonrpc") {
Some(Json::String("2.0")) => true
_ => false
}
match (method_name, id) {
(Some(m), Some(id)) => {
if !version_ok {
return Some(mcp_error(id, -32600, "Invalid Request"))
}
mcp_handle_request(m, id, entries, getenv)
}
_ => None
}
}
///|
/// 已确认「请求」(有 method 有 id、jsonrpc 合法)的方法分发
async fn mcp_handle_request(
method_name : String,
id : Json,
entries : Map[String, Json],
getenv : (String) -> String?,
) -> String? {
match method_name {
"initialize" => Some(mcp_initialize(id, entries))
"ping" => Some(mcp_ok(id, "{}"))
"tools/list" =>
Some(mcp_ok(id, "{\"tools\":[\{mcp_query_tool()},\{mcp_export_tool()}]}"))
"tools/call" => mcp_tools_call(id, entries, getenv)
other => Some(mcp_error(id, -32601, "Method not found: \{other}"))
}
}
///|
/// 版本协商(规范 lifecycle):支持客户端请求版本则 MUST 回显同版本;
/// 否则回自身支持的最新版本。客户端不支持时会自行断开。
fn mcp_latest_version() -> String {
"2025-06-18"
}
///|
fn mcp_version_supported(v : String) -> Bool {
v == "2024-11-05" || v == "2025-03-26" || v == "2025-06-18"
}
///|
fn mcp_initialize(id : Json, entries : Map[String, Json]) -> String {
let requested : String? = match entries.get("params") {
Some(Json::Object(p)) =>
match p.get("protocolVersion") {
Some(Json::String(v)) => Some(v)
_ => None
}
_ => None
}
let negotiated = match requested {
Some(v) => if mcp_version_supported(v) { v } else { mcp_latest_version() }
None => mcp_latest_version()
}
// serverInfo 只带 name/version(对齐 atuin Implementation::new 两参形态);
// capabilities 只声明 tools 且无 listChanged(工具集静态,不发变更通知)
"{\"jsonrpc\":\"2.0\",\"id\":\{id.stringify()},\"result\":{\"protocolVersion\":\"\{negotiated}\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"jumbit\",\"version\":\"\{@jumbit.VERSION}\"},\"instructions\":\"\{json_escape(mcp_instructions())}\"}}"
}
///|
/// server instructions:客户端注入模型 system prompt 的「何时用 jumbit」
/// 叙事(atuin 同杠杆)。保持短小(atuin 以 2000 字符为上限自检)。
fn mcp_instructions() -> String {
"jumbit is the user's directory memory: a frecency-ranked history of directories they have actually worked in (a MoonBit rewrite of zoxide, own database). " +
"Use jumbit_query when the user refers to a project by a loose or partial name ('the backend repo', 'that lab dir') instead of guessing paths or running find. " +
"Results carry matched_by evidence: trust exact hits, verify fuzzy ones before acting. " +
"Use jumbit_export_agents to read the human-annotated project map before exploring the filesystem; the user's other projects live in this history, and 'not in this repository' is not an answer."
}
///|
fn mcp_query_tool() -> String {
"{\"name\":\"jumbit_query\"," +
"\"title\":\"Search directory history\"," +
"\"description\":\"\{json_escape(mcp_query_description())}\"," +
"\"inputSchema\":{\"type\":\"object\",\"properties\":{" +
"\"keywords\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"Keywords matched against recorded paths (AND-ed); the last keyword anchors the final path component.\"}," +
"\"fuzzy\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Fall back to substring matching of any path component when exact matching finds nothing.\"}," +
"\"limit\":{\"type\":\"integer\",\"minimum\":0,\"description\":\"Maximum number of results. Omit for all.\"}" +
"},\"required\":[\"keywords\"]}}"
}
///|
fn mcp_export_tool() -> String {
"{\"name\":\"jumbit_export_agents\"," +
"\"title\":\"Export annotated project map\"," +
"\"description\":\"\{json_escape(mcp_export_description())}\"," +
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}}"
}
///|
fn mcp_query_description() -> String {
"Search the user's frecency-ranked directory history and return matching paths with score, last_accessed and matched_by evidence, as a single-line JSON array (same shape as jumbit query --json). " +
"Keywords match like zoxide: case-insensitive; the last keyword must land in the final path component, earlier ones are consumed right-to-left. " +
"With fuzzy=true, keywords fall back to substring matches of any path component when exact matching finds nothing. " +
"A miss is a normal outcome, reported with isError=true and the reason as text."
}
///|
fn mcp_export_description() -> String {
"Export the user's annotated project map: a Markdown table (path | description) of directories that carry human-written notes, ordered by frecency descending — the same output as jumbit export --agents. " +
"Use it to learn what the user's main projects are and what each one is for."
}
///|
/// tools/call:工具名分发 + 参数校验。未知工具/参数形态非法走协议层
/// -32602(对齐 atuin 的 invalid_params 划分);工具执行失败(miss、
/// 配置/打开库错误)走结果内 isError=true,文案与 CLI stderr 同源。
async fn mcp_tools_call(
id : Json,
entries : Map[String, Json],
getenv : (String) -> String?,
) -> String? {
let params : Map[String, Json] = match entries.get("params") {
Some(Json::Object(p)) => p
_ =>
return Some(
mcp_error(id, -32602, "Invalid arguments: missing params object"),
)
}
let name = match params.get("name") {
Some(Json::String(n)) => n
_ =>
return Some(mcp_error(id, -32602, "Invalid arguments: missing tool name"))
}
let args : Map[String, Json] = match params.get("arguments") {
Some(Json::Object(a)) => a
// arguments 可省略(无参工具的合法调用形态)
None => {}
_ =>
return Some(
mcp_error(id, -32602, "Invalid arguments: arguments must be an object"),
)
}
match name {
"jumbit_query" => {
let qopts = match mcp_query_options(args) {
Ok(opts) => opts
Err(msg) => return Some(mcp_error(id, -32602, msg))
}
let errs : Array[String] = []
let outs : Array[String] = []
let code = query_exec(qopts, getenv, errs, fn(s) { outs.push(s) })
let text = if code == 0 { join_lines(outs) } else { join_lines(errs) }
Some(mcp_tool_result(id, text, code != 0))
}
"jumbit_export_agents" => {
let errs : Array[String] = []
let outs : Array[String] = []
let code = export_exec({ agents: true, }, getenv, errs, fn(s) {
outs.push(s)
})
let text = if code == 0 { join_lines(outs) } else { join_lines(errs) }
Some(mcp_tool_result(id, text, code != 0))
}
other => Some(mcp_error(id, -32602, "Unknown tool: \{other}"))
}
}
///|
/// jumbit_query 参数 → QueryOptions(--json 形态;all/interactive/tsv 不外露)。
/// limit 值域对齐 CLI parse_limit_value:非负整数、拒小数、拒超 Int 上限。
fn mcp_query_options(
args : Map[String, Json],
) -> Result[@cli.QueryOptions, String] {
// keywords 必填(对齐 inputSchema required 与 USAGE tool 注册草案):
// 缺失走协议层 -32602,不静默按「无关键词查最高分」执行
let keywords : Array[String] = match args.get("keywords") {
None => return Err("Invalid arguments: missing keywords")
Some(Json::Array(items)) => {
let kws : Array[String] = []
for item in items {
match item {
Json::String(s) => kws.push(s)
_ =>
return Err(
"Invalid arguments: keywords must be an array of strings",
)
}
}
kws
}
Some(_) =>
return Err("Invalid arguments: keywords must be an array of strings")
}
let fuzzy = match args.get("fuzzy") {
None => false
Some(Json::True) => true
Some(Json::False) => false
Some(_) => return Err("Invalid arguments: fuzzy must be a boolean")
}
let limit : Int? = match args.get("limit") {
None => None
Some(Json::Number(n, ..)) => {
if n < 0.0 || n > 2147483647.0 || n != n.floor() {
return Err("Invalid arguments: limit must be a non-negative integer")
}
Some(n.to_int())
}
Some(_) =>
return Err("Invalid arguments: limit must be a non-negative integer")
}
Ok({
keywords,
all: false,
list: false,
score: false,
interactive: false,
exclude: None,
json: true,
fuzzy,
limit,
tsv: false,
})
}
///|
fn join_lines(lines : Array[String]) -> String {
let out : StringBuilder = StringBuilder()
for i, line in lines {
if i > 0 {
out.write_string("\n")
}
out.write_string(line)
}
out.to_string()
}
///|
/// 信封写出器:键序手工固定(响应字节确定性;Json::Object 的 stringify
/// 走哈希表键序不稳定,不用于信封)。id 经 Json::stringify 原样回显
/// (Number 保留客户端字面量 repr,字符串 id 正确转义)。
fn mcp_ok(id : Json, result : String) -> String {
"{\"jsonrpc\":\"2.0\",\"id\":\{id.stringify()},\"result\":\{result}}"
}
///|
fn mcp_error(id : Json, code : Int, message : String) -> String {
"{\"jsonrpc\":\"2.0\",\"id\":\{id.stringify()},\"error\":{\"code\":\{code},\"message\":\"\{json_escape(message)}\"}}"
}
///|
fn mcp_tool_result(id : Json, text : String, is_error : Bool) -> String {
"{\"jsonrpc\":\"2.0\",\"id\":\{id.stringify()},\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"\{json_escape(text)}\"}],\"isError\":\{is_error}}}"
}