2362 lines
92 KiB
Rust
2362 lines
92 KiB
Rust
// mcp.rs --- T M9.1 Model Context Protocol client core.
|
||
|
||
//! Model Context Protocol client core. Spec §sec:m9-ai. JSON-RPC 2.0
|
||
//! over newline-delimited JSON on a child process's stdin/stdout
|
||
//! (the MCP-stdio transport), riding on the same
|
||
//! [`crate::process::ProcessSupervisor`] (M4.4) the LSP layer uses.
|
||
//!
|
||
//! # Topology
|
||
//!
|
||
//! ```text
|
||
//! main thread supervisor (M4.4) MCP server
|
||
//! ----------- ------------------- -------------
|
||
//! McpManager ---> spawn + write_stdin ---> child stdin
|
||
//! ^ child stdout
|
||
//! +-- line parse <--- Stdout(Vec<u8>) <------- reader thread
|
||
//! +-- line parse <--- Stderr(Vec<u8>) <------- reader thread
|
||
//! ```
|
||
//!
|
||
//! The supervisor's reader thread already chunks pipe output off the
|
||
//! main thread; the MCP layer just buffers those chunks per server
|
||
//! and splits newline-delimited JSON bodies out of them in
|
||
//! [`McpManager::tick`]. Parsing is incremental: a body split across
|
||
//! two `Stdout` events is reassembled at the buffer boundary without
|
||
//! re-allocating.
|
||
//!
|
||
//! # Protocol uniformity
|
||
//!
|
||
//! M9 makes good on the protocol-uniformity claim from
|
||
//! [spec §sec:concurrency]: MCP rides on the same dispatch path as
|
||
//! LSP (supervisor → bytes → parser → state machine → events). The
|
||
//! only LSP/MCP difference is the framer — `Content-Length` headers
|
||
//! for LSP, `\n`-delimited bodies for MCP. The dispatch machinery
|
||
//! itself is unchanged. See M9.1 audit for the explicit
|
||
//! "no new dispatch path" walkthrough.
|
||
//!
|
||
//! # Lifecycle
|
||
//!
|
||
//! ```text
|
||
//! Starting --(supervisor: Started)--> Initializing
|
||
//! --(initialize response)--> Initialized { capabilities }
|
||
//! --(stdin EOF / SIGTERM / SIGKILL + exit)--> Stopped
|
||
//! --(unexpected exit / signal)--> Crashed
|
||
//! --(restart policy)--> Initializing (new pid)
|
||
//! ```
|
||
//!
|
||
//! Mirrors LSP exactly: every transition emits an [`McpEvent`]
|
||
//! visible through [`McpManager::take_events`], crashes restart
|
||
//! automatically per [`McpRestartPolicy`], and a restart re-runs
|
||
//! `initialize` from scratch (MCP servers, like LSP servers, do not
|
||
//! survive their process).
|
||
//!
|
||
//! # Concurrency
|
||
//!
|
||
//! A single [`McpManager`] lives on the main thread inside the
|
||
//! editor's `Rc<RefCell<...>>`, sharing the supervisor with
|
||
//! `pmacs.lsp.*` and `pmacs.process.*`. All inbound bytes arrive on
|
||
//! supervisor reader threads (one per pipe); all outbound writes go
|
||
//! through [`crate::process::ProcessSupervisor::write_stdin`]
|
||
//! synchronously from the main thread. Per-server traffic is small
|
||
//! (handshake + occasional resource/tool requests), so the
|
||
//! synchronous write is acceptable for v0.1.
|
||
|
||
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
use std::rc::Rc;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::{Duration, Instant};
|
||
|
||
use nix::sys::signal::Signal;
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::async_runtime::{JobId, JobKind, SharedAsyncRuntime};
|
||
use crate::process::{
|
||
ProcessEvent, ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, RestartPolicy,
|
||
};
|
||
use crate::worker::CancellationToken;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Protocol version negotiation
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// MCP protocol version pmacs prefers when initializing. The MCP
|
||
/// lifecycle spec mandates that the client sends the latest version
|
||
/// it supports; pmacs targets the current revision. See
|
||
/// <https://modelcontextprotocol.io/specification/latest/basic/lifecycle>.
|
||
pub const PREFERRED_PROTOCOL_VERSION: &str = "2025-11-25";
|
||
|
||
/// Versions pmacs accepts in an `initialize` response. If the server
|
||
/// echoes back any of these, the handshake completes; any other value
|
||
/// is treated as a protocol violation and the server is terminated.
|
||
/// We accept the immediately-prior revision as a back-compat
|
||
/// concession (real-world servers lag the latest spec). A future
|
||
/// pmacs may add or drop entries here as the MCP version landscape
|
||
/// evolves.
|
||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[&str] = &["2025-11-25", "2024-11-05"];
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Identity
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Stable identifier for one managed MCP server. Allocated in
|
||
/// monotonic order from a process-wide counter; an MCP id and an LSP
|
||
/// id share no namespace so the two are always distinguishable.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
|
||
pub struct McpServerId(u64);
|
||
|
||
impl McpServerId {
|
||
/// Mint a fresh id.
|
||
#[must_use]
|
||
pub fn next() -> Self {
|
||
static COUNTER: AtomicU64 = AtomicU64::new(1);
|
||
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
|
||
}
|
||
|
||
/// Raw counter value. Used for Lua boundary marshalling and
|
||
/// debug formatting.
|
||
#[must_use]
|
||
pub fn raw(self) -> u64 {
|
||
self.0
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for McpServerId {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
write!(f, "McpServerId({})", self.0)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Configuration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Spec for one MCP server.
|
||
///
|
||
/// MCP's `initialize` request carries protocol version, client
|
||
/// capabilities, and client info. The pmacs implementation supplies
|
||
/// sensible defaults for all three; this spec carries only what the
|
||
/// process supervisor needs (command + env + cwd) plus the restart
|
||
/// policy. M9.2+ may extend this surface as concrete consumers
|
||
/// (resource fetching, tool invocation) reveal what fields users
|
||
/// actually need to override.
|
||
#[derive(Clone, Debug)]
|
||
pub struct McpServerSpec {
|
||
/// Human-readable label. Surfaced in events and the
|
||
/// `pmacs.mcp.list` output.
|
||
pub label: String,
|
||
/// Program to execute. PATH-resolved unless absolute.
|
||
pub command: String,
|
||
/// Argument vector.
|
||
pub args: Vec<String>,
|
||
/// Working directory for the spawn. `None` inherits from the
|
||
/// editor process.
|
||
pub cwd: Option<PathBuf>,
|
||
/// Environment overrides for the child process.
|
||
pub env: Vec<(String, String)>,
|
||
/// Restart policy for the server. Mirrors
|
||
/// [`crate::process::RestartPolicy`] and parallels the LSP
|
||
/// equivalent.
|
||
pub restart: McpRestartPolicy,
|
||
}
|
||
|
||
impl McpServerSpec {
|
||
/// Construct a spec with the bare-minimum fields.
|
||
#[must_use]
|
||
pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
|
||
Self {
|
||
label: label.into(),
|
||
command: command.into(),
|
||
args: Vec::new(),
|
||
cwd: None,
|
||
env: Vec::new(),
|
||
restart: McpRestartPolicy::OnCrash,
|
||
}
|
||
}
|
||
|
||
fn to_process_spec(&self) -> ProcessSpec {
|
||
let mut p = ProcessSpec::new(format!("mcp:{}", self.label), &self.command);
|
||
p.args.clone_from(&self.args);
|
||
p.cwd.clone_from(&self.cwd);
|
||
p.env.clone_from(&self.env);
|
||
p.mode = ProcessMode::Pipes;
|
||
// We handle restart at the MCP layer (because we need to
|
||
// re-run `initialize` after each restart); the supervisor
|
||
// never restarts behind our back.
|
||
p.restart = RestartPolicy::Never;
|
||
p
|
||
}
|
||
}
|
||
|
||
/// What to do when the MCP server process terminates unexpectedly.
|
||
/// Parallels [`crate::lsp::LspRestartPolicy`].
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
pub enum McpRestartPolicy {
|
||
/// Never restart.
|
||
Never,
|
||
/// Restart on signal or non-zero exit.
|
||
OnCrash,
|
||
/// Restart on any termination (clean or otherwise).
|
||
Always,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Lifecycle state
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// MCP client state machine. One instance per managed server.
|
||
/// Parallels [`crate::lsp::LspClientState`].
|
||
#[derive(Clone, Debug)]
|
||
pub enum McpClientState {
|
||
/// Process spawning; supervisor hasn't reported `Started` yet.
|
||
Starting,
|
||
/// Process is alive; `initialize` request is in flight.
|
||
Initializing {
|
||
/// JSON-RPC id of the in-flight `initialize` request.
|
||
init_request_id: u64,
|
||
/// When the server process started.
|
||
started: Instant,
|
||
},
|
||
/// `initialize` has been answered; `notifications/initialized`
|
||
/// has been sent. The server is ready for arbitrary requests.
|
||
Initialized {
|
||
/// `capabilities` returned by the server's `initialize`
|
||
/// result. The full JSON is preserved; M9.2+ projects the
|
||
/// bits each consumer needs.
|
||
capabilities: Value,
|
||
/// `serverInfo` from the initialize result, if reported.
|
||
server_info: Option<Value>,
|
||
/// `protocolVersion` as echoed back by the server. Stored
|
||
/// verbatim so a future M9.x can validate against pmacs's
|
||
/// supported set.
|
||
protocol_version: Option<String>,
|
||
/// When the server reached this state.
|
||
initialized_at: Instant,
|
||
},
|
||
/// User-initiated shutdown is in progress: the client has closed
|
||
/// the child's stdin and is waiting for the process to exit.
|
||
/// Pass-3 finding 1: MCP stdio has no protocol-level shutdown
|
||
/// message — closing stdin is the canonical EOF signal, with
|
||
/// SIGTERM after one grace window and SIGKILL after another. See
|
||
/// <https://modelcontextprotocol.io/specification/latest/basic/lifecycle>.
|
||
ShuttingDown,
|
||
/// Process exited cleanly (either after stdin EOF, after
|
||
/// user-stop escalation, or on its own).
|
||
Stopped {
|
||
/// When the process exited.
|
||
ended: Instant,
|
||
},
|
||
/// Process died unexpectedly.
|
||
Crashed {
|
||
/// Why it died.
|
||
reason: String,
|
||
/// When the supervisor reported the termination.
|
||
ended: Instant,
|
||
},
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One MCP-layer event. Visible to the editor via
|
||
/// [`McpManager::take_events`] and to Lua via `pmacs.mcp.events_take`.
|
||
/// Parallels [`crate::lsp::LspEvent`].
|
||
#[derive(Clone, Debug)]
|
||
pub struct McpEvent {
|
||
/// Server the event belongs to.
|
||
pub server: McpServerId,
|
||
/// What happened.
|
||
pub kind: McpEventKind,
|
||
/// When the event was generated (monotonic).
|
||
pub at: Instant,
|
||
}
|
||
|
||
/// Discriminator over MCP-layer event kinds. Parallels
|
||
/// [`crate::lsp::LspEventKind`].
|
||
#[derive(Clone, Debug)]
|
||
pub enum McpEventKind {
|
||
/// Process spawned and is running. The `initialize` request was
|
||
/// just sent.
|
||
Started {
|
||
/// OS pid of the new generation.
|
||
pid: u32,
|
||
},
|
||
/// `initialize` response was received and
|
||
/// `notifications/initialized` was sent.
|
||
Initialized {
|
||
/// Server `capabilities` from the response.
|
||
capabilities: Value,
|
||
},
|
||
/// Server-originated notification. MCP servers send these for
|
||
/// `notifications/resources/updated`, etc.
|
||
Notification {
|
||
/// JSON-RPC method name.
|
||
method: String,
|
||
/// Params payload (already JSON-decoded).
|
||
params: Value,
|
||
},
|
||
/// Server-originated request. MCP servers may issue
|
||
/// `roots/list` etc. against the client; pmacs surfaces these so
|
||
/// a consumer can `send_response`.
|
||
Request {
|
||
/// Inbound id; the client will respond with this id.
|
||
id: Value,
|
||
/// JSON-RPC method.
|
||
method: String,
|
||
/// Params payload.
|
||
params: Value,
|
||
},
|
||
/// Response to a client-initiated request was received.
|
||
Response {
|
||
/// Id of the original request (echoed back).
|
||
id: u64,
|
||
/// `result` field; `Null` if absent.
|
||
result: Value,
|
||
/// `error` field, if any (in which case `result` is `Null`).
|
||
error: Option<McpError>,
|
||
/// Method of the original request, copied here for
|
||
/// convenience so consumers don't have to track ids.
|
||
method: String,
|
||
},
|
||
/// User shutdown started by closing stdin; the next terminal
|
||
/// process event observed will be the actual exit.
|
||
ShuttingDown,
|
||
/// Server process exited cleanly. Final state for this generation.
|
||
Stopped,
|
||
/// Server process died (signal or non-zero exit outside
|
||
/// user-initiated shutdown).
|
||
Crashed {
|
||
/// Display-formatted reason.
|
||
reason: String,
|
||
},
|
||
/// The manager is about to restart this server.
|
||
Restarting {
|
||
/// Cumulative spawn attempt for this server (1 = first
|
||
/// spawn, 2 = first restart, ...).
|
||
attempt: u32,
|
||
},
|
||
/// Bytes from the server's stderr. Servers commonly write log
|
||
/// chatter here.
|
||
Stderr(Vec<u8>),
|
||
/// A protocol violation was observed and recovered from.
|
||
ProtocolError {
|
||
/// Display-formatted description of what went wrong.
|
||
message: String,
|
||
},
|
||
}
|
||
|
||
/// JSON-RPC error object (`{code, message, data?}`). Parallels
|
||
/// [`crate::lsp::LspError`]; the JSON-RPC layer is the same.
|
||
#[derive(Clone, Debug)]
|
||
pub struct McpError {
|
||
/// Numeric error code.
|
||
pub code: i64,
|
||
/// Human-readable message.
|
||
pub message: String,
|
||
/// Optional `data` field, free-form JSON.
|
||
pub data: Option<Value>,
|
||
}
|
||
|
||
impl McpError {
|
||
fn from_value(v: &Value) -> Self {
|
||
let code = v.get("code").and_then(Value::as_i64).unwrap_or(0);
|
||
let message = v
|
||
.get("message")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("")
|
||
.to_owned();
|
||
let data = v.get("data").cloned();
|
||
Self {
|
||
code,
|
||
message,
|
||
data,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Frame codec
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Incremental newline-delimited frame parser for JSON-RPC over MCP
|
||
/// stdio. Holds a single `Vec<u8>` buffer that grows with appended
|
||
/// bytes; complete bodies (terminated by `\n`) are extracted off the
|
||
/// front and returned to the caller.
|
||
///
|
||
/// The MCP-stdio spec uses `\n` (LF) as the body terminator. Embedded
|
||
/// `\n` inside a JSON string would corrupt framing — but JSON
|
||
/// serializers escape control characters by default, so a
|
||
/// well-formed implementation never produces them. The parser does
|
||
/// not attempt to recover from a misframed peer; an unrecoverable
|
||
/// frame error is the caller's signal to terminate the server.
|
||
#[derive(Default)]
|
||
pub struct NdjsonParser {
|
||
buf: Vec<u8>,
|
||
}
|
||
|
||
impl NdjsonParser {
|
||
/// New empty parser.
|
||
#[must_use]
|
||
pub fn new() -> Self {
|
||
Self { buf: Vec::new() }
|
||
}
|
||
|
||
/// Append `chunk` to the internal buffer.
|
||
pub fn extend(&mut self, chunk: &[u8]) {
|
||
self.buf.extend_from_slice(chunk);
|
||
}
|
||
|
||
/// Pop one complete body off the front of the buffer if a
|
||
/// `\n` terminator is present, returning the body bytes (without
|
||
/// the terminator). Returns `None` if no complete body is
|
||
/// buffered yet. Empty bodies are skipped silently.
|
||
pub fn next_frame(&mut self) -> Option<Vec<u8>> {
|
||
loop {
|
||
let nl = self.buf.iter().position(|&b| b == b'\n')?;
|
||
let mut body = self.buf[..nl].to_vec();
|
||
self.buf.drain(..=nl);
|
||
// Trim a trailing `\r` so peers using CRLF line endings
|
||
// (rare but legal in JSON) don't break parsing.
|
||
if body.last() == Some(&b'\r') {
|
||
body.pop();
|
||
}
|
||
if body.is_empty() {
|
||
continue;
|
||
}
|
||
return Some(body);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Encode a JSON-RPC body as a newline-terminated MCP-stdio frame.
|
||
/// The newline is required; peers split on `\n`.
|
||
#[must_use]
|
||
pub fn encode_frame(body: &[u8]) -> Vec<u8> {
|
||
let mut out = Vec::with_capacity(body.len() + 1);
|
||
out.extend_from_slice(body);
|
||
out.push(b'\n');
|
||
out
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// McpClient
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One Lua-visible awaiter bound to an in-flight request. Each
|
||
/// awaiter carries its own [`CancellationToken`] (from the runtime's
|
||
/// `register_external`) so per-awaiter cancellation is independent:
|
||
/// cancelling one handle settles that handle without disturbing the
|
||
/// other awaiters or aborting the in-flight wire request.
|
||
///
|
||
/// Pass-2 (M9.2) finding 2: prior to this fix, sibling awaiters
|
||
/// attached via coalescing only stored a [`JobId`], so:
|
||
/// (a) cancelling a sibling didn't wake it until the primary
|
||
/// completed, and (b) cancelling the primary cancelled every
|
||
/// sibling regardless of whether they still wanted the result.
|
||
/// Both violated the async-Handle contract.
|
||
#[derive(Clone, Debug)]
|
||
struct Awaiter {
|
||
job_id: JobId,
|
||
token: CancellationToken,
|
||
}
|
||
|
||
/// In-flight request bound to one or more Lua-visible async-runtime
|
||
/// jobs. The JSON-RPC response, when it lands, settles every
|
||
/// non-cancelled awaiter via
|
||
/// [`SharedAsyncRuntime::complete_external_ok`] (or `_failed` if the
|
||
/// server returned an error).
|
||
///
|
||
/// `cache_key` is `Some` for requests dispatched via the resource
|
||
/// cache (T M9.2 `read_resource`); on settlement, the cache entry
|
||
/// transitions `InFlight` → `Cached` if the cache still references
|
||
/// this request (`Ok` outcome only) or `InFlight` → Absent on
|
||
/// failure/cancellation/invalidation. `awaiters` is the live set of
|
||
/// callers waiting for this response; per-awaiter cancellation
|
||
/// removes entries one at a time without disturbing the in-flight
|
||
/// request, while the entry itself disappears once `awaiters` is
|
||
/// empty.
|
||
#[derive(Clone, Debug)]
|
||
struct PendingExternal {
|
||
method: String,
|
||
/// Cache slot to settle alongside this request. `None` for
|
||
/// non-cached requests (regular `send_request`).
|
||
cache_key: Option<(McpServerId, String)>,
|
||
/// Awaiters waiting for the response. The first entry is the
|
||
/// caller who originally dispatched the request; later entries
|
||
/// are siblings attached via M3.5 coalescing. All settle
|
||
/// equivalently when the response lands; any subset can cancel
|
||
/// independently. Invariant: never empty when this entry exists
|
||
/// in `pending_external` (the entry is removed when the last
|
||
/// awaiter cancels).
|
||
awaiters: Vec<Awaiter>,
|
||
}
|
||
|
||
/// Outcome handed to [`McpManager::settle_in_flight_with`] — the
|
||
/// canonical exit point for any in-flight external request. The rule
|
||
/// the helper enforces: **`InFlight` → `Cached` only on `Ok` for a
|
||
/// request whose cache slot still references this `request_id`;**
|
||
/// every other terminal state results in `InFlight` → Absent. T M9.2.
|
||
#[derive(Clone, Debug)]
|
||
enum SettleOutcome {
|
||
Ok(Value),
|
||
Failed(String),
|
||
Cancelled,
|
||
}
|
||
|
||
/// One drained pending entry ready to feed into
|
||
/// [`McpManager::settle_in_flight_with`]: the cache key, the
|
||
/// request id, and the awaiters (one or more) that were waiting
|
||
/// for the response. T M9.2.
|
||
type DrainedPending = (Option<(McpServerId, String)>, u64, Vec<Awaiter>);
|
||
|
||
/// State machine for one entry in the per-(server, uri) resource
|
||
/// cache. T M9.2.
|
||
///
|
||
/// - `InFlight { request_id }` — a `resources/read` request is on
|
||
/// the wire under that JSON-RPC id. The cache entry is the
|
||
/// coordination point for M3.5 coalescing: subsequent
|
||
/// `read_resource` calls for the same key attach to the existing
|
||
/// request rather than dispatching afresh.
|
||
/// - `Cached { result }` — the most recent successful result.
|
||
/// `read_resource` returns it via the runtime; `invalidate_resource`
|
||
/// drops the entry.
|
||
///
|
||
/// "Absent" is encoded as the entry being missing from the cache map.
|
||
#[derive(Clone, Debug)]
|
||
enum ResourceCacheState {
|
||
InFlight {
|
||
/// JSON-RPC id of the in-flight request. The settlement
|
||
/// helper compares this against the response's id so a
|
||
/// stale response that arrives after `invalidate_resource`
|
||
/// does not clobber a fresh in-flight or Cached entry.
|
||
request_id: u64,
|
||
},
|
||
Cached {
|
||
result: Value,
|
||
},
|
||
}
|
||
|
||
/// Discriminator for [`McpManager::handle_response`]'s lookup phase.
|
||
/// `Internal` matches the in-manager bookkeeping for `initialize`;
|
||
/// `External` matches a Lua-visible
|
||
/// `pmacs.mcp.send_request`; `Unknown` is the response-for-no-request
|
||
/// protocol error.
|
||
enum PendingKind {
|
||
Internal(String),
|
||
External(PendingExternal),
|
||
Unknown,
|
||
}
|
||
|
||
/// Next shutdown escalation to perform for a child that has not
|
||
/// exited after stdin EOF.
|
||
#[derive(Copy, Clone, Debug)]
|
||
enum ShutdownEscalation {
|
||
SigtermAt(Instant),
|
||
SigkillAt(Instant),
|
||
}
|
||
|
||
/// One managed MCP server: process + JSON-RPC framing + state
|
||
/// machine. Owned by [`McpManager`]; the supervisor handles process
|
||
/// I/O underneath.
|
||
pub struct McpClient {
|
||
spec: McpServerSpec,
|
||
state: McpClientState,
|
||
process: Option<ProcessId>,
|
||
stdout: NdjsonParser,
|
||
/// JSON-RPC request id counter. Monotonically increasing.
|
||
next_request_id: u64,
|
||
/// Internal-request bookkeeping. Holds the method name for
|
||
/// requests the manager itself originates (`initialize`);
|
||
/// responses surface as state-machine transitions rather than as
|
||
/// Lua-visible events. Keeping the internal table separate from
|
||
/// `pending_external` means there is no risk of settling an
|
||
/// async-runtime job for an internal request that has no job id.
|
||
pending_internal: HashMap<u64, String>,
|
||
/// External-request bookkeeping. Holds the `(method, job_id,
|
||
/// token)` triple for requests dispatched through
|
||
/// `pmacs.mcp.send_request`. The response settles the
|
||
/// corresponding async-runtime job.
|
||
pending_external: HashMap<u64, PendingExternal>,
|
||
/// Cumulative spawn attempts (1 = first spawn, 2 = first
|
||
/// restart, ...).
|
||
attempt: u32,
|
||
/// When the manager should attempt the next restart, if any.
|
||
next_restart_at: Option<Instant>,
|
||
/// Next live shutdown escalation, if the child has not exited
|
||
/// after stdin EOF.
|
||
/// Pass-3 finding 1.
|
||
shutdown_escalation: Option<ShutdownEscalation>,
|
||
/// Request ids that we've sent `notifications/cancelled` for and
|
||
/// no longer have awaiters listening on. T M9.3: when a late
|
||
/// response for one of these arrives (cancel/response race), we
|
||
/// silently drop it rather than emitting a "response for unknown
|
||
/// request id" `ProtocolError`. Bounded by user-initiated
|
||
/// cancellations; cleared on terminal/restart.
|
||
cancelled_rids: std::collections::HashSet<u64>,
|
||
}
|
||
|
||
impl McpClient {
|
||
fn new(spec: McpServerSpec) -> Self {
|
||
Self {
|
||
spec,
|
||
state: McpClientState::Starting,
|
||
process: None,
|
||
stdout: NdjsonParser::new(),
|
||
next_request_id: 1,
|
||
pending_internal: HashMap::new(),
|
||
pending_external: HashMap::new(),
|
||
attempt: 0,
|
||
next_restart_at: None,
|
||
shutdown_escalation: None,
|
||
cancelled_rids: std::collections::HashSet::new(),
|
||
}
|
||
}
|
||
|
||
/// Current spec.
|
||
#[must_use]
|
||
pub fn spec(&self) -> &McpServerSpec {
|
||
&self.spec
|
||
}
|
||
|
||
/// Current lifecycle state.
|
||
#[must_use]
|
||
pub fn state(&self) -> &McpClientState {
|
||
&self.state
|
||
}
|
||
|
||
/// Process id of the running generation, if any.
|
||
#[must_use]
|
||
pub fn process(&self) -> Option<ProcessId> {
|
||
self.process
|
||
}
|
||
|
||
/// Server capabilities once initialized; `None` otherwise.
|
||
#[must_use]
|
||
pub fn capabilities(&self) -> Option<&Value> {
|
||
match &self.state {
|
||
McpClientState::Initialized { capabilities, .. } => Some(capabilities),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// McpManager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Per-editor registry of [`McpClient`]s. Owns no threads of its own;
|
||
/// ticks via [`Self::tick`] from the run loop. Holds a handle to the
|
||
/// async runtime so per-request response delivery settles
|
||
/// Lua-visible job ids without occupying a worker thread.
|
||
pub struct McpManager {
|
||
supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
||
runtime: SharedAsyncRuntime,
|
||
clients: HashMap<McpServerId, McpClient>,
|
||
/// Reverse lookup: which server is which process.
|
||
process_to_server: HashMap<ProcessId, McpServerId>,
|
||
/// Per-server pending event buffer.
|
||
pending: HashMap<McpServerId, Vec<McpEvent>>,
|
||
/// Constant restart back-off.
|
||
restart_backoff: Duration,
|
||
/// How long to wait between shutdown escalation stages:
|
||
/// stdin EOF -> SIGTERM -> SIGKILL. Pass-3 finding 1. Default
|
||
/// 1s; tunable for tests.
|
||
shutdown_grace: Duration,
|
||
/// Per-(server, uri) resource cache. T M9.2. Cache hits return
|
||
/// the stored result via the async runtime; concurrent
|
||
/// `read_resource` calls coalesce onto a single in-flight
|
||
/// request via the `InFlight` state.
|
||
///
|
||
/// Cache is per-process, in-memory; cleared on editor restart
|
||
/// and on `pmacs.packages.reload`. Persistent caching is not in
|
||
/// scope for v0.1.
|
||
resource_cache: HashMap<(McpServerId, String), ResourceCacheState>,
|
||
/// Notification dispatcher (T M9.5). Method names that have at
|
||
/// least one Lua-side handler registered via
|
||
/// `pmacs.mcp.on_notification`. Notifications matching a
|
||
/// subscribed method are queued in `notification_queue` for the
|
||
/// Lua-side tick to drain and dispatch. Notifications for
|
||
/// non-subscribed methods are not queued (they still surface
|
||
/// via `events_take` for callers using the raw event stream).
|
||
///
|
||
/// M9.5–M9.7 share this dispatcher: M9.5 hooks
|
||
/// `notifications/resources/updated`, M9.6 hooks
|
||
/// `notifications/tools/list_changed`, M9.7 will hook
|
||
/// `notifications/prompts/list_changed`. One walk over events
|
||
/// per tick, dispatched per-method.
|
||
notification_subscriptions: std::collections::HashSet<String>,
|
||
/// Per-method queue of `(server, params)` pairs awaiting Lua-
|
||
/// side dispatch. Drained by `_drain_notifications`. T M9.5.
|
||
notification_queue: HashMap<String, Vec<(McpServerId, Value)>>,
|
||
}
|
||
|
||
impl McpManager {
|
||
/// Construct a fresh manager wired to `supervisor` and the
|
||
/// editor's `runtime`. The runtime is the bridge between the
|
||
/// supervisor's reader-thread response delivery and Lua-side
|
||
/// `Handle:await()` resumption.
|
||
#[must_use]
|
||
pub fn new(
|
||
supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
||
runtime: SharedAsyncRuntime,
|
||
) -> Self {
|
||
Self {
|
||
supervisor,
|
||
runtime,
|
||
clients: HashMap::new(),
|
||
process_to_server: HashMap::new(),
|
||
pending: HashMap::new(),
|
||
restart_backoff: Duration::from_millis(500),
|
||
shutdown_grace: Duration::from_secs(1),
|
||
resource_cache: HashMap::new(),
|
||
notification_subscriptions: std::collections::HashSet::new(),
|
||
notification_queue: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
/// T M9.5: register interest in a JSON-RPC notification method.
|
||
/// Once subscribed, every `notifications/<method>` arriving from
|
||
/// any server is queued for Lua-side dispatch via
|
||
/// [`Self::drain_notifications`]. Idempotent.
|
||
pub fn subscribe_notification(&mut self, method: impl Into<String>) {
|
||
self.notification_subscriptions.insert(method.into());
|
||
}
|
||
|
||
/// T M9.5: drop interest in a method. Pending queued
|
||
/// notifications for the method are also dropped.
|
||
pub fn unsubscribe_notification(&mut self, method: &str) {
|
||
self.notification_subscriptions.remove(method);
|
||
self.notification_queue.remove(method);
|
||
}
|
||
|
||
/// T M9.5: drain all queued notifications. Returns a map
|
||
/// `method → [(server, params), ...]` and clears the queues.
|
||
/// Called by the Lua-side tick to dispatch handlers; one walk
|
||
/// per tick, all subscribed methods drained.
|
||
pub fn drain_notifications(&mut self) -> HashMap<String, Vec<(McpServerId, Value)>> {
|
||
std::mem::take(&mut self.notification_queue)
|
||
}
|
||
|
||
/// Override the restart back-off. Test helper.
|
||
pub fn set_restart_backoff(&mut self, d: Duration) {
|
||
self.restart_backoff = d;
|
||
}
|
||
|
||
/// Override the stdin-close-to-SIGTERM grace window. Test helper.
|
||
pub fn set_shutdown_grace(&mut self, d: Duration) {
|
||
self.shutdown_grace = d;
|
||
}
|
||
|
||
/// Spawn a server. Synchronous; the spawn itself is reported via
|
||
/// the next tick's `Started` event (or `Crashed` if the supervisor
|
||
/// refused the exec).
|
||
pub fn spawn(&mut self, spec: McpServerSpec) -> Result<McpServerId, String> {
|
||
let id = McpServerId::next();
|
||
let mut client = McpClient::new(spec);
|
||
self.start_generation(id, &mut client)?;
|
||
self.clients.insert(id, client);
|
||
Ok(id)
|
||
}
|
||
|
||
fn start_generation(&mut self, id: McpServerId, client: &mut McpClient) -> Result<(), String> {
|
||
client.attempt += 1;
|
||
client.next_restart_at = None;
|
||
client.shutdown_escalation = None;
|
||
client.stdout = NdjsonParser::new();
|
||
client.pending_internal.clear();
|
||
// T M9.3: drop the cancelled-rid set; the new generation has
|
||
// fresh request-id numbering, so any outstanding rids from
|
||
// the previous generation can never collide with a new
|
||
// response.
|
||
client.cancelled_rids.clear();
|
||
// Drop any in-flight external requests onto the floor as
|
||
// cancelled. The new generation has fresh request-id numbering;
|
||
// no response on the new channel could match the old job ids
|
||
// anyway, so the await()s never wake without this cancel.
|
||
// T M9.2: route through `settle_in_flight_with` so awaiters
|
||
// and cache state transitions are uniform.
|
||
let cancelled: Vec<DrainedPending> = client
|
||
.pending_external
|
||
.drain()
|
||
.map(|(rid, p)| (p.cache_key, rid, p.awaiters))
|
||
.collect();
|
||
for (cache_key, rid, awaiters) in cancelled {
|
||
self.settle_in_flight_with(cache_key, rid, &awaiters, SettleOutcome::Cancelled);
|
||
}
|
||
// Pass-2 (M9.2) finding 1: clear cached entries for this
|
||
// server before the new generation starts. Cached results
|
||
// belong to the previous generation; the post-restart
|
||
// server may serve different content for the same URI.
|
||
self.drop_resource_cache_for(id);
|
||
client.state = McpClientState::Starting;
|
||
let proc_spec = client.spec.to_process_spec();
|
||
let pid = self.supervisor.borrow_mut().spawn(proc_spec)?;
|
||
client.process = Some(pid);
|
||
self.process_to_server.insert(pid, id);
|
||
Ok(())
|
||
}
|
||
|
||
/// Drop every cache entry whose key starts with `sid`. Called on
|
||
/// terminal-state transitions, on `forget`, and on restart so a
|
||
/// stale `read_resource(old_sid, uri)` against a server that has
|
||
/// stopped, been forgotten, or restarted does not return cache
|
||
/// data. Pass-2 (M9.2) finding 1.
|
||
fn drop_resource_cache_for(&mut self, sid: McpServerId) {
|
||
self.resource_cache.retain(|(s, _), _| *s != sid);
|
||
}
|
||
|
||
/// Initiate shutdown. Pass-3 finding 1: MCP stdio has no
|
||
/// protocol-level shutdown message. The compliant path is to
|
||
/// close the child's stdin (signaling EOF), wait for the child
|
||
/// to exit on its own, then escalate to SIGTERM after a grace
|
||
/// window and SIGKILL after a second grace window.
|
||
/// See <https://modelcontextprotocol.io/specification/latest/basic/lifecycle>.
|
||
///
|
||
/// Idempotent: a second call against an already-stopping or
|
||
/// already-terminal server is a no-op.
|
||
pub fn stop(&mut self, id: McpServerId) -> Result<(), String> {
|
||
let Some(client) = self.clients.get_mut(&id) else {
|
||
return Err(format!("unknown server: {id}"));
|
||
};
|
||
// Disable restart so a clean exit doesn't trigger a respawn.
|
||
client.spec.restart = McpRestartPolicy::Never;
|
||
client.next_restart_at = None;
|
||
// Already terminal or already stopping — nothing to do.
|
||
if matches!(
|
||
client.state,
|
||
McpClientState::Stopped { .. }
|
||
| McpClientState::Crashed { .. }
|
||
| McpClientState::ShuttingDown
|
||
) {
|
||
return Ok(());
|
||
}
|
||
// Close stdin so the child sees EOF on its next read.
|
||
// Servers that follow the MCP lifecycle spec exit on EOF;
|
||
// anything still alive after `shutdown_grace` gets SIGTERM,
|
||
// then SIGKILL after another grace window.
|
||
if let Some(pid) = client.process {
|
||
let _ = self.supervisor.borrow_mut().close_stdin(pid);
|
||
}
|
||
client.state = McpClientState::ShuttingDown;
|
||
client.shutdown_escalation = Some(ShutdownEscalation::SigtermAt(
|
||
Instant::now() + self.shutdown_grace,
|
||
));
|
||
Ok(())
|
||
}
|
||
|
||
/// Send a JSON-RPC request to `id`. Returns the async-runtime
|
||
/// [`JobId`] the response will settle. The Lua surface wraps that
|
||
/// id in a `pmacs.workers` Handle; package code awaits the handle
|
||
/// and receives the response's `result` value as a Lua table (or
|
||
/// the structured `error` raised through `Handle:await()`'s
|
||
/// failure path). Mirrors the dispatch shape of the M3-built
|
||
/// handlers (`pmacs.workers.compute_sum`, `pmacs.fs.read_dir`,
|
||
/// etc.) — the server's response settles a runtime job rather
|
||
/// than a per-protocol poll-style queue. T M9.1 / Pass-2 finding 1.
|
||
pub fn send_request(
|
||
&mut self,
|
||
id: McpServerId,
|
||
method: impl Into<String>,
|
||
params: Value,
|
||
) -> Result<JobId, String> {
|
||
let method = method.into();
|
||
let client = self
|
||
.clients
|
||
.get_mut(&id)
|
||
.ok_or_else(|| format!("unknown server: {id}"))?;
|
||
if !matches!(client.state, McpClientState::Initialized { .. }) {
|
||
return Err(format!(
|
||
"server {id} is not ready for requests (state: {})",
|
||
state_label(&client.state)
|
||
));
|
||
}
|
||
let req_id = next_request_id(client);
|
||
let body = make_request(req_id, &method, params);
|
||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
client.pending_external.insert(
|
||
req_id,
|
||
PendingExternal {
|
||
method,
|
||
cache_key: None,
|
||
awaiters: vec![Awaiter { job_id, token }],
|
||
},
|
||
);
|
||
if let Err(e) = send_frame_to(&self.supervisor, client, &body) {
|
||
// Roll back the registration: the request never made it
|
||
// onto the wire, so no response will ever arrive. Settle
|
||
// every awaiter (just the one here) as failed so the
|
||
// handle wakes with an error rather than blocking forever.
|
||
if let Some(p) = client.pending_external.remove(&req_id) {
|
||
let msg = format!("send_request: {e}");
|
||
for a in p.awaiters {
|
||
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
Ok(job_id)
|
||
}
|
||
|
||
/// Read a resource via `resources/read`. T M9.2.
|
||
///
|
||
/// Returns the async-runtime [`JobId`] the response will settle.
|
||
/// Three paths:
|
||
///
|
||
/// 1. **Cache hit (`Cached`)** — registers a fresh job and
|
||
/// immediately settles it with the stored result. The
|
||
/// awaiter wakes one runtime tick later. The dispatch shape
|
||
/// is the contract; cached values still arrive through a
|
||
/// Handle.
|
||
/// 2. **In-flight (`InFlight`)** — registers a fresh job and
|
||
/// attaches it to the primary's `sibling_awaiters`. M3.5
|
||
/// coalescing: 10 concurrent `read_resource` calls produce
|
||
/// 1 wire request and 10 awaiters that all wake with the
|
||
/// same response.
|
||
/// 3. **Cache miss (`Absent`)** — dispatches a fresh
|
||
/// `resources/read`, marks the cache entry `InFlight`,
|
||
/// returns the primary's job id.
|
||
///
|
||
/// Errors during the wire write roll back the cache state so
|
||
/// subsequent reads can re-dispatch.
|
||
pub fn read_resource(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
uri: impl Into<String>,
|
||
) -> Result<JobId, String> {
|
||
let uri = uri.into();
|
||
|
||
// Pass-2 (M9.2) finding 1: validate the server exists and is
|
||
// initialized BEFORE consulting the cache. A cached value is
|
||
// only valid in the context of a running, initialized
|
||
// server; a stale handle that survived `stop`/`forget` (or
|
||
// worse, points at an `McpServerId` that was reused for an
|
||
// unrelated server) must not return cache data.
|
||
let client = self
|
||
.clients
|
||
.get_mut(&sid)
|
||
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
||
if !matches!(client.state, McpClientState::Initialized { .. }) {
|
||
return Err(format!(
|
||
"server {sid} is not ready for requests (state: {})",
|
||
state_label(&client.state)
|
||
));
|
||
}
|
||
|
||
let key = (sid, uri.clone());
|
||
|
||
// (1) Cache hit.
|
||
if let Some(ResourceCacheState::Cached { result }) = self.resource_cache.get(&key).cloned()
|
||
{
|
||
let (job_id, _token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
self.runtime.complete_external_ok(job_id, result);
|
||
return Ok(job_id);
|
||
}
|
||
|
||
// (2) In-flight: attach a fresh awaiter (with its own
|
||
// CancellationToken) to the existing entry. Pass-2 (M9.2)
|
||
// finding 2: per-awaiter tokens let siblings cancel
|
||
// independently.
|
||
if let Some(ResourceCacheState::InFlight { request_id }) = self.resource_cache.get(&key) {
|
||
let in_flight_rid = *request_id;
|
||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
if let Some(p) = client.pending_external.get_mut(&in_flight_rid) {
|
||
p.awaiters.push(Awaiter { job_id, token });
|
||
return Ok(job_id);
|
||
}
|
||
// Race: cache claims InFlight but the entry's bookkeeping
|
||
// is gone. Drop the stale cache entry and fall through
|
||
// to the cache-miss path so we re-dispatch.
|
||
self.resource_cache.remove(&key);
|
||
self.runtime.complete_external_cancelled(job_id);
|
||
}
|
||
|
||
// (3) Cache miss: dispatch.
|
||
let req_id = next_request_id(client);
|
||
let body = make_request(req_id, "resources/read", json!({ "uri": uri }));
|
||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
client.pending_external.insert(
|
||
req_id,
|
||
PendingExternal {
|
||
method: "resources/read".to_owned(),
|
||
cache_key: Some(key.clone()),
|
||
awaiters: vec![Awaiter { job_id, token }],
|
||
},
|
||
);
|
||
if let Err(e) = send_frame_to(&self.supervisor, client, &body) {
|
||
if let Some(p) = client.pending_external.remove(&req_id) {
|
||
let msg = format!("read_resource: {e}");
|
||
for a in p.awaiters {
|
||
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
// Successful write — install the InFlight marker. Any
|
||
// subsequent read_resource for the same key attaches as a
|
||
// sibling rather than dispatching afresh.
|
||
self.resource_cache
|
||
.insert(key, ResourceCacheState::InFlight { request_id: req_id });
|
||
Ok(job_id)
|
||
}
|
||
|
||
/// Invalidate the cache entry for `(sid, uri)`. T M9.2.
|
||
///
|
||
/// Per the M9.2 design (option `i`): invalidation while in-flight
|
||
/// settles existing awaiters with the arriving result, but does
|
||
/// **not** re-cache. A subsequent `read_resource` after
|
||
/// invalidation re-dispatches, ensuring the post-invalidation
|
||
/// reader observes a fresh fetch (per spec: "subsequent reads
|
||
/// refetch"). The mechanism: `settle_in_flight_with` checks
|
||
/// whether the cache entry still references the response's
|
||
/// request id; after invalidation it does not, so the result
|
||
/// flows to awaiters but the cache stays Absent.
|
||
pub fn invalidate_resource(&mut self, sid: McpServerId, uri: impl Into<String>) {
|
||
let key = (sid, uri.into());
|
||
self.resource_cache.remove(&key);
|
||
}
|
||
|
||
/// Invoke a tool via `tools/call`. T M9.3.
|
||
///
|
||
/// Returns the async-runtime [`JobId`] the response will settle.
|
||
/// Each `invoke_tool` is a fresh wire request — no caching, no
|
||
/// coalescing (tool calls are distinct, may have side effects).
|
||
///
|
||
/// Two error paths converge on the runtime's Failed outcome:
|
||
/// 1. **JSON-RPC error response** — the server returns
|
||
/// `{"error":{...}}`. Standard `settle_in_flight_with`
|
||
/// Failed path applies.
|
||
/// 2. **MCP "tool errored" success response** — the server
|
||
/// returns `{"result":{"isError": true, "content": [...]}}`.
|
||
/// The response handler's per-method translator inspects
|
||
/// the `isError` flag and converts it to `SettleOutcome::
|
||
/// Failed` with the extracted text. The translation is a
|
||
/// deliberate API choice, not implementation detail: the
|
||
/// contract is `invoke_tool` raises Lua errors on tool
|
||
/// failure; callers needing structured access to the raw
|
||
/// `{isError, content}` table use
|
||
/// `pmacs.mcp.send_request("tools/call", ...)` to bypass
|
||
/// the translator.
|
||
pub fn invoke_tool(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
name: impl Into<String>,
|
||
arguments: Value,
|
||
) -> Result<JobId, String> {
|
||
// `arguments` is consumed below: the request body moves it
|
||
// into the JSON-RPC params map. clippy's
|
||
// needless_pass_by_value heuristic doesn't see that through
|
||
// the build steps.
|
||
let name = name.into();
|
||
let client = self
|
||
.clients
|
||
.get_mut(&sid)
|
||
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
||
if !matches!(client.state, McpClientState::Initialized { .. }) {
|
||
return Err(format!(
|
||
"server {sid} is not ready for requests (state: {})",
|
||
state_label(&client.state)
|
||
));
|
||
}
|
||
let req_id = next_request_id(client);
|
||
// Build params explicitly so `arguments` is moved (rather
|
||
// than referenced by `json!`); avoids a needless-pass-by-
|
||
// value clippy complaint and matches `send_request`'s shape.
|
||
let mut params_map = Map::new();
|
||
params_map.insert("name".into(), Value::String(name));
|
||
params_map.insert("arguments".into(), arguments);
|
||
let body = make_request(req_id, "tools/call", Value::Object(params_map));
|
||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
client.pending_external.insert(
|
||
req_id,
|
||
PendingExternal {
|
||
method: "tools/call".to_owned(),
|
||
cache_key: None,
|
||
awaiters: vec![Awaiter { job_id, token }],
|
||
},
|
||
);
|
||
if let Err(e) = send_frame_to(&self.supervisor, client, &body) {
|
||
if let Some(p) = client.pending_external.remove(&req_id) {
|
||
let msg = format!("invoke_tool: {e}");
|
||
for a in p.awaiters {
|
||
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
Ok(job_id)
|
||
}
|
||
|
||
/// Resolve a prompt template via `prompts/get`. T M9.4.
|
||
///
|
||
/// Returns the async-runtime [`JobId`] the response will settle.
|
||
/// Each `get_prompt` is a fresh wire request — no caching, no
|
||
/// coalescing.
|
||
///
|
||
/// Unlike `tools/call`, `prompts/get` has **no `isError`-style
|
||
/// semantic-failure path**. The MCP spec defines exactly two
|
||
/// outcomes: success (returns `messages`) or JSON-RPC error
|
||
/// (typically `-32602` for missing required args, unknown
|
||
/// prompt, etc.). The standard `Failed` path covers the latter
|
||
/// without translation.
|
||
///
|
||
/// `arguments` is sent as the `arguments` field of the request
|
||
/// params verbatim (after Lua-to-JSON marshaling). The MCP spec
|
||
/// requires the field even when there are no arguments; the
|
||
/// Lua boundary at `_get_prompt_raw` translates `None`/`Nil` to
|
||
/// an empty object `{}` rather than omitting the field.
|
||
pub fn get_prompt(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
name: impl Into<String>,
|
||
arguments: Value,
|
||
) -> Result<JobId, String> {
|
||
// `arguments` is consumed below via the params Map::insert.
|
||
let name = name.into();
|
||
let client = self
|
||
.clients
|
||
.get_mut(&sid)
|
||
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
||
if !matches!(client.state, McpClientState::Initialized { .. }) {
|
||
return Err(format!(
|
||
"server {sid} is not ready for requests (state: {})",
|
||
state_label(&client.state)
|
||
));
|
||
}
|
||
let req_id = next_request_id(client);
|
||
let mut params_map = Map::new();
|
||
params_map.insert("name".into(), Value::String(name));
|
||
params_map.insert("arguments".into(), arguments);
|
||
let body = make_request(req_id, "prompts/get", Value::Object(params_map));
|
||
let (job_id, token) = self.runtime.register_external(JobKind::McpRequest, None);
|
||
client.pending_external.insert(
|
||
req_id,
|
||
PendingExternal {
|
||
method: "prompts/get".to_owned(),
|
||
cache_key: None,
|
||
awaiters: vec![Awaiter { job_id, token }],
|
||
},
|
||
);
|
||
if let Err(e) = send_frame_to(&self.supervisor, client, &body) {
|
||
if let Some(p) = client.pending_external.remove(&req_id) {
|
||
let msg = format!("get_prompt: {e}");
|
||
for a in p.awaiters {
|
||
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||
}
|
||
}
|
||
return Err(e);
|
||
}
|
||
Ok(job_id)
|
||
}
|
||
|
||
/// Send a JSON-RPC notification to `id`. Fire-and-forget.
|
||
pub fn send_notification(
|
||
&mut self,
|
||
id: McpServerId,
|
||
method: impl Into<String>,
|
||
params: Value,
|
||
) -> Result<(), String> {
|
||
let method = method.into();
|
||
let client = self
|
||
.clients
|
||
.get_mut(&id)
|
||
.ok_or_else(|| format!("unknown server: {id}"))?;
|
||
if matches!(
|
||
client.state,
|
||
McpClientState::Stopped { .. } | McpClientState::Crashed { .. }
|
||
) {
|
||
return Err(format!(
|
||
"server {id} is not running (state: {})",
|
||
state_label(&client.state)
|
||
));
|
||
}
|
||
let body = make_notification(&method, params);
|
||
send_frame_to(&self.supervisor, client, &body)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Reply to a server-initiated request.
|
||
pub fn send_response(
|
||
&mut self,
|
||
id: McpServerId,
|
||
request_id: Value,
|
||
result: Result<Value, McpError>,
|
||
) -> Result<(), String> {
|
||
let client = self
|
||
.clients
|
||
.get_mut(&id)
|
||
.ok_or_else(|| format!("unknown server: {id}"))?;
|
||
let body = make_response(request_id, result);
|
||
send_frame_to(&self.supervisor, client, &body)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// One pass: drain process events into per-server frame buffers,
|
||
/// parse frames, dispatch to per-server state. Apply restart
|
||
/// policy on terminations. Also drain any user-cancelled handles
|
||
/// so abandoned requests do not retain bookkeeping past the tick
|
||
/// in which they were cancelled.
|
||
pub fn tick(&mut self) {
|
||
let server_ids: Vec<McpServerId> = self.clients.keys().copied().collect();
|
||
for sid in server_ids {
|
||
self.drain_process_events(sid);
|
||
self.drain_cancelled_externals(sid);
|
||
self.maybe_terminate_shutting_down(sid);
|
||
self.maybe_restart(sid);
|
||
}
|
||
}
|
||
|
||
/// Pass-3 finding 1: a server that has not exited within the
|
||
/// shutdown grace window after stdin close gets SIGTERM, then
|
||
/// SIGKILL after a second grace window. The server's actual exit
|
||
/// lands as the next `Exited` / `Signaled` event in the
|
||
/// supervisor's queue.
|
||
fn maybe_terminate_shutting_down(&mut self, sid: McpServerId) {
|
||
let now = Instant::now();
|
||
let escalation = {
|
||
let Some(client) = self.clients.get(&sid) else {
|
||
return;
|
||
};
|
||
if !matches!(client.state, McpClientState::ShuttingDown) {
|
||
return;
|
||
}
|
||
client.shutdown_escalation
|
||
};
|
||
match escalation {
|
||
Some(ShutdownEscalation::SigtermAt(at)) if at <= now => {
|
||
let pid = self.clients.get(&sid).and_then(|c| c.process);
|
||
if let Some(pid) = pid {
|
||
let _ = self.supervisor.borrow_mut().terminate(pid);
|
||
}
|
||
if let Some(client) = self.clients.get_mut(&sid) {
|
||
client.shutdown_escalation =
|
||
Some(ShutdownEscalation::SigkillAt(now + self.shutdown_grace));
|
||
}
|
||
}
|
||
Some(ShutdownEscalation::SigkillAt(at)) if at <= now => {
|
||
let pid = self.clients.get(&sid).and_then(|c| c.process);
|
||
if let Some(pid) = pid {
|
||
let _ = self.supervisor.borrow_mut().signal(pid, Signal::SIGKILL);
|
||
}
|
||
if let Some(client) = self.clients.get_mut(&sid) {
|
||
client.shutdown_escalation = None;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Walk the per-client `pending_external` table per awaiter,
|
||
/// looking for cancelled handles. Pass-2 (M9.2) finding 2: each
|
||
/// awaiter has its own [`CancellationToken`], so cancellation is
|
||
/// per-handle rather than per-request.
|
||
///
|
||
/// Behaviour:
|
||
/// - An awaiter whose token is flipped is removed from its
|
||
/// entry and settled as Cancelled. The in-flight wire request
|
||
/// continues; other awaiters on the same entry continue to
|
||
/// wait for the response.
|
||
/// - When the last awaiter on an entry cancels, the entry is
|
||
/// removed and the cache (if any) is cleaned up via
|
||
/// [`Self::settle_in_flight_with`] with no remaining awaiters.
|
||
/// T M9.3: at this point we also send `notifications/cancelled`
|
||
/// to the server (best-effort) and record the request id in
|
||
/// the client's `cancelled_rids` set so a late response —
|
||
/// the cancel/response race — is silently dropped rather
|
||
/// than emitting a "response for unknown request id"
|
||
/// `ProtocolError`.
|
||
fn drain_cancelled_externals(&mut self, sid: McpServerId) {
|
||
// First pass: per-awaiter cancellation drain. Collect the
|
||
// job ids of cancelled awaiters and any entries whose
|
||
// awaiter list is now empty.
|
||
let (per_awaiter_cancellations, abandoned_entries): (Vec<JobId>, Vec<DrainedPending>) = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
let mut cancelled_awaiters: Vec<JobId> = Vec::new();
|
||
let mut abandoned: Vec<DrainedPending> = Vec::new();
|
||
client.pending_external.retain(|rid, p| {
|
||
let mut still_pending: Vec<Awaiter> = Vec::with_capacity(p.awaiters.len());
|
||
for a in p.awaiters.drain(..) {
|
||
if a.token.is_cancelled() {
|
||
cancelled_awaiters.push(a.job_id);
|
||
} else {
|
||
still_pending.push(a);
|
||
}
|
||
}
|
||
p.awaiters = still_pending;
|
||
if p.awaiters.is_empty() {
|
||
// Last awaiter cancelled — drop the entry and
|
||
// route through settle_in_flight_with so the
|
||
// cache state transitions correctly. The
|
||
// already-cancelled awaiters are settled
|
||
// individually below; settle_in_flight_with sees
|
||
// an empty awaiter list and only does cache
|
||
// cleanup.
|
||
abandoned.push((p.cache_key.clone(), *rid, Vec::new()));
|
||
false
|
||
} else {
|
||
true
|
||
}
|
||
});
|
||
(cancelled_awaiters, abandoned)
|
||
};
|
||
// Settle the per-awaiter cancellations directly: each one
|
||
// already saw its token flipped, so we just mark the runtime
|
||
// job cancelled. (settle_in_flight_with would also work but
|
||
// would settle the entire entry; we want per-awaiter here.)
|
||
for job_id in per_awaiter_cancellations {
|
||
self.runtime.complete_external_cancelled(job_id);
|
||
}
|
||
// T M9.3: for entries fully abandoned by cancellation, send
|
||
// `notifications/cancelled` to the server so it can release
|
||
// any tied-up resources. Best-effort: a server that has
|
||
// already died returns Err, which we ignore. Per-awaiter
|
||
// cancellations (where siblings still wait) do NOT send the
|
||
// notification — the server's work is still wanted.
|
||
for (_cache_key, rid, _) in &abandoned_entries {
|
||
self.send_cancellation_notification(sid, *rid);
|
||
// Record the rid so a late response is dropped silently.
|
||
if let Some(client) = self.clients.get_mut(&sid) {
|
||
client.cancelled_rids.insert(*rid);
|
||
}
|
||
}
|
||
// For entries whose awaiter list emptied entirely: route
|
||
// through settle_in_flight_with so cache state transitions
|
||
// are uniform with other failure paths.
|
||
for (cache_key, rid, awaiters) in abandoned_entries {
|
||
self.settle_in_flight_with(cache_key, rid, &awaiters, SettleOutcome::Cancelled);
|
||
}
|
||
}
|
||
|
||
/// Send `notifications/cancelled { requestId, reason }` to the
|
||
/// server for `rid` on `sid`. Best-effort: errors (e.g. the
|
||
/// server died between the cancel and this call) are ignored.
|
||
/// T M9.3.
|
||
fn send_cancellation_notification(&self, sid: McpServerId, rid: u64) {
|
||
let Some(client) = self.clients.get(&sid) else {
|
||
return;
|
||
};
|
||
// Don't bother trying if the server isn't accepting writes.
|
||
if matches!(
|
||
client.state,
|
||
McpClientState::Stopped { .. }
|
||
| McpClientState::Crashed { .. }
|
||
| McpClientState::ShuttingDown
|
||
) {
|
||
return;
|
||
}
|
||
let body = make_notification(
|
||
"notifications/cancelled",
|
||
json!({
|
||
"requestId": rid,
|
||
"reason": "cancelled by pmacs handle"
|
||
}),
|
||
);
|
||
let _ = send_frame_to(&self.supervisor, client, &body);
|
||
}
|
||
|
||
/// Canonical settlement for an in-flight external request.
|
||
///
|
||
/// Settles the primary awaiter and any sibling awaiters with the
|
||
/// same outcome, then updates the resource cache: a successful
|
||
/// outcome for a request whose cache entry still references this
|
||
/// `request_id` transitions `InFlight` → `Cached`. Anything else
|
||
/// (failure, cancellation, or invalidation that re-dispatched
|
||
/// under a new id) leaves the cache entry absent.
|
||
///
|
||
/// T M9.2 design rule: the `InFlight` → `Cached` transition only
|
||
/// happens on success of the request the cache currently
|
||
/// references; every other terminal state results in
|
||
/// `InFlight` → Absent. Centralising the rule here means the four
|
||
/// settlement call sites (response handler, cancellation drain,
|
||
/// `on_exit`, restart cancellation) cannot drift apart.
|
||
fn settle_in_flight_with(
|
||
&mut self,
|
||
cache_key: Option<(McpServerId, String)>,
|
||
request_id: u64,
|
||
awaiters: &[Awaiter],
|
||
outcome: SettleOutcome,
|
||
) {
|
||
// Settle every awaiter. Clone the value per awaiter; the
|
||
// runtime's bus-encoded delivery already requires owned
|
||
// values. An empty awaiter list (from
|
||
// `drain_cancelled_externals`'s "last awaiter cancelled"
|
||
// path) is fine — we still need to update the cache below.
|
||
//
|
||
// Cancellation is checked again at settlement time, not only
|
||
// in `drain_cancelled_externals`: a response and a user
|
||
// cancellation can both be queued before this manager tick.
|
||
// In that race, the cancelled handle must still settle as
|
||
// Cancelled rather than receiving the response.
|
||
match &outcome {
|
||
SettleOutcome::Ok(value) => {
|
||
for a in awaiters {
|
||
if a.token.is_cancelled() {
|
||
self.runtime.complete_external_cancelled(a.job_id);
|
||
} else {
|
||
self.runtime.complete_external_ok(a.job_id, value.clone());
|
||
}
|
||
}
|
||
}
|
||
SettleOutcome::Failed(msg) => {
|
||
for a in awaiters {
|
||
if a.token.is_cancelled() {
|
||
self.runtime.complete_external_cancelled(a.job_id);
|
||
} else {
|
||
self.runtime.complete_external_failed(a.job_id, msg.clone());
|
||
}
|
||
}
|
||
}
|
||
SettleOutcome::Cancelled => {
|
||
for a in awaiters {
|
||
self.runtime.complete_external_cancelled(a.job_id);
|
||
}
|
||
}
|
||
}
|
||
// Update cache.
|
||
let Some(key) = cache_key else {
|
||
return;
|
||
};
|
||
let in_flight_for_us = matches!(
|
||
self.resource_cache.get(&key),
|
||
Some(ResourceCacheState::InFlight { request_id: rid }) if *rid == request_id
|
||
);
|
||
if !in_flight_for_us {
|
||
// Cache entry was invalidated, replaced by a newer
|
||
// in-flight request, or was never installed (race during
|
||
// a wire-write rollback). Don't touch it.
|
||
return;
|
||
}
|
||
match outcome {
|
||
SettleOutcome::Ok(value) => {
|
||
self.resource_cache
|
||
.insert(key, ResourceCacheState::Cached { result: value });
|
||
}
|
||
SettleOutcome::Failed(_) | SettleOutcome::Cancelled => {
|
||
self.resource_cache.remove(&key);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn drain_process_events(&mut self, sid: McpServerId) {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
let Some(pid) = client.process else {
|
||
return;
|
||
};
|
||
let mut sup = self.supervisor.borrow_mut();
|
||
let events = sup.take_events(pid);
|
||
drop(sup);
|
||
for ev in events {
|
||
self.handle_process_event(sid, ev);
|
||
}
|
||
}
|
||
|
||
fn handle_process_event(&mut self, sid: McpServerId, ev: ProcessEvent) {
|
||
match ev.kind {
|
||
ProcessEventKind::Started { pid } => self.on_started(sid, pid, ev.at),
|
||
ProcessEventKind::Stdout(bytes) => {
|
||
if let Some(client) = self.clients.get_mut(&sid) {
|
||
client.stdout.extend(&bytes);
|
||
}
|
||
self.parse_frames(sid);
|
||
}
|
||
ProcessEventKind::Stderr(bytes) => {
|
||
self.push_event(sid, ev.at, McpEventKind::Stderr(bytes));
|
||
}
|
||
ProcessEventKind::Ansi(_) => {
|
||
self.push_event(
|
||
sid,
|
||
ev.at,
|
||
McpEventKind::ProtocolError {
|
||
message: "supervisor emitted ANSI events for pipe-mode MCP process".into(),
|
||
},
|
||
);
|
||
}
|
||
ProcessEventKind::Exited { code } => {
|
||
// Pass3-2 finding 5: distinguish clean exits from
|
||
// crashes. Code 0 is clean; non-zero is a crash even
|
||
// if the process exited "normally". OnCrash policy
|
||
// restarts only on the latter.
|
||
self.on_exit(sid, ev.at, format!("exit code {code}"), code == 0);
|
||
}
|
||
ProcessEventKind::Signaled { signal } => {
|
||
// A signal-killed process is never clean — even
|
||
// SIGTERM during user-initiated `stop` lands here as
|
||
// !clean, but `was_shutdown` overrides the policy
|
||
// path so the server transitions to Stopped anyway.
|
||
self.on_exit(sid, ev.at, format!("signal {signal}"), false);
|
||
}
|
||
ProcessEventKind::Crashed { error } => {
|
||
self.on_exit(sid, ev.at, format!("crashed: {error}"), false);
|
||
}
|
||
ProcessEventKind::Restarting { .. } => {
|
||
// The supervisor's own restart accounting is unused
|
||
// here (we run with RestartPolicy::Never on the
|
||
// supervisor side). If a Restarting event ever
|
||
// arrives, log it as a protocol-layer surprise.
|
||
self.push_event(
|
||
sid,
|
||
ev.at,
|
||
McpEventKind::ProtocolError {
|
||
message: "supervisor emitted Restarting under MCP-managed process".into(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn on_started(&mut self, sid: McpServerId, pid: u32, at: Instant) {
|
||
let init_request_id = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
let req_id = next_request_id(client);
|
||
client.state = McpClientState::Initializing {
|
||
init_request_id: req_id,
|
||
started: at,
|
||
};
|
||
client
|
||
.pending_internal
|
||
.insert(req_id, "initialize".to_owned());
|
||
req_id
|
||
};
|
||
let body = build_initialize(init_request_id);
|
||
if let Some(client) = self.clients.get(&sid)
|
||
&& let Err(e) = send_frame_to(&self.supervisor, client, &body)
|
||
{
|
||
self.push_event(
|
||
sid,
|
||
at,
|
||
McpEventKind::ProtocolError {
|
||
message: format!("failed to send initialize: {e}"),
|
||
},
|
||
);
|
||
}
|
||
self.push_event(sid, at, McpEventKind::Started { pid });
|
||
}
|
||
|
||
fn on_exit(&mut self, sid: McpServerId, at: Instant, reason: String, clean: bool) {
|
||
let (terminal_kind, restart, cancelled_externals) = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
let was_shutdown = matches!(client.state, McpClientState::ShuttingDown);
|
||
// Pass-2 finding 5: clean exits go to Stopped (not
|
||
// Crashed), and the OnCrash policy does not respawn
|
||
// them. Non-clean exits go to Crashed unless they happen
|
||
// during a user-initiated stop, where SIGTERM/SIGKILL
|
||
// may be the expected shutdown path.
|
||
let terminal_kind = if was_shutdown || clean {
|
||
TerminalKind::Stopped
|
||
} else {
|
||
TerminalKind::Crashed
|
||
};
|
||
client.state = match terminal_kind {
|
||
TerminalKind::Stopped => McpClientState::Stopped { ended: at },
|
||
TerminalKind::Crashed => McpClientState::Crashed {
|
||
reason: reason.clone(),
|
||
ended: at,
|
||
},
|
||
};
|
||
if let Some(pid) = client.process.take() {
|
||
self.process_to_server.remove(&pid);
|
||
let _ = self.supervisor.borrow_mut().forget(pid);
|
||
}
|
||
// Drain in-flight external requests; the server died
|
||
// without responding to any of them. Every awaiter
|
||
// wakes with a structured cancelled error. T M9.2:
|
||
// route through `settle_in_flight_with` so awaiters
|
||
// and cache state transitions are uniform.
|
||
let cancelled: Vec<DrainedPending> = client
|
||
.pending_external
|
||
.drain()
|
||
.map(|(rid, p)| (p.cache_key, rid, p.awaiters))
|
||
.collect();
|
||
// Drop internal initialize bookkeeping without ceremony;
|
||
// it has no Lua-visible counterpart.
|
||
client.pending_internal.clear();
|
||
// T M9.3: clear the cancelled-rid set; the server is
|
||
// gone, no responses are coming anyway.
|
||
client.cancelled_rids.clear();
|
||
let restart = should_restart(client.spec.restart, clean, was_shutdown);
|
||
(terminal_kind, restart, cancelled)
|
||
};
|
||
for (cache_key, rid, awaiters) in cancelled_externals {
|
||
self.settle_in_flight_with(cache_key, rid, &awaiters, SettleOutcome::Cancelled);
|
||
}
|
||
// Pass-2 (M9.2) finding 1: drop every cached entry for this
|
||
// server. Once the server has reached a terminal state, a
|
||
// stale `read_resource(sid, uri)` against `sid` must error
|
||
// ("not ready") rather than return cache data left over
|
||
// from before the exit.
|
||
self.drop_resource_cache_for(sid);
|
||
match terminal_kind {
|
||
TerminalKind::Stopped => self.push_event(sid, at, McpEventKind::Stopped),
|
||
TerminalKind::Crashed => self.push_event(sid, at, McpEventKind::Crashed { reason }),
|
||
}
|
||
if restart && let Some(client) = self.clients.get_mut(&sid) {
|
||
client.next_restart_at = Some(at + self.restart_backoff);
|
||
}
|
||
}
|
||
|
||
fn parse_frames(&mut self, sid: McpServerId) {
|
||
loop {
|
||
let frame = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
client.stdout.next_frame()
|
||
};
|
||
match frame {
|
||
Some(body) => self.dispatch_inbound(sid, &body),
|
||
None => return,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn dispatch_inbound(&mut self, sid: McpServerId, body: &[u8]) {
|
||
let now = Instant::now();
|
||
let value: Value = match serde_json::from_slice(body) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!("invalid JSON in frame: {e}"),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
let Value::Object(map) = value else {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: "frame body was not a JSON object".into(),
|
||
},
|
||
);
|
||
return;
|
||
};
|
||
if let Some(jsonrpc) = map.get("jsonrpc").and_then(Value::as_str) {
|
||
if jsonrpc != "2.0" {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!("unexpected jsonrpc version: {jsonrpc:?}"),
|
||
},
|
||
);
|
||
}
|
||
} else {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: "frame missing 'jsonrpc' field".into(),
|
||
},
|
||
);
|
||
}
|
||
let id = map.get("id").cloned();
|
||
let method = map.get("method").and_then(Value::as_str).map(str::to_owned);
|
||
let params = map.get("params").cloned().unwrap_or(Value::Null);
|
||
let result = map.get("result").cloned();
|
||
let error = map.get("error").map(McpError::from_value);
|
||
match (id, method, result.is_some() || error.is_some()) {
|
||
(Some(idv), None, true) => self.handle_response(sid, &idv, result, error, now),
|
||
(Some(idv), Some(m), false) => self.handle_request(sid, idv, m, params, now),
|
||
(None, Some(m), false) => self.handle_notification(sid, m, params, now),
|
||
_ => self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: "frame is neither request, response, nor notification".into(),
|
||
},
|
||
),
|
||
}
|
||
}
|
||
|
||
fn handle_response(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
idv: &Value,
|
||
result: Option<Value>,
|
||
error: Option<McpError>,
|
||
now: Instant,
|
||
) {
|
||
// Look up the request as either internal (`initialize`) or
|
||
// external (a Lua-visible send_request).
|
||
// Internal/external request-id namespaces share the same
|
||
// counter so a given rid lands in at most one of the two
|
||
// tables; the dual-lookup is just a flat-table search.
|
||
let Some(rid) = idv.as_u64() else {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!("response id is not a u64: {idv:?}"),
|
||
},
|
||
);
|
||
return;
|
||
};
|
||
let kind = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
if let Some(method) = client.pending_internal.remove(&rid) {
|
||
PendingKind::Internal(method)
|
||
} else if let Some(p) = client.pending_external.remove(&rid) {
|
||
PendingKind::External(p)
|
||
} else if client.cancelled_rids.remove(&rid) {
|
||
// T M9.3: cancel/response race. We sent
|
||
// `notifications/cancelled` for this request; a
|
||
// late response arrived anyway. Drop it silently —
|
||
// by definition no awaiter is listening (the entry
|
||
// was abandoned by cancellation), and emitting
|
||
// `ProtocolError` here would be log spam for an
|
||
// expected outcome.
|
||
return;
|
||
} else {
|
||
PendingKind::Unknown
|
||
}
|
||
};
|
||
match kind {
|
||
PendingKind::Unknown => {
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!("response for unknown request id {rid}"),
|
||
},
|
||
);
|
||
}
|
||
PendingKind::Internal(method) if method == "initialize" => {
|
||
self.handle_initialize_response(sid, result, error, now);
|
||
}
|
||
PendingKind::Internal(other) => {
|
||
// Internal request the manager doesn't know how to
|
||
// handle. Pass-3 finding 1 removed `shutdown` from
|
||
// this set (MCP stdio has no protocol shutdown
|
||
// message); only `initialize` is internal now. Any
|
||
// other entry would be a manager bug.
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!(
|
||
"internal pending entry for unrecognised method {other:?}"
|
||
),
|
||
},
|
||
);
|
||
}
|
||
PendingKind::External(p) => {
|
||
// Settle the awaiters (one or more, attached via M3.5
|
||
// coalescing for `read_resource`; always one for
|
||
// `send_request` and `invoke_tool`). The `Response`
|
||
// event still fires for callers observing the raw
|
||
// event stream, but the canonical Lua-side delivery
|
||
// path is the awaited handle.
|
||
//
|
||
// T M9.3: per-method translation of MCP "tool
|
||
// errored" responses. A `tools/call` response with
|
||
// `isError: true` is a tool-level failure and must
|
||
// propagate as a Lua error, not as an Ok with the
|
||
// flag set. Other methods pass through with the
|
||
// raw result.
|
||
let outcome = if let Some(err) = error.as_ref() {
|
||
SettleOutcome::Failed(format!("[{}] {}", err.code, err.message))
|
||
} else if p.method == "tools/call"
|
||
&& let Some(result_obj) = result.as_ref()
|
||
&& result_obj
|
||
.get("isError")
|
||
.and_then(Value::as_bool)
|
||
.unwrap_or(false)
|
||
{
|
||
SettleOutcome::Failed(extract_tool_error_message(result_obj))
|
||
} else {
|
||
SettleOutcome::Ok(result.clone().unwrap_or(Value::Null))
|
||
};
|
||
self.settle_in_flight_with(p.cache_key.clone(), rid, &p.awaiters, outcome);
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::Response {
|
||
id: rid,
|
||
result: result.unwrap_or(Value::Null),
|
||
error,
|
||
method: p.method,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Handle the response to the manager-internal `initialize`
|
||
/// request. Pass-2 findings 2 + 3:
|
||
///
|
||
/// - **Finding 2**: a JSON-RPC `error` reply means the server
|
||
/// refused to initialize. We emit a `ProtocolError` + terminate
|
||
/// the process rather than masquerading as Initialized.
|
||
/// - **Finding 3**: the server's `protocolVersion` echo is
|
||
/// validated against [`SUPPORTED_PROTOCOL_VERSIONS`]. An
|
||
/// unsupported version is a fatal protocol mismatch per the
|
||
/// MCP lifecycle spec; we terminate without sending
|
||
/// `notifications/initialized`.
|
||
fn handle_initialize_response(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
result: Option<Value>,
|
||
error: Option<McpError>,
|
||
now: Instant,
|
||
) {
|
||
if let Some(err) = error {
|
||
let message = format!("server refused initialize: [{}] {}", err.code, err.message);
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: message.clone(),
|
||
},
|
||
);
|
||
self.terminate_after_protocol_error(sid);
|
||
return;
|
||
}
|
||
let result_val = result.unwrap_or(Value::Null);
|
||
let server_pv = result_val
|
||
.get("protocolVersion")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_owned);
|
||
let pv_acceptable = server_pv
|
||
.as_deref()
|
||
.is_some_and(|v| SUPPORTED_PROTOCOL_VERSIONS.contains(&v));
|
||
if !pv_acceptable {
|
||
let reported = server_pv.as_deref().unwrap_or("<missing>");
|
||
let supported = SUPPORTED_PROTOCOL_VERSIONS.join(", ");
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!(
|
||
"server reported unsupported protocolVersion {reported:?}; pmacs supports {{{supported}}}"
|
||
),
|
||
},
|
||
);
|
||
self.terminate_after_protocol_error(sid);
|
||
return;
|
||
}
|
||
// Pass-3 finding 2: the MCP `initialize` result is required
|
||
// to include a `capabilities` object. A missing or non-object
|
||
// value is a protocol violation; we terminate rather than
|
||
// transitioning to Initialized with a defaulted-Null
|
||
// capabilities table that downstream consumers can't
|
||
// distinguish from a real "no capabilities advertised".
|
||
let caps_value = result_val.get("capabilities");
|
||
let caps = match caps_value {
|
||
Some(v) if v.is_object() => v.clone(),
|
||
_ => {
|
||
let observed = caps_value.map_or_else(
|
||
|| "<missing>".to_owned(),
|
||
|v| {
|
||
match v {
|
||
Value::Null => "null",
|
||
Value::Bool(_) => "boolean",
|
||
Value::Number(_) => "number",
|
||
Value::String(_) => "string",
|
||
Value::Array(_) => "array",
|
||
Value::Object(_) => "object",
|
||
}
|
||
.to_owned()
|
||
},
|
||
);
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::ProtocolError {
|
||
message: format!(
|
||
"initialize result missing required capabilities object (got {observed})"
|
||
),
|
||
},
|
||
);
|
||
self.terminate_after_protocol_error(sid);
|
||
return;
|
||
}
|
||
};
|
||
let server_info = result_val.get("serverInfo").cloned();
|
||
// Send `notifications/initialized` per the MCP spec.
|
||
let body = make_notification("notifications/initialized", json!({}));
|
||
if let Some(client) = self.clients.get(&sid) {
|
||
let _ = send_frame_to(&self.supervisor, client, &body);
|
||
}
|
||
if let Some(client) = self.clients.get_mut(&sid) {
|
||
client.state = McpClientState::Initialized {
|
||
capabilities: caps.clone(),
|
||
server_info,
|
||
protocol_version: server_pv,
|
||
initialized_at: now,
|
||
};
|
||
}
|
||
self.push_event(sid, now, McpEventKind::Initialized { capabilities: caps });
|
||
}
|
||
|
||
/// Terminate the running generation after a fatal protocol
|
||
/// violation. The process exit will land in the next tick as a
|
||
/// `Crashed` event, which the configured restart policy then
|
||
/// processes per [`should_restart`]. This is the MCP-equivalent
|
||
/// of the LSP layer's "frame violations are unrecoverable on the
|
||
/// same byte stream" path.
|
||
fn terminate_after_protocol_error(&mut self, sid: McpServerId) {
|
||
if let Some(client) = self.clients.get(&sid)
|
||
&& let Some(pid) = client.process
|
||
{
|
||
let _ = self.supervisor.borrow_mut().terminate(pid);
|
||
}
|
||
}
|
||
|
||
fn handle_request(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
idv: Value,
|
||
method: String,
|
||
params: Value,
|
||
now: Instant,
|
||
) {
|
||
// Surface the request to the consumer; pmacs doesn't
|
||
// synthesize a default reply (M9.5+ wires per-method handling).
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::Request {
|
||
id: idv,
|
||
method,
|
||
params,
|
||
},
|
||
);
|
||
}
|
||
|
||
fn handle_notification(
|
||
&mut self,
|
||
sid: McpServerId,
|
||
method: String,
|
||
params: Value,
|
||
now: Instant,
|
||
) {
|
||
// T M9.5: if the method is subscribed via on_notification,
|
||
// queue (server, params) for Lua-side dispatch on the next
|
||
// tick. The raw event still fires through push_event so
|
||
// callers using events_take continue to see notifications.
|
||
if self.notification_subscriptions.contains(&method) {
|
||
self.notification_queue
|
||
.entry(method.clone())
|
||
.or_default()
|
||
.push((sid, params.clone()));
|
||
}
|
||
self.push_event(sid, now, McpEventKind::Notification { method, params });
|
||
}
|
||
|
||
fn maybe_restart(&mut self, sid: McpServerId) {
|
||
let now = Instant::now();
|
||
let restart_now = {
|
||
let Some(client) = self.clients.get(&sid) else {
|
||
return;
|
||
};
|
||
let McpClientState::Crashed { .. } = &client.state else {
|
||
return;
|
||
};
|
||
match client.next_restart_at {
|
||
Some(at) => at <= now,
|
||
None => false,
|
||
}
|
||
};
|
||
if !restart_now {
|
||
return;
|
||
}
|
||
let attempt = {
|
||
let Some(client) = self.clients.get_mut(&sid) else {
|
||
return;
|
||
};
|
||
client.attempt + 1
|
||
};
|
||
self.push_event(sid, now, McpEventKind::Restarting { attempt });
|
||
let mut client = self.clients.remove(&sid).expect("checked existence above");
|
||
// Pass-2 finding 4: surface spawn failures rather than
|
||
// swallowing them. If the executable disappears, cwd is
|
||
// invalid, etc., emit Crashed with the spawn error and
|
||
// re-arm next_restart_at per policy. The retry loop is
|
||
// bounded only by the user dropping the manager or stopping
|
||
// the server; for v0.1 that's acceptable, since "binary
|
||
// gone" is a configuration error worth reporting loudly.
|
||
if let Err(err) = self.start_generation(sid, &mut client) {
|
||
client.state = McpClientState::Crashed {
|
||
reason: format!("respawn failed: {err}"),
|
||
ended: now,
|
||
};
|
||
client.next_restart_at = if should_restart(client.spec.restart, false, false) {
|
||
Some(now + self.restart_backoff)
|
||
} else {
|
||
None
|
||
};
|
||
self.clients.insert(sid, client);
|
||
self.push_event(
|
||
sid,
|
||
now,
|
||
McpEventKind::Crashed {
|
||
reason: format!("respawn failed: {err}"),
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
self.clients.insert(sid, client);
|
||
}
|
||
|
||
fn push_event(&mut self, sid: McpServerId, at: Instant, kind: McpEventKind) {
|
||
let event = McpEvent {
|
||
server: sid,
|
||
kind,
|
||
at,
|
||
};
|
||
self.pending.entry(sid).or_default().push(event);
|
||
}
|
||
|
||
/// Drain the pending event queue for `sid`. Returns an empty vec
|
||
/// for unknown ids.
|
||
pub fn take_events(&mut self, sid: McpServerId) -> Vec<McpEvent> {
|
||
self.pending.remove(&sid).unwrap_or_default()
|
||
}
|
||
|
||
/// Drain every pending event across every server. Returns events
|
||
/// sorted by enqueue time.
|
||
pub fn take_all_events(&mut self) -> Vec<McpEvent> {
|
||
let mut all = Vec::new();
|
||
for (_id, mut evs) in std::mem::take(&mut self.pending) {
|
||
all.append(&mut evs);
|
||
}
|
||
all.sort_by_key(|e| e.at);
|
||
all
|
||
}
|
||
|
||
/// Iterator over every server id, in arbitrary order.
|
||
pub fn ids(&self) -> impl Iterator<Item = McpServerId> + '_ {
|
||
self.clients.keys().copied()
|
||
}
|
||
|
||
/// Server state for `sid`, or `None` if the id is unknown.
|
||
#[must_use]
|
||
pub fn state(&self, sid: McpServerId) -> Option<&McpClientState> {
|
||
self.clients.get(&sid).map(|c| &c.state)
|
||
}
|
||
|
||
/// Server spec for `sid`, or `None` if the id is unknown.
|
||
#[must_use]
|
||
pub fn spec(&self, sid: McpServerId) -> Option<&McpServerSpec> {
|
||
self.clients.get(&sid).map(|c| &c.spec)
|
||
}
|
||
|
||
/// Server capabilities for `sid`, or `None` if the id is unknown
|
||
/// or the server isn't initialized.
|
||
#[must_use]
|
||
pub fn capabilities(&self, sid: McpServerId) -> Option<&Value> {
|
||
self.clients.get(&sid).and_then(McpClient::capabilities)
|
||
}
|
||
|
||
/// Server-reported `serverInfo` from the initialize response, if
|
||
/// the server provided one.
|
||
#[must_use]
|
||
pub fn server_info(&self, sid: McpServerId) -> Option<&Value> {
|
||
match self.state(sid)? {
|
||
McpClientState::Initialized { server_info, .. } => server_info.as_ref(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Server-reported `protocolVersion`, if any.
|
||
#[must_use]
|
||
pub fn protocol_version(&self, sid: McpServerId) -> Option<&str> {
|
||
match self.state(sid)? {
|
||
McpClientState::Initialized {
|
||
protocol_version, ..
|
||
} => protocol_version.as_deref(),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Cumulative spawn-attempt count for `sid`. 1 = first spawn,
|
||
/// 2 = first restart, ...
|
||
#[must_use]
|
||
pub fn attempt(&self, sid: McpServerId) -> Option<u32> {
|
||
self.clients.get(&sid).map(|c| c.attempt)
|
||
}
|
||
|
||
/// Forget about `sid`. Server must already be in a terminal state.
|
||
pub fn forget(&mut self, sid: McpServerId) -> Result<(), String> {
|
||
let client = self
|
||
.clients
|
||
.get(&sid)
|
||
.ok_or_else(|| format!("unknown server: {sid}"))?;
|
||
if !matches!(
|
||
client.state,
|
||
McpClientState::Stopped { .. } | McpClientState::Crashed { .. }
|
||
) {
|
||
return Err(format!("server {sid} is not in a terminal state"));
|
||
}
|
||
self.clients.remove(&sid);
|
||
self.pending.remove(&sid);
|
||
// Pass-2 (M9.2) finding 1: belt-and-braces. `on_exit`
|
||
// already cleared the cache when the server reached a
|
||
// terminal state; this second sweep handles the (rare)
|
||
// case where forget is called against a server that
|
||
// somehow accumulated cache entries after on_exit, and
|
||
// makes the post-forget invariant ("no cache for sid")
|
||
// explicit at this call site too.
|
||
self.drop_resource_cache_for(sid);
|
||
Ok(())
|
||
}
|
||
|
||
/// Initiate shutdown for every server. Mirrors
|
||
/// [`crate::lsp::LspManager::shutdown_all`].
|
||
pub fn shutdown_all(&mut self) {
|
||
let ids: Vec<McpServerId> = self.clients.keys().copied().collect();
|
||
for id in ids {
|
||
let _ = self.stop(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn next_request_id(client: &mut McpClient) -> u64 {
|
||
let id = client.next_request_id;
|
||
client.next_request_id += 1;
|
||
id
|
||
}
|
||
|
||
fn state_label(state: &McpClientState) -> &'static str {
|
||
match state {
|
||
McpClientState::Starting => "starting",
|
||
McpClientState::Initializing { .. } => "initializing",
|
||
McpClientState::Initialized { .. } => "initialized",
|
||
McpClientState::ShuttingDown => "shutting-down",
|
||
McpClientState::Stopped { .. } => "stopped",
|
||
McpClientState::Crashed { .. } => "crashed",
|
||
}
|
||
}
|
||
|
||
/// Translate an [`McpClientState`] to a stable `&'static str` label.
|
||
/// Used by the Lua surface and tests so the spelling of state names
|
||
/// is locked down in one place.
|
||
#[must_use]
|
||
pub fn state_label_for(state: &McpClientState) -> &'static str {
|
||
state_label(state)
|
||
}
|
||
|
||
/// Should the manager respawn after this exit? Pass-2 finding 5:
|
||
/// `OnCrash` matches the documented "signal or non-zero exit"
|
||
/// contract — clean exits do not trigger a restart. `Always`
|
||
/// restarts regardless. A user-initiated `stop` (`was_shutdown`)
|
||
/// always wins: even `Always` does not respawn when we asked the
|
||
/// server to shut down.
|
||
fn should_restart(policy: McpRestartPolicy, clean: bool, was_shutdown: bool) -> bool {
|
||
if was_shutdown {
|
||
return false;
|
||
}
|
||
match policy {
|
||
McpRestartPolicy::Never => false,
|
||
McpRestartPolicy::OnCrash => !clean,
|
||
McpRestartPolicy::Always => true,
|
||
}
|
||
}
|
||
|
||
#[derive(Copy, Clone, Debug)]
|
||
enum TerminalKind {
|
||
Stopped,
|
||
Crashed,
|
||
}
|
||
|
||
fn make_request(id: u64, method: &str, params: Value) -> Vec<u8> {
|
||
let mut body = Map::new();
|
||
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
||
body.insert("id".into(), id.into());
|
||
body.insert("method".into(), Value::String(method.to_owned()));
|
||
body.insert("params".into(), params);
|
||
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
||
encode_frame(&bytes)
|
||
}
|
||
|
||
fn make_notification(method: &str, params: Value) -> Vec<u8> {
|
||
let mut body = Map::new();
|
||
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
||
body.insert("method".into(), Value::String(method.to_owned()));
|
||
body.insert("params".into(), params);
|
||
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
||
encode_frame(&bytes)
|
||
}
|
||
|
||
/// Extract a human-readable error message from a `tools/call`
|
||
/// `isError: true` response. T M9.3.
|
||
///
|
||
/// MCP's `content` array may have multiple parts (text, image, etc.).
|
||
/// The Failed outcome carries a single string, so:
|
||
/// - Text parts are concatenated in order with newline separators.
|
||
/// - Non-text parts are replaced with `[non-text content omitted]`
|
||
/// placeholders, preserving their position so users can see where
|
||
/// in the message the omitted content was.
|
||
/// - Order is preserved exactly as the server returned it; we don't
|
||
/// reorder, drop, or collapse adjacent parts.
|
||
///
|
||
/// Lua callers needing structured access to the raw `{isError,
|
||
/// content}` table use `pmacs.mcp.send_request("tools/call", ...)`
|
||
/// to bypass the translator.
|
||
fn extract_tool_error_message(result: &Value) -> String {
|
||
let Some(content) = result.get("content").and_then(Value::as_array) else {
|
||
return "tool errored (no content provided)".to_owned();
|
||
};
|
||
let parts: Vec<String> = content
|
||
.iter()
|
||
.map(|part| {
|
||
let kind = part.get("type").and_then(Value::as_str);
|
||
if kind == Some("text") {
|
||
part.get("text")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("")
|
||
.to_owned()
|
||
} else {
|
||
"[non-text content omitted]".to_owned()
|
||
}
|
||
})
|
||
.collect();
|
||
if parts.is_empty() {
|
||
"tool errored (empty content)".to_owned()
|
||
} else {
|
||
parts.join("\n")
|
||
}
|
||
}
|
||
|
||
fn make_response(id: Value, result: Result<Value, McpError>) -> Vec<u8> {
|
||
let mut body = Map::new();
|
||
body.insert("jsonrpc".into(), Value::String("2.0".into()));
|
||
body.insert("id".into(), id);
|
||
match result {
|
||
Ok(v) => {
|
||
body.insert("result".into(), v);
|
||
}
|
||
Err(e) => {
|
||
let mut err_obj = Map::new();
|
||
err_obj.insert("code".into(), e.code.into());
|
||
err_obj.insert("message".into(), Value::String(e.message));
|
||
if let Some(d) = e.data {
|
||
err_obj.insert("data".into(), d);
|
||
}
|
||
body.insert("error".into(), Value::Object(err_obj));
|
||
}
|
||
}
|
||
let bytes = serde_json::to_vec(&Value::Object(body)).expect("json serialize");
|
||
encode_frame(&bytes)
|
||
}
|
||
|
||
fn send_frame_to(
|
||
supervisor: &crate::lua_bindings::SharedProcessSupervisor,
|
||
client: &McpClient,
|
||
framed: &[u8],
|
||
) -> Result<(), String> {
|
||
let pid = client
|
||
.process
|
||
.ok_or_else(|| "server has no live process".to_owned())?;
|
||
supervisor.borrow_mut().write_stdin(pid, framed)
|
||
}
|
||
|
||
/// Build the `initialize` request body for the v0.1 MCP client. The
|
||
/// payload advertises the conservative client capability set
|
||
/// (currently empty) plus pmacs's clientInfo. Mirrors LSP's
|
||
/// `build_initialize` in shape; the field set is MCP-specific.
|
||
///
|
||
/// Pass-2 finding 3: `protocolVersion` is the latest revision pmacs
|
||
/// supports per [`PREFERRED_PROTOCOL_VERSION`]; servers respond with
|
||
/// the version they negotiated and pmacs validates it against
|
||
/// [`SUPPORTED_PROTOCOL_VERSIONS`] in
|
||
/// [`McpManager::handle_initialize_response`].
|
||
fn build_initialize(request_id: u64) -> Vec<u8> {
|
||
let params = json!({
|
||
"protocolVersion": PREFERRED_PROTOCOL_VERSION,
|
||
"capabilities": default_client_capabilities(),
|
||
"clientInfo": {
|
||
"name": "pmacs",
|
||
"version": env!("CARGO_PKG_VERSION"),
|
||
},
|
||
});
|
||
make_request(request_id, "initialize", params)
|
||
}
|
||
|
||
/// Conservative default MCP client capabilities. v0.1 advertises
|
||
/// nothing (no roots/list, no sampling); M9.5+ may add fields as
|
||
/// concrete client-side features land.
|
||
fn default_client_capabilities() -> Value {
|
||
json!({})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SharedMcpManager
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Main-thread shared handle, mirroring
|
||
/// [`crate::lsp::SharedLspManager`].
|
||
pub type SharedMcpManager = Rc<RefCell<McpManager>>;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn ndjson_parser_extracts_complete_lines() {
|
||
let mut p = NdjsonParser::new();
|
||
p.extend(b"{\"a\":1}\n{\"b\":2}\n");
|
||
assert_eq!(p.next_frame().as_deref(), Some(&b"{\"a\":1}"[..]));
|
||
assert_eq!(p.next_frame().as_deref(), Some(&b"{\"b\":2}"[..]));
|
||
assert!(p.next_frame().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ndjson_parser_buffers_partial_line() {
|
||
let mut p = NdjsonParser::new();
|
||
p.extend(b"{\"a\":");
|
||
assert!(p.next_frame().is_none());
|
||
p.extend(b"1}\n");
|
||
assert_eq!(p.next_frame().as_deref(), Some(&b"{\"a\":1}"[..]));
|
||
}
|
||
|
||
#[test]
|
||
fn ndjson_parser_skips_empty_lines() {
|
||
let mut p = NdjsonParser::new();
|
||
p.extend(b"\n\n{\"a\":1}\n");
|
||
assert_eq!(p.next_frame().as_deref(), Some(&b"{\"a\":1}"[..]));
|
||
assert!(p.next_frame().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn ndjson_parser_strips_crlf() {
|
||
let mut p = NdjsonParser::new();
|
||
p.extend(b"{\"a\":1}\r\n");
|
||
assert_eq!(p.next_frame().as_deref(), Some(&b"{\"a\":1}"[..]));
|
||
}
|
||
|
||
#[test]
|
||
fn encode_frame_appends_newline() {
|
||
assert_eq!(encode_frame(b"{\"x\":1}"), b"{\"x\":1}\n");
|
||
}
|
||
|
||
#[test]
|
||
fn server_id_is_monotonic() {
|
||
let a = McpServerId::next();
|
||
let b = McpServerId::next();
|
||
assert!(a.raw() < b.raw());
|
||
}
|
||
}
|