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) -> JsonValue {
156    let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
157    let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
158    let message = params
159        .get("message")
160        .and_then(|value| value.as_str())
161        .unwrap_or("")
162        .to_string();
163    let requested_schema = params
164        .get("requestedSchema")
165        .cloned()
166        .unwrap_or_else(|| json!({}));
167
168    // Surface the inbound elicitation to live observers (the ACP adapter
169    // renders it as an `_harn/agentEvent` of kind `mcp_notification`) so a
170    // thin client can show the prompt. This is observability only — the
171    // request still resolves through the host bridge / decline fallback
172    // below; the response semantics are unchanged.
173    if let Some(session_id) = crate::llm::current_agent_session_id() {
174        crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
175            session_id,
176            server: server_name.to_string(),
177            method: ELICITATION_METHOD.to_string(),
178            direction: "request".to_string(),
179            params,
180        });
181    }
182
183    // Build the params bundle dispatched to the host bridge / mock.
184    // Includes the originating server name so a single host can route
185    // by source, and copies the raw schema through unmodified.
186    let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
187    bridge_params.put_str("server", server_name);
188    bridge_params.put_str("message", message.as_str());
189    bridge_params.insert(
190        crate::value::intern_key("requestedSchema"),
191        json_to_vm_value(&requested_schema),
192    );
193
194    let bridge_result = dispatch_mock_host_call("mcp", "elicit", &bridge_params)
195        .or_else(|| dispatch_host_call_bridge("mcp", "elicit", &bridge_params));
196
197    let envelope_value: JsonValue = match bridge_result {
198        Some(Ok(value)) => crate::mcp::vm_value_to_serde(&value),
199        Some(Err(error)) => {
200            let detail = match error {
201                VmError::Thrown(VmValue::String(s)) => s.to_string(),
202                VmError::Thrown(other) => other.display(),
203                VmError::Runtime(s) | VmError::TypeError(s) => s,
204                other => format!("{other:?}"),
205            };
206            return crate::jsonrpc::error_response(id, -32000, &detail);
207        }
208        None => {
209            // No host bridge installed — decline politely.
210            json!({ "action": "decline" })
211        }
212    };
213
214    // Coerce a few common shapes into the canonical envelope. A real
215    // host may return {action, content} directly; a host that doesn't
216    // know about MCP may return a bare value, in which case we treat it
217    // as accept-with-content.
218    let envelope = normalize_inbound_envelope(envelope_value);
219
220    // Enforce schema validation on accept so we don't propagate garbage
221    // up to the calling MCP server.
222    if envelope.get("action").and_then(JsonValue::as_str) == Some("accept") {
223        if let Some(content) = envelope.get("content") {
224            if let Err(error) = validate_accepted_content(content, &requested_schema) {
225                let detail = match error {
226                    VmError::Thrown(VmValue::String(s)) => s.to_string(),
227                    other => format!("{other:?}"),
228                };
229                return crate::jsonrpc::error_response(id, -32602, &detail);
230            }
231        }
232    }
233
234    crate::jsonrpc::response(id, envelope)
235}
236
237fn normalize_inbound_envelope(value: JsonValue) -> JsonValue {
238    let object = match value {
239        JsonValue::Object(map) => map,
240        JsonValue::Null => return json!({ "action": "decline" }),
241        other => {
242            // Bare value — treat as accept with content.
243            return json!({ "action": "accept", "content": other });
244        }
245    };
246
247    if object.contains_key("action") {
248        return JsonValue::Object(object);
249    }
250    // No action field: synthesize one based on whether content is present.
251    let mut out = serde_json::Map::new();
252    if object.is_empty() {
253        out.insert("action".into(), JsonValue::String("decline".into()));
254    } else {
255        out.insert("action".into(), JsonValue::String("accept".into()));
256        out.insert("content".into(), JsonValue::Object(object));
257    }
258    JsonValue::Object(out)
259}
260
261#[cfg(test)]
262mod tests {
263    use tokio::sync::mpsc;
264
265    use super::*;
266
267    #[test]
268    fn validate_requested_schema_rejects_non_object() {
269        assert!(validate_requested_schema(&json!({"type": "string"})).is_err());
270        assert!(validate_requested_schema(&json!("not an object")).is_err());
271    }
272
273    #[test]
274    fn validate_requested_schema_accepts_object() {
275        assert!(validate_requested_schema(&json!({"type": "object"})).is_ok());
276    }
277
278    #[test]
279    fn envelope_from_response_decline_omits_content() {
280        let envelope =
281            envelope_from_response(&json!({"action": "decline"}), &json!({"type": "object"}))
282                .unwrap();
283        let dict = envelope.as_dict().unwrap();
284        assert_eq!(dict.get("action").unwrap().display(), "decline");
285        assert!(dict.get("content").is_none());
286    }
287
288    #[test]
289    fn envelope_from_response_accept_validates_content() {
290        let schema = json!({
291            "type": "object",
292            "properties": {"choice": {"type": "string"}},
293            "required": ["choice"]
294        });
295        let envelope = envelope_from_response(
296            &json!({"action": "accept", "content": {"choice": "A"}}),
297            &schema,
298        )
299        .unwrap();
300        let dict = envelope.as_dict().unwrap();
301        let content = dict.get("content").unwrap().as_dict().unwrap();
302        assert_eq!(content.get("choice").unwrap().display(), "A");
303    }
304
305    #[test]
306    fn envelope_from_response_accept_rejects_invalid_content() {
307        let schema = json!({
308            "type": "object",
309            "properties": {"choice": {"type": "string"}},
310            "required": ["choice"]
311        });
312        let result = envelope_from_response(
313            &json!({"action": "accept", "content": {"choice": 7}}),
314            &schema,
315        );
316        assert!(result.is_err());
317    }
318
319    #[test]
320    fn envelope_from_response_rejects_unknown_action() {
321        let result = envelope_from_response(&json!({"action": "wat"}), &json!({"type": "object"}));
322        assert!(result.is_err());
323    }
324
325    #[test]
326    fn route_response_returns_false_for_request() {
327        let (tx, _rx) = mpsc::unbounded_channel();
328        let bus = ElicitationBus::new(tx);
329        assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})));
330        assert!(
331            !bus.route_response(&json!({"jsonrpc": "2.0", "method": "notifications/cancelled"}))
332        );
333    }
334
335    #[test]
336    fn route_response_ignores_unknown_id() {
337        let (tx, _rx) = mpsc::unbounded_channel();
338        let bus = ElicitationBus::new(tx);
339        assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": "ghost", "result": {}})));
340    }
341
342    #[test]
343    fn normalize_inbound_envelope_passes_action_through() {
344        let v = normalize_inbound_envelope(json!({"action": "decline"}));
345        assert_eq!(v["action"], json!("decline"));
346    }
347
348    #[test]
349    fn normalize_inbound_envelope_synthesizes_accept_for_bare_dict() {
350        let v = normalize_inbound_envelope(json!({"choice": "A"}));
351        assert_eq!(v["action"], json!("accept"));
352        assert_eq!(v["content"]["choice"], json!("A"));
353    }
354
355    #[test]
356    fn normalize_inbound_envelope_decline_for_null() {
357        let v = normalize_inbound_envelope(JsonValue::Null);
358        assert_eq!(v["action"], json!("decline"));
359    }
360
361    #[tokio::test]
362    async fn elicit_round_trip_validates_accept() {
363        let (tx, mut rx) = mpsc::unbounded_channel();
364        let bus = ElicitationBus::new(tx);
365        let bus_for_responder = bus.clone();
366        tokio::spawn(async move {
367            let outbound = rx.recv().await.expect("elicit request emitted");
368            let id = outbound["id"].clone();
369            assert_eq!(outbound["method"], json!(ELICITATION_METHOD));
370            let response = json!({
371                "jsonrpc": "2.0",
372                "id": id,
373                "result": {"action": "accept", "content": {"choice": "A"}}
374            });
375            assert!(bus_for_responder.route_response(&response));
376        });
377        let result = bus
378            .elicit(
379                "Pick one".to_string(),
380                json!({
381                    "type": "object",
382                    "properties": {"choice": {"type": "string"}},
383                    "required": ["choice"],
384                }),
385            )
386            .await
387            .expect("elicit succeeds");
388        let dict = result.as_dict().unwrap();
389        assert_eq!(dict.get("action").unwrap().display(), "accept");
390    }
391
392    #[tokio::test]
393    async fn elicit_propagates_jsonrpc_error_from_client() {
394        let (tx, mut rx) = mpsc::unbounded_channel();
395        let bus = ElicitationBus::new(tx);
396        let bus_for_responder = bus.clone();
397        tokio::spawn(async move {
398            let outbound = rx.recv().await.expect("elicit request emitted");
399            let id = outbound["id"].clone();
400            let response = json!({
401                "jsonrpc": "2.0",
402                "id": id,
403                "error": {"code": -32601, "message": "client refused"}
404            });
405            assert!(bus_for_responder.route_response(&response));
406        });
407        let result = bus
408            .elicit("Pick one".to_string(), json!({"type": "object"}))
409            .await;
410        let err = result.expect_err("error is propagated");
411        let message = match err {
412            VmError::Thrown(VmValue::String(s)) => s.to_string(),
413            other => format!("{other:?}"),
414        };
415        assert!(message.contains("client refused"), "got: {message}");
416    }
417}