// lua_bindings/mcp.rs --- pmacs.mcp: MCP client surface (T M9.1). //! `pmacs.mcp.*` — Model Context Protocol client bindings. Split out of //! `lua_bindings.rs` verbatim (audit F-016); behavior unchanged. Reaches //! the shared JSON converters (still physically in the `lsp` section of //! `mod.rs`) via `super::`. use std::cell::RefCell; use std::rc::Rc; use mlua::{FromLua, Lua, Table, UserData, UserDataMethods, Value}; use super::{SharedProcessSupervisor, json_to_lua, lua_to_json}; use crate::mcp::{ McpClientState, McpError, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId, McpServerSpec, SharedMcpManager, state_label_for as mcp_state_label_for, }; /// Lua-facing wrapper around [`McpServerId`]. Mirrors /// [`LspServerIdLua`]. #[derive(Copy, Clone)] pub struct McpServerIdLua(pub McpServerId); impl McpServerIdLua { /// The wrapped [`McpServerId`]. #[must_use] pub fn id(self) -> McpServerId { self.0 } } impl FromLua for McpServerIdLua { fn from_lua(value: Value, _: &Lua) -> mlua::Result { match value { Value::UserData(ud) => Ok(*ud.borrow::()?), other => Err(mlua::Error::FromLuaConversionError { from: other.type_name(), to: "McpServerIdLua".to_string(), message: Some("expected an MCP server handle".to_string()), }), } } } impl UserData for McpServerIdLua { fn add_methods>(methods: &mut M) { methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| { Ok(format!("{}", this.0)) }); methods.add_meta_method(mlua::MetaMethod::Eq, |_, this, other: McpServerIdLua| { Ok(this.0 == other.0) }); methods.add_method("raw", |_, this, ()| Ok(this.0.raw())); } } fn parse_mcp_restart(name: &str) -> mlua::Result { Ok(match name { "never" | "Never" => McpRestartPolicy::Never, "on_crash" | "OnCrash" | "on-crash" => McpRestartPolicy::OnCrash, "always" | "Always" => McpRestartPolicy::Always, other => { return Err(mlua::Error::external(format!( "unknown MCP restart policy: {other:?} (expected never|on_crash|always)" ))); } }) } fn lua_to_mcp_spec(t: &Table) -> mlua::Result { let label: String = t.get("label").unwrap_or_else(|_| "unnamed".to_owned()); let command: String = t.get("command")?; let args: Vec = t.get("args").unwrap_or_default(); let cwd: Option = t.get("cwd").ok().flatten(); let env_t: Option = t.get("env").ok().flatten(); let mut env: Vec<(String, String)> = Vec::new(); if let Some(env_t) = env_t { env_t.for_each(|k: String, v: String| { env.push((k, v)); Ok(()) })?; } let restart = match t.get::>("restart").ok().flatten() { Some(s) => parse_mcp_restart(&s)?, None => McpRestartPolicy::OnCrash, }; Ok(McpServerSpec { label, command, args, cwd: cwd.map(std::path::PathBuf::from), env, restart, }) } fn mcp_state_to_lua(lua: &Lua, state: &McpClientState) -> mlua::Result
{ let t = lua.create_table_with_capacity(0, 5)?; t.set("kind", mcp_state_label_for(state))?; match state { McpClientState::Starting | McpClientState::Stopped { .. } | McpClientState::ShuttingDown => {} McpClientState::Initializing { init_request_id, .. } => { t.set("init_request_id", *init_request_id)?; } McpClientState::Initialized { capabilities, server_info, protocol_version, .. } => { t.set("capabilities", json_to_lua(lua, capabilities)?)?; if let Some(info) = server_info { t.set("server_info", json_to_lua(lua, info)?)?; } if let Some(v) = protocol_version { t.set("protocol_version", v.as_str())?; } } McpClientState::Crashed { reason, .. } => { t.set("reason", reason.as_str())?; } } Ok(t) } fn mcp_event_to_lua(lua: &Lua, ev: &McpEvent) -> mlua::Result
{ let t = lua.create_table_with_capacity(0, 5)?; t.set("server", McpServerIdLua(ev.server))?; match &ev.kind { McpEventKind::Started { pid } => { t.set("kind", "started")?; t.set("pid", *pid)?; } McpEventKind::Initialized { capabilities } => { t.set("kind", "initialized")?; t.set("capabilities", json_to_lua(lua, capabilities)?)?; } McpEventKind::Notification { method, params } => { t.set("kind", "notification")?; t.set("method", method.as_str())?; t.set("params", json_to_lua(lua, params)?)?; } McpEventKind::Request { id, method, params } => { t.set("kind", "request")?; t.set("request_id", json_to_lua(lua, id)?)?; t.set("method", method.as_str())?; t.set("params", json_to_lua(lua, params)?)?; } McpEventKind::Response { id, result, error, method, } => { t.set("kind", "response")?; t.set("request_id", *id)?; t.set("method", method.as_str())?; t.set("result", json_to_lua(lua, result)?)?; if let Some(e) = error { let err_t = lua.create_table_with_capacity(0, 3)?; err_t.set("code", e.code)?; err_t.set("message", e.message.as_str())?; if let Some(d) = &e.data { err_t.set("data", json_to_lua(lua, d)?)?; } t.set("error", err_t)?; } } McpEventKind::ShuttingDown => { t.set("kind", "shutting_down")?; } McpEventKind::Stopped => { t.set("kind", "stopped")?; } McpEventKind::Crashed { reason } => { t.set("kind", "crashed")?; t.set("reason", reason.as_str())?; } McpEventKind::Restarting { attempt } => { t.set("kind", "restarting")?; t.set("attempt", *attempt)?; } McpEventKind::Stderr(bytes) => { t.set("kind", "stderr")?; t.set("bytes", lua.create_string(bytes)?)?; } McpEventKind::ProtocolError { message } => { t.set("kind", "protocol_error")?; t.set("message", message.as_str())?; } } Ok(t) } /// Lua → [`McpError`]. Either a plain string (becomes a generic /// `code = -32603` internal error) or a table with `{code, message, /// data?}`. fn lua_to_mcp_error(value: Value) -> mlua::Result { match value { Value::String(s) => Ok(McpError { code: -32603, message: s.to_str()?.to_owned(), data: None, }), Value::Table(t) => { let code: i64 = t.get("code").unwrap_or(-32603); let message: String = t.get("message").unwrap_or_else(|_| "error".to_owned()); let data = match t.get::>("data")? { Some(Value::Nil) | None => None, Some(other) => Some(lua_to_json(other)?), }; Ok(McpError { code, message, data, }) } other => Err(mlua::Error::external(format!( "mcp error must be a string or table, got {}", other.type_name() ))), } } /// Install `pmacs.mcp.*` (T M9.1). /// /// Function inventory (10 entries; each parallels the same-named /// `pmacs.lsp.*` entry, with the single addition of `capabilities` /// for M9.1's "discoverable through the worker" acceptance bullet): /// /// | `pmacs.mcp.*` | `pmacs.lsp.*` | Notes | /// |----------------------|----------------------|-----------------------------------------| /// | `spawn` | `spawn` | Same shape; MCP spec drops `language_id`/`root_uri`. | /// | `stop` | `stop` | Identical. | /// | `send_request` | `send_request` | Identical (JSON-RPC 2.0 layer is same). | /// | `send_notification` | `send_notification` | Identical. | /// | `send_response` | `send_response` | Identical. | /// | `events_take` | `events_take` | Identical. | /// | `list` | `list` | Identical. | /// | `forget` | `forget` | Identical. | /// | `_tick` | `_tick` | Identical. | /// | `capabilities` | (none — folded into `list`/`status_summary`) | M9.1 acceptance: `pmacs.mcp.capabilities(id)` returns the server's declared capabilities table or nil. | #[allow( clippy::too_many_lines, reason = "linear list of raw bindings; splitting fragments a coherent surface" )] pub fn install_mcp(lua: &Lua, manager: &SharedMcpManager) -> mlua::Result<()> { lua.set_app_data(manager.clone()); let pmacs: Table = lua.globals().get("pmacs")?; let mcp_mod = lua.create_table()?; { let m = manager.clone(); mcp_mod.set( "spawn", lua.create_function(move |_, spec: Table| { let parsed = lua_to_mcp_spec(&spec)?; let id = m .borrow_mut() .spawn(parsed) .map_err(mlua::Error::external)?; Ok(McpServerIdLua(id)) })?, )?; } { let m = manager.clone(); mcp_mod.set( "stop", lua.create_function(move |_, id: McpServerIdLua| { m.borrow_mut().stop(id.0).map_err(mlua::Error::external)?; Ok(()) })?, )?; } { // Raw MCP request dispatcher. Returns the async-runtime // [`JobId`] the response will settle. Package code does not // call this directly — `builtin/runtime/mcp.lua` wraps it // into a Handle-returning `pmacs.mcp.send_request` that // matches the dispatch shape of `pmacs.workers.compute_sum`, // `pmacs.fs.read_dir`, etc. The underscore-prefixed name // marks this as the unwrapped primitive (mirrors // `pmacs._async._dispatch_*` in async.lua). let m = manager.clone(); mcp_mod.set( "_send_request_raw", lua.create_function( move |_, (id, method, params): (McpServerIdLua, String, Option)| { let json_params = match params { Some(Value::Nil) | None => serde_json::Value::Null, Some(other) => lua_to_json(other)?, }; let job_id = m .borrow_mut() .send_request(id.0, method, json_params) .map_err(mlua::Error::external)?; Ok(job_id) }, )?, )?; } { let m = manager.clone(); mcp_mod.set( "send_notification", lua.create_function( move |_, (id, method, params): (McpServerIdLua, String, Option)| { let json_params = match params { Some(Value::Nil) | None => serde_json::Value::Null, Some(other) => lua_to_json(other)?, }; m.borrow_mut() .send_notification(id.0, method, json_params) .map_err(mlua::Error::external)?; Ok(()) }, )?, )?; } { let m = manager.clone(); mcp_mod.set( "send_response", lua.create_function( move |_, (id, request_id, result, err): ( McpServerIdLua, Value, Value, Option, )| { let request_id_json = lua_to_json(request_id)?; let outcome = match err { Some(Value::Nil) | None => Ok(lua_to_json(result)?), Some(e) => Err(lua_to_mcp_error(e)?), }; m.borrow_mut() .send_response(id.0, request_id_json, outcome) .map_err(mlua::Error::external)?; Ok(()) }, )?, )?; } { let m = manager.clone(); mcp_mod.set( "events_take", lua.create_function(move |lua, id: McpServerIdLua| { let evs = m.borrow_mut().take_events(id.0); let out = lua.create_table_with_capacity(evs.len(), 0)?; for (i, ev) in evs.iter().enumerate() { out.set(i + 1, mcp_event_to_lua(lua, ev)?)?; } Ok(out) })?, )?; } { let m = manager.clone(); mcp_mod.set( "list", lua.create_function(move |lua, ()| { let mgr = m.borrow(); let ids: Vec = mgr.ids().collect(); let out = lua.create_table_with_capacity(ids.len(), 0)?; for (i, id) in ids.iter().enumerate() { let row = lua.create_table_with_capacity(0, 4)?; row.set("id", McpServerIdLua(*id))?; if let Some(spec) = mgr.spec(*id) { row.set("label", spec.label.as_str())?; row.set("command", spec.command.as_str())?; } if let Some(state) = mgr.state(*id) { row.set("state", mcp_state_to_lua(lua, state)?)?; } if let Some(attempt) = mgr.attempt(*id) { row.set("attempt", attempt)?; } out.set(i + 1, row)?; } Ok(out) })?, )?; } { let m = manager.clone(); mcp_mod.set( "forget", lua.create_function(move |_, id: McpServerIdLua| { m.borrow_mut().forget(id.0).map_err(mlua::Error::external)?; Ok(()) })?, )?; } { let m = manager.clone(); mcp_mod.set( "_tick", lua.create_function(move |_, ()| { m.borrow_mut().tick(); Ok(()) })?, )?; } { // M9.1 acceptance: declared capabilities are discoverable // through the worker. Returns the server's `capabilities` // table (as JSON-translated Lua) or `nil` if the server is // not yet initialized. let m = manager.clone(); mcp_mod.set( "capabilities", lua.create_function(move |lua, id: McpServerIdLua| { let mgr_ref = m.borrow(); match mgr_ref.capabilities(id.0) { Some(caps) => json_to_lua(lua, caps), None => Ok(Value::Nil), } })?, )?; } { // T M9.2: raw resource-read dispatcher. Returns the // async-runtime [`JobId`] the response will settle. // `builtin/runtime/mcp.lua` wraps it into a Handle-returning // `pmacs.mcp.read_resource(server, uri)`. let m = manager.clone(); mcp_mod.set( "_read_resource_raw", lua.create_function(move |_, (id, uri): (McpServerIdLua, String)| { let job_id = m .borrow_mut() .read_resource(id.0, uri) .map_err(mlua::Error::external)?; Ok(job_id) })?, )?; } { // T M9.2: explicit cache invalidation. Drops the per-(server, // uri) cache entry; subsequent `read_resource` calls // re-dispatch. In-flight requests at the moment of // invalidation still settle their awaiters with the arriving // result, but do not re-cache. let m = manager.clone(); mcp_mod.set( "invalidate_resource", lua.create_function(move |_, (id, uri): (McpServerIdLua, String)| { m.borrow_mut().invalidate_resource(id.0, uri); Ok(()) })?, )?; } { // T M9.3: raw tool dispatcher. Returns the async-runtime // [`JobId`] the response will settle. `builtin/runtime/mcp.lua` // wraps it into a Handle-returning // `pmacs.mcp.invoke_tool(server, name, args)`. let m = manager.clone(); mcp_mod.set( "_invoke_tool_raw", lua.create_function( move |_, (id, name, args): (McpServerIdLua, String, Option)| { let json_args = match args { Some(Value::Nil) | None => { serde_json::Value::Object(serde_json::Map::default()) } Some(other) => lua_to_json(other)?, }; let job_id = m .borrow_mut() .invoke_tool(id.0, name, json_args) .map_err(mlua::Error::external)?; Ok(job_id) }, )?, )?; } { // T M9.5: register interest in a JSON-RPC notification method. // The notification dispatcher in `builtin/runtime/mcp.lua` // calls this when the first handler for a method is added, // and `_unsubscribe_notification` when the last one drops. let m = manager.clone(); mcp_mod.set( "_subscribe_notification", lua.create_function(move |_, method: String| { m.borrow_mut().subscribe_notification(method); Ok(()) })?, )?; } { let m = manager.clone(); mcp_mod.set( "_unsubscribe_notification", lua.create_function(move |_, method: String| { m.borrow_mut().unsubscribe_notification(&method); Ok(()) })?, )?; } { // T M9.5: drain all queued subscribed notifications. Returns // a Lua table { [method] = { { server = ..., params = ... }, ... } }. // The Lua tick hook walks this and invokes registered // handlers. let m = manager.clone(); mcp_mod.set( "_drain_notifications", lua.create_function(move |lua, ()| { let drained = m.borrow_mut().drain_notifications(); let out = lua.create_table_with_capacity(0, drained.len())?; for (method, entries) in drained { let arr = lua.create_table_with_capacity(entries.len(), 0)?; for (i, (sid, params)) in entries.into_iter().enumerate() { let entry = lua.create_table_with_capacity(0, 2)?; entry.set("server", McpServerIdLua(sid))?; entry.set("params", json_to_lua(lua, ¶ms)?)?; arr.set(i + 1, entry)?; } out.set(method.as_str(), arr)?; } Ok(out) })?, )?; } { // T M9.4: raw prompt dispatcher. Returns the async-runtime // [`JobId`] the response will settle. `builtin/runtime/mcp.lua` // wraps it into a Handle-returning // `pmacs.mcp.get_prompt(server, name, args)`. // // `args` of `nil` (or omitted) translates to an empty object // on the wire — the MCP spec requires the `arguments` field // even for prompts that take no arguments. Three Lua call // patterns produce identical wire requests: // pmacs.mcp.get_prompt(s, "p") (no third arg) // pmacs.mcp.get_prompt(s, "p", nil) (explicit nil) // pmacs.mcp.get_prompt(s, "p", {}) (empty table) let m = manager.clone(); mcp_mod.set( "_get_prompt_raw", lua.create_function( move |_, (id, name, args): (McpServerIdLua, String, Option)| { let json_args = match args { Some(Value::Nil) | None => { serde_json::Value::Object(serde_json::Map::default()) } Some(other) => lua_to_json(other)?, }; let job_id = m .borrow_mut() .get_prompt(id.0, name, json_args) .map_err(mlua::Error::external)?; Ok(job_id) }, )?, )?; } pmacs.set("mcp", mcp_mod)?; Ok(()) } /// Build a fresh [`McpManager`] over `supervisor` and install /// `pmacs.mcp.*` over it. Mirrors [`make_lsp_manager`] in shape; /// also takes the editor's [`SharedAsyncRuntime`] so MCP responses /// settle Lua-visible job ids without occupying a worker thread /// (T M9.1, Pass-2 finding 1). pub fn make_mcp_manager( lua: &Lua, supervisor: SharedProcessSupervisor, runtime: crate::async_runtime::SharedAsyncRuntime, ) -> mlua::Result { let manager = Rc::new(RefCell::new(McpManager::new(supervisor, runtime))); install_mcp(lua, &manager)?; Ok(manager) }