1use std::sync::Arc;
28
29use forge_primitives::SessionSlot;
30use forge_primitives::browser::{BrowserPart, HandOffEnding};
31use forge_sdk::mcp::server::McpServerBuilder;
32use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
33
34#[cfg(test)]
35use forge_sdk::mcp::server::McpServer;
36use serde_json::Value;
37
38use crate::mcp::browser::facade::BrowserFacade;
39
40pub mod facade;
41
42pub mod specs;
43
44use specs::ToolSpec;
45
46#[cfg(test)]
50pub fn build_server(facade: Arc<dyn BrowserFacade>, slot: SessionSlot) -> McpServer {
51 add_tools(McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")), facade, slot).build()
52}
53
54pub(crate) fn add_tools(
61 builder: McpServerBuilder,
62 facade: Arc<dyn BrowserFacade>,
63 slot: SessionSlot,
64) -> McpServerBuilder {
65 let mut builder = builder;
66 let mut specs = specs::specs().into_iter().peekable();
67 while let Some(spec) = specs.next() {
68 if specs.peek().is_none() {
69 return builder.tool(BrowserTool { spec, facade, slot });
70 }
71 builder =
72 builder.tool(BrowserTool { spec, facade: Arc::clone(&facade), slot: slot.clone() });
73 }
74 builder
75}
76
77struct BrowserTool {
79 spec: ToolSpec,
80 facade: Arc<dyn BrowserFacade>,
81 slot: SessionSlot,
82}
83
84#[async_trait::async_trait]
85impl Tool for BrowserTool {
86 fn name(&self) -> &'static str {
87 self.spec.name
88 }
89
90 fn description(&self) -> &'static str {
91 self.spec.description
92 }
93
94 fn input_schema(&self) -> Value {
95 self.spec.schema.clone()
96 }
97
98 async fn call(&self, input: ToolInput) -> ToolOutput {
99 let started = std::time::Instant::now();
100 let outcome = match (self.spec.name, self.slot.is_lead()) {
101 ("browser_end_host", false) => {
106 Err("browser_end_host is the project lead's: it ends the browser host shared by \
107 every session on the profile this project resolves to, including sessions in \
108 other projects. Ask your lead to call it."
109 .to_owned())
110 }
111 ("browser_hand_off", _) => self.hand_off(input.value).await,
112 _ => self.facade.call(&self.slot, self.spec.name, input.value).await,
113 };
114 tracing::debug!(
118 target: "forge_workspace::browser",
119 event_name = "browser_call",
120 tool = self.spec.name,
121 slot = %self.slot.display(),
122 ok = outcome.is_ok(),
123 ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
124 "a browser tool call returned",
125 );
126 match outcome {
127 Ok(parts) => parts_output(parts),
128 Err(why) => ToolOutput::error(why),
129 }
130 }
131}
132
133impl BrowserTool {
134 async fn hand_off(&self, args: Value) -> Result<Vec<BrowserPart>, String> {
139 let Some(reason) =
143 args.get("reason").and_then(Value::as_str).filter(|reason| !reason.trim().is_empty())
144 else {
145 return Err("browser_hand_off needs `reason`: what the person should do".to_owned());
146 };
147 if args.get("tab").is_none() && args.get("index").is_some() {
148 return Err(
149 "browser_hand_off takes `tab`, the page id its listing prints first; `index` is \
150 gone"
151 .to_owned(),
152 );
153 }
154 let tab = match args.get("tab") {
155 Some(Value::String(tab)) if !tab.trim().is_empty() => {
156 let tab = tab.trim();
157 if !is_page_id(tab) {
158 return Err(format!(
159 "browser_hand_off needs `tab` as the page id browser_tabs prints first \
160 in its row: exactly 32 hex characters, not '{tab}'. If your tool list \
161 shows `tab` as a number, only a fresh session in this slot refreshes it."
162 ));
163 }
164 tab
165 }
166 Some(Value::String(_)) => return Err("a page id cannot be blank".to_owned()),
167 Some(_) => {
168 return Err(
169 "browser_hand_off needs `tab` as a page id string, not a number. If your \
170 tool list shows `tab` as a number it was recorded before forge addressed \
171 tabs by page id: a forge restart does not refresh it, and only a fresh \
172 session in this slot picks up the current schema."
173 .to_owned(),
174 );
175 }
176 None => {
177 return Err(
178 "browser_hand_off needs `tab`, the page id string of one of this session's \
179 tabs, from browser_tabs list"
180 .to_owned(),
181 );
182 }
183 };
184 match self.facade.hand_off(&self.slot, reason, tab).await? {
185 HandOffEnding::Done => Ok(vec![BrowserPart::Text {
186 text: "The person is done in the browser; carry on from where you left off."
187 .to_owned(),
188 }]),
189 HandOffEnding::NotNow => Err(
190 "The person said not now; the browser is as it was. Ask them again later if you \
191 still need them on the page."
192 .to_owned(),
193 ),
194 HandOffEnding::Feedback => {
199 Err("The person says something is off. This is not approval: their words are the \
200 message beside this result. Address them and hand the page off again for \
201 another answer."
202 .to_owned())
203 }
204 HandOffEnding::Abandoned => Err("The hand-off ended without an answer.".to_owned()),
207 }
208 }
209}
210
211fn is_page_id(tab: &str) -> bool {
216 tab.len() == 32 && tab.bytes().all(|byte| byte.is_ascii_hexdigit())
217}
218
219fn parts_output(parts: Vec<BrowserPart>) -> ToolOutput {
222 let blocks = parts
223 .into_iter()
224 .map(|part| match part {
225 BrowserPart::Text { text } => ToolOutputBlock::Text { text },
226 BrowserPart::Image { mime_type, bytes } => {
227 ToolOutputBlock::Image { mime_type, data: bytes }
228 }
229 })
230 .collect();
231 ToolOutput { blocks, is_error: false }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use crate::mcp::browser::facade::MockBrowserFacade;
238 use serde_json::json;
239
240 fn seat() -> SessionSlot {
241 SessionSlot::from_str_for_test("caller")
242 }
243
244 const PAGE_ID: &str = "0123456789ABCDEF0123456789ABCDEF";
247
248 fn names(facade: Arc<dyn BrowserFacade>) -> Vec<String> {
250 let debug = format!("{:?}", build_server(facade, seat()));
251 let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
252 let (tools, _) = tools.split_once(']').expect("the tool list is closed");
253 tools
254 .split(", ")
255 .map(|name| name.trim_matches('"').to_owned())
256 .filter(|name| !name.is_empty())
257 .collect()
258 }
259
260 #[test]
271 fn the_whole_surface_registers_with_the_tables_names() {
272 let mut registered = names(MockBrowserFacade::new().into_arc());
273 registered.sort();
274 let mut expected: Vec<&str> = specs::specs().iter().map(|spec| spec.name).collect();
275 expected.sort_unstable();
276 assert_eq!(
277 registered, expected,
278 "the registered names are the table's, and nothing else's"
279 );
280 assert!(
281 registered.iter().all(|name| !name.contains("__")),
282 "every browser tool crosses unprefixed: {registered:?}",
283 );
284 }
285
286 #[test]
293 fn the_corrected_shapes_and_the_added_arguments_are_what_the_capture_says() {
294 const EXPECTED: &[(&str, &[&str], &[&str])] = &[
295 (
298 "browser_take_screenshot",
299 &["element", "target", "type", "filename", "fullPage", "scale"],
300 &["scale"],
301 ),
302 ("browser_evaluate", &["element", "target", "function", "filename"], &["function"]),
304 ("browser_run_code_unsafe", &["code", "filename"], &[]),
306 (
308 "browser_click",
309 &["element", "target", "doubleClick", "button", "modifiers", "force"],
310 &["target"],
311 ),
312 ("browser_wait_for", &["time", "text", "textGone", "expression"], &[]),
313 ];
314 let all = specs::specs();
315 for (name, properties, required) in EXPECTED {
316 let spec = all
317 .iter()
318 .find(|spec| spec.name == *name)
319 .unwrap_or_else(|| panic!("{name} is not on the surface"));
320 let schema = &spec.schema;
321 let mut got: Vec<&str> = schema["properties"]
322 .as_object()
323 .expect("a schema describes an object")
324 .keys()
325 .map(String::as_str)
326 .collect();
327 got.sort_unstable();
328 let mut want: Vec<&str> = properties.to_vec();
329 want.sort_unstable();
330 assert_eq!(got, want, "{name}: the argument names are the capture's");
331
332 let got_required: Vec<&str> = schema["required"]
333 .as_array()
334 .map_or_else(Vec::new, |list| list.iter().filter_map(Value::as_str).collect());
335 assert_eq!(got_required, *required, "{name}: the required arguments are the capture's");
336 }
337 }
338
339 fn spec_named(name: &str) -> ToolSpec {
342 specs::specs()
343 .into_iter()
344 .find(|spec| spec.name == name)
345 .unwrap_or_else(|| panic!("{name} is not on the surface"))
346 }
347
348 #[tokio::test]
351 async fn a_call_carries_the_arguments_and_the_callers_slot() {
352 let mock = Arc::new(MockBrowserFacade::new());
353 let tool = BrowserTool {
354 spec: spec_named("browser_navigate"),
355 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
356 slot: seat(),
357 };
358
359 let out = tool.call(ToolInput { value: json!({"url": "https://example.com"}) }).await;
360
361 assert!(!out.is_error, "the mock's answer is a result: {:?}", out.blocks);
362 assert_eq!(
363 mock.calls.lock().as_slice(),
364 [("browser_navigate".to_owned(), json!({"url": "https://example.com"}))],
365 "the host is asked for the tool the model called, with its arguments verbatim",
366 );
367 assert_eq!(
368 mock.seats.lock().as_slice(),
369 [seat()],
370 "and for the seat that called, so a wrong slot cannot show the wrong session as the asker",
371 );
372 assert_eq!(
373 out.blocks,
374 vec![ToolOutputBlock::Text { text: "done".to_owned() }],
375 "and the host's parts are what the tool returns",
376 );
377 }
378
379 #[tokio::test]
382 async fn a_refused_call_is_a_tool_error_naming_the_reason() {
383 let mock = Arc::new(MockBrowserFacade::new());
384 *mock.answer.lock() = Err(crate::browser::NO_BROWSER_HOST.to_owned());
385 let tool = BrowserTool {
386 spec: spec_named("browser_navigate"),
387 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
388 slot: seat(),
389 };
390
391 let out = tool.call(ToolInput { value: json!({"url": "https://example.com"}) }).await;
392
393 assert!(out.is_error, "the refusal is the call's failure");
394 assert_eq!(
395 out.blocks,
396 vec![ToolOutputBlock::Text { text: crate::browser::NO_BROWSER_HOST.to_owned() }],
397 "and it carries the reason a session can act on",
398 );
399 }
400
401 #[tokio::test]
406 async fn the_end_host_door_is_the_leads_and_reaches_the_fleet() {
407 let mock = Arc::new(MockBrowserFacade::new());
408 let worker = BrowserTool {
409 spec: spec_named("browser_end_host"),
410 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
411 slot: seat(),
412 };
413
414 let out = worker.call(ToolInput { value: json!({}) }).await;
415
416 assert!(out.is_error, "a worker cannot end a host other sessions are using");
417 assert!(mock.calls.lock().is_empty(), "the fleet is never asked");
418 let refusal = match out.blocks.as_slice() {
419 [ToolOutputBlock::Text { text }] => text.clone(),
420 other => panic!("the refusal is one text block: {other:?}"),
421 };
422 assert!(refusal.contains("lead"), "the refusal names who can end it: {refusal}");
423
424 let lead = BrowserTool {
425 spec: spec_named("browser_end_host"),
426 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
427 slot: SessionSlot::lead("TestOrg", "test-project"),
428 };
429
430 let out = lead.call(ToolInput { value: json!({}) }).await;
431
432 assert!(!out.is_error, "the lead's call reaches the fleet: {:?}", out.blocks);
433 assert_eq!(
434 mock.calls.lock().as_slice(),
435 [("browser_end_host".to_owned(), json!({}))],
436 "the door is the tool the fleet hears",
437 );
438 }
439
440 #[derive(Clone, Default)]
442 struct Caught(std::sync::Arc<parking_lot::Mutex<Vec<(tracing::Level, String, String)>>>);
443
444 #[derive(Default)]
445 struct CaughtFields(String);
446
447 impl tracing::field::Visit for CaughtFields {
448 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
449 use std::fmt::Write as _;
450 if !self.0.is_empty() {
451 self.0.push(' ');
452 }
453 let _ = write!(&mut self.0, "{}={value:?}", field.name());
454 }
455 }
456
457 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Caught {
458 fn on_event(
459 &self,
460 event: &tracing::Event<'_>,
461 _ctx: tracing_subscriber::layer::Context<'_, S>,
462 ) {
463 let mut fields = CaughtFields::default();
464 event.record(&mut fields);
465 self.0.lock().push((
466 *event.metadata().level(),
467 event.metadata().target().to_owned(),
468 fields.0,
469 ));
470 }
471 }
472
473 #[tokio::test]
476 async fn a_refused_end_host_call_is_recorded() {
477 use tracing_subscriber::layer::SubscriberExt as _;
478 let mock = Arc::new(MockBrowserFacade::new());
479 let tool = BrowserTool {
480 spec: spec_named("browser_end_host"),
481 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
482 slot: seat(),
483 };
484 let caught = Caught::default();
485 let subscriber = tracing_subscriber::registry().with(caught.clone());
486 let _guard = tracing::subscriber::set_default(subscriber);
487
488 let out = tool.call(ToolInput { value: json!({}) }).await;
489
490 assert!(out.is_error, "the worker is refused");
491 let records = caught.0.lock();
492 assert!(
493 records.iter().any(|(level, target, fields)| {
494 *level == tracing::Level::DEBUG
495 && target == "forge_workspace::browser"
496 && fields.contains("event_name=\"browser_call\"")
497 && fields.contains("ok=false")
498 }),
499 "the refusal is recorded: {records:?}",
500 );
501 }
502
503 #[tokio::test]
507 async fn an_image_answer_crosses_as_an_image_block() {
508 let mock = Arc::new(MockBrowserFacade::new());
509 *mock.answer.lock() = Ok(vec![
510 BrowserPart::Text { text: "captured".to_owned() },
511 BrowserPart::Image { mime_type: "image/png".to_owned(), bytes: vec![1, 2] },
512 ]);
513 let tool = BrowserTool {
514 spec: spec_named("browser_take_screenshot"),
515 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
516 slot: seat(),
517 };
518
519 let out = tool.call(ToolInput { value: json!({}) }).await;
520
521 assert_eq!(
522 out.blocks,
523 vec![
524 ToolOutputBlock::Text { text: "captured".to_owned() },
525 ToolOutputBlock::Image { mime_type: "image/png".to_owned(), data: vec![1, 2] },
526 ],
527 "the parts cross in order, the image's bytes with them",
528 );
529 }
530
531 #[tokio::test]
537 async fn a_tool_driven_through_the_server_reaches_the_installed_host() {
538 use crate::mcp::browser::facade::ProdBrowserFacade;
539 use crate::mcp::cron::facade::MockCronFacade;
540 use crate::mcp::gotify::facade::MockGotifyFacade;
541 use crate::mcp::peers::facade::MockWorkspaceFacade;
542 use crate::mcp::review::facade::MockReviewFacade;
543 use crate::mcp::slack::facade::MockSlackFacade;
544 use crate::mcp::tasks::facade::MockTasksFacade;
545 use crate::mcp::workers::facade::MockWorkerFacade;
546 use tokio::sync::mpsc;
547
548 let (workspace, _updates) = crate::workspace::Workspace::testing_stub();
549 let (calls, mut heard) = mpsc::unbounded_channel();
550 workspace
551 .install_browser_host(std::sync::Arc::new(crate::browser::testing::StubHost { calls }));
552
553 let server = crate::mcp::build_forge_server(
554 crate::mcp::ForgeServerFacades {
555 workspace: MockWorkspaceFacade::new().into_arc(),
556 worker: MockWorkerFacade::new().into_arc(),
557 browser: ProdBrowserFacade::from_workspace(&workspace),
559 terminal: crate::mcp::terminal::facade::MockTerminalFacade::new().into_arc(),
560 documents: crate::mcp::documents::facade::ProdDocumentFacade::from_workspace(
561 &workspace,
562 ),
563 review: MockReviewFacade::new().into_arc(),
564 cron: MockCronFacade::new().into_arc(),
565 gotify: MockGotifyFacade::new().into_arc(),
566 slack: MockSlackFacade::new().into_arc(),
567 settings: crate::mcp::settings::facade::ProdSettingsFacade::from_arc(&workspace),
568 vault: None,
569 tasks: MockTasksFacade::new().into_arc(),
570 systemone: None,
571 voice: workspace.voice_service(),
572 },
573 &crate::mcp::McpFamily::all(),
574 &seat(),
575 crate::mcp::SessionKind::Worker,
576 );
577
578 let answer = server
579 .dispatch(&forge_sdk::mcp::protocol::JsonRpcRequest {
580 jsonrpc: "2.0".to_owned(),
581 id: Some(json!(1)),
582 method: "tools/call".to_owned(),
583 params: Some(json!({ "name": "browser_close", "arguments": {} })),
584 })
585 .await
586 .expect("a tools/call is answered");
587
588 let heard = heard.try_recv().expect(
589 "the call reached the installed host rather than the tool answering without one",
590 );
591 assert_eq!(heard.0, "browser_close", "and names the tool that was called");
592 let encoded = format!("{answer:?}");
593 assert!(
594 encoded.contains("clicked"),
595 "the tool's answer is the host's parts, so the call reached it: {encoded}",
596 );
597 assert!(!encoded.contains("no browser-capable client"), "{encoded}");
598 }
599
600 #[tokio::test]
604 async fn a_hand_off_parks_on_the_person_and_a_done_answers_it() {
605 let mock = Arc::new(MockBrowserFacade::new());
606 let tool = BrowserTool {
607 spec: spec_named("browser_hand_off"),
608 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
609 slot: seat(),
610 };
611
612 let out = tool
613 .call(ToolInput { value: json!({ "reason": "solve the CAPTCHA", "tab": PAGE_ID }) })
614 .await;
615
616 assert!(!out.is_error, "{:?}", out.blocks);
617 assert!(
618 mock.calls.lock().is_empty(),
619 "the hand-off never reaches the browser: the person is the ask, not the tool",
620 );
621 assert_eq!(
622 mock.hand_offs.lock().as_slice(),
623 [("solve the CAPTCHA".to_owned(), PAGE_ID.to_owned())],
624 "the reason and the tab cross to the park verbatim",
625 );
626 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
627 assert!(text.contains("carry on"), "Done tells the model to carry on: {text}");
628 }
629
630 #[tokio::test]
633 async fn a_hand_off_decline_is_a_tool_error() {
634 let mock = Arc::new(MockBrowserFacade::new());
635 *mock.hand_off_answer.lock() = Ok(HandOffEnding::NotNow);
636 let tool = BrowserTool {
637 spec: spec_named("browser_hand_off"),
638 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
639 slot: seat(),
640 };
641
642 let out =
643 tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
644
645 assert!(out.is_error, "{:?}", out.blocks);
646 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
647 assert!(text.contains("said not now"), "{text}");
648 assert!(text.contains("Ask them again later"), "{text}");
649 }
650
651 #[tokio::test]
656 async fn a_hand_off_feedback_is_a_refusal() {
657 let mock = Arc::new(MockBrowserFacade::new());
658 *mock.hand_off_answer.lock() = Ok(HandOffEnding::Feedback);
659 let tool = BrowserTool {
660 spec: spec_named("browser_hand_off"),
661 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
662 slot: seat(),
663 };
664
665 let out =
666 tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
667
668 assert!(out.is_error, "{:?}", out.blocks);
669 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
670 assert!(text.contains("not approval"), "the answer names the refusal: {text}");
671 assert!(text.contains("message beside this result"), "the words are named: {text}");
672 assert!(text.contains("hand the page off again"), "and the next move: {text}");
673 assert!(!text.contains("carry on"), "and never tells the model to carry on: {text}");
674 }
675
676 #[tokio::test]
679 async fn a_hand_off_without_a_tab_is_refused() {
680 let mock = Arc::new(MockBrowserFacade::new());
681 let tool = BrowserTool {
682 spec: spec_named("browser_hand_off"),
683 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
684 slot: seat(),
685 };
686
687 let out = tool.call(ToolInput { value: json!({ "reason": "sign in" }) }).await;
688
689 assert!(out.is_error, "{:?}", out.blocks);
690 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
691 assert!(text.contains("`tab`"), "{text}");
692 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
693 }
694
695 #[tokio::test]
699 async fn a_hand_off_requires_a_page_id_string_and_names_the_retired_field() {
700 let mock = Arc::new(MockBrowserFacade::new());
701 let tool = BrowserTool {
702 spec: spec_named("browser_hand_off"),
703 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
704 slot: seat(),
705 };
706
707 let numeric =
708 tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": 2 }) }).await;
709 assert!(numeric.is_error, "{:?}", numeric.blocks);
710 let text = crate::mcp::test_support::block_text(&numeric.blocks[0]);
711 assert!(text.contains("page id string"), "{text}");
712 assert!(text.contains("not a number"), "{text}");
713
714 let retired =
715 tool.call(ToolInput { value: json!({ "reason": "sign in", "index": 2 }) }).await;
716 assert!(retired.is_error, "{:?}", retired.blocks);
717 let text = crate::mcp::test_support::block_text(&retired.blocks[0]);
718 assert!(text.contains("`index` is gone"), "{text}");
719
720 let blank =
722 tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": " " }) }).await;
723 assert!(blank.is_error, "{:?}", blank.blocks);
724 let text = crate::mcp::test_support::block_text(&blank.blocks[0]);
725 assert!(text.contains("cannot be blank"), "{text}");
726 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
727 }
728
729 #[tokio::test]
735 async fn a_stale_numeric_tab_fails_by_name_and_names_the_fresh_session() {
736 let mock = Arc::new(MockBrowserFacade::new());
737 let tool = BrowserTool {
738 spec: spec_named("browser_hand_off"),
739 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
740 slot: seat(),
741 };
742
743 let out = tool.call(ToolInput { value: json!({ "reason": "look", "tab": 241 }) }).await;
744
745 assert!(out.is_error, "{:?}", out.blocks);
746 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
747 assert!(text.contains("page id string"), "{text}");
748 assert!(text.contains("not a number"), "{text}");
749 assert!(text.contains("fresh session in this slot"), "{text}");
750 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
751 }
752
753 #[tokio::test]
758 async fn a_tab_that_is_not_a_page_id_is_refused_by_name() {
759 let mock = Arc::new(MockBrowserFacade::new());
760 let tool = BrowserTool {
761 spec: spec_named("browser_hand_off"),
762 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
763 slot: seat(),
764 };
765
766 let long = format!("{PAGE_ID}0");
767 let non_hex = format!("ZZ{}", &PAGE_ID[2..]);
768 for tab in ["241", &PAGE_ID[..31], long.as_str(), non_hex.as_str()] {
769 let out = tool.call(ToolInput { value: json!({ "reason": "look", "tab": tab }) }).await;
770 assert!(out.is_error, "{tab}: {:?}", out.blocks);
771 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
772 assert!(text.contains("32 hex"), "{tab}: {text}");
773 assert!(text.contains(tab), "the refused value is named: {tab}: {text}");
774 assert!(text.contains("fresh session in this slot"), "{tab}: {text}");
775 }
776 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
777 }
778
779 #[tokio::test]
782 async fn a_hand_off_trims_its_page_id() {
783 let mock = Arc::new(MockBrowserFacade::new());
784 let tool = BrowserTool {
785 spec: spec_named("browser_hand_off"),
786 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
787 slot: seat(),
788 };
789
790 let out = tool
791 .call(ToolInput {
792 value: json!({ "reason": "sign in", "tab": format!(" {PAGE_ID} ") }),
793 })
794 .await;
795
796 assert!(!out.is_error, "{:?}", out.blocks);
797 assert_eq!(
798 mock.hand_offs.lock().as_slice(),
799 [("sign in".to_owned(), PAGE_ID.to_owned())],
800 "the trimmed id is what parks",
801 );
802 }
803
804 #[tokio::test]
807 async fn a_hand_off_with_a_blank_reason_is_refused() {
808 let mock = Arc::new(MockBrowserFacade::new());
809 let tool = BrowserTool {
810 spec: spec_named("browser_hand_off"),
811 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
812 slot: seat(),
813 };
814
815 let out = tool.call(ToolInput { value: json!({ "reason": " ", "tab": PAGE_ID }) }).await;
816
817 assert!(out.is_error, "{:?}", out.blocks);
818 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
819 assert!(text.contains("`reason`"), "{text}");
820 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
821 }
822
823 #[tokio::test]
825 async fn a_hand_off_without_a_reason_is_refused() {
826 let mock = Arc::new(MockBrowserFacade::new());
827 let tool = BrowserTool {
828 spec: spec_named("browser_hand_off"),
829 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
830 slot: seat(),
831 };
832
833 let out = tool.call(ToolInput { value: json!({}) }).await;
834
835 assert!(out.is_error, "{:?}", out.blocks);
836 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
837 assert!(text.contains("`reason`"), "{text}");
838 assert!(mock.hand_offs.lock().is_empty(), "and nothing was parked");
839 }
840
841 #[tokio::test]
844 async fn a_hand_off_that_cannot_be_shown_fails_closed() {
845 let mock = Arc::new(MockBrowserFacade::new());
846 *mock.hand_off_answer.lock() =
847 Err("no attached client can show the browser hand-off".to_owned());
848 let tool = BrowserTool {
849 spec: spec_named("browser_hand_off"),
850 facade: Arc::clone(&mock) as Arc<dyn BrowserFacade>,
851 slot: seat(),
852 };
853
854 let out =
855 tool.call(ToolInput { value: json!({ "reason": "sign in", "tab": PAGE_ID }) }).await;
856
857 assert!(out.is_error, "{:?}", out.blocks);
858 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
859 assert!(text.contains("no attached client"), "{text}");
860 }
861}