Skip to main content

harn_vm/
mcp_input.rs

1//! MCP 2026-07-28 multi-round-trip input requests.
2//!
3//! Stable MCP servers do not issue requests on a live client connection.
4//! Instead, a handler returns `input_required`; the client resolves the
5//! embedded requests and retries the original operation. This module gives
6//! Harn handlers a small synchronous-looking boundary over that re-entry.
7
8use std::cell::RefCell;
9use std::future::Future;
10
11use serde_json::{Map as JsonMap, Value as JsonValue};
12
13use crate::value::{VmError, VmValue};
14
15/// Uncatchable VM control flow carrying the next stable MCP input round.
16#[derive(Clone, Debug)]
17pub struct McpInputRequired {
18    pub key: String,
19    pub request: JsonValue,
20    pub request_state: String,
21}
22
23#[derive(Debug)]
24struct InputContext {
25    client_capabilities: JsonValue,
26    responses: JsonMap<String, JsonValue>,
27    next_request: usize,
28}
29
30tokio::task_local! {
31    static INPUT_CONTEXT: RefCell<InputContext>;
32}
33
34pub(crate) async fn scope_input_context<F>(
35    params: &JsonValue,
36    client_capabilities: JsonValue,
37    future: F,
38) -> Result<F::Output, String>
39where
40    F: Future,
41{
42    let mut responses = match params.get("requestState") {
43        Some(JsonValue::String(state)) => serde_json::from_str::<JsonMap<String, JsonValue>>(state)
44            .map_err(|error| format!("invalid MCP requestState: {error}"))?,
45        Some(JsonValue::Null) | None => JsonMap::new(),
46        Some(_) => return Err("MCP requestState must be a string".to_string()),
47    };
48    if let Some(current) = params.get("inputResponses") {
49        let current = current
50            .as_object()
51            .ok_or_else(|| "MCP inputResponses must be an object".to_string())?;
52        responses.extend(current.clone());
53    }
54    Ok(INPUT_CONTEXT
55        .scope(
56            RefCell::new(InputContext {
57                client_capabilities,
58                responses,
59                next_request: 0,
60            }),
61            future,
62        )
63        .await)
64}
65
66/// Resolve an embedded input response or suspend the handler with the request
67/// the client must fulfill before retrying the original operation.
68pub(crate) fn request_input(
69    method: &str,
70    params: JsonValue,
71    error_prefix: &str,
72) -> Result<JsonValue, VmError> {
73    INPUT_CONTEXT
74        .try_with(|cell| {
75            let mut context = cell.borrow_mut();
76            if !supports_input(&context.client_capabilities, method, &params) {
77                return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
78                    format!("{error_prefix}: the MCP client did not advertise support for {method}"),
79                ))));
80            }
81
82            let key = format!("harn-input-{}", context.next_request);
83            context.next_request += 1;
84            if let Some(response) = context.responses.get(&key) {
85                return Ok(response.clone());
86            }
87
88            let request_state = serde_json::to_string(&context.responses)
89                .expect("MCP input responses are JSON serializable");
90            Err(VmError::McpInputRequired(Box::new(McpInputRequired {
91                key,
92                request: serde_json::json!({"method": method, "params": params}),
93                request_state,
94            })))
95        })
96        .unwrap_or_else(|_| {
97            Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
98                format!(
99                    "{error_prefix}: no active MCP request; this builtin is only valid inside a served MCP handler"
100                ),
101            ))))
102        })
103}
104
105fn supports_input(capabilities: &JsonValue, method: &str, params: &JsonValue) -> bool {
106    match method {
107        "elicitation/create" => {
108            let Some(elicitation) = capabilities
109                .get("elicitation")
110                .and_then(JsonValue::as_object)
111            else {
112                return false;
113            };
114            match params
115                .get("mode")
116                .and_then(JsonValue::as_str)
117                .unwrap_or("form")
118            {
119                "form" => elicitation.contains_key("form"),
120                "url" => elicitation.contains_key("url"),
121                _ => false,
122            }
123        }
124        "roots/list" => capabilities.get("roots").is_some(),
125        "sampling/createMessage" => capabilities.get("sampling").is_some(),
126        _ => false,
127    }
128}
129
130pub(crate) fn input_result(error: McpInputRequired) -> JsonValue {
131    serde_json::json!({
132        "resultType": "input_required",
133        "inputRequests": {error.key: error.request},
134        "requestState": error.request_state,
135    })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[tokio::test]
143    async fn preserves_responses_across_reentered_rounds() {
144        let params = serde_json::json!({
145            "requestState": "{\"harn-input-0\":{\"roots\":[]}}",
146            "inputResponses": {"harn-input-1": {"action": "decline"}},
147        });
148        let result = scope_input_context(
149            &params,
150            serde_json::json!({"roots": {}, "elicitation": {"form": {}}}),
151            async {
152                let roots = request_input("roots/list", serde_json::json!({}), "roots").unwrap();
153                let elicitation = request_input(
154                    "elicitation/create",
155                    serde_json::json!({"mode": "form"}),
156                    "elicit",
157                )
158                .unwrap();
159                (roots, elicitation)
160            },
161        )
162        .await
163        .unwrap();
164        assert_eq!(result.0, serde_json::json!({"roots": []}));
165        assert_eq!(result.1, serde_json::json!({"action": "decline"}));
166    }
167
168    #[tokio::test]
169    async fn suspends_with_stable_input_required_payload() {
170        let error = scope_input_context(
171            &serde_json::json!({}),
172            serde_json::json!({"elicitation": {"form": {}}}),
173            async {
174                request_input(
175                    "elicitation/create",
176                    serde_json::json!({"mode": "form", "message": "Continue?"}),
177                    "elicit",
178                )
179            },
180        )
181        .await
182        .unwrap()
183        .unwrap_err();
184        let VmError::McpInputRequired(error) = error else {
185            panic!("expected input-required control flow")
186        };
187        let result = input_result(*error);
188        assert_eq!(result["resultType"], "input_required");
189        assert_eq!(
190            result["inputRequests"]["harn-input-0"]["method"],
191            "elicitation/create"
192        );
193    }
194
195    #[test]
196    fn requires_the_declared_elicitation_mode() {
197        let form = serde_json::json!({"mode": "form"});
198        let url = serde_json::json!({"mode": "url"});
199
200        assert!(!supports_input(
201            &serde_json::json!({"elicitation": {}}),
202            "elicitation/create",
203            &form,
204        ));
205        assert!(supports_input(
206            &serde_json::json!({"elicitation": {"form": {}}}),
207            "elicitation/create",
208            &form,
209        ));
210        assert!(!supports_input(
211            &serde_json::json!({"elicitation": {"form": {}}}),
212            "elicitation/create",
213            &url,
214        ));
215        assert!(supports_input(
216            &serde_json::json!({"elicitation": {"url": {}}}),
217            "elicitation/create",
218            &url,
219        ));
220    }
221}