From e8e56bdbf1cd37605b026ab00a5a7f095ed68c6b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Mon, 6 Jul 2026 11:49:07 -0400 Subject: [PATCH] refactor(lua): extract pmacs.mcp into its own module (F-016, tranche 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third tranche of the F-016 split. Extract the pmacs.mcp surface (MCP client bindings) from src/lua_bindings/mod.rs into src/lua_bindings/mcp.rs, verbatim. Corrected model (see framing): a helper-hoist is NOT a prerequisite for most domains. The contamination that stopped parse/theme bites only when a shared helper is *defined inside* the range being extracted. A domain that merely *uses* a cross-section helper reaches it via `super::` (parent-private access). So mcp extracts cleanly: all its items are self-contained, and it reaches the JSON converters (still in the lsp section) via super::json_to_lua / lua_to_json, and SharedProcessSupervisor via super::. The JSON-helper hoist is deferred to the tranche that extracts lsp itself (where they're defined). mod.rs declares `mod mcp;` and re-exports make_mcp_manager (external caller editor.rs) and McpServerIdLua — the latter to preserve its public-API path crate::lua_bindings::McpServerIdLua (moving it into a private module had dropped it from the crate surface; the split must not shrink the public API). Pure code motion, no behavior change. mod.rs: 14603 → 14020 lines. Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib suite 1437 passed / 0 failed under both luajit and lua54. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U --- docs/lua-bindings-split-framing.md | 42 +- src/lua_bindings/mcp.rs | 602 +++++++++++++++++++++++++++++ src/lua_bindings/mod.rs | 598 +--------------------------- 3 files changed, 640 insertions(+), 602 deletions(-) create mode 100644 src/lua_bindings/mcp.rs diff --git a/docs/lua-bindings-split-framing.md b/docs/lua-bindings-split-framing.md index 61bee30..c387516 100644 --- a/docs/lua-bindings-split-framing.md +++ b/docs/lua-bindings-split-framing.md @@ -118,15 +118,25 @@ pattern; coupled domains follow their dependencies. of the `minibuffer` range — all shared, used across sections. So `parse`/`theme`, `window`, and `minibuffer` are **not** clean line-range extractions until the hoist below runs. -2. **Hoist the misplaced helpers (must precede the contaminated leaves).** - Move every shared helper stranded in a domain section to its proper - home: JSON converters → shared core; ANSI converters → the `ansi` - module; style/color converters + `caller_source` + command/menu - builders → shared core. This is a within-`mod.rs` repositioning (no - behavior change) that makes the domain sections clean line-range units. - Then extract `parse`/`theme`, `window`, `minibuffer`, `ansi`, - `packages` (the last after lifting the misplaced core installers out of - its range too). +2. **Extract `mcp` — and the corrected mental model.** A helper-hoist is + *not* a prerequisite for most domains. The contamination that stopped + `parse`/`theme` is narrow: it bites only when a shared helper is + **defined inside the range being extracted** (the style helpers live in + the `theme` range and are used elsewhere, so extracting that range + removes them from `mod.rs`). A domain that merely *uses* a cross-section + helper reaches it via `super::` (parent-private access) — no hoist + needed. So `mcp` extracts cleanly today: all its items are + self-contained, and it reaches the JSON converters (still in the `lsp` + section) via `super::json_to_lua`/`lua_to_json`, exactly like `index`. + The JSON-helper hoist is deferred to the tranche that extracts **`lsp` + itself** (where they are *defined*). +3. **Hoist-then-extract the genuinely contaminated sections.** Only the + sections that *define* shared helpers used elsewhere need a hoist first: + `theme` (style/color converters), `minibuffer`/`window` (`caller_ + source`, command/menu builders), `lsp` (JSON converters), `packages` + (the core module installers). Hoist those helpers to shared-core + position (a within-`mod.rs` reposition, no behavior change), then + extract the now-clean ranges. 3. **The `lsp` hub + its JSON consumers.** `lsp`, then `async`, `mcp`, `completion` (edge-free once the JSON helpers are hoisted). 4. **The coupled tail.** `process` (→ansi), `project` (→lsp), @@ -208,3 +218,17 @@ source`, and command/menu helpers, so they need the helper-hoist tranche Validated: `cargo fmt` clean; `clippy --lib` clean under **both** flavors; full lib suite **1437 passed / 0 failed** under **both** luajit and lua54. + +**Tranche 2 (this PR).** Extracted `pmacs.mcp` (the MCP client surface) +into `src/lua_bindings/mcp.rs` (602 lines), verbatim. All mcp items are +self-contained; it reaches the JSON converters via `super::json_to_lua`/ +`lua_to_json` and `SharedProcessSupervisor` via `super::` — no hoist +needed (see the corrected model in the tranche plan). `mod.rs` re-exports +`make_mcp_manager` (external caller `editor.rs`) and `McpServerIdLua` — the +latter to **preserve the public-API path** `crate::lua_bindings:: +McpServerIdLua` (moving it into a private module had dropped it from the +crate surface, surfacing as dead-code on `id()`; the split must not shrink +the public API). `mod.rs`: 14,603 → 14,020 lines. + +Validated: `cargo fmt` clean; `clippy --lib` clean under **both** flavors; +full lib suite **1437 passed / 0 failed** under **both** luajit and lua54. diff --git a/src/lua_bindings/mcp.rs b/src/lua_bindings/mcp.rs new file mode 100644 index 0000000..d85ef7a --- /dev/null +++ b/src/lua_bindings/mcp.rs @@ -0,0 +1,602 @@ +// 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) +} diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 842fe79..412a813 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -80,7 +80,12 @@ use crate::workers_buffer; // stable. mod diag; mod index; +mod mcp; pub use index::{SharedProjectIndexer, make_project_indexer}; +// `McpServerIdLua` is re-exported to preserve its prior public path +// (`crate::lua_bindings::McpServerIdLua`) — the split must not shrink the +// public API surface. +pub use mcp::{McpServerIdLua, make_mcp_manager}; // --------------------------------------------------------------------------- // Shared registry alias @@ -8109,599 +8114,6 @@ pub fn make_lsp_manager( Ok(manager) } -// --------------------------------------------------------------------------- -// pmacs.mcp: MCP client surface (T M9.1) -// --------------------------------------------------------------------------- - -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) -} - // --------------------------------------------------------------------------- // pmacs.completion / pmacs.hover / pmacs.signature: T M4.7 surfaces // ---------------------------------------------------------------------------