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:
parent
214fdbe06f
commit
e8e56bdbf1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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)
|
||||
}
|
||||
|
|
@ -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<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, ¶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<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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue