refactor(lua): extract pmacs.mcp into its own module (F-016, tranche 2)

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
This commit is contained in:
Levi Neuwirth 2026-07-06 11:49:07 -04:00
parent 214fdbe06f
commit e8e56bdbf1
3 changed files with 640 additions and 602 deletions

View File

@ -118,15 +118,25 @@ pattern; coupled domains follow their dependencies.
of the `minibuffer` range — all shared, used across sections. So of the `minibuffer` range — all shared, used across sections. So
`parse`/`theme`, `window`, and `minibuffer` are **not** clean `parse`/`theme`, `window`, and `minibuffer` are **not** clean
line-range extractions until the hoist below runs. line-range extractions until the hoist below runs.
2. **Hoist the misplaced helpers (must precede the contaminated leaves).** 2. **Extract `mcp` — and the corrected mental model.** A helper-hoist is
Move every shared helper stranded in a domain section to its proper *not* a prerequisite for most domains. The contamination that stopped
home: JSON converters → shared core; ANSI converters → the `ansi` `parse`/`theme` is narrow: it bites only when a shared helper is
module; style/color converters + `caller_source` + command/menu **defined inside the range being extracted** (the style helpers live in
builders → shared core. This is a within-`mod.rs` repositioning (no the `theme` range and are used elsewhere, so extracting that range
behavior change) that makes the domain sections clean line-range units. removes them from `mod.rs`). A domain that merely *uses* a cross-section
Then extract `parse`/`theme`, `window`, `minibuffer`, `ansi`, helper reaches it via `super::` (parent-private access) — no hoist
`packages` (the last after lifting the misplaced core installers out of needed. So `mcp` extracts cleanly today: all its items are
its range too). 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`, 3. **The `lsp` hub + its JSON consumers.** `lsp`, then `async`, `mcp`,
`completion` (edge-free once the JSON helpers are hoisted). `completion` (edge-free once the JSON helpers are hoisted).
4. **The coupled tail.** `process` (→ansi), `project` (→lsp), 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; Validated: `cargo fmt` clean; `clippy --lib` clean under **both** flavors;
full lib suite **1437 passed / 0 failed** under **both** luajit and full lib suite **1437 passed / 0 failed** under **both** luajit and
lua54. 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.

602
src/lua_bindings/mcp.rs Normal file
View File

@ -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<Self> {
match value {
Value::UserData(ud) => Ok(*ud.borrow::<Self>()?),
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<M: UserDataMethods<Self>>(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<McpRestartPolicy> {
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<McpServerSpec> {
let label: String = t.get("label").unwrap_or_else(|_| "unnamed".to_owned());
let command: String = t.get("command")?;
let args: Vec<String> = t.get("args").unwrap_or_default();
let cwd: Option<String> = t.get("cwd").ok().flatten();
let env_t: Option<Table> = 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::<Option<String>>("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<Table> {
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<Table> {
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<McpError> {
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::<Option<Value>>("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<Value>)| {
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<Value>)| {
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<Value>,
)| {
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<McpServerId> = 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<Value>)| {
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, &params)?)?;
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<Value>)| {
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<SharedMcpManager> {
let manager = Rc::new(RefCell::new(McpManager::new(supervisor, runtime)));
install_mcp(lua, &manager)?;
Ok(manager)
}

View File

@ -80,7 +80,12 @@ use crate::workers_buffer;
// stable. // stable.
mod diag; mod diag;
mod index; mod index;
mod mcp;
pub use index::{SharedProjectIndexer, make_project_indexer}; 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 // Shared registry alias
@ -8109,599 +8114,6 @@ pub fn make_lsp_manager(
Ok(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<Self> {
match value {
Value::UserData(ud) => Ok(*ud.borrow::<Self>()?),
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<M: UserDataMethods<Self>>(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<McpRestartPolicy> {
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<McpServerSpec> {
let label: String = t.get("label").unwrap_or_else(|_| "unnamed".to_owned());
let command: String = t.get("command")?;
let args: Vec<String> = t.get("args").unwrap_or_default();
let cwd: Option<String> = t.get("cwd").ok().flatten();
let env_t: Option<Table> = 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::<Option<String>>("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<Table> {
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<Table> {
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<McpError> {
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::<Option<Value>>("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<Value>)| {
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<Value>)| {
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<Value>,
)| {
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<McpServerId> = 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<Value>)| {
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, &params)?)?;
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<Value>)| {
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<SharedMcpManager> {
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 // pmacs.completion / pmacs.hover / pmacs.signature: T M4.7 surfaces
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------