forge_workspace/mcp/
voice.rs1use std::sync::Arc;
9
10use forge_sdk::mcp::server::McpServerBuilder;
11use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
12
13use crate::SessionSlot;
14use crate::voice::{SayError, VoiceService};
15
16pub(crate) fn add_tools(
18 builder: McpServerBuilder,
19 voice: Arc<VoiceService>,
20 slot: SessionSlot,
21) -> McpServerBuilder {
22 builder.tool(Say { voice, slot })
23}
24
25struct Say {
26 voice: Arc<VoiceService>,
27 slot: SessionSlot,
28}
29
30#[derive(serde::Deserialize)]
31#[serde(deny_unknown_fields)]
32struct Args {
33 text: String,
34}
35
36#[async_trait::async_trait]
37impl Tool for Say {
38 fn name(&self) -> &'static str {
39 "voice__say"
40 }
41
42 fn description(&self) -> &'static str {
43 "Speak one utterance to the person listening to this seat. While a listener is present, \
44 call this with the substance of each user-facing message, phrased for the ear rather \
45 than read from the page. Use it for anything they should hear; the text you write in \
46 the chat is not spoken. Returns `spoken: queued` when the first audio renders, or `no \
47 listener` when no client has voice on for this seat."
48 }
49
50 fn input_schema(&self) -> serde_json::Value {
51 serde_json::json!({
52 "type": "object",
53 "properties": {
54 "text": {
55 "type": "string",
56 "description": "One utterance, typically a sentence or a short paragraph.",
57 },
58 },
59 "required": ["text"],
60 "additionalProperties": false,
61 })
62 }
63
64 async fn call(&self, input: ToolInput) -> ToolOutput {
65 let Args { text } = match serde_json::from_value(input.value) {
66 Ok(args) => args,
67 Err(error) => return ToolOutput::error(format!("invalid arguments: {error}")),
68 };
69 if text.trim().is_empty() {
70 return ToolOutput::error("nothing to say: the utterance is empty");
71 }
72 match self.voice.say(&self.slot, text).await {
73 Ok(()) => ToolOutput::text("spoken: queued"),
74 Err(SayError::NoListener) => ToolOutput::text("no listener"),
75 Err(error) => ToolOutput::error(error.to_string()),
76 }
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use std::sync::Arc;
83
84 use forge_primitives::SessionSlot;
85 use forge_sdk::mcp::server::McpServerBuilder;
86 use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput, ToolOutputBlock};
87 use forge_tts::Chunk;
88
89 use super::{Say, add_tools};
90 use crate::voice::{AudioSink, NoteDispatch, VoiceService, VoiceSettings};
91
92 fn slot() -> SessionSlot {
93 SessionSlot::new("Org", "project", "lead")
94 }
95
96 struct SilentSink;
97 impl AudioSink for SilentSink {
98 fn push(&self, _seat: &SessionSlot, _chunk: Chunk) {}
99 }
100
101 struct SilentNotes;
102 impl NoteDispatch for SilentNotes {
103 fn note(&self, _seat: &SessionSlot, _text: &str) {}
104 }
105
106 fn service() -> Arc<VoiceService> {
107 let loader = Arc::new(|| Err("no model loads in this test".to_owned()));
108 Arc::new(VoiceService::new(
109 loader,
110 VoiceSettings::default(),
111 Arc::new(SilentSink) as Arc<dyn AudioSink>,
112 Arc::new(SilentNotes) as Arc<dyn NoteDispatch>,
113 ))
114 }
115
116 async fn say(text: &str) -> ToolOutput {
117 Say { voice: service(), slot: slot() }
118 .call(ToolInput { value: serde_json::json!({ "text": text }) })
119 .await
120 }
121
122 #[tokio::test]
123 async fn a_say_with_no_listener_answers_no_listener() {
124 let output = say("Hello.").await;
125 assert!(!output.is_error, "no listener is an answer, not a failure: {output:?}");
126 let ToolOutputBlock::Text { text } = &output.blocks[0] else {
127 panic!("the answer is a line of text");
128 };
129 assert_eq!(text, "no listener");
130 }
131
132 #[tokio::test]
133 async fn an_empty_text_is_refused() {
134 let output = say(" ").await;
135 assert!(output.is_error, "an empty utterance is refused");
136 let ToolOutputBlock::Text { text } = &output.blocks[0] else {
137 panic!("the refusal is a line of text");
138 };
139 assert_eq!(text, "nothing to say: the utterance is empty");
140 }
141
142 #[test]
143 fn the_family_registers_one_tool_named_voice_say() {
144 let server = add_tools(McpServerBuilder::new("forge", "0.0.0"), service(), slot()).build();
145 assert_eq!(server.tool_names(), vec!["voice__say".to_owned()]);
146 }
147
148 #[test]
149 fn the_description_carries_the_conversational_rule() {
150 let description = Say { voice: service(), slot: slot() }.description();
151 assert!(
152 description.contains(
153 "While a listener is present, call this with the substance of each user-facing \
154 message, phrased for the ear rather than read from the page."
155 ),
156 "the tool description carries the voice-mode rule: {description}",
157 );
158 }
159}