1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum SessionKind {
92 Lead,
95 Worker,
98}
99
100#[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 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 pub fn all() -> BTreeSet<McpFamily> {
129 Self::ALL.into_iter().collect()
130 }
131
132 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 pub fn parse(name: &str) -> Option<Self> {
148 Self::ALL.into_iter().find(|family| family.as_str() == name)
149 }
150}
151
152pub(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 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
193pub(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
217pub(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 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
248pub(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
293pub 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 pub vault: Option<Arc<dyn VaultFacade>>,
313}
314
315pub 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 builder = browser::add_tools(builder, browser, slot.clone());
384 builder = terminal::add_tools(builder, terminal, slot.clone());
388 let review_available = families.contains(&McpFamily::Review);
394 builder = documents::add_tools(builder, documents, slot.clone(), review_available);
395 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 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 if families.contains(&McpFamily::Voice) && voice.enabled() {
424 builder = voice::add_tools(builder, voice, slot.clone());
425 }
426 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
436pub(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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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 #[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 .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 .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 #[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 #[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 .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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 const REPLAY_ONLY: &str = "replay-only:";
1260
1261 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 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 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 #[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 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 #[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 #[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 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}