Skip to main content

forge_workspace/
mcp.rs

1//! In-process MCP server forge exposes to every spawned `claude`
2//! subprocess.
3//!
4//! The single MCP server is named `forge` and grouped by submodule:
5//!
6//! - `agents` - every session reaches every other by its slot: `list`,
7//!   `tell`, `ask`, `whoami` and `handoff` for any caller, plus `spawn`,
8//!   `despawn`, `update` and `capacity` for a lead. Tools render as
9//!   `mcp__forge__agents__<name>`.
10//! - `documents` - the document hand-off: a session hands the person at a
11//!   client a document that opens in the client's own viewer. Renders as
12//!   `mcp__forge__documents__hand_off`.
13//! - `review` - the review-conversation loop (list / get / reply /
14//!   resolve). Tools render as `mcp__forge__review__<name>`.
15//! - `cron` - the caller's own durable crons.
16//! - `tasks` - the caller's own project's live task list.
17//! - `gotify` - the caller's own Gotify subscriptions.
18//! - `slack` - the caller's own Slack subscriptions, reads and held
19//!   outbound actions.
20//! - `settings` - forge's own configuration (`index`, `get`, `set`,
21//!   `unset`), with every write held for the reader's approval.
22//! - `systemone` - the decision tools (`ask_noul`, `ask_choice`,
23//!   `ask_score`), injected only when `[systemone]` is configured and
24//!   enabled.
25//! - `voice` - `say`: one utterance spoken aloud to the person listening to
26//!   the seat. Renders as `mcp__forge__voice__say`.
27//! - `vault` - the Vaultwarden family: search, read with a reveal, create,
28//!   update, soft-delete and restore items, compute TOTP codes, move
29//!   attachments, and manage folders. Every call is held for the reader's
30//!   approval. Renders as `mcp__forge__vault__<name>`.
31//!
32//! Tool surface depends on the calling session's kind:
33//!
34//! - **Lead** sessions (project leads, including project sessions
35//!   another agent spawned) see all eight `agents__*` verbs. A lead is
36//!   the only role that can spawn, despawn, update or read capacity,
37//!   because each of those acts on the caller's own project.
38//! - **Worker** sessions see the four shared verbs and none of the
39//!   lead-only ones. The reach is the same: a worker may address any
40//!   other session by its slot, its own lead and siblings included. A
41//!   handoff is shared because a seat is its own to hand over.
42//!
43//! Future submodules slot in alongside these (e.g. `worktree`,
44//! `memory`) without changing the server name or the auto-approve
45//! fast-path in `forge-sdk::control_dispatch` (which matches the
46//! `mcp__forge__` prefix at the tool-name level).
47
48use std::collections::BTreeSet;
49use std::sync::Arc;
50
51use forge_sdk::mcp::server::{McpServer, McpServerBuilder};
52
53use crate::SessionSlot;
54use crate::mcp::agents::facade::AgentDispatcher;
55use crate::mcp::browser::facade::BrowserFacade;
56use crate::mcp::cron::facade::CronFacade;
57use crate::mcp::documents::facade::DocumentFacade;
58use crate::mcp::gotify::facade::GotifyFacade;
59use crate::mcp::peers::facade::WorkspaceFacade;
60use crate::mcp::review::facade::ReviewFacade;
61use crate::mcp::settings::facade::SettingsFacade;
62use crate::mcp::slack::facade::SlackFacade;
63use crate::mcp::systemone::facade::SystemOneFacade;
64use crate::mcp::tasks::facade::TasksFacade;
65use crate::mcp::vault::facade::VaultFacade;
66use crate::mcp::workers::facade::WorkerFacade;
67
68pub mod agents;
69pub mod browser;
70pub(crate) mod caller_context;
71pub mod cron;
72pub(crate) mod deleted;
73pub mod documents;
74pub mod gotify;
75pub mod peers;
76pub mod review;
77pub mod settings;
78pub mod slack;
79pub mod systemone;
80pub mod tasks;
81pub mod terminal;
82#[cfg(test)]
83pub(crate) mod test_support;
84pub mod vault;
85pub mod voice;
86pub mod workers;
87
88/// Identifies which kind of session the MCP server is being built
89/// for. Drives the tool-surface filter in [`build_forge_server`].
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SessionKind {
92    /// Project lead - the session representing a project. Sees all
93    /// eight `agents__*` verbs.
94    Lead,
95    /// Worker - a child agent a lead spawned. Sees the four shared
96    /// `agents__*` verbs and none of the lead-only ones.
97    Worker,
98}
99
100/// The toggleable MCP families. `agents` is always on and is not here:
101/// a worker without `tell`/`ask` cannot report to its lead.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
103pub enum McpFamily {
104    Review,
105    Cron,
106    Tasks,
107    Gotify,
108    Slack,
109    Systemone,
110    Voice,
111    Vault,
112}
113
114impl McpFamily {
115    /// Every toggleable family, in wire order.
116    pub const ALL: [McpFamily; 8] = [
117        McpFamily::Review,
118        McpFamily::Cron,
119        McpFamily::Tasks,
120        McpFamily::Gotify,
121        McpFamily::Slack,
122        McpFamily::Systemone,
123        McpFamily::Voice,
124        McpFamily::Vault,
125    ];
126
127    /// The default set: a worker whose row names none gets everything.
128    pub fn all() -> BTreeSet<McpFamily> {
129        Self::ALL.into_iter().collect()
130    }
131
132    /// The wire spelling, also the family segment its tool names carry.
133    pub fn as_str(self) -> &'static str {
134        match self {
135            McpFamily::Review => "review",
136            McpFamily::Cron => "cron",
137            McpFamily::Tasks => "tasks",
138            McpFamily::Gotify => "gotify",
139            McpFamily::Slack => "slack",
140            McpFamily::Systemone => "systemone",
141            McpFamily::Voice => "voice",
142            McpFamily::Vault => "vault",
143        }
144    }
145
146    /// Parse a spawn argument's spelling; `None` for anything else.
147    pub fn parse(name: &str) -> Option<Self> {
148        Self::ALL.into_iter().find(|family| family.as_str() == name)
149    }
150}
151
152/// A stored selection's resolved set: absent or empty means every
153/// family; names are canonicalised into wire order and de-duplicated.
154/// Unknown names cannot be written by either tool (both validate), but a
155/// hand-edited row could carry one - it is dropped loudly rather than
156/// widening or narrowing the surface silently.
157pub(crate) fn resolve_mcp_families(stored: Option<&[String]>) -> BTreeSet<McpFamily> {
158    let Some(stored) = stored else {
159        return McpFamily::all();
160    };
161    if stored.is_empty() {
162        return McpFamily::all();
163    }
164    let requested: BTreeSet<McpFamily> = stored
165        .iter()
166        .filter_map(|name| {
167            let parsed = McpFamily::parse(name);
168            if parsed.is_none() {
169                tracing::warn!(
170                    target: "forge_workspace::mcp",
171                    event_name = "unknown_mcp_family_in_row",
172                    name = %name,
173                    "a stored mcp family name is not selectable; ignoring it",
174                );
175            }
176            parsed
177        })
178        .collect();
179    if requested.is_empty() {
180        // A row nothing valid survives from is corrupt, not a selection
181        // of nothing: fail open to every family the way an unreadable
182        // row does, never to agents-only beside a withheld line per family.
183        tracing::warn!(
184            target: "forge_workspace::mcp",
185            event_name = "mcp_family_row_all_unknown",
186            "every stored mcp family name was unselectable; reading the row as every family",
187        );
188        return McpFamily::all();
189    }
190    requested
191}
192
193/// The effective set: the stored selection resolved, then narrowed by
194/// what this forge's config can actually inject - the same gates the
195/// build applies, so the prompt's withheld lines cannot drift from the
196/// tools the session really gets. `[systemone]` and `[tts]` off are the
197/// config gates that remove a family outright.
198pub(crate) fn effective_mcp_families(
199    stored: Option<&[String]>,
200    systemone_available: bool,
201    voice_available: bool,
202    vault_available: bool,
203) -> BTreeSet<McpFamily> {
204    let mut families = resolve_mcp_families(stored);
205    if !systemone_available {
206        families.remove(&McpFamily::Systemone);
207    }
208    if !voice_available {
209        families.remove(&McpFamily::Voice);
210    }
211    if !vault_available {
212        families.remove(&McpFamily::Vault);
213    }
214    families
215}
216
217/// The canonical stored form of a selection: names in wire order,
218/// de-duplicated; an empty list means every family, so it is stored as
219/// `None`.
220pub(crate) fn canonical_mcp_families(names: &[String]) -> Result<Option<Vec<String>>, String> {
221    if names.is_empty() {
222        return Ok(None);
223    }
224    let mut selected: BTreeSet<McpFamily> = BTreeSet::new();
225    for name in names {
226        if name == "agents" {
227            return Err("`agents` is always on for every worker and cannot be listed".to_owned());
228        }
229        // The browser is the other always-on group, for a different reason:
230        // it is one machine-global client rather than a project's scope, so
231        // there is nothing a project could withhold.
232        if name == "browser" {
233            return Err("`browser` is always on for every session and cannot be listed".to_owned());
234        }
235        let Some(family) = McpFamily::parse(name) else {
236            let selectable: Vec<&str> =
237                McpFamily::ALL.iter().map(|family| family.as_str()).collect();
238            return Err(format!(
239                "unknown MCP family `{name}`; the selectable families are: {}",
240                selectable.join(", ")
241            ));
242        };
243        selected.insert(family);
244    }
245    Ok(Some(selected.into_iter().map(McpFamily::as_str).map(str::to_owned).collect()))
246}
247
248/// The lines a worker's prompt carries for the families it was NOT
249/// given: one per withheld family, in wire order. All families selected
250/// means no lines. This wording ships as the worker's own instruction
251/// (rule 17), so it says what is absent and what to do instead.
252pub(crate) fn withheld_family_lines(families: &BTreeSet<McpFamily>) -> Vec<&'static str> {
253    McpFamily::ALL
254        .into_iter()
255        .filter(|family| !families.contains(family))
256        .map(|family| match family {
257            McpFamily::Review => {
258                "This session has no `review__*` tools. Review threads for your work are \
259                 handled through your lead - ask it to relay findings or to post your replies."
260            }
261            McpFamily::Cron => {
262                "This session has no `cron__*` tools. You cannot schedule durable work; ask \
263                 your lead to set up any recurring task."
264            }
265            McpFamily::Tasks => {
266                "This session has no `tasks__*` tools. The project task list is not yours to \
267                 edit; report status to your lead instead."
268            }
269            McpFamily::Gotify => {
270                "This session has no `gotify__*` tools. Notifications from Gotify do not \
271                 reach you; your lead carries anything urgent."
272            }
273            McpFamily::Slack => {
274                "This session has no `slack__*` tools. Channel reads and posts go through \
275                 your lead."
276            }
277            McpFamily::Systemone => {
278                "This session has no `systemone__*` decision tools. When a decision is \
279                 material and not obvious, route it to your lead instead of guessing."
280            }
281            McpFamily::Voice => {
282                "This session has no `voice__*` tools. The person listening cannot hear this \
283                 session; put anything they should know in your message to your lead."
284            }
285            McpFamily::Vault => {
286                "This session has no `vault__*` tools. Credentials in the vault are not \
287                 reachable from this session; ask your lead to read or change them."
288            }
289        })
290        .collect()
291}
292
293/// The facades one session's server is composed from: the agents pair
294/// plus one per toggleable family.
295pub struct ForgeServerFacades {
296    pub workspace: Arc<dyn WorkspaceFacade>,
297    pub worker: Arc<dyn WorkerFacade>,
298    pub browser: Arc<dyn BrowserFacade>,
299    pub terminal: Arc<dyn crate::mcp::terminal::facade::TerminalFacade>,
300    pub documents: Arc<dyn DocumentFacade>,
301    pub review: Arc<dyn ReviewFacade>,
302    pub cron: Arc<dyn CronFacade>,
303    pub gotify: Arc<dyn GotifyFacade>,
304    pub slack: Arc<dyn SlackFacade>,
305    pub settings: Arc<dyn SettingsFacade>,
306    pub tasks: Arc<dyn TasksFacade>,
307    pub systemone: Option<Arc<dyn SystemOneFacade>>,
308    pub voice: Arc<crate::voice::VoiceService>,
309    /// The vault family's facade, `None` when `[vault]` is absent. The tools
310    /// are doubly gated like systemone's: the family must be selected and a
311    /// session must exist.
312    pub vault: Option<Arc<dyn VaultFacade>>,
313}
314
315/// Build the per-session `forge` MCP server. ONE McpServer named
316/// `forge` carrying the coordination tool groups appropriate for the
317/// calling session's [`SessionKind`]:
318///
319/// - [`SessionKind::Lead`] → agents (all seven) + the selected families.
320/// - [`SessionKind::Worker`] → agents (the shared three) + the selected
321///   families.
322///
323/// `families` narrows the toggleable groups: only the selected ones
324/// register. The `agents` core is unconditional, and `systemone` is
325/// doubly gated - the family and a configured client.
326///
327/// `review`, `cron`, `tasks`, `gotify` and `slack` are any-caller, so they
328/// register for both kinds - unlike the four lead-only `agents__*` verbs. What
329/// each acts on is scoped rather than gated on session kind: a review
330/// conversation belongs to a project and branch, crons and subscriptions to
331/// the caller that made them, and tasks to the caller's project. A review
332/// nudge lands on a worker seat, so the family must be offered to workers
333/// and the nudge must stay usable when it is withheld.
334///
335/// All submodules share the server name so the LLM sees a single
336/// namespace (`mcp__forge__<group>__*`) and the auto-approve fast-path
337/// in `forge-sdk::control_dispatch` matches every tool group with one
338/// `mcp__forge__` prefix check.
339///
340/// Building two separate `McpServer::builder("forge")` instances and
341/// pushing both into `OptionsBuilder::mcp_server` would collide on
342/// the duplicate name and the CLI would reject one - so the right
343/// shape is to combine the (selected) tool sets into a single
344/// builder here.
345///
346/// `slot` is the calling session's slot. Each tool holds its own clone
347/// of it, which is safe because a slot is stable across `/new` and
348/// `/resume` - those swap the occupant and leave the slot alone.
349pub fn build_forge_server(
350    facades: ForgeServerFacades,
351    families: &BTreeSet<McpFamily>,
352    slot: &SessionSlot,
353    kind: SessionKind,
354) -> McpServer {
355    let ForgeServerFacades {
356        workspace,
357        worker,
358        browser,
359        terminal,
360        documents,
361        review,
362        cron,
363        gotify,
364        slack,
365        settings,
366        tasks,
367        systemone,
368        voice,
369        vault,
370    } = facades;
371    let mut builder = McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION"));
372    let dispatcher = Arc::new(AgentDispatcher::new(workspace, worker.clone()));
373    builder = agents::add_shared_tools(builder, dispatcher, slot.clone());
374    if matches!(kind, SessionKind::Lead) {
375        builder = agents::add_lead_tools(builder, worker, slot.clone());
376    }
377    // Unconditional for both kinds: the browser is the SERVER's fleet rather
378    // than a per-project scope, so every session may drive it, and a server
379    // with no host answers with its own sentence rather than a tool that
380    // varies by who is running. The one exception is `browser_end_host`: every
381    // session is offered it, and only a lead may use it, because it ends a
382    // host every session on the profile shares.
383    builder = browser::add_tools(builder, browser, slot.clone());
384    // Unconditional for the same reason: the terminal is one per seat on the
385    // machine, not a per-project scope, and it carries no approval gate -
386    // Bash through the CLI already reaches the shell here.
387    builder = terminal::add_tools(builder, terminal, slot.clone());
388    // Unconditional for the same reason: a hand-off reads the caller's own
389    // tree, so every session may hand a document and no project has anything
390    // to withhold. The tool still takes the caller's review family, because a
391    // session without it can hand a document off but cannot read the review
392    // back, so its answer says so.
393    let review_available = families.contains(&McpFamily::Review);
394    builder = documents::add_tools(builder, documents, slot.clone(), review_available);
395    // Unconditional for the same reason: the config is every session's own
396    // environment, so no project has anything to withhold and every write is
397    // held for the reader regardless.
398    builder = settings::add_tools(builder, settings, slot.clone());
399    if review_available {
400        builder = review::add_tools(builder, review, slot.clone());
401    }
402    if families.contains(&McpFamily::Cron) {
403        builder = cron::add_tools(builder, cron, slot.clone());
404    }
405    if families.contains(&McpFamily::Gotify) {
406        builder = gotify::add_tools(builder, gotify, slot.clone());
407    }
408    if families.contains(&McpFamily::Tasks) {
409        builder = tasks::add_tools(builder, tasks, slot.clone(), matches!(kind, SessionKind::Lead));
410    }
411    // Doubly gated: the family is selected AND a client exists - a
412    // disabled `[systemone]` leaves no tool a session could try.
413    if families.contains(&McpFamily::Systemone)
414        && let Some(systemone) = systemone
415    {
416        builder = systemone::add_tools(builder, systemone);
417    }
418    if families.contains(&McpFamily::Slack) {
419        builder = slack::add_tools(builder, slack, slot.clone());
420    }
421    // Doubly gated like `systemone`: a switched-off `[tts]` leaves no tool a
422    // session could try, even when the family is selected.
423    if families.contains(&McpFamily::Voice) && voice.enabled() {
424        builder = voice::add_tools(builder, voice, slot.clone());
425    }
426    // Doubly gated like `systemone`: an absent `[vault]` leaves no tool a
427    // session could try, even when the family is selected.
428    if families.contains(&McpFamily::Vault)
429        && let Some(vault) = vault
430    {
431        builder = vault::add_tools(builder, &vault, slot);
432    }
433    builder.instructions(TASK_BOARD_INSTRUCTIONS).build()
434}
435
436/// What every session is told about the board at session start, through
437/// the protocol's `instructions` field - the channel that reaches the
438/// model without a tool call. Claude Code truncates it at 2048
439/// characters, so the critical half leads; the shipped-text gate (rule
440/// 17) covers the wording.
441pub(crate) const TASK_BOARD_INSTRUCTIONS: &str = "\
442forge's task board is the shared surface for work in flight. It is for running \
443work: a landed row with no children and no epic above it is deleted in the same \
444pass, never left completed; a landed child of an epic - open or already terminal \
445- is completed and left terminal so the epic can read `to close` (a verify=user \
446child parks in waiting on the user first). Read it \
447with tasks__list: a `ready` mark means a row can be started, `worked` and \
448`overdue` say how long it has run against its estimate, and an over-estimate \
449message is an answer you owe. Claim an unassigned row \
450before working: tasks__claim by `id`, or by `epic` to pull that epic's top \
451ready row; one row in progress per worker. Keep your row true: tasks__update on every \
452state change - the update is also your alive signal - and attach the PR \
453the moment it exists. If you cannot proceed, tasks__wait: `kind` is \
454decision (a question for the user, minted only by the lead - a hold they \
455asked for, something ambiguous after the decision model, peers and your \
456own review, or a look they should give when they return), dependency \
457(another task - put its id \
458in `on`) or resource (an account, a CI run, a machine); then move to your \
459next held row, not sit still. A lead who can continue but wants \
460the person's look raises the same wait - prose is not a surface. File what \
461you find mid-flight at \
462once with tasks__create, under your epic, owned or unowned, except a \
463defect in a cut under review, which the lead fixes in line rather than \
464filing; nothing else is deferred. A row with verify=user cannot complete until the user approves \
465it, and that gate is minted on the row - never inherited from an epic, \
466whose own gate governs only its own completion; a look the user already \
467gave in chat is completed by the lead with tasks__approve and the approval \
468quoted (never a decision, which is the user's to answer); completing the \
469ROOT row of an epic closes it and archives the tree. \
470The chase asks an over-estimate owner once and escalates to the lead once \
471- answer with an update, not silence.";
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::mcp::browser::facade::MockBrowserFacade;
477    use crate::mcp::cron::facade::MockCronFacade;
478    use crate::mcp::documents::facade::MockDocumentFacade;
479    use crate::mcp::gotify::facade::MockGotifyFacade;
480    use crate::mcp::peers::facade::MockWorkspaceFacade;
481    use crate::mcp::review::facade::MockReviewFacade;
482    use crate::mcp::settings::facade::MockSettingsFacade;
483    use crate::mcp::slack::facade::MockSlackFacade;
484    use crate::mcp::systemone::facade::MockSystemOneFacade;
485    use crate::mcp::tasks::facade::MockTasksFacade;
486    use crate::mcp::workers::facade::MockWorkerFacade;
487
488    fn fake_key(s: &str) -> SessionSlot {
489        SessionSlot::from_str_for_test(s)
490    }
491
492    /// The configured shape: a systemone client exists, so its any-caller
493    /// tools register for both kinds.
494    fn forge_server(kind: SessionKind) -> McpServer {
495        forge_server_with(kind, Some(MockSystemOneFacade::new().into_arc()))
496    }
497
498    fn forge_server_with(
499        kind: SessionKind,
500        systemone_facade: Option<Arc<dyn SystemOneFacade>>,
501    ) -> McpServer {
502        forge_server_families(kind, &McpFamily::all(), systemone_facade)
503    }
504
505    /// Claude Code truncates server instructions at 2048 characters, so
506    /// the board's contract has to fit whole - a truncated tail would drop
507    /// whatever came last, which is why the critical half leads.
508    #[test]
509    fn the_task_instructions_fit_the_client_cap() {
510        assert!(
511            TASK_BOARD_INSTRUCTIONS.len() < 2048,
512            "the instructions are {} characters; the client truncates at 2048",
513            TASK_BOARD_INSTRUCTIONS.len(),
514        );
515    }
516
517    /// The built server hands the contract to every session: the constant
518    /// reaches the real initialize result, not just the source.
519    #[tokio::test]
520    async fn the_built_server_carries_the_task_instructions() {
521        let server = forge_server(SessionKind::Lead);
522        let request: forge_sdk::mcp::protocol::JsonRpcRequest =
523            serde_json::from_value(serde_json::json!({
524                "jsonrpc": "2.0",
525                "id": 1,
526                "method": "initialize",
527                "params": { "protocolVersion": "2025-06-18" },
528            }))
529            .expect("an initialize request");
530        let response = server.dispatch(&request).await.expect("an id-bearing request is answered");
531        let result = serde_json::to_value(response.result.expect("initialize succeeds"))
532            .expect("serializes");
533        assert_eq!(
534            result["instructions"], TASK_BOARD_INSTRUCTIONS,
535            "the built server's initialize carries the board's contract",
536        );
537    }
538
539    /// The board contract is where a session first learns that
540    /// `kind: decision` routes to the user, so the test for parking there
541    /// rides the same line: without it the contract teaches the route and
542    /// leaves out when taking it is right.
543    #[test]
544    fn the_task_instructions_carry_the_attention_rule() {
545        assert!(
546            TASK_BOARD_INSTRUCTIONS.contains(
547                "a hold they asked for, something ambiguous after the decision model, peers and \
548                 your own review, or a look they should give when they return"
549            ),
550            "the parking test rides the kind: decision clause, channels and bar together",
551        );
552        assert!(
553            TASK_BOARD_INSTRUCTIONS
554                .contains("decision (a question for the user, minted only by the lead"),
555            "the decision wait is the lead's to mint: {TASK_BOARD_INSTRUCTIONS}",
556        );
557        assert!(
558            TASK_BOARD_INSTRUCTIONS.contains(
559                "A lead who can continue but wants the person's look raises the same wait - \
560                 prose is not a surface"
561            ),
562            "the at-convenience channel rides the same rule: {TASK_BOARD_INSTRUCTIONS}",
563        );
564    }
565
566    /// A finding of a cut under review is fixed in line, not filed: the
567    /// instructions every session reads are where the filing habit was
568    /// taught, so they carry the carve-out too.
569    #[test]
570    fn the_task_instructions_fix_a_cuts_finding_in_line() {
571        assert!(
572            TASK_BOARD_INSTRUCTIONS.contains(
573                "except a defect in a cut under review, which the lead fixes in line rather \
574                 than filing"
575            ),
576            "the filing clause carries the in-line carve-out: {TASK_BOARD_INSTRUCTIONS}",
577        );
578        assert!(
579            TASK_BOARD_INSTRUCTIONS.contains("nothing else is deferred"),
580            "the deferral ban still covers everything outside the cut",
581        );
582    }
583
584    /// The board's shape is stated before any tool call (#2377): the
585    /// board is for running work, so a landed row with no children and no
586    /// epic above it is deleted in the same pass - the discipline the
587    /// instructions owe a session that reads no description. The claim is
588    /// the discipline rather than a filter: the drawn board keeps its
589    /// terminal lanes, so "shows only running rows" would be false of the
590    /// surface a caller is about to read. The epic carve-out rides here
591    /// too, since the instructions are all some sessions read.
592    #[test]
593    fn the_task_instructions_name_the_board_shape() {
594        assert!(
595            TASK_BOARD_INSTRUCTIONS.contains(
596                "It is for running work: a landed row with no children and no epic above it \
597                 is deleted in the same pass, never left completed; a landed child of an \
598                 epic - open or already terminal - is completed and left terminal so the \
599                 epic can read `to close` (a verify=user child parks in waiting on the user \
600                 first)"
601            ),
602            "the instructions name the board's shape: {TASK_BOARD_INSTRUCTIONS}",
603        );
604        // The archive case rides the shape too (#2377): a later edit
605        // dropping "completing the ROOT row ... archives the tree" would
606        // leave the carve-out pointing at a close signal nothing acts on.
607        assert!(
608            TASK_BOARD_INSTRUCTIONS
609                .contains("completing the ROOT row of an epic closes it and archives the tree"),
610            "the instructions state the archive that closes an epic: {TASK_BOARD_INSTRUCTIONS}",
611        );
612    }
613
614    /// The user gate is minted on the row and never inherited from its
615    /// epic (#2423): the always-on block states it, so no session reads
616    /// an epic's gate as its children's and parks a completed child on a
617    /// look nobody owes.
618    #[test]
619    fn the_task_instructions_state_the_minted_gate() {
620        assert!(
621            TASK_BOARD_INSTRUCTIONS.contains(
622                "that gate is minted on the row - never inherited from an epic, whose own \
623                 gate governs only its own completion"
624            ),
625            "the instructions state where the gate lives: {TASK_BOARD_INSTRUCTIONS}",
626        );
627    }
628
629    /// The claim is scoped to unassigned rows (#2416): a lead assigns a
630    /// worker's row at creation, and a worker reading only this block
631    /// would otherwise pull a different row with `claim` by epic.
632    #[test]
633    fn the_task_instructions_claim_only_an_unassigned_row() {
634        assert!(
635            TASK_BOARD_INSTRUCTIONS.contains("Claim an unassigned row before working"),
636            "the instructions scope the claim: {TASK_BOARD_INSTRUCTIONS}",
637        );
638    }
639
640    struct SilentSink;
641    impl crate::voice::AudioSink for SilentSink {
642        fn push(&self, _seat: &SessionSlot, _chunk: forge_tts::Chunk) {}
643    }
644
645    struct SilentNotes;
646    impl crate::voice::NoteDispatch for SilentNotes {
647        fn note(&self, _seat: &SessionSlot, _text: &str) {}
648    }
649
650    fn silent_voice() -> Arc<crate::voice::VoiceService> {
651        Arc::new(crate::voice::VoiceService::new(
652            Arc::new(|| Err("no model loads in this test".to_owned())),
653            crate::voice::VoiceSettings::default(),
654            Arc::new(SilentSink),
655            Arc::new(SilentNotes),
656        ))
657    }
658
659    fn forge_server_families(
660        kind: SessionKind,
661        families: &std::collections::BTreeSet<McpFamily>,
662        systemone_facade: Option<Arc<dyn SystemOneFacade>>,
663    ) -> McpServer {
664        build_forge_server(
665            ForgeServerFacades {
666                workspace: MockWorkspaceFacade::new().into_arc(),
667                worker: MockWorkerFacade::new().into_arc(),
668                browser: MockBrowserFacade::new().into_arc(),
669                terminal: crate::mcp::terminal::facade::MockTerminalFacade::new().into_arc(),
670                documents: MockDocumentFacade::new().into_arc(),
671                review: MockReviewFacade::new().into_arc(),
672                cron: MockCronFacade::new().into_arc(),
673                gotify: MockGotifyFacade::new().into_arc(),
674                slack: MockSlackFacade::new().into_arc(),
675                settings: MockSettingsFacade::new().into_arc(),
676                tasks: MockTasksFacade::new().into_arc(),
677                systemone: systemone_facade,
678                voice: silent_voice(),
679                vault: Some(crate::mcp::vault::facade::MockVaultFacade::new().into_arc()),
680            },
681            families,
682            &fake_key("test"),
683            kind,
684        )
685    }
686
687    /// Every tool name the server registered, read off its debug
688    /// listing - which is exactly the set the LLM is offered.
689    fn registered_names(kind: SessionKind) -> Vec<String> {
690        names_of(&forge_server(kind))
691    }
692
693    fn names_of(server: &McpServer) -> Vec<String> {
694        let debug = format!("{server:?}");
695        let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
696        let (tools, _) = tools.split_once(']').expect("the tool list is closed");
697        tools
698            .split(", ")
699            .map(|name| name.trim_matches('"').to_owned())
700            .filter(|name| !name.is_empty())
701            .collect()
702    }
703
704    fn agents_tools(kind: SessionKind) -> Vec<String> {
705        registered_names(kind).into_iter().filter(|name| name.starts_with("agents__")).collect()
706    }
707
708    /// The names the agents family has retired. None may survive: an
709    /// alias would leave two names for one verb in the shipped text,
710    /// which is the confusion the merge exists to remove.
711    ///
712    /// Two waves. The eleven `peers__*` / `workers__*` verbs the family
713    /// replaced, and the two the family then folded into
714    /// `agents__send_message` - a reply is another message, so the split
715    /// between them was never a thing a message had to be.
716    const OLD_NAMES: [&str; 13] = [
717        "peers__whoami",
718        "peers__list_agents",
719        "peers__tell_agent",
720        "peers__ask_agent",
721        "workers__spawn",
722        "workers__list",
723        "workers__capacity",
724        "workers__tell",
725        "workers__ask",
726        "workers__despawn",
727        "workers__update",
728        "agents__tell",
729        "agents__ask",
730    ];
731
732    /// Every group that is any-caller BESIDE the browser: both session kinds
733    /// manage their own project's reviews, crons, tasks, subscriptions and
734    /// decisions. The browser's own names are read off its table instead -
735    /// one list per family, so a tool added there cannot leave this behind.
736    const ANY_CALLER_TOOLS: [&str; 52] = [
737        "review__list",
738        "review__get",
739        "review__reply",
740        "review__resolve",
741        "cron__create",
742        "cron__list",
743        "cron__delete",
744        "tasks__create",
745        "tasks__update",
746        "tasks__list",
747        "tasks__delete",
748        "gotify__subscribe",
749        "gotify__list",
750        "gotify__unsubscribe",
751        "gotify__apps",
752        "gotify__recent",
753        "slack__list",
754        "slack__subscribe",
755        "slack__unsubscribe",
756        "slack__post",
757        "slack__edit",
758        "slack__react",
759        "slack__attachment",
760        "slack__search",
761        "slack__history",
762        "slack__replies",
763        "slack__user",
764        "slack__users",
765        "slack__me",
766        "slack__pins",
767        "slack__bookmarks",
768        "settings__index",
769        "settings__get",
770        "settings__set",
771        "settings__unset",
772        "systemone__ask_noul",
773        "systemone__ask_choice",
774        "systemone__ask_score",
775        "voice__say",
776        "vault__search",
777        "vault__get",
778        "vault__create",
779        "vault__update",
780        "vault__trash",
781        "vault__restore",
782        "vault__totp_code",
783        "vault__attachment_get",
784        "vault__attachment_put",
785        "vault__folder_list",
786        "vault__folder_create",
787        "vault__folder_update",
788        "vault__folder_delete",
789    ];
790
791    #[test]
792    fn a_lead_sees_all_eight() {
793        assert_eq!(
794            agents_tools(SessionKind::Lead),
795            [
796                "agents__capacity",
797                "agents__despawn",
798                "agents__handoff",
799                "agents__list",
800                "agents__send_message",
801                "agents__spawn",
802                "agents__update",
803                "agents__whoami",
804            ],
805        );
806    }
807
808    #[test]
809    fn a_worker_sees_only_the_shared_four() {
810        // The role gate: a worker gains the cross-project reach the
811        // merge adds, and must NOT gain a lead-only verb with it. The
812        // handoff is shared because a seat is its own to hand over.
813        assert_eq!(
814            agents_tools(SessionKind::Worker),
815            ["agents__handoff", "agents__list", "agents__send_message", "agents__whoami"],
816        );
817    }
818
819    #[test]
820    fn the_retired_names_are_gone() {
821        for kind in [SessionKind::Lead, SessionKind::Worker] {
822            let names = registered_names(kind);
823            for old in OLD_NAMES {
824                assert!(!names.contains(&old.to_owned()), "{old} survives for {kind:?}: {names:?}");
825            }
826        }
827    }
828
829    #[test]
830    fn every_any_caller_group_registers_for_both_kinds() {
831        let browser: Vec<String> =
832            crate::mcp::browser::specs::specs().iter().map(|spec| spec.name.to_owned()).collect();
833        for kind in [SessionKind::Lead, SessionKind::Worker] {
834            let names = registered_names(kind);
835            for expected in ANY_CALLER_TOOLS
836                .iter()
837                .map(|name| (*name).to_owned())
838                .chain(browser.iter().cloned())
839            {
840                assert!(names.contains(&expected), "{expected} must register for {kind:?}");
841            }
842        }
843    }
844
845    /// The lead's attestation door is lead-only (#2423/B): a worker is not
846    /// offered the verb at all, and a lead is.
847    #[test]
848    fn tasks_approve_registers_for_a_lead_only() {
849        let lead = registered_names(SessionKind::Lead);
850        let worker = registered_names(SessionKind::Worker);
851        assert!(lead.contains(&"tasks__approve".to_owned()), "a lead misses the door: {lead:?}");
852        assert!(
853            !worker.contains(&"tasks__approve".to_owned()),
854            "a worker is offered the lead's door: {worker:?}",
855        );
856    }
857
858    /// The injection gate: without a configured `[systemone]` the decision
859    /// tools must not appear at all - a session cannot try what forge
860    /// chose not to offer.
861    #[test]
862    fn systemone_tools_absent_without_a_client() {
863        for kind in [SessionKind::Lead, SessionKind::Worker] {
864            let names = names_of(&forge_server_with(kind, None));
865            for absent in ["systemone__ask_noul", "systemone__ask_choice", "systemone__ask_score"] {
866                assert!(
867                    !names.contains(&absent.to_owned()),
868                    "{absent} must be absent without a client for {kind:?}: {names:?}"
869                );
870            }
871        }
872    }
873
874    /// Withhold every toggleable family and only the always-on cores
875    /// survive: the three shared verbs plus the browser surface for a
876    /// worker, all seven for a lead.
877    ///
878    /// **The browser tools are here rather than in a family, and that is the
879    /// rule this test now pins**: they register for a session whose every
880    /// toggleable family was withheld, because the browser is one
881    /// machine-global client rather than a scope a project can be denied.
882    #[test]
883    fn withholding_every_family_leaves_only_the_agents_core() {
884        let none: std::collections::BTreeSet<McpFamily> = std::collections::BTreeSet::new();
885        let mut worker = names_of(&forge_server_families(
886            SessionKind::Worker,
887            &none,
888            Some(MockSystemOneFacade::new().into_arc()),
889        ));
890        worker.sort();
891        let mut expected: Vec<String> =
892            ["agents__handoff", "agents__list", "agents__send_message", "agents__whoami"]
893                .into_iter()
894                .map(str::to_owned)
895                .chain(crate::mcp::browser::specs::specs().iter().map(|spec| spec.name.to_owned()))
896                // The document hand-off and the settings tools are always-on
897                // beside the browser.
898                .chain(
899                    [
900                        "documents__hand_off",
901                        "settings__index",
902                        "settings__get",
903                        "settings__set",
904                        "settings__unset",
905                    ]
906                    .into_iter()
907                    .map(str::to_owned),
908                )
909                // The terminal is any-caller and always on, like the browser.
910                .chain(
911                    [
912                        "terminal__open",
913                        "terminal__write",
914                        "terminal__read",
915                        "terminal__wait",
916                        "terminal__resize",
917                        "terminal__close",
918                        "terminal__hand_off",
919                        "terminal__run",
920                    ]
921                    .into_iter()
922                    .map(str::to_owned),
923                )
924                .collect();
925        expected.sort();
926        assert_eq!(
927            worker, expected,
928            "a withheld family leaves no trace, and the browser core is not one"
929        );
930
931        let lead = names_of(&forge_server_families(SessionKind::Lead, &none, None));
932        assert!(lead.contains(&"agents__spawn".to_owned()), "{lead:?}");
933        assert!(
934            !lead.iter().any(|name| {
935                name.starts_with("cron__")
936                    || name.starts_with("systemone__")
937                    || name.starts_with("slack__")
938            }),
939            "no toggleable family registers for a lead with everything withheld: {lead:?}"
940        );
941    }
942
943    /// The documents tool's own tests inject the flag, so only a built
944    /// server proves it comes from the caller's families.
945    #[tokio::test]
946    async fn the_document_hand_off_knows_whether_the_session_has_review() {
947        let withheld: std::collections::BTreeSet<McpFamily> =
948            McpFamily::all().into_iter().filter(|family| *family != McpFamily::Review).collect();
949        for (families, expected) in [
950            (McpFamily::all(), "read them back with review__get"),
951            (withheld, "spawned without the `review` MCP family"),
952        ] {
953            let documents = MockDocumentFacade::new();
954            *documents.answer.lock() = Ok((
955                "/proj/docs/plan.md".to_owned(),
956                forge_primitives::document::DocumentHandOffEnding::Approve {
957                    review_id: Some("r-9".to_owned()),
958                    note: None,
959                },
960            ));
961            let server = build_forge_server(
962                ForgeServerFacades {
963                    workspace: MockWorkspaceFacade::new().into_arc(),
964                    worker: MockWorkerFacade::new().into_arc(),
965                    browser: MockBrowserFacade::new().into_arc(),
966                    terminal: crate::mcp::terminal::facade::MockTerminalFacade::new().into_arc(),
967                    documents: documents.into_arc(),
968                    review: MockReviewFacade::new().into_arc(),
969                    cron: MockCronFacade::new().into_arc(),
970                    gotify: MockGotifyFacade::new().into_arc(),
971                    slack: MockSlackFacade::new().into_arc(),
972                    settings: MockSettingsFacade::new().into_arc(),
973                    tasks: MockTasksFacade::new().into_arc(),
974                    systemone: None,
975                    voice: silent_voice(),
976                    vault: None,
977                },
978                &families,
979                &fake_key("test"),
980                SessionKind::Worker,
981            );
982            let answer = server
983                .dispatch(&forge_sdk::mcp::protocol::JsonRpcRequest {
984                    jsonrpc: "2.0".to_owned(),
985                    id: Some(serde_json::json!(1)),
986                    method: "tools/call".to_owned(),
987                    params: Some(serde_json::json!({
988                        "name": "documents__hand_off",
989                        "arguments": { "path": "docs/plan.md", "reviewable": true },
990                    })),
991                })
992                .await
993                .expect("a tools/call is answered");
994            let result = serde_json::to_value(answer.result.expect("the call is answered"))
995                .expect("serializes");
996            assert!(
997                result["content"][0]["text"].as_str().is_some_and(|text| text.contains(expected)),
998                "expected '{expected}' in {result}",
999            );
1000        }
1001    }
1002
1003    /// A narrow selection registers exactly the core plus the chosen
1004    /// family.
1005    #[test]
1006    fn a_cron_only_selection_registers_the_core_and_cron() {
1007        let families: std::collections::BTreeSet<McpFamily> =
1008            [McpFamily::Cron].into_iter().collect();
1009        let mut names = names_of(&forge_server_families(SessionKind::Worker, &families, None));
1010        names.sort();
1011        let mut expected: Vec<String> =
1012            ["agents__handoff", "agents__list", "agents__send_message", "agents__whoami"]
1013                .into_iter()
1014                .map(str::to_owned)
1015                .chain(crate::mcp::browser::specs::specs().iter().map(|spec| spec.name.to_owned()))
1016                .chain(
1017                    [
1018                        "documents__hand_off",
1019                        "settings__index",
1020                        "settings__get",
1021                        "settings__set",
1022                        "settings__unset",
1023                    ]
1024                    .into_iter()
1025                    .map(str::to_owned),
1026                )
1027                .chain(
1028                    ["cron__create", "cron__delete", "cron__list"].into_iter().map(str::to_owned),
1029                )
1030                // The terminal is any-caller and always on, so a cron-only
1031                // selection still carries its eight tools.
1032                .chain(
1033                    [
1034                        "terminal__open",
1035                        "terminal__write",
1036                        "terminal__read",
1037                        "terminal__wait",
1038                        "terminal__resize",
1039                        "terminal__close",
1040                        "terminal__hand_off",
1041                        "terminal__run",
1042                    ]
1043                    .into_iter()
1044                    .map(str::to_owned),
1045                )
1046                .collect();
1047        expected.sort();
1048        assert_eq!(names, expected, "{names:?}");
1049    }
1050
1051    /// A stored selection resolves to the canonical set: absent and
1052    /// empty both mean every family, known names land in wire order, and
1053    /// a name no longer selectable is dropped rather than widening or
1054    /// narrowing the surface silently.
1055    #[test]
1056    fn stored_family_names_resolve_to_the_canonical_set() {
1057        assert_eq!(resolve_mcp_families(None), McpFamily::all(), "absent means every family");
1058        assert_eq!(resolve_mcp_families(Some(&[])), McpFamily::all(), "empty means every family");
1059
1060        let stored = vec!["slack".to_owned(), "cron".to_owned()];
1061        assert_eq!(
1062            resolve_mcp_families(Some(&stored)),
1063            [McpFamily::Cron, McpFamily::Slack].into_iter().collect(),
1064            "known names resolve into the set"
1065        );
1066
1067        let with_unknown = vec!["bogus".to_owned(), "tasks".to_owned()];
1068        assert_eq!(
1069            resolve_mcp_families(Some(&with_unknown)),
1070            [McpFamily::Tasks].into_iter().collect(),
1071            "an unknown name is dropped"
1072        );
1073    }
1074
1075    /// One line per withheld family, in wire order; the present family
1076    /// gets none, and nothing withheld means no lines at all.
1077    #[test]
1078    fn withheld_lines_are_pinned_verbatim() {
1079        let none: BTreeSet<McpFamily> = BTreeSet::new();
1080        assert_eq!(
1081            withheld_family_lines(&none),
1082            [
1083                "This session has no `review__*` tools. Review threads for your work are handled through your lead - ask it to relay findings or to post your replies.",
1084                "This session has no `cron__*` tools. You cannot schedule durable work; ask your lead to set up any recurring task.",
1085                "This session has no `tasks__*` tools. The project task list is not yours to edit; report status to your lead instead.",
1086                "This session has no `gotify__*` tools. Notifications from Gotify do not reach you; your lead carries anything urgent.",
1087                "This session has no `slack__*` tools. Channel reads and posts go through your lead.",
1088                "This session has no `systemone__*` decision tools. When a decision is material and not obvious, route it to your lead instead of guessing.",
1089                "This session has no `voice__*` tools. The person listening cannot hear this session; put anything they should know in your message to your lead.",
1090                "This session has no `vault__*` tools. Credentials in the vault are not reachable from this session; ask your lead to read or change them.",
1091            ],
1092        );
1093
1094        let slack_only: BTreeSet<McpFamily> = [McpFamily::Slack].into_iter().collect();
1095        let lines = withheld_family_lines(&slack_only);
1096        assert_eq!(
1097            lines.len(),
1098            McpFamily::ALL.len() - 1,
1099            "one family is present, so every other family is withheld",
1100        );
1101        for line in &lines {
1102            assert!(!line.contains("slack__"), "the present family gets no line: {line}");
1103        }
1104        assert!(withheld_family_lines(&McpFamily::all()).is_empty(), "nothing withheld, no lines");
1105    }
1106
1107    /// The config gate reaches the EFFECTIVE set - what the build injects
1108    /// and what the prompt composer sees - so a selected-but-disabled
1109    /// systemone reads as withheld on both.
1110    #[test]
1111    fn the_config_gate_reaches_the_effective_set() {
1112        let selected = vec!["systemone".to_owned(), "tasks".to_owned()];
1113        assert_eq!(
1114            effective_mcp_families(Some(&selected), true, true, true),
1115            [McpFamily::Tasks, McpFamily::Systemone].into_iter().collect(),
1116        );
1117        assert_eq!(
1118            effective_mcp_families(Some(&selected), false, true, true),
1119            [McpFamily::Tasks].into_iter().collect(),
1120            "a disabled [systemone] cannot be part of the effective set",
1121        );
1122
1123        let all_without_config = effective_mcp_families(None, false, true, true);
1124        assert!(!all_without_config.contains(&McpFamily::Systemone));
1125        assert_eq!(
1126            all_without_config.len(),
1127            McpFamily::ALL.len() - 1,
1128            "systemone is the only family this gate removes",
1129        );
1130    }
1131
1132    /// `[tts]` off removes the voice family from the effective set too, so
1133    /// the prompt's withheld line and the tools agree.
1134    #[test]
1135    fn a_disabled_tts_removes_voice_from_the_effective_set() {
1136        let all_without_voice = effective_mcp_families(None, true, false, true);
1137        assert!(!all_without_voice.contains(&McpFamily::Voice));
1138        assert_eq!(
1139            all_without_voice.len(),
1140            McpFamily::ALL.len() - 1,
1141            "voice is the only family this gate removes",
1142        );
1143        assert!(
1144            withheld_family_lines(&all_without_voice).iter().any(|line| line.contains("voice__")),
1145            "the prompt says the family is absent",
1146        );
1147    }
1148
1149    /// `[vault]` absent removes the vault family from the effective set too,
1150    /// so the tools and the worker's prompt agree on the double gate.
1151    #[test]
1152    fn an_absent_vault_removes_it_from_the_effective_set() {
1153        let all_without_vault = effective_mcp_families(None, true, true, false);
1154        assert!(!all_without_vault.contains(&McpFamily::Vault));
1155        assert_eq!(
1156            all_without_vault.len(),
1157            McpFamily::ALL.len() - 1,
1158            "vault is the only family this gate removes",
1159        );
1160        assert!(
1161            withheld_family_lines(&all_without_vault).iter().any(|line| line.contains("vault__")),
1162            "the prompt says the family is absent",
1163        );
1164    }
1165
1166    /// The config gate reaches the COMPOSER too: a selected-but-disabled
1167    /// systemone produces its withheld line, which is what the spawn
1168    /// description promises a worker it was not given.
1169    #[test]
1170    fn a_disabled_systemone_still_produces_its_withheld_line() {
1171        let selected = vec!["systemone".to_owned()];
1172        let line = "This session has no `systemone__*` decision tools. When a decision is material and not obvious, route it to your lead instead of guessing.";
1173        assert!(
1174            !withheld_family_lines(&effective_mcp_families(Some(&selected), true, true, true))
1175                .contains(&line),
1176            "with the section on, the family is present and gets no line",
1177        );
1178        assert!(
1179            withheld_family_lines(&effective_mcp_families(Some(&selected), false, true, true))
1180                .contains(&line),
1181            "[systemone] off is withheld for the worker's prompt as well as its tools",
1182        );
1183    }
1184
1185    /// The two groups a spawn CANNOT list are refused by name, saying they
1186    /// are always on: a caller listing one is asking for a narrower surface
1187    /// than the one it already has, and `unknown family` would send it
1188    /// looking for a spelling.
1189    #[test]
1190    fn the_always_on_groups_are_refused_by_name() {
1191        for (name, why) in [("agents", "worker"), ("browser", "session")] {
1192            let refused = canonical_mcp_families(&[name.to_owned()])
1193                .expect_err("an always-on group cannot be listed");
1194            assert!(refused.contains(name), "the refusal names it: {refused}");
1195            assert!(
1196                refused.contains("always on") && refused.contains(why),
1197                "and says it is always on for every {why}: {refused}",
1198            );
1199            assert!(!refused.contains("unknown"), "it is not a spelling problem: {refused}");
1200        }
1201    }
1202
1203    /// A stored row holding only unselectable names is corrupt, not a
1204    /// selection of nothing: it fails open to every family the way an
1205    /// unreadable row does, never to agents-only beside a withheld line
1206    /// per family.
1207    #[test]
1208    fn an_all_corrupt_selection_fails_open() {
1209        let corrupt = vec!["bogus".to_owned(), "agents".to_owned()];
1210        assert_eq!(
1211            resolve_mcp_families(Some(&corrupt)),
1212            McpFamily::all(),
1213            "a row nothing valid survives from reads as absent",
1214        );
1215    }
1216
1217    /// The family set and the systemone client are independent gates: a
1218    /// set naming systemone injects nothing when the section is off, and
1219    /// a set omitting it injects nothing even when a client exists.
1220    #[test]
1221    fn the_family_set_and_the_systemone_client_gate_independently() {
1222        let without_systemone: std::collections::BTreeSet<McpFamily> =
1223            McpFamily::all().into_iter().filter(|family| *family != McpFamily::Systemone).collect();
1224        let names = names_of(&forge_server_families(
1225            SessionKind::Worker,
1226            &without_systemone,
1227            Some(MockSystemOneFacade::new().into_arc()),
1228        ));
1229        assert!(
1230            !names.iter().any(|name| name.starts_with("systemone__")),
1231            "a client alone does not inject the family: {names:?}"
1232        );
1233
1234        let names = names_of(&forge_server_families(SessionKind::Worker, &McpFamily::all(), None));
1235        assert!(
1236            !names.iter().any(|name| name.starts_with("systemone__")),
1237            "[systemone] off injects nothing however the set reads: {names:?}"
1238        );
1239    }
1240
1241    /// A `replay-only:` marker exempts the retired tools it NAMES, on one
1242    /// of the three lines around it.
1243    ///
1244    /// Two rules, and each closes a hole the other leaves. Naming: a marker
1245    /// exempts only the tools written after it, so an unlisted retired name
1246    /// beside it still fails - a marker naming something absent is merely
1247    /// inert, and what it cannot do is cover a name it does not list.
1248    /// Adjacency: the marker must sit on the exempted line, or the line
1249    /// directly above or below it, so a marker cannot launder a SECOND use
1250    /// of the same name elsewhere in the same comment block - which is a
1251    /// stale instruction wearing a replay marker's clothes. Three positions
1252    /// rather than one because the formatter moves a trailing comment
1253    /// between an arm's own line and its body, and the two neighbours mean
1254    /// one marker covers a line either side of it.
1255    ///
1256    /// It exists for text that cites a retired name as history: a reader of
1257    /// what a session recorded before the rename. Nothing can call a retired
1258    /// tool, so the marker cannot be an alias.
1259    const REPLAY_ONLY: &str = "replay-only:";
1260
1261    /// The span of the `OLD_NAMES` declaration itself, which is the only
1262    /// place a bare quoted retired name is allowed to sit.
1263    ///
1264    /// **Scoped to the declaration rather than to a line's shape.** The list
1265    /// has to name the retired tools in order to assert them away, and a line
1266    /// that merely LOOKS like an entry is not the list: a retired name
1267    /// re-registered in a tool list has exactly that shape, and it is the
1268    /// alias this scan exists to refuse. `None` when the span will
1269    /// not read, which fails the scan loudly rather than exempting every
1270    /// entry.
1271    fn old_names_declaration(text: &str) -> Option<std::ops::RangeInclusive<usize>> {
1272        let lines: Vec<&str> = text.lines().collect();
1273        let start = lines.iter().position(|line| line.contains("const OLD_NAMES"))?;
1274        let end = start + lines[start..].iter().position(|line| line.trim() == "];")?;
1275        Some(start..=end)
1276    }
1277
1278    /// The retired names a marker on, above or below line `at` exempts.
1279    fn exempted_names(lines: &[&str], at: usize) -> Vec<&'static str> {
1280        let adjacent = [at.checked_sub(1), Some(at), at.checked_add(1)]
1281            .into_iter()
1282            .flatten()
1283            .filter_map(|i| lines.get(i));
1284        let mut out = Vec::new();
1285        for line in adjacent {
1286            let Some((_, named)) = line.split_once(REPLAY_ONLY) else { continue };
1287            out.extend(OLD_NAMES.iter().copied().filter(|old| named.contains(old)));
1288        }
1289        out
1290    }
1291
1292    /// The tracked source files, read from the working tree, paired with how
1293    /// many were listed.
1294    ///
1295    /// Tracked content, not a filesystem walk. A surface forge ships is a
1296    /// file in the repository, and a walk descends into everything git
1297    /// excludes - `docs/superpowers/`, `.claude/plans/`, another
1298    /// worktree's checkout - so it reports a clean tree or a red one
1299    /// depending on who else is using the machine. The recorded baselines
1300    /// are `.jsonl`, so the extension filter leaves them out: a capture
1301    /// holds whatever the capture machine printed.
1302    ///
1303    /// **An untracked file is the one blind spot**: `git ls-files` lists
1304    /// the index, so a source file added but not yet staged is not
1305    /// scanned. The mirror case - deleted but not staged - fails the
1306    /// caller's count assertion rather than passing quietly.
1307    ///
1308    /// **The client's own files count, and they are the ones this most
1309    /// rewrote.** A rename that reached `client/src/` and stopped there leaves
1310    /// the page calling a tool nothing registers, which is the failure the scan
1311    /// exists to catch - so `.ts` and `.svelte` are in, and the exemption is
1312    /// line-based like everything else.
1313    fn tracked_source_files(root: &std::path::Path) -> (usize, Vec<(String, String)>) {
1314        let listed = std::process::Command::new("git")
1315            .arg("-C")
1316            .arg(root)
1317            .args(["ls-files", "-z"])
1318            .output()
1319            .expect("git ls-files runs");
1320        assert!(listed.status.success(), "git ls-files failed: {listed:?}");
1321        let listing = String::from_utf8(listed.stdout).expect("git lists UTF-8 paths");
1322        let paths: Vec<&str> = listing
1323            .split('\0')
1324            .filter(|rel| {
1325                matches!(
1326                    std::path::Path::new(rel).extension().and_then(|ext| ext.to_str()),
1327                    Some("rs" | "md" | "ts" | "svelte")
1328                )
1329            })
1330            .collect();
1331        let listed_count = paths.len();
1332        let files = paths
1333            .into_iter()
1334            .filter_map(|rel| {
1335                std::fs::read_to_string(root.join(rel)).ok().map(|text| (rel.to_owned(), text))
1336            })
1337            .collect();
1338        (listed_count, files)
1339    }
1340
1341    /// The names are gone rather than aliased, so a session that follows a
1342    /// stale instruction calls a tool that no longer exists, and nothing
1343    /// errors until it does.
1344    #[test]
1345    fn no_surface_still_names_a_retired_tool() {
1346        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1347        let (listed, files) = tracked_source_files(&root);
1348        // Two questions, because a scan that read nothing reports the same
1349        // clean verdict as a scan that read everything. The count is of
1350        // files that READ, against what git listed, so a partial failure
1351        // cannot pass; the second is a floor, so an empty listing cannot.
1352        assert!(
1353            !files.is_empty() && files.len() == listed,
1354            "read {} of {listed} tracked source files, so the scan is not the tree it claims",
1355            files.len(),
1356        );
1357        assert!(
1358            files.len() > 400,
1359            "the scan read only {} files, too few for its verdict to mean anything",
1360            files.len(),
1361        );
1362        let mut offenders = Vec::new();
1363        for (path, text) in files {
1364            let lines: Vec<&str> = text.lines().collect();
1365            let declaration = old_names_declaration(&text);
1366            for (number, line) in lines.iter().enumerate() {
1367                let exempt = exempted_names(&lines, number);
1368                let unexempted: Vec<&str> = OLD_NAMES
1369                    .iter()
1370                    .copied()
1371                    .filter(|old| line.contains(old) && !exempt.contains(old))
1372                    .collect();
1373                let declared = declaration.as_ref().is_some_and(|span| span.contains(&number));
1374                if !unexempted.is_empty() && !declared {
1375                    offenders.push(format!("{path}:{} names {unexempted:?}", number + 1));
1376                }
1377            }
1378        }
1379        assert!(
1380            offenders.is_empty(),
1381            "a retired tool name survives at: {offenders:?}. If the line cites one as history \
1382             rather than calling it, put a `{REPLAY_ONLY}` comment naming that tool on the line \
1383             itself or the one next to it.",
1384        );
1385    }
1386
1387    /// The exemption has to REJECT, not only accept. Without this the helper
1388    /// could return every retired name unconditionally - or scan the whole
1389    /// file rather than the adjacent lines - and the gate would pass while
1390    /// exempting everything, which is how the pre-rewrite version went wrong
1391    /// when it skipped the file outright.
1392    ///
1393    /// The fixture is built from `OLD_NAMES` rather than repeating the names,
1394    /// so it cannot drift from what the gate scans for.
1395    #[test]
1396    fn the_replay_exemption_names_its_tools_and_only_those() {
1397        let marker_line = |tool: &str| format!("// {REPLAY_ONLY} {tool}");
1398        let used_line = |tool: &str| format!("\"mcp__forge__{tool}\",");
1399        let (wrapped, other) = (OLD_NAMES[9], OLD_NAMES[3]);
1400        let (mark_wrapped, use_wrapped) = (marker_line(wrapped), used_line(wrapped));
1401        let (mark_other, use_other) = (marker_line(other), used_line(other));
1402        let lines = [
1403            mark_wrapped.as_str(),
1404            use_wrapped.as_str(),
1405            "",
1406            mark_wrapped.as_str(),
1407            use_other.as_str(),
1408            "",
1409            mark_other.as_str(),
1410            "",
1411            "",
1412            use_other.as_str(),
1413        ];
1414        assert_eq!(exempted_names(&lines, 1), vec![wrapped], "a marker exempts the tool it names");
1415        assert!(
1416            !exempted_names(&lines, 4).contains(&other),
1417            "a marker naming another tool does not cover this line's tool",
1418        );
1419        assert!(
1420            exempted_names(&lines, 9).is_empty(),
1421            "a marker three lines away is not adjacent, whatever it names",
1422        );
1423        assert!(
1424            exempted_names(&[use_wrapped.as_str(), "// nothing here"], 0).is_empty(),
1425            "an unmarked line is exempt from nothing",
1426        );
1427    }
1428
1429    /// **The entry exemption is scoped to the declaration, not to a line's
1430    /// shape.** A retired name registered as an alias has exactly the shape of
1431    /// an entry - a bare quoted string on a line of its own - so exempting by
1432    /// shape is how a re-created alias would pass this scan while being the
1433    /// thing the scan exists to refuse. The fixture is built from the list
1434    /// rather than from a literal, which is what keeps this file's own text
1435    /// from tripping the scan it tests.
1436    #[test]
1437    fn a_bare_quoted_name_is_exempt_only_inside_the_declaration() {
1438        let listing = format!("let tools = [\n    \"{}\",\n];\n", OLD_NAMES[0]);
1439        assert!(
1440            old_names_declaration(&listing).is_none(),
1441            "a text that declares no list exempts nothing at all",
1442        );
1443
1444        let source = std::fs::read_to_string(
1445            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/mcp.rs"),
1446        )
1447        .expect("this file reads");
1448        let span = old_names_declaration(&source).expect("this file declares OLD_NAMES");
1449        let lines: Vec<&str> = source.lines().collect();
1450        assert_eq!(
1451            lines[span.start() + 1].trim().trim_end_matches(',').trim_matches('"'),
1452            OLD_NAMES[0],
1453            "the span opens on the first entry",
1454        );
1455        assert!(
1456            !span.contains(&(span.end() + 1)),
1457            "and a line past the declaration's close is not exempt",
1458        );
1459    }
1460
1461    #[test]
1462    fn the_server_is_named_forge_for_every_session_kind() {
1463        // The SDK auto-approve fast-path matches `mcp__forge__` once, so
1464        // every group has to share the server name.
1465        for kind in [SessionKind::Lead, SessionKind::Worker] {
1466            let debug = format!("{:?}", forge_server(kind));
1467            assert!(
1468                debug.contains("name: \"forge\""),
1469                "server name must be 'forge'; debug: {debug}"
1470            );
1471        }
1472    }
1473}