Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/acp_handler/mcp.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

321 lines
12 KiB
Rust

use super::*;
/// Cached `mcp.push_server_status` flag resolution.
///
/// Resolution mirrors the `mcp.liveness_watchers` flag pattern but
/// only stacks the env+default layers — the pager process does not load
/// `config.toml` / `requirements.toml`. The first call performs one
/// `BoolFlag::env` read; every subsequent call is a pure
/// `OnceLock::get` (single atomic load). Default `true`; set
/// `KIGI_MCP_PUSH_SERVER_STATUS=0` to disable.
pub(super) fn push_server_status_enabled() -> bool {
use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
kigi_shell::util::config::resolve_mcp_push_server_status(
/* requirements */ None, /* user */ None, /* managed */ None,
)
})
}
pub(super) fn handle_mcp_init_progress(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
total: u32,
connected: u32,
#[serde(default)]
session_id: Option<String>,
}
let Ok(payload) = serde_json::from_str::<Payload>(notif.params.get()) else {
return false;
};
let Some((is_active, agent)) = mcp_target_agent(app, payload.session_id.as_deref()) else {
return false;
};
if let Some(ref mut progress) = agent.mcp_init_progress {
progress.total = payload.total;
progress.connected = payload.connected;
} else {
agent.mcp_init_progress = Some(super::super::agent_view::McpInitProgress {
total: payload.total,
connected: payload.connected,
started_at: std::time::Instant::now(),
});
}
is_active
}
/// Handle `kigi/mcp/tools_changed` and `kigi/mcp_initialized`.
///
/// Routing rules (verified against the four shell emit sites in
/// `kigi-shell/src/session/acp_session.rs` — toggle-tool ~L6661,
/// `emit_mcp_tools_changed_notifications` ~L8997 and post-handshake
/// ~L10156, plus `mcp_initialized` ~L10157):
///
/// 1. Try `notif.params.sessionId`. All `tools_changed` emit
/// sites carry `sessionId` (the typed
/// [`kigi_shell::extensions::mcp::McpToolsChanged`] struct), and
/// `mcp_initialized` already carried it. So the sessionId branch
/// is the primary path for current builds.
///
/// 2. Older shells / forward-compat (**`tools_changed` only**): a
/// payload with no `sessionId` falls back to
/// `app.active_view`. Older shells emit `tools_changed` as
/// `{serverName, tools}` with no `sessionId`; the fallback keeps
/// those in-flight payloads working. The `mcp_initialized`
/// variant does NOT need this fallback — its emitter already
/// carries `sessionId`, so
/// the sessionId branch (step 1) is the only matched-build path
/// for `mcp_initialized`.
///
/// 3. When the owning agent has an open extensions modal, schedules
/// a debounced [`Effect::FetchMcpsList`] coalesced **per-agent**
/// (see [`agent_has_pending_mcps_fetch`]). A pending fetch
/// on agent A does NOT drop a notification for agent B.
///
/// Always clears `mcp_init_progress` on the `mcp_initialized` variant.
pub(super) fn handle_mcp_tools_changed(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
let method = notif.method.as_ref();
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Payload {
#[serde(default)]
session_id: Option<String>,
}
let session_id = serde_json::from_str::<Payload>(notif.params.get())
.ok()
.and_then(|p| p.session_id);
let target: Option<(bool, AgentId)> = match session_id.as_deref() {
Some(sid) => {
let sid = acp::SessionId::new(sid);
match find_session_match(app, &sid) {
// Subagent (child) sessions don't own the top-level MCP
// modal / connecting indicator — drop them.
Some(SessionMatch::Child(_)) => None,
Some(matched) => {
let id = matched.agent_id();
Some((is_matched_agent_active(app, id), id))
}
None => None,
}
}
None => match app.active_view {
ActiveView::Agent(id) => Some((true, id)),
_ => None,
},
};
let Some((is_active, id)) = target else {
return false;
};
let mut redraw = false;
// `mcp_initialized` clears the matched agent's connecting indicator.
if method == "kigi/mcp_initialized"
&& let Some(agent) = app.agents.get_mut(&id)
&& agent.mcp_init_progress.take().is_some()
{
redraw |= is_active;
}
// Modal refresh: schedule a debounced refetch for the OWNING agent
// (routed by sessionId), per-agent coalesced.
let modal_open = app
.agents
.get(&id)
.is_some_and(|a| a.extensions_modal.is_some());
if modal_open
&& !agent_has_pending_mcps_fetch(app, id)
&& let Some(session_id) = app
.agents
.get(&id)
.and_then(|a| a.session.session_id.clone())
{
app.pending_effects.push(Effect::FetchMcpsList {
agent_id: id,
session_id,
cache: true,
});
redraw |= is_active;
}
redraw
}
/// Per-agent coalescing test for [`Effect::FetchMcpsList`]. Keyed on
/// `agent_id` so a pending fetch on agent A does not drop the push for
/// agent B; each agent's refetch is independently debounced.
pub(super) fn agent_has_pending_mcps_fetch(app: &AppView, agent_id: AgentId) -> bool {
app.pending_effects.iter().any(|e| {
matches!(
e,
Effect::FetchMcpsList { agent_id: a, .. } if *a == agent_id
)
})
}
/// Handle `kigi/mcp/server_status`.
///
/// Routes by the notification's `sessionId` via
/// [`find_session_match`] — the matched agent's extensions modal is
/// patched in-place via [`crate::views::mcps_modal::patch_server_row`]
/// using the per-row delta, avoiding the full `mcp/list` round trip
/// the legacy `tools_changed` debounced refetch path requires.
///
/// No-ops when:
/// - the `sessionId` does not match any known agent (drop),
/// - the matched agent has no extensions modal open (cheap path —
/// the next `/mcps` open will pull fresh data anyway),
/// - the modal's `mcps_data` is not yet `Loaded` (Loading / Error
/// states would produce incoherent patches; the in-flight fetch
/// will land a consistent snapshot shortly),
/// - the named server is not present in the cached `servers` vec
/// ([`patch_server_row`] silently returns).
///
/// Re-uses the shell's canonical wire types
/// ([`kigi_shell::extensions::mcp::McpServerStatusPayload`] +
/// [`kigi_shell::extensions::mcp::McpServerStatus`]) instead of
/// re-declaring a parallel pager enum. Later variants (e.g.
/// `RestartSucceeded` / `RestartFailed`) ride through automatically
/// without a pager code change.
///
/// `status` is **not** `serde(default)`; a malformed
/// payload falls into the `tracing::warn!` arm rather than silently
/// re-painting the row red.
///
/// `tools` is decoded loosely as
/// `Option<serde_json::Value>` so a future non-array shape doesn't
/// drop the entire push — `status` still applies, and `tools` is
/// silently skipped (warn-logged) on shape mismatch.
///
/// Returns `true` (request redraw) only when the row mutation
/// happened AND the matched agent is the currently active view.
pub(super) fn handle_mcp_server_status(notif: &acp::ExtNotification, app: &mut AppView) -> bool {
use crate::views::extensions_modal::TabDataState;
use crate::views::mcps_modal::{McpServerDisplayStatus, McpToolDetail, patch_server_row};
use kigi_shell::extensions::mcp::{McpServerStatus, McpServerStatusPayload, McpToolEntry};
let Ok(payload) = serde_json::from_str::<McpServerStatusPayload>(notif.params.get()) else {
tracing::warn!(
"Failed to parse kigi/mcp/server_status: {}",
&notif.params.get()
[..crate::render::line_utils::floor_char_boundary(notif.params.get(), 100)]
);
return false;
};
let session_id = acp::SessionId::new(payload.session_id);
let Some(matched) = find_session_match(app, &session_id) else {
return false;
};
let id = matched.agent_id();
let is_active = is_matched_agent_active(app, id);
let Some(agent) = app.agents.get_mut(&id) else {
return false;
};
// Cheap path: modal closed. Drop the push — the next `/mcps`
// open will fetch a fresh full list.
let Some(modal) = agent.extensions_modal.as_mut() else {
return false;
};
// Cheap path: list still loading / errored. Patching would
// produce incoherent state; the in-flight fetch will land
// a consistent snapshot momentarily.
let TabDataState::Loaded(ref mut servers) = modal.mcps_data else {
return false;
};
let display_status = match payload.status {
McpServerStatus::Ready => McpServerDisplayStatus::Ready,
McpServerStatus::Initializing => McpServerDisplayStatus::Initializing,
McpServerStatus::Unavailable => McpServerDisplayStatus::Unavailable,
McpServerStatus::NeedsAuth => McpServerDisplayStatus::NeedsAuth,
};
// Decode `tools` loosely. Shell types this as
// `Option<serde_json::Value>` (always `null` today;
// reserved). If the value is present but not an array of
// `McpToolEntry`-isomorphic objects we drop ONLY the tools
// update and still apply the status.
let new_tools = payload.tools.and_then(|raw| {
match serde_json::from_value::<Vec<McpToolEntry>>(raw) {
Ok(entries) => Some(
entries
.into_iter()
.map(|t| McpToolDetail {
name: t.name,
display_name: t.display_name,
description: t.description,
enabled: t.enabled,
})
.collect::<Vec<_>>(),
),
Err(e) => {
tracing::warn!(
server = %payload.name,
error = %e,
"kigi/mcp/server_status: tools field present but not Vec<McpToolEntry>; status still applied"
);
None
}
}
});
let mutated = patch_server_row(servers, &payload.name, display_status, new_tools);
mutated && is_active
}
/// Handle `kigi/mcp/servers_updated`.
///
/// Emitted by the shell from `MvpAgent` on managed-config resolve and
/// on config reload (`crates/codegen/kigi-shell/src/agent/mvp_agent.rs`
/// → `notify_servers_updated`). The shell's
/// `McpServersUpdated` wire shape (`{ mcpServers: [...] }`) is
/// intentionally session-agnostic by design. Routing by `sessionId`
/// would therefore always fall back to `app.active_view` and
/// reintroduce the multi-agent bug.
///
/// Routing broadcasts: every agent with an open
/// extensions modal gets a per-agent debounced [`Effect::FetchMcpsList`].
/// Per-agent coalescing keeps a second push from displacing an
/// in-flight fetch on the same agent. Agents without an open modal
/// drop the push (cheap path).
pub(super) fn handle_mcp_servers_updated(_notif: &acp::ExtNotification, app: &mut AppView) -> bool {
// `_notif` is intentionally unread. The shell's
// `McpServersUpdated` payload is `{ mcpServers: [...] }` with no
// `sessionId` (the protocol forbids extending it), so there is
// nothing in the notification body the broadcast model needs.
// Do NOT "fix" this back to per-session routing without
// re-reading the rustdoc above.
//
// Snapshot (agent_id, session_id, modal_open) up front so the
// mutable `pending_effects` borrow can proceed without
// aliasing `app.agents`.
let targets: Vec<(AgentId, acp::SessionId)> = app
.agents
.iter()
.filter_map(|(id, agent)| {
if agent.extensions_modal.is_some() {
agent.session.session_id.clone().map(|sid| (*id, sid))
} else {
None
}
})
.collect();
if targets.is_empty() {
return false;
}
let mut redraw = false;
for (id, session_id) in targets {
if agent_has_pending_mcps_fetch(app, id) {
continue;
}
let is_active = is_matched_agent_active(app, id);
app.pending_effects.push(Effect::FetchMcpsList {
agent_id: id,
session_id,
cache: true,
});
redraw |= is_active;
}
redraw
}