use serde_json::{Value as JsonValue, json};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use crate::lua;
const SERVER_NAME: &str = "assay";
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
const DEFAULT_PROTOCOL_VERSION: &str = "2024-11-05";
const DEFAULT_CONTEXT_LIMIT: usize = 5;
static CALL_COUNTER: AtomicU64 = AtomicU64::new(0);
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
pub async fn serve() -> ExitCode {
let mut reader = BufReader::new(tokio::io::stdin());
let mut stdout = tokio::io::stdout();
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, Ok(_) => {}
Err(e) => {
eprintln!("mcp: reading stdin: {e}");
return ExitCode::from(1);
}
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let response = match serde_json::from_str::<JsonValue>(trimmed) {
Ok(request) => handle_message(request).await,
Err(_) => Some(error_response(JsonValue::Null, PARSE_ERROR, "parse error")),
};
if let Some(response) = response
&& let Err(e) = write_message(&mut stdout, &response).await
{
eprintln!("mcp: writing stdout: {e}");
return ExitCode::from(1);
}
}
ExitCode::SUCCESS
}
async fn handle_message(request: JsonValue) -> Option<JsonValue> {
let Some(id) = request.get("id").cloned() else {
return None; };
let method = match request.get("method").and_then(JsonValue::as_str) {
Some(m) => m,
None => return Some(error_response(id, INVALID_REQUEST, "missing method")),
};
let params = request.get("params").cloned().unwrap_or(JsonValue::Null);
let response = match method {
"initialize" => success_response(id, initialize_result(¶ms)),
"ping" => success_response(id, json!({})),
"tools/list" => success_response(id, tools_list_result()),
"tools/call" => handle_tools_call(id, params).await,
other => error_response(id, METHOD_NOT_FOUND, &format!("method not found: {other}")),
};
Some(response)
}
fn initialize_result(params: &JsonValue) -> JsonValue {
let protocol_version = params
.get("protocolVersion")
.and_then(JsonValue::as_str)
.filter(|v| !v.is_empty())
.unwrap_or(DEFAULT_PROTOCOL_VERSION);
json!({
"protocolVersion": protocol_version,
"capabilities": { "tools": {} },
"serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION },
})
}
fn tools_list_result() -> JsonValue {
json!({ "tools": [assay_run_tool(), assay_context_tool()] })
}
fn assay_run_tool() -> JsonValue {
json!({
"name": "assay_run",
"description": "Run a Lua script through the assay runtime and return the tool-mode JSON envelope (status ok | needs_approval | error). Every embedded assay module is available via require(\"assay.<module>\") alongside the builtins (http, json, fs, crypto, ...). Execution is always gated: 'readonly' blocks mutating builtins, 'approval' suspends each mutating operation and returns a resume token. Unrestricted execution is intentionally not exposed.",
"inputSchema": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "Lua source to execute. Return a table or value to surface it as the tool output.",
},
"mode": {
"type": "string",
"enum": ["readonly", "approval"],
"default": "readonly",
"description": "Execution gate. 'readonly' (default) blocks mutating builtins; 'approval' suspends each mutating operation for per-operation approval. 'unrestricted' is not accepted.",
},
"timeout_secs": {
"type": "number",
"description": "Maximum execution time in seconds (default 20).",
},
"args": {
"type": "array",
"items": { "type": "string" },
"description": "Positional arguments exposed to the script as the 1-indexed `arg` global.",
},
},
"required": ["script", "mode"],
},
})
}
fn assay_context_tool() -> JsonValue {
json!({
"name": "assay_context",
"description": "Search assay's embedded modules and return prompt-ready Markdown docs (method signatures, env vars, builtins) for the matches. Call this before writing an assay_run script to discover the correct module APIs.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Module search query, e.g. 'grafana', 'kubernetes', 'oauth'.",
},
"limit": {
"type": "number",
"description": "Maximum number of modules to return (default 5).",
},
},
"required": ["query"],
},
})
}
async fn handle_tools_call(id: JsonValue, params: JsonValue) -> JsonValue {
let arguments = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
match params.get("name").and_then(JsonValue::as_str) {
Some("assay_run") => match call_assay_run(&arguments).await {
Ok(result) => success_response(id, result),
Err(message) => success_response(id, tool_error_content(message)),
},
Some("assay_context") => match call_assay_context(&arguments) {
Ok(result) => success_response(id, result),
Err(message) => success_response(id, tool_error_content(message)),
},
Some(other) => error_response(id, INVALID_PARAMS, &format!("unknown tool: {other}")),
None => error_response(id, INVALID_PARAMS, "missing tool name"),
}
}
async fn call_assay_run(arguments: &JsonValue) -> Result<JsonValue, String> {
let script = arguments
.get("script")
.and_then(JsonValue::as_str)
.ok_or_else(|| "assay_run requires a 'script' string argument".to_string())?;
let exec_mode = resolve_mode(arguments.get("mode"))?;
let timeout_secs = arguments
.get("timeout_secs")
.and_then(JsonValue::as_u64)
.unwrap_or(crate::DEFAULT_TOOL_TIMEOUT_SECS);
let script_args = parse_string_array(arguments.get("args"));
let approval = if exec_mode.is_approval() {
lua::approval_config_from_env()
} else {
lua::ApprovalConfig::default()
};
let script_file = write_temp_script(script).map_err(|e| format!("writing temp script: {e}"))?;
let stripped = lua::async_bridge::strip_shebang(script);
let outcome = crate::execute_tool_mode(
&script_file.path,
stripped,
timeout_secs,
script_args,
exec_mode,
&approval,
)
.await;
if outcome.status != "needs_approval" {
script_file.cleanup();
}
let is_error = !matches!(outcome.status, "ok" | "needs_approval");
Ok(tool_text_content(outcome.envelope, is_error))
}
fn call_assay_context(arguments: &JsonValue) -> Result<JsonValue, String> {
let query = arguments
.get("query")
.and_then(JsonValue::as_str)
.ok_or_else(|| "assay_context requires a 'query' string argument".to_string())?;
let limit = arguments
.get("limit")
.and_then(JsonValue::as_u64)
.map(|n| n as usize)
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_CONTEXT_LIMIT);
let markdown = crate::render_context(query, limit)?;
Ok(tool_text_content(markdown, false))
}
fn resolve_mode(value: Option<&JsonValue>) -> Result<lua::ExecMode, String> {
match value {
None | Some(JsonValue::Null) => Ok(lua::ExecMode::ReadOnly),
Some(JsonValue::String(mode)) => match mode.as_str() {
"readonly" => Ok(lua::ExecMode::ReadOnly),
"approval" => Ok(lua::ExecMode::Approval),
other => Err(format!(
"mode must be \"readonly\" or \"approval\"; \"{other}\" is not accepted (unrestricted execution is never exposed)"
)),
},
Some(_) => Err("mode must be a string: \"readonly\" or \"approval\"".to_string()),
}
}
fn parse_string_array(value: Option<&JsonValue>) -> Vec<String> {
value
.and_then(JsonValue::as_array)
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
fn tool_text_content(text: String, is_error: bool) -> JsonValue {
json!({
"content": [{ "type": "text", "text": text }],
"isError": is_error,
})
}
fn tool_error_content(message: String) -> JsonValue {
tool_text_content(message, true)
}
fn success_response(id: JsonValue, result: JsonValue) -> JsonValue {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
fn error_response(id: JsonValue, code: i64, message: &str) -> JsonValue {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
async fn write_message(stdout: &mut tokio::io::Stdout, message: &JsonValue) -> std::io::Result<()> {
let mut bytes = serde_json::to_vec(message).unwrap_or_else(|_| b"{}".to_vec());
bytes.push(b'\n');
stdout.write_all(&bytes).await?;
stdout.flush().await
}
struct TempScript {
path: PathBuf,
dir: PathBuf,
}
impl TempScript {
fn cleanup(&self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn write_temp_script(script: &str) -> std::io::Result<TempScript> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let seq = CALL_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("assay-mcp-{nonce}-{seq}"));
std::fs::create_dir_all(&dir)?;
let path = dir.join("script.lua");
std::fs::write(&path, script)?;
Ok(TempScript { path, dir })
}