1use std::sync::Arc;
24
25use forge_primitives::SessionSlot;
26use forge_primitives::document::DocumentHandOffEnding;
27use forge_primitives::review::doc_scope_ref;
28use forge_sdk::mcp::server::McpServerBuilder;
29use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
30use serde_json::Value;
31
32use crate::mcp::documents::facade::DocumentFacade;
33
34pub mod facade;
35
36#[cfg(test)]
40pub fn build_server(
41 facade: Arc<dyn DocumentFacade>,
42 slot: SessionSlot,
43 review_available: bool,
44) -> forge_sdk::mcp::server::McpServer {
45 add_tools(
46 McpServerBuilder::new("forge", env!("CARGO_PKG_VERSION")),
47 facade,
48 slot,
49 review_available,
50 )
51 .build()
52}
53
54pub(crate) fn add_tools(
57 builder: McpServerBuilder,
58 facade: Arc<dyn DocumentFacade>,
59 slot: SessionSlot,
60 review_available: bool,
61) -> McpServerBuilder {
62 builder.tool(HandOffTool { facade, slot, review_available })
63}
64
65const DESCRIPTION: &str = "\
68Hand the reader a document from this session's working tree, or from one of \
69the project's document roots, opened in their own viewer. This tool is for \
70documents - Markdown, PDF, plain text; a visual artifact - an HTML mockup, \
71a PNG - goes to the browser instead: navigate to it and hand off that tab \
72with `browser_hand_off`, never this tool. Use it when a \
73document is the thing the reader should see - a plan, a report, a spec - \
74rather than part of the conversation; a document the reader is meant to \
75review goes here with `reviewable: true`, never as a path or link in \
76chat. `path` is relative to the working directory, or absolute under \
77one of the project's `document_roots` in `forge.toml`. Set `reviewable` \
78when you want review comments back: the reader opens it, comments on \
79lines and answers, and their review then arrives filed under the \
80document's own scope. A PDF always opens read-only. **The answer is \
81approve or send feedback**: feedback means revise the document, hand it \
82off again, and keep handing it off until the reader approves; approval \
83is the one signal to proceed, and neither may be read as the other; the \
84one feedback box reaches you with either \
85answer, though approve may leave it empty and send feedback may not. \
86The reader is asked to open it or not, and **this call waits for them**: \
87it answers when they approve, send feedback or say not now.";
88
89const DESCRIPTION_WITHOUT_REVIEW: &str = "\
94Hand the reader a document from this session's working tree, or from one of \
95the project's document roots, opened in their own viewer. This tool is for \
96documents - Markdown, PDF, plain text; a visual artifact - an HTML mockup, \
97a PNG - goes to the browser instead: navigate to it and hand off that tab \
98with `browser_hand_off`, never this tool. Use it when a \
99document is the thing the reader should see - a plan, a report, a spec - \
100rather than part of the conversation; a document the reader is meant to \
101review goes here with `reviewable: true`, never as a path or link in \
102chat. `path` is relative to the working directory, or absolute under \
103one of the project's `document_roots` in `forge.toml`. Set `reviewable` \
104to ask the reader for a review of it: they open it, comment on lines \
105and answer, and their review is filed under the document's own scope. \
106This session was spawned without the `review` MCP family, so you cannot \
107read it back; the answer names the review's id and scope, which your lead \
108reads with review__get. A PDF always opens read-only. **The answer is \
109approve or send feedback**: feedback means revise the document, hand it \
110off again, and keep handing it off until the reader approves; approval \
111is the one signal to proceed, and neither may be read as the other; the \
112one feedback box reaches you with either \
113answer, though approve may leave it empty and send feedback may not. \
114The reader is asked to open it or not, and **this call waits for them**: \
115it answers when they approve, send feedback or say not now.";
116
117pub(crate) struct HandOffTool {
119 facade: Arc<dyn DocumentFacade>,
120 slot: SessionSlot,
121 review_available: bool,
124}
125
126#[async_trait::async_trait]
127impl Tool for HandOffTool {
128 fn name(&self) -> &'static str {
129 "documents__hand_off"
134 }
135
136 fn description(&self) -> &'static str {
137 if self.review_available { DESCRIPTION } else { DESCRIPTION_WITHOUT_REVIEW }
138 }
139
140 fn input_schema(&self) -> Value {
141 serde_json::json!({
142 "type": "object",
143 "properties": {
144 "path": {
145 "type": "string",
146 "description": "The document's path, relative to the session's working \
147 directory, or absolute under one of the project's \
148 `document_roots` in `forge.toml`.",
149 },
150 "reviewable": {
151 "type": "boolean",
152 "description": "Ask the reader for a review of this document. A document a \
153 client can read offers its review either way; a PDF always \
154 opens read-only.",
155 },
156 },
157 "required": ["path"],
158 "additionalProperties": false,
159 })
160 }
161
162 async fn call(&self, input: ToolInput) -> ToolOutput {
163 let started = std::time::Instant::now();
164 let outcome = self.hand_off(input.value).await;
165 tracing::debug!(
169 target: "forge_workspace::documents",
170 event_name = "document_hand_off",
171 slot = %self.slot.display(),
172 ok = outcome.is_ok(),
173 ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
174 "a document hand-off returned",
175 );
176 match outcome {
177 Ok(text) => {
178 ToolOutput { blocks: vec![ToolOutputBlock::Text { text }], is_error: false }
179 }
180 Err(why) => ToolOutput::error(why),
181 }
182 }
183}
184
185impl HandOffTool {
186 async fn hand_off(&self, args: Value) -> Result<String, String> {
187 let Some(path) = args.get("path").and_then(Value::as_str) else {
188 return Err("documents__hand_off needs `path`: the document's path, relative to this \
189 session's working directory or absolute under one of the project's document_roots"
190 .to_owned());
191 };
192 let reviewable = match args.get("reviewable") {
193 None | Some(Value::Null) => false,
194 Some(Value::Bool(flag)) => *flag,
195 Some(other) => return Err(format!("`reviewable` is true or false, not {other}")),
196 };
197 let (resolved, ending) = self.facade.hand_off(&self.slot, path, reviewable).await?;
198 Ok(match ending {
199 DocumentHandOffEnding::Approve { review_id, note } => {
200 let opened = format!(
201 "The reader approved {resolved}. Approval is the one signal to proceed."
202 );
203 format!(
204 "{opened}{}{}",
205 Self::note_line(note.as_deref()),
206 Self::review_line(self.review_available, &resolved, review_id.as_deref())
207 )
208 }
209 DocumentHandOffEnding::Feedback { review_id, note } => {
210 let refused = format!(
211 "The reader sent feedback at {resolved}. This is not approval: \
212 revise the document and hand it off again, and keep handing it \
213 off until the reader approves."
214 );
215 return Err(format!(
216 "{refused}{}{}",
217 Self::note_line(note.as_deref()),
218 Self::review_line(self.review_available, &resolved, review_id.as_deref())
219 ));
220 }
221 DocumentHandOffEnding::NotNow => {
222 return Err("the reader said not now, so nothing opened".to_owned());
223 }
224 DocumentHandOffEnding::Abandoned => {
225 return Err("The hand-off ended without an answer.".to_owned());
226 }
227 })
228 }
229
230 fn note_line(note: Option<&str>) -> String {
232 match note {
233 Some(note) => format!(" Their note: {note}"),
234 None => String::new(),
235 }
236 }
237
238 fn review_line(review_available: bool, resolved: &str, review_id: Option<&str>) -> String {
242 let Some(review_id) = review_id else {
243 return String::new();
244 };
245 let scope = doc_scope_ref(resolved);
246 if review_available {
247 format!(
248 " Their comments are filed as the review {review_id} under the document's own \
249 scope, {scope} - read them back with review__get and pass that scope."
250 )
251 } else {
252 format!(
253 " Their comments are filed as the review {review_id} under the document's own \
254 scope, {scope}. This session was spawned without the `review` MCP family, so \
255 you cannot read them back - hand your lead the review id and scope above, \
256 which it reads with review__get."
257 )
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use std::sync::Arc;
265
266 use forge_primitives::SessionSlot;
267 use serde_json::json;
268
269 use super::*;
270 use crate::mcp::documents::facade::{DocumentFacade, MockDocumentFacade};
271
272 fn seat() -> SessionSlot {
273 SessionSlot::from_str_for_test("caller")
274 }
275
276 fn tool(facade: Arc<dyn DocumentFacade>) -> HandOffTool {
277 tool_with_review(facade, true)
278 }
279
280 fn tool_with_review(facade: Arc<dyn DocumentFacade>, review_available: bool) -> HandOffTool {
281 HandOffTool { facade, slot: seat(), review_available }
282 }
283
284 #[tokio::test]
288 async fn a_hand_off_carries_the_path_and_the_flag_for_the_callers_seat() {
289 let mock = Arc::new(MockDocumentFacade::new());
290 *mock.answer.lock() = Ok((
291 "/proj/docs/readme.md".to_owned(),
292 DocumentHandOffEnding::Approve { review_id: None, note: None },
293 ));
294
295 let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
296 .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
297 .await;
298
299 assert!(!out.is_error, "{:?}", out.blocks);
300 assert_eq!(
301 mock.calls.lock().as_slice(),
302 [("docs/readme.md".to_owned(), true)],
303 "the path and the flag cross verbatim",
304 );
305 assert_eq!(
306 mock.seats.lock().as_slice(),
307 [seat()],
308 "and for the seat that called, so the path cannot resolve against another tree",
309 );
310 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
311 assert!(text.contains("/proj/docs/readme.md"), "the answer carries the resolved path");
312 }
313
314 #[tokio::test]
317 async fn a_refused_read_is_a_tool_error_with_the_reads_reason() {
318 let mock = Arc::new(MockDocumentFacade::new());
319 *mock.answer.lock() =
320 Err("the path resolves outside the session's tree: ../../etc/passwd".to_owned());
321
322 let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
323 .call(ToolInput { value: json!({ "path": "../../etc/passwd" }) })
324 .await;
325
326 assert!(out.is_error, "{:?}", out.blocks);
327 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
328 assert!(text.contains("outside the session's tree"), "{text}");
329 }
330
331 #[tokio::test]
335 async fn a_finished_hand_offs_answer_names_the_review_and_its_scope() {
336 let mock = Arc::new(MockDocumentFacade::new());
337 *mock.answer.lock() = Ok((
338 "/proj/docs/readme.md".to_owned(),
339 DocumentHandOffEnding::Approve { review_id: Some("r-7".to_owned()), note: None },
340 ));
341 let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
342
343 let with_review = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
344 assert!(!with_review.is_error, "{:?}", with_review.blocks);
345 let text = crate::mcp::test_support::block_text(&with_review.blocks[0]);
346 assert!(text.contains("r-7"), "the answer names the review: {text}");
347 assert!(text.contains("doc:/proj/docs/readme.md"), "and the scope: {text}");
348 assert!(
349 text.contains("read them back with review__get and pass that scope"),
350 "and the call that reads them back, for a session that has it: {text}",
351 );
352
353 *mock.answer.lock() = Ok((
354 "/proj/docs/readme.md".to_owned(),
355 DocumentHandOffEnding::Approve { review_id: None, note: None },
356 ));
357 let without = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
358 assert!(!without.is_error, "{:?}", without.blocks);
359 let text = crate::mcp::test_support::block_text(&without.blocks[0]);
360 assert!(!text.contains("doc:"), "nothing commented, so no scope to name: {text}");
361 }
362
363 #[tokio::test]
368 async fn approve_and_feedback_carry_the_note_and_never_read_as_each_other() {
369 let mock = Arc::new(MockDocumentFacade::new());
370 let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
371
372 *mock.answer.lock() = Ok((
373 "/proj/docs/readme.md".to_owned(),
374 DocumentHandOffEnding::Approve {
375 review_id: Some("r-7".to_owned()),
376 note: Some("ship it".to_owned()),
377 },
378 ));
379 let approved = tool
380 .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
381 .await;
382 assert!(!approved.is_error, "an approval is a success: {:?}", approved.blocks);
383 let text = crate::mcp::test_support::block_text(&approved.blocks[0]);
384 assert!(text.contains("approved"), "the answer says what happened: {text}");
385 assert!(text.contains("ship it"), "and carries the note verbatim: {text}");
386 assert!(text.contains("read them back with review__get"), "and the review: {text}");
387
388 *mock.answer.lock() = Ok((
389 "/proj/docs/readme.md".to_owned(),
390 DocumentHandOffEnding::Feedback {
391 review_id: Some("r-8".to_owned()),
392 note: Some("the table is wrong".to_owned()),
393 },
394 ));
395 let refused = tool
396 .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
397 .await;
398 assert!(refused.is_error, "feedback is not a hand-off success: {:?}", refused.blocks);
399 let text = crate::mcp::test_support::block_text(&refused.blocks[0]);
400 assert!(text.contains("sent feedback"), "the reader's answer is named: {text}");
401 assert!(text.contains("the table is wrong"), "the note reaches the agent: {text}");
402 assert!(
403 text.contains("revise the document and hand it off again"),
404 "and the loop's next step is stated: {text}",
405 );
406 assert!(
407 text.contains("keep handing it off until the reader approves"),
408 "and the loop is until approval: {text}",
409 );
410 assert!(text.contains("not approval"), "never read as done: {text}");
411 }
412
413 #[tokio::test]
418 async fn a_hand_off_from_a_session_without_review_names_the_family_it_lacks() {
419 let mock = Arc::new(MockDocumentFacade::new());
420 let tool = tool_with_review(Arc::clone(&mock) as Arc<dyn DocumentFacade>, false);
421
422 *mock.answer.lock() = Ok((
423 "/proj/docs/readme.md".to_owned(),
424 DocumentHandOffEnding::Approve { review_id: Some("r-7".to_owned()), note: None },
425 ));
426 let out = tool
427 .call(ToolInput { value: json!({ "path": "docs/readme.md", "reviewable": true }) })
428 .await;
429 assert!(!out.is_error, "{:?}", out.blocks);
430 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
431 assert!(text.contains("r-7"), "the review is still named: {text}");
432 assert!(
433 text.contains("spawned without the `review` MCP family"),
434 "the answer names the family the session lacks: {text}",
435 );
436 assert!(text.contains("your lead"), "and routes the scope to the lead: {text}");
437 assert!(
438 !text.contains("read them back with review__get and pass that scope"),
439 "it must not send a session without review to read them itself: {text}",
440 );
441 }
442
443 #[test]
447 fn the_description_names_the_review_gap_before_the_call() {
448 let without = tool_with_review(MockDocumentFacade::new().into_arc(), false);
449 assert!(
450 without.description().contains("spawned without the `review` MCP family"),
451 "the pre-call text names the family this session lacks: {}",
452 without.description(),
453 );
454 let with = tool(MockDocumentFacade::new().into_arc());
455 assert!(
456 !with.description().contains("spawned without the `review` MCP family"),
457 "and a session that has it is not told otherwise: {}",
458 with.description(),
459 );
460 }
461
462 #[tokio::test]
466 async fn a_declined_or_abandoned_hand_off_fails_with_its_reason() {
467 let mock = Arc::new(MockDocumentFacade::new());
468 let tool = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>);
469
470 *mock.answer.lock() =
471 Ok(("/proj/docs/readme.md".to_owned(), DocumentHandOffEnding::NotNow));
472 let out = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
473 assert!(out.is_error, "a decline is not a success: {:?}", out.blocks);
474 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
475 assert!(text.contains("not now"), "{text}");
476
477 *mock.answer.lock() =
478 Ok(("/proj/docs/readme.md".to_owned(), DocumentHandOffEnding::Abandoned));
479 let out = tool.call(ToolInput { value: json!({ "path": "docs/readme.md" }) }).await;
480 assert!(out.is_error, "an abandoned hand-off is not a success: {:?}", out.blocks);
481 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
482 assert_eq!(text, "The hand-off ended without an answer.", "{text}");
483 }
484
485 #[tokio::test]
488 async fn reviewable_is_off_unless_asked_for() {
489 let mock = Arc::new(MockDocumentFacade::new());
490
491 let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
492 .call(ToolInput { value: json!({ "path": "notes.md" }) })
493 .await;
494
495 assert!(!out.is_error, "{:?}", out.blocks);
496 assert_eq!(
497 mock.calls.lock().as_slice(),
498 [("notes.md".to_owned(), false)],
499 "an absent flag reads as read-only",
500 );
501 }
502
503 #[tokio::test]
505 async fn a_hand_off_without_a_path_is_refused() {
506 let mock = Arc::new(MockDocumentFacade::new());
507
508 let out = tool(Arc::clone(&mock) as Arc<dyn DocumentFacade>)
509 .call(ToolInput { value: json!({}) })
510 .await;
511
512 assert!(out.is_error, "{:?}", out.blocks);
513 let text = crate::mcp::test_support::block_text(&out.blocks[0]);
514 assert!(text.contains("`path`"), "{text}");
515 assert!(mock.calls.lock().is_empty(), "and nothing read");
516 }
517
518 #[test]
523 fn the_description_sends_a_review_document_through_the_hand_off() {
524 for review_available in [true, false] {
525 let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
526 assert!(
527 tool.description().contains(
528 "a document the reader is meant to review goes here with `reviewable: true`, \
529 never as a path or link in chat"
530 ),
531 "the description carries the delivery rule (review_available: {review_available}): \
532 {}",
533 tool.description(),
534 );
535 }
536 }
537
538 #[test]
543 fn both_descriptions_route_visual_artifacts_to_the_browser() {
544 for review_available in [true, false] {
545 let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
546 let description = tool.description();
547 for phrase in [
548 "a visual artifact - an HTML mockup, a PNG - goes to the browser instead",
549 "hand off that tab with `browser_hand_off`, never this tool",
550 ] {
551 assert!(
552 description.contains(phrase),
553 "the routing rule is stated (review_available: {review_available}): \
554 {description}",
555 );
556 }
557 }
558 }
559
560 #[test]
566 fn both_descriptions_state_the_hand_offs_loop() {
567 for review_available in [true, false] {
568 let tool = tool_with_review(MockDocumentFacade::new().into_arc(), review_available);
569 let description = tool.description();
570 assert!(
571 description.contains("The answer is approve or send feedback"),
572 "the two outcomes are named (review_available: {review_available}): {description}",
573 );
574 assert!(
575 description.contains(
576 "feedback means revise the document, hand it off again, and keep handing it \
577 off until the reader approves; approval is the one signal to proceed, and \
578 neither may be read as the other"
579 ),
580 "feedback's next step and approval's exclusivity are stated \
581 (review_available: {review_available}): {description}",
582 );
583 assert!(
584 description.contains(
585 "the one feedback box reaches you with either answer, though approve may \
586 leave it empty and send feedback may not"
587 ),
588 "the box's delivery is stated (review_available: {review_available}): {description}",
589 );
590 }
591 }
592
593 #[test]
596 fn the_family_registers_one_tool_named_documents_hand_off() {
597 let server = build_server(MockDocumentFacade::new().into_arc(), seat(), true);
598 let debug = format!("{server:?}");
599 let (_, tools) = debug.split_once("tools: [").expect("debug lists the tool names");
600 let (tools, _) = tools.split_once(']').expect("the tool list is closed");
601 let names: Vec<&str> = tools
602 .split(", ")
603 .map(|name| name.trim_matches('"'))
604 .filter(|n| !n.is_empty())
605 .collect();
606 assert_eq!(names, ["documents__hand_off"]);
607 }
608}