Skip to main content

agentd/mcp/
elicit.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Elicitation → `ask_human`**: letting an MCP server ask the operator.
3//!
4//! MCP servers may send the client an `elicitation/create` request — "I need a
5//! value from the person before I can continue". agentd is unusually well
6//! placed to answer one: `ask_human` already suspends the asker, renders the
7//! question as an answerable row in every attached display client, survives a
8//! daemon restart, and has a configured fallback for when nobody is watching.
9//! Elicitation is that machinery with a different caller.
10//!
11//! The wiring problem is where the two live. MCP connections are held by the
12//! **turn worker child** (the supervisor makes no model or MCP calls), while
13//! `ask_human` runs on the **supervisor**, which owns the tasks and the gates.
14//! The child already has a round-trip for exactly this — `AgentMsg::ToolRequest`
15//! out, a reply slot back — and both halves of it are shareable (`Arc<Mutex<_>>`
16//! writer, `Arc<Replies>`), so the handler runs on the MCP event thread and
17//! blocks there rather than on the agent's own thread. A server waiting on an
18//! elicitation therefore does not stall the turn that is talking to it.
19//!
20//! **What we can honestly promise about the schema.** The spec wants the
21//! response `content` to match the server's `requestedSchema`. A human answers
22//! in prose. So: a reply that parses as a JSON object is passed through; a
23//! single-property schema binds the text (coerced to the declared primitive);
24//! anything else cannot be guaranteed to conform, and we return `cancel` rather
25//! than hand a server data that violates the contract it asked for.
26
27use crate::subagent::control::{Up, send_up};
28use crate::subagent::protocol::AgentMsg;
29use crate::subagent::replies::{Replies, Reply};
30use mcp::inbound::{Answer, Handler, Inbound};
31use serde_json::{Value, json};
32use std::sync::Arc;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::time::{Duration, Instant};
35
36/// Bridges an MCP server's elicitation to the supervisor's `ask_human`.
37pub struct ElicitationBridge {
38    up: Up,
39    replies: Arc<Replies>,
40    cancel: Arc<AtomicBool>,
41    /// How long to wait for a human before giving the server a `cancel`. The
42    /// gate itself may outlive this; the server does not have to.
43    timeout: Duration,
44}
45
46impl ElicitationBridge {
47    pub fn new(
48        up: Up,
49        replies: Arc<Replies>,
50        cancel: Arc<AtomicBool>,
51        timeout: Duration,
52    ) -> ElicitationBridge {
53        ElicitationBridge {
54            up,
55            replies,
56            cancel,
57            timeout,
58        }
59    }
60}
61
62impl Handler for ElicitationBridge {
63    fn handle(&self, req: Inbound) -> Option<Answer> {
64        let Inbound::Elicit {
65            message,
66            requested_schema,
67        } = req
68        else {
69            // `roots/list` is not wired: we do not advertise the capability, so
70            // this arm is unreachable in practice.
71            return None;
72        };
73        if self.cancel.load(Ordering::Relaxed) {
74            return Some(Answer::Cancel);
75        }
76
77        let id = self.replies.next_id();
78        send_up(
79            &self.up,
80            &AgentMsg::ToolRequest {
81                id,
82                name: "ask_human".to_string(),
83                args: json!({
84                    "question": message,
85                    "schema": requested_schema.clone(),
86                }),
87            },
88        );
89
90        let deadline = Instant::now() + self.timeout;
91        match self.replies.wait(id, deadline, &self.cancel) {
92            Some(Reply::Tool { result, is_error }) => {
93                if is_error {
94                    // The ask failed or the configured fallback refused it.
95                    return Some(Answer::Cancel);
96                }
97                if result
98                    .get("timed_out")
99                    .and_then(Value::as_bool)
100                    .unwrap_or(false)
101                {
102                    return Some(Answer::Cancel);
103                }
104                Some(shape_reply(
105                    result.get("reply").unwrap_or(&Value::Null),
106                    &requested_schema,
107                ))
108            }
109            // Cancelled, channel gone, or past the deadline.
110            _ => Some(Answer::Cancel),
111        }
112    }
113}
114
115/// Fit a human's answer to the server's `requestedSchema`, or decline to.
116///
117/// Kept pure and separately tested: it is the only place where a free-text
118/// answer meets a typed contract, and getting it wrong means handing a server
119/// data that violates the schema it asked for.
120pub(crate) fn shape_reply(reply: &Value, schema: &Value) -> Answer {
121    // Already structured (a client answered with JSON, or the gate carried an
122    // object through) — pass it on.
123    if reply.is_object() {
124        return Answer::Accept(reply.clone());
125    }
126
127    let text = match reply {
128        Value::String(s) => s.trim().to_string(),
129        Value::Null => return Answer::Cancel,
130        other => other.to_string(),
131    };
132    if text.is_empty() {
133        return Answer::Cancel;
134    }
135
136    // A human may have typed JSON at a JSON-shaped question.
137    if let Ok(Value::Object(m)) = serde_json::from_str::<Value>(&text) {
138        return Answer::Accept(Value::Object(m));
139    }
140
141    // A single-property schema is unambiguous: the answer IS that property.
142    let props = schema.get("properties").and_then(Value::as_object);
143    if let Some(props) = props
144        && props.len() == 1
145        && let Some((key, spec)) = props.iter().next()
146    {
147        let declared = spec.get("type").and_then(Value::as_str).unwrap_or("string");
148        if let Some(v) = coerce(&text, declared) {
149            return Answer::Accept(json!({ key: v }));
150        }
151        return Answer::Cancel;
152    }
153
154    // Multi-property (or unschema'd) — prose cannot be guaranteed to conform,
155    // and a server that asked for a shape deserves a refusal over a violation.
156    Answer::Cancel
157}
158
159/// Coerce a human's text to the schema's declared primitive. `None` when it
160/// plainly is not one (a word where a number was demanded).
161fn coerce(text: &str, declared: &str) -> Option<Value> {
162    match declared {
163        "string" => Some(Value::String(text.to_string())),
164        "boolean" => match text.to_ascii_lowercase().as_str() {
165            "true" | "yes" | "y" | "1" | "ok" => Some(Value::Bool(true)),
166            "false" | "no" | "n" | "0" => Some(Value::Bool(false)),
167            _ => None,
168        },
169        "integer" => text.parse::<i64>().ok().map(|n| json!(n)),
170        "number" => text
171            .parse::<f64>()
172            .ok()
173            .and_then(|n| serde_json::Number::from_f64(n).map(Value::Number)),
174        _ => None,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn one_prop(name: &str, ty: &str) -> Value {
183        json!({"type": "object", "properties": { name: {"type": ty} }})
184    }
185
186    fn accepted(a: Answer) -> Value {
187        match a {
188            Answer::Accept(v) => v,
189            other => panic!("expected Accept, got {other:?}"),
190        }
191    }
192
193    #[test]
194    fn a_structured_reply_passes_through() {
195        let r = shape_reply(&json!({"env": "staging"}), &one_prop("env", "string"));
196        assert_eq!(accepted(r)["env"], "staging");
197    }
198
199    #[test]
200    fn a_single_property_schema_binds_the_text() {
201        let r = shape_reply(&json!("staging"), &one_prop("env", "string"));
202        assert_eq!(accepted(r), json!({"env": "staging"}));
203
204        // …coercing to the declared primitive.
205        let r = shape_reply(&json!("42"), &one_prop("count", "integer"));
206        assert_eq!(accepted(r), json!({"count": 42}));
207        let r = shape_reply(&json!("yes"), &one_prop("confirm", "boolean"));
208        assert_eq!(accepted(r), json!({"confirm": true}));
209
210        // A word where a number was demanded is not silently stringified.
211        assert!(matches!(
212            shape_reply(&json!("soon"), &one_prop("count", "integer")),
213            Answer::Cancel
214        ));
215    }
216
217    #[test]
218    fn typed_json_is_honoured_over_the_single_property_shortcut() {
219        let r = shape_reply(
220            &json!(r#"{"env":"prod","force":true}"#),
221            &one_prop("env", "string"),
222        );
223        let v = accepted(r);
224        assert_eq!(v["env"], "prod");
225        assert_eq!(v["force"], true);
226    }
227
228    #[test]
229    fn prose_against_a_multi_property_schema_is_declined_not_guessed() {
230        // The server asked for a shape; handing it something that violates the
231        // schema is worse than telling it nobody answered.
232        let schema = json!({"type": "object", "properties": {
233            "env": {"type": "string"}, "force": {"type": "boolean"}
234        }});
235        assert!(matches!(
236            shape_reply(&json!("just do it on staging"), &schema),
237            Answer::Cancel
238        ));
239    }
240
241    #[test]
242    fn nothing_to_say_is_a_cancel() {
243        assert!(matches!(
244            shape_reply(&Value::Null, &one_prop("x", "string")),
245            Answer::Cancel
246        ));
247        assert!(matches!(
248            shape_reply(&json!("   "), &one_prop("x", "string")),
249            Answer::Cancel
250        ));
251    }
252}