Skip to main content

harn_vm/
mcp_elicit.rs

1//! MCP `elicitation/create` plumbing — server-to-client structured prompts.
2//!
3//! When Harn is acting as an MCP **server**, a tool handler can call
4//! `mcp_elicit({ message, requestedSchema })` to ask the connected
5//! client to surface a structured prompt to its end user. The reply
6//! envelope is `{ action: "accept" | "decline" | "cancel", content?: ... }`,
7//! where `content` is validated against `requestedSchema` (a JSON Schema
8//! restricted to a flat object of primitives per MCP 2025-11-25).
9//!
10//! When Harn is acting as an MCP **client**, an inbound
11//! `elicitation/create` request from a peer server is dispatched to the
12//! embedder via the `HostCallBridge` (`capability="mcp"`,
13//! `operation="elicit"`). If no host bridge is wired up, the client
14//! responds with `{ action: "decline" }` so the server can make a
15//! sensible fallback decision.
16//!
17//! See the spec at
18//! <https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation>.
19
20use serde_json::{json, Value as JsonValue};
21
22use crate::mcp_client_request::ClientRequestBus;
23use crate::schema::{elicitation_validate, elicitation_validate_schema, json_to_vm_value};
24use crate::stdlib::host::{dispatch_host_call_bridge, dispatch_mock_host_call};
25use crate::value::VmDictExt;
26use crate::value::{VmError, VmValue};
27
28pub use crate::mcp_client_request::{
29    current_bus, install_bus, ClientRequestBus as ElicitationBus, OutboundSender,
30};
31
32/// JSON-RPC method name for elicitation requests.
33pub const ELICITATION_METHOD: &str = "elicitation/create";
34
35impl ClientRequestBus {
36    /// Send an `elicitation/create` request to the peer and await its
37    /// reply. The returned envelope follows the spec: `{ action, content? }`.
38    /// `content` is validated against `requested_schema` when present
39    /// and the action is `accept`.
40    pub async fn elicit(
41        &self,
42        message: String,
43        requested_schema: JsonValue,
44    ) -> Result<VmValue, VmError> {
45        validate_requested_schema(&requested_schema)?;
46
47        let result = self
48            .request(
49                "elicit",
50                ELICITATION_METHOD,
51                json!({
52                    "message": message,
53                    "requestedSchema": requested_schema,
54                }),
55                "mcp_elicit",
56            )
57            .await?;
58        envelope_from_response(&result, &requested_schema)
59    }
60}
61
62/// Spec-compliant elicitation request schemas are flat objects whose
63/// properties are primitive types (string / number / integer / boolean),
64/// optionally with `enum` or numeric/length bounds. We don't enforce the
65/// full restriction, but we do require an object schema so that
66/// validation has well-defined semantics.
67fn validate_requested_schema(schema: &JsonValue) -> Result<(), VmError> {
68    let object = schema.as_object().ok_or_else(|| {
69        VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
70            "mcp_elicit: requestedSchema must be a JSON object",
71        )))
72    })?;
73    match object.get("type").and_then(|value| value.as_str()) {
74        Some("object") => Ok(()),
75        Some(other) => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
76            format!("mcp_elicit: requestedSchema.type must be \"object\" (got {other:?})"),
77        )))),
78        None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
79            "mcp_elicit: requestedSchema.type is required and must be \"object\"",
80        )))),
81    }
82}
83
84/// Parse the client's response into the canonical `{action, content?}`
85/// envelope. When the action is `accept`, the `content` field is
86/// validated against `requested_schema` so scripts can rely on it.
87pub(crate) fn envelope_from_response(
88    result: &JsonValue,
89    requested_schema: &JsonValue,
90) -> Result<VmValue, VmError> {
91    let action = result
92        .get("action")
93        .and_then(|value| value.as_str())
94        .ok_or_else(|| {
95            VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
96                "mcp_elicit: client response missing 'action'",
97            )))
98        })?;
99    if !matches!(action, "accept" | "decline" | "cancel") {
100        return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
101            "mcp_elicit: client response action must be 'accept'/'decline'/'cancel' (got {action:?})"
102        )))));
103    }
104
105    let mut envelope: crate::value::DictMap = crate::value::DictMap::new();
106    envelope.put_str("action", action);
107
108    if action == "accept" {
109        let content = result
110            .get("content")
111            .cloned()
112            .unwrap_or(JsonValue::Object(Default::default()));
113        let validated = validate_accepted_content(&content, requested_schema)?;
114        envelope.insert(crate::value::intern_key("content"), validated);
115    }
116
117    Ok(VmValue::dict(envelope))
118}
119
120/// Validate the `content` field of an `accept` response against the
121/// JSON-Schema-shaped `requestedSchema`. Returns the canonicalized VM
122/// value on success.
123pub(crate) fn validate_accepted_content(
124    content: &JsonValue,
125    requested_schema: &JsonValue,
126) -> Result<VmValue, VmError> {
127    let canonical_schema = elicitation_validate_schema(&json_to_vm_value(requested_schema))
128        .map_err(|error| match error {
129            VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
130                arcstr::ArcStr::from(format!("mcp_elicit: invalid requestedSchema: {s}")),
131            )),
132            other => other,
133        })?;
134    let content_vm = json_to_vm_value(content);
135    elicitation_validate(&content_vm, &canonical_schema).map_err(|error| match error {
136        VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
137            arcstr::ArcStr::from(format!("mcp_elicit: content failed schema validation: {s}")),
138        )),
139        other => other,
140    })
141}
142
143/// Dispatch an inbound server-to-client `elicitation/create` request
144/// (received while Harn is acting as an MCP client) and return the
145/// JSON-RPC response we should send back to the server.
146///
147/// The implementation order matches existing HITL primitives:
148///   1. If a `host_mock("mcp", "elicit", ...)` matches, use that.
149///   2. Otherwise, dispatch through the installed `HostCallBridge`.
150///   3. If no host can take the call, decline with a structured error
151///      so the server can fall back to a sensible default.
152pub(crate) async fn dispatch_inbound_elicitation(
153    server_name: &str,
154    request: &JsonValue,
155    fixtures: Option<&crate::harness::CapabilityFixtureState>,
156) -> JsonValue {
157    let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
158    let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
159    let message = params
160        .get("message")
161        .and_then(|value| value.as_str())
162        .unwrap_or("")
163        .to_string();
164    let requested_schema = params
165        .get("requestedSchema")
166        .cloned()
167        .unwrap_or_else(|| json!({}));
168
169    // Surface the inbound elicitation to live observers (the ACP adapter
170    // renders it as an `_harn/agentEvent` of kind `mcp_notification`) so a
171    // thin client can show the prompt. This is observability only — the
172    // request still resolves through the host bridge / decline fallback
173    // below; the response semantics are unchanged.
174    if let Some(session_id) = crate::llm::current_agent_session_id() {
175        crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
176            session_id,
177            server: server_name.to_string(),
178            method: ELICITATION_METHOD.to_string(),
179            direction: "request".to_string(),
180            params,
181        });
182    }
183
184    // Build the params bundle dispatched to the host bridge / mock.
185    // Includes the originating server name so a single host can route
186    // by source, and copies the raw schema through unmodified.
187    let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
188    bridge_params.put_str("server", server_name);
189    bridge_params.put_str("message", message.as_str());
190    bridge_params.insert(
191        crate::value::intern_key("requestedSchema"),
192        json_to_vm_value(&requested_schema),
193    );
194
195    let bridge_result = match fixtures
196        .and_then(|fixtures| fixtures.dispatch_host("mcp", "elicit", &bridge_params))
197    {
198        Some(result) => Some(result),
199        None => match dispatch_mock_host_call("mcp", "elicit", &bridge_params) {
200            Some(result) => Some(result),
201            None => dispatch_host_call_bridge("mcp", "elicit", &bridge_params).await,
202        },
203    };
204
205    let envelope_value: JsonValue = match bridge_result {
206        Some(Ok(value)) => crate::mcp::vm_value_to_serde(&value),
207        Some(Err(error)) => {
208            let detail = match error {
209                VmError::Thrown(VmValue::String(s)) => s.to_string(),
210                VmError::Thrown(other) => other.display(),
211                VmError::Runtime(s) | VmError::TypeError(s) => s,
212                other => format!("{other:?}"),
213            };
214            return crate::jsonrpc::error_response(id, -32000, &detail);
215        }
216        None => {
217            // No host bridge installed — decline politely.
218            json!({ "action": "decline" })
219        }
220    };
221
222    // Coerce a few common shapes into the canonical envelope. A real
223    // host may return {action, content} directly; a host that doesn't
224    // know about MCP may return a bare value, in which case we treat it
225    // as accept-with-content.
226    let envelope = normalize_inbound_envelope(envelope_value);
227
228    // Enforce schema validation on accept so we don't propagate garbage
229    // up to the calling MCP server.
230    if envelope.get("action").and_then(JsonValue::as_str) == Some("accept") {
231        if let Some(content) = envelope.get("content") {
232            if let Err(error) = validate_accepted_content(content, &requested_schema) {
233                let detail = match error {
234                    VmError::Thrown(VmValue::String(s)) => s.to_string(),
235                    other => format!("{other:?}"),
236                };
237                return crate::jsonrpc::error_response(id, -32602, &detail);
238            }
239        }
240    }
241
242    crate::jsonrpc::response(id, envelope)
243}
244
245fn normalize_inbound_envelope(value: JsonValue) -> JsonValue {
246    let object = match value {
247        JsonValue::Object(map) => map,
248        JsonValue::Null => return json!({ "action": "decline" }),
249        other => {
250            // Bare value — treat as accept with content.
251            return json!({ "action": "accept", "content": other });
252        }
253    };
254
255    if object.contains_key("action") {
256        return JsonValue::Object(object);
257    }
258    // No action field: synthesize one based on whether content is present.
259    let mut out = serde_json::Map::new();
260    if object.is_empty() {
261        out.insert("action".into(), JsonValue::String("decline".into()));
262    } else {
263        out.insert("action".into(), JsonValue::String("accept".into()));
264        out.insert("content".into(), JsonValue::Object(object));
265    }
266    JsonValue::Object(out)
267}
268
269#[cfg(test)]
270mod tests {
271    use tokio::sync::mpsc;
272
273    use super::*;
274
275    #[test]
276    fn validate_requested_schema_rejects_non_object() {
277        assert!(validate_requested_schema(&json!({"type": "string"})).is_err());
278        assert!(validate_requested_schema(&json!("not an object")).is_err());
279    }
280
281    #[test]
282    fn validate_requested_schema_accepts_object() {
283        assert!(validate_requested_schema(&json!({"type": "object"})).is_ok());
284    }
285
286    #[test]
287    fn envelope_from_response_decline_omits_content() {
288        let envelope =
289            envelope_from_response(&json!({"action": "decline"}), &json!({"type": "object"}))
290                .unwrap();
291        let dict = envelope.as_dict().unwrap();
292        assert_eq!(dict.get("action").unwrap().display(), "decline");
293        assert!(dict.get("content").is_none());
294    }
295
296    #[test]
297    fn envelope_from_response_accept_validates_content() {
298        let schema = json!({
299            "type": "object",
300            "properties": {"choice": {"type": "string"}},
301            "required": ["choice"]
302        });
303        let envelope = envelope_from_response(
304            &json!({"action": "accept", "content": {"choice": "A"}}),
305            &schema,
306        )
307        .unwrap();
308        let dict = envelope.as_dict().unwrap();
309        let content = dict.get("content").unwrap().as_dict().unwrap();
310        assert_eq!(content.get("choice").unwrap().display(), "A");
311    }
312
313    #[test]
314    fn envelope_from_response_accept_rejects_invalid_content() {
315        let schema = json!({
316            "type": "object",
317            "properties": {"choice": {"type": "string"}},
318            "required": ["choice"]
319        });
320        let result = envelope_from_response(
321            &json!({"action": "accept", "content": {"choice": 7}}),
322            &schema,
323        );
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn envelope_from_response_rejects_unknown_action() {
329        let result = envelope_from_response(&json!({"action": "wat"}), &json!({"type": "object"}));
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn route_response_returns_false_for_request() {
335        let (tx, _rx) = mpsc::unbounded_channel();
336        let bus = ElicitationBus::new(tx);
337        assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})));
338        assert!(
339            !bus.route_response(&json!({"jsonrpc": "2.0", "method": "notifications/cancelled"}))
340        );
341    }
342
343    #[test]
344    fn route_response_ignores_unknown_id() {
345        let (tx, _rx) = mpsc::unbounded_channel();
346        let bus = ElicitationBus::new(tx);
347        assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": "ghost", "result": {}})));
348    }
349
350    #[test]
351    fn normalize_inbound_envelope_passes_action_through() {
352        let v = normalize_inbound_envelope(json!({"action": "decline"}));
353        assert_eq!(v["action"], json!("decline"));
354    }
355
356    #[test]
357    fn normalize_inbound_envelope_synthesizes_accept_for_bare_dict() {
358        let v = normalize_inbound_envelope(json!({"choice": "A"}));
359        assert_eq!(v["action"], json!("accept"));
360        assert_eq!(v["content"]["choice"], json!("A"));
361    }
362
363    #[test]
364    fn normalize_inbound_envelope_decline_for_null() {
365        let v = normalize_inbound_envelope(JsonValue::Null);
366        assert_eq!(v["action"], json!("decline"));
367    }
368
369    #[tokio::test]
370    async fn elicit_round_trip_validates_accept() {
371        let (tx, mut rx) = mpsc::unbounded_channel();
372        let bus = ElicitationBus::new(tx);
373        let bus_for_responder = bus.clone();
374        tokio::spawn(async move {
375            let outbound = rx.recv().await.expect("elicit request emitted");
376            let id = outbound["id"].clone();
377            assert_eq!(outbound["method"], json!(ELICITATION_METHOD));
378            let response = json!({
379                "jsonrpc": "2.0",
380                "id": id,
381                "result": {"action": "accept", "content": {"choice": "A"}}
382            });
383            assert!(bus_for_responder.route_response(&response));
384        });
385        let result = bus
386            .elicit(
387                "Pick one".to_string(),
388                json!({
389                    "type": "object",
390                    "properties": {"choice": {"type": "string"}},
391                    "required": ["choice"],
392                }),
393            )
394            .await
395            .expect("elicit succeeds");
396        let dict = result.as_dict().unwrap();
397        assert_eq!(dict.get("action").unwrap().display(), "accept");
398    }
399
400    #[tokio::test]
401    async fn elicit_propagates_jsonrpc_error_from_client() {
402        let (tx, mut rx) = mpsc::unbounded_channel();
403        let bus = ElicitationBus::new(tx);
404        let bus_for_responder = bus.clone();
405        tokio::spawn(async move {
406            let outbound = rx.recv().await.expect("elicit request emitted");
407            let id = outbound["id"].clone();
408            let response = json!({
409                "jsonrpc": "2.0",
410                "id": id,
411                "error": {"code": -32601, "message": "client refused"}
412            });
413            assert!(bus_for_responder.route_response(&response));
414        });
415        let result = bus
416            .elicit("Pick one".to_string(), json!({"type": "object"}))
417            .await;
418        let err = result.expect_err("error is propagated");
419        let message = match err {
420            VmError::Thrown(VmValue::String(s)) => s.to_string(),
421            other => format!("{other:?}"),
422        };
423        assert!(message.contains("client refused"), "got: {message}");
424    }
425}