use std::sync::Arc;
use std::thread;
use mlua::prelude::*;
use mlua_probe_core::{
testing, BreakpointId, DebugController, DebugEvent, DebugSession, PauseReason, SessionState,
StackFrame, Variable,
};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ServerHandler,
};
use serde::Deserialize;
use serde_json::json;
use tokio::sync::Mutex;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct LaunchParams {
pub code: String,
pub chunk_name: Option<String>,
pub stop_on_entry: Option<bool>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SetBreakpointParams {
pub source: String,
pub line: usize,
pub condition: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RemoveBreakpointParams {
pub id: u32,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FrameParams {
pub frame_id: usize,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct EvaluateParams {
pub expression: String,
pub frame_id: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TestLaunchParams {
pub code: String,
pub chunk_name: Option<String>,
}
fn format_stack_frames(frames: &[StackFrame]) -> serde_json::Value {
frames
.iter()
.map(|f| {
json!({
"id": f.id,
"name": f.name,
"source": f.source,
"line": f.line,
"kind": format!("{:?}", f.what),
})
})
.collect()
}
fn format_variables(vars: &[Variable]) -> serde_json::Value {
vars.iter()
.map(|v| {
json!({
"name": v.name,
"value": v.value,
"type": v.type_name,
})
})
.collect()
}
fn format_event(event: &DebugEvent) -> serde_json::Value {
match event {
DebugEvent::Paused { reason, stack } => {
let reason_json = match reason {
PauseReason::Breakpoint(id) => {
json!({"type": "breakpoint", "breakpoint_id": id.as_raw()})
}
PauseReason::Step => json!({"type": "step"}),
PauseReason::UserPause => json!({"type": "user_pause"}),
PauseReason::Error(msg) => json!({"type": "error", "message": msg}),
PauseReason::Entry => json!({"type": "entry"}),
};
json!({
"event": "paused",
"reason": reason_json,
"stack": format_stack_frames(stack),
})
}
DebugEvent::Continued => json!({"event": "continued"}),
DebugEvent::Terminated { result, error } => json!({
"event": "terminated",
"result": result,
"error": error,
}),
DebugEvent::Output {
category,
text,
source,
line,
} => json!({
"event": "output",
"category": format!("{category:?}"),
"text": text,
"source": source,
"line": line,
}),
}
}
fn format_state(state: &SessionState) -> &'static str {
match state {
SessionState::Idle => "idle",
SessionState::Running => "running",
SessionState::Paused(_) => "paused",
SessionState::Terminated => "terminated",
}
}
struct ActiveSession {
controller: DebugController,
session: DebugSession,
lua: Arc<Lua>,
vm_thread: thread::JoinHandle<()>,
}
#[derive(Clone)]
pub struct DebugMcpHandler {
tool_router: ToolRouter<Self>,
active: Arc<Mutex<Option<ActiveSession>>>,
}
macro_rules! get_ctrl {
($self:expr) => {{
let guard = $self.active.lock().await;
match guard.as_ref() {
Some(s) => s.controller.clone(),
None => {
return Err("No active debug session. Call debug_launch first.".to_string());
}
}
}};
}
#[tool_router]
impl DebugMcpHandler {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
active: Arc::new(Mutex::new(None)),
}
}
#[tool(name = "debug_launch")]
async fn debug_launch(
&self,
Parameters(params): Parameters<LaunchParams>,
) -> Result<String, String> {
let mut guard = self.active.lock().await;
if guard.is_some() {
return Err("A debug session is already active. Call disconnect first.".to_string());
}
let chunk_name = params.chunk_name.unwrap_or_else(|| "@main.lua".to_string());
let stop_on_entry = params.stop_on_entry.unwrap_or(true);
let code = params.code;
let lua = Arc::new(Lua::new());
let (session, controller) = DebugSession::new();
session.set_stop_on_entry(stop_on_entry);
session
.register_source(&chunk_name, &code)
.map_err(|e| format!("Failed to register source: {e}"))?;
session
.attach(&lua)
.map_err(|e| format!("Failed to attach debugger: {e}"))?;
let notifier = session.completion_notifier();
let lua_clone = lua.clone();
let name_clone = chunk_name.clone();
let vm_thread = thread::spawn(move || {
let result = lua_clone.load(&code).set_name(&name_clone).exec();
notifier.notify(result.err().map(|e| e.to_string()));
});
*guard = Some(ActiveSession {
controller,
session,
lua,
vm_thread,
});
if stop_on_entry {
Ok(format!(
"Debug session started (chunk: {chunk_name}, stop_on_entry: true). \
Call wait_event to receive the initial pause."
))
} else {
Ok(format!(
"Debug session started (chunk: {chunk_name}). Code is running."
))
}
}
#[tool(name = "disconnect")]
async fn disconnect(&self) -> Result<String, String> {
let mut guard = self.active.lock().await;
let active = guard.take().ok_or("No active debug session.")?;
active.session.detach(&active.lua);
let vm_thread = active.vm_thread;
tokio::task::spawn(async move {
if let Ok(Err(_)) = tokio::task::spawn_blocking(move || vm_thread.join()).await {
tracing::warn!("VM thread panicked during session teardown");
}
});
Ok("Session disconnected.".to_string())
}
#[tool(name = "get_state")]
async fn get_state(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let state = ctrl.state();
Ok(json!({"state": format_state(&state)}).to_string())
}
#[tool(name = "set_breakpoint")]
async fn set_breakpoint(
&self,
Parameters(params): Parameters<SetBreakpointParams>,
) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let id = ctrl
.set_breakpoint(¶ms.source, params.line, params.condition.as_deref())
.map_err(|e| format!("Failed to set breakpoint: {e}"))?;
Ok(json!({
"breakpoint_id": id.as_raw(),
"source": params.source,
"line": params.line,
})
.to_string())
}
#[tool(name = "remove_breakpoint")]
async fn remove_breakpoint(
&self,
Parameters(params): Parameters<RemoveBreakpointParams>,
) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let id = BreakpointId::from_raw(params.id)
.ok_or_else(|| format!("Invalid breakpoint ID: {} (must be > 0)", params.id))?;
let removed = ctrl
.remove_breakpoint(id)
.map_err(|e| format!("Failed to remove breakpoint: {e}"))?;
if removed {
Ok(format!("Breakpoint {} removed.", params.id))
} else {
Err(format!("Breakpoint {} not found.", params.id))
}
}
#[tool(name = "list_breakpoints")]
async fn list_breakpoints(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let bps = ctrl
.list_breakpoints()
.map_err(|e| format!("Failed to list breakpoints: {e}"))?;
let list: Vec<_> = bps
.iter()
.map(|bp| {
json!({
"id": bp.id.as_raw(),
"source": &*bp.source,
"line": bp.line,
"condition": bp.condition,
"enabled": bp.enabled,
})
})
.collect();
Ok(json!({"breakpoints": list}).to_string())
}
#[tool(name = "continue_execution")]
async fn continue_execution(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
ctrl.continue_execution()
.map_err(|e| format!("Continue failed: {e}"))?;
Ok("Resumed. Call wait_event for the next pause.".to_string())
}
#[tool(name = "step_into")]
async fn step_into(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
ctrl.step_into()
.map_err(|e| format!("Step into failed: {e}"))?;
Ok("Stepping into. Call wait_event for the next pause.".to_string())
}
#[tool(name = "step_over")]
async fn step_over(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
ctrl.step_over()
.map_err(|e| format!("Step over failed: {e}"))?;
Ok("Stepping over. Call wait_event for the next pause.".to_string())
}
#[tool(name = "step_out")]
async fn step_out(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
ctrl.step_out()
.map_err(|e| format!("Step out failed: {e}"))?;
Ok("Stepping out. Call wait_event for the next pause.".to_string())
}
#[tool(name = "pause")]
async fn pause_execution(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
ctrl.pause().map_err(|e| format!("Pause failed: {e}"))?;
Ok("Pause requested. Call wait_event.".to_string())
}
#[tool(name = "wait_event")]
async fn wait_event(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let result = tokio::task::spawn_blocking(move || ctrl.wait_event())
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(event) => Ok(format_event(&event).to_string()),
Err(e) => Err(format!("Event wait failed (session may be closed): {e}")),
}
}
#[tool(name = "get_stack_trace")]
async fn get_stack_trace(&self) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let result = tokio::task::spawn_blocking(move || ctrl.get_stack_trace())
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(frames) => Ok(json!({"frames": format_stack_frames(&frames)}).to_string()),
Err(e) => Err(format!("Failed to get stack trace: {e}")),
}
}
#[tool(name = "get_locals")]
async fn get_locals(
&self,
Parameters(params): Parameters<FrameParams>,
) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let frame_id = params.frame_id;
let result = tokio::task::spawn_blocking(move || ctrl.get_locals(frame_id))
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(vars) => Ok(json!({"locals": format_variables(&vars)}).to_string()),
Err(e) => Err(format!("Failed to get locals: {e}")),
}
}
#[tool(name = "get_upvalues")]
async fn get_upvalues(
&self,
Parameters(params): Parameters<FrameParams>,
) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let frame_id = params.frame_id;
let result = tokio::task::spawn_blocking(move || ctrl.get_upvalues(frame_id))
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(vars) => Ok(json!({"upvalues": format_variables(&vars)}).to_string()),
Err(e) => Err(format!("Failed to get upvalues: {e}")),
}
}
#[tool(name = "evaluate")]
async fn evaluate(
&self,
Parameters(params): Parameters<EvaluateParams>,
) -> Result<String, String> {
let ctrl = get_ctrl!(self);
let expr = params.expression;
let frame = params.frame_id;
let result = tokio::task::spawn_blocking(move || ctrl.evaluate(&expr, frame))
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(val) => Ok(json!({"result": val}).to_string()),
Err(e) => Err(format!("Evaluation failed: {e}")),
}
}
#[tool(name = "test_launch")]
async fn test_launch(
&self,
Parameters(params): Parameters<TestLaunchParams>,
) -> Result<String, String> {
let code = params.code;
let chunk_name = params.chunk_name.unwrap_or_else(|| "@test.lua".to_string());
let result =
tokio::task::spawn_blocking(move || testing::framework::run_tests(&code, &chunk_name))
.await
.map_err(|e| format!("Internal error: {e}"))?;
match result {
Ok(summary) => {
let tests: Vec<serde_json::Value> = summary
.tests
.iter()
.map(|t| {
json!({
"suite": t.suite,
"name": t.name,
"passed": t.passed,
"error": t.error,
})
})
.collect();
Ok(json!({
"passed": summary.passed,
"failed": summary.failed,
"total": summary.total,
"tests": tests,
})
.to_string())
}
Err(e) => Err(e),
}
}
}
#[tool_handler]
impl ServerHandler for DebugMcpHandler {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"Lua debugger and test runner (mlua-probe) for environments without DAP support. \
Provides breakpoints, stepping, variable inspection, expression \
evaluation, and a built-in test framework for Lua code running in an mlua VM.\n\n\
Debugging: Start with debug_launch, then use wait_event to receive pause events.\n\n\
Testing: Use test_launch to run Lua tests with the mlua-lspec framework \
(describe/it/expect/spy). Returns structured JSON results.\n\n\
SECURITY: debug_launch, evaluate, and test_launch execute arbitrary Lua code \
with full standard library access (including os and io modules). \
Only use with trusted input."
.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
..Default::default()
}
}
}