Skip to main content

harn_vm/
mcp_sampling.rs

1//! MCP `sampling/createMessage` plumbing — server-to-client LLM sampling.
2//!
3//! When Harn is acting as an MCP **client**, an inbound
4//! `sampling/createMessage` request from a peer server is parsed,
5//! gated through the embedder via the `HostCallBridge`
6//! (`capability="mcp"`, `operation="sample"`), and — if approved —
7//! dispatched to Harn's own `llm_call` execution path. The assistant's
8//! reply is returned to the server in the spec response shape:
9//! `{role: "assistant", content: {type: "text", text}, model, stopReason}`.
10//!
11//! When no host bridge is wired up, inbound sampling requests are
12//! declined with a structured JSON-RPC error so the originating server
13//! can fall back to a sensible default. This is the safe default
14//! because sampling spends the user's API budget — a connected MCP
15//! server should never get to drive an LLM call without an explicit
16//! approval surface.
17//!
18//! See the spec at
19//! <https://modelcontextprotocol.io/specification/2025-11-25/client/sampling>.
20
21use crate::value::VmDictExt;
22
23use serde_json::{json, Value as JsonValue};
24
25use crate::schema::json_to_vm_value;
26use crate::stdlib::host::{dispatch_host_call_bridge, dispatch_mock_host_call};
27use crate::value::{VmError, VmValue};
28
29/// JSON-RPC method name for sampling requests.
30pub const SAMPLING_METHOD: &str = "sampling/createMessage";
31
32/// Parsed sampling request — the script-shape of a `sampling/createMessage`
33/// payload after we translate it out of the raw JSON-RPC envelope.
34#[derive(Debug, Clone)]
35struct SamplingRequest {
36    /// Conversation history. Each message is a `{role, content}` shape
37    /// where `content` may be a single block or an array of blocks.
38    messages: Vec<JsonValue>,
39    /// Optional system prompt prepended to the conversation.
40    system: Option<String>,
41    /// Required token budget. Mapped to `llm_call`'s `max_tokens`.
42    max_tokens: i64,
43    /// Sampling temperature in `[0, 1]`. Optional.
44    temperature: Option<f64>,
45    /// Stop sequences. Optional.
46    stop_sequences: Option<Vec<String>>,
47    /// Model preferences hint chain — see [`pick_model_hint`].
48    model_preferences: Option<JsonValue>,
49    /// Tool definitions (2025-11-25 sampling additions). Forwarded to
50    /// `llm_call`'s `tools` option when present.
51    tools: Option<JsonValue>,
52    /// `tool_choice` directive — forwarded as-is to `llm_call`.
53    tool_choice: Option<JsonValue>,
54    /// Spec-aligned thinking config — forwarded to `llm_call`'s
55    /// `thinking` option when present.
56    thinking: Option<JsonValue>,
57    /// Pass-through metadata from the originating server. Surfaced to
58    /// the host bridge so policy decisions can consider it.
59    metadata: Option<JsonValue>,
60    /// Soft-deprecated `includeContext` hint. Forwarded to the host
61    /// bridge for visibility but otherwise ignored — Harn's
62    /// orchestrator never auto-attaches host context.
63    include_context: Option<String>,
64}
65
66/// Outcome of asking the embedder whether to honor the sampling request.
67#[derive(Debug, Clone)]
68enum ApprovalDecision {
69    /// Approved — proceed with the listed `llm_call` option overrides
70    /// merged on top of the request-derived defaults.
71    Accept(crate::value::DictMap),
72    /// Declined — propagate as a JSON-RPC error to the server with the
73    /// reason string the host supplied (or a default).
74    Decline(String),
75}
76
77/// Dispatch an inbound server-to-client `sampling/createMessage`
78/// request (received while Harn is acting as an MCP client) and return
79/// the JSON-RPC response we should send back to the server.
80///
81/// The implementation order matches existing HITL primitives:
82///   1. If a `host_mock("mcp", "sample", ...)` matches, use that.
83///   2. Otherwise, dispatch through the installed `HostCallBridge`.
84///   3. If no host can take the call, decline with a structured error
85///      so the server can fall back to a sensible default.
86///
87/// On approval, the request is translated into Harn's `llm_call`
88/// boundary (`extract_llm_options` + `execute_llm_call`) so providers
89/// pick up the same routing, capability gating, mock interception,
90/// and budget plumbing as a script-side `llm_call`.
91pub async fn dispatch_inbound_sampling(server_name: &str, request: &JsonValue) -> JsonValue {
92    let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
93    let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
94
95    // Surface the inbound sampling request to live observers (the ACP
96    // adapter renders it as an `_harn/agentEvent` of kind
97    // `mcp_notification`) so a thin client can show the affordance. This
98    // is observability only — the request still resolves through the host
99    // approval / decline path below with unchanged response semantics.
100    if let Some(session_id) = crate::llm::current_agent_session_id() {
101        crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
102            session_id,
103            server: server_name.to_string(),
104            method: SAMPLING_METHOD.to_string(),
105            direction: "request".to_string(),
106            params: params.clone(),
107        });
108    }
109
110    let parsed = match parse_sampling_request(&params) {
111        Ok(p) => p,
112        Err(detail) => return crate::jsonrpc::error_response(id, -32602, &detail),
113    };
114
115    let approval = ask_host_approval(server_name, &params).await;
116    let overrides = match approval {
117        ApprovalDecision::Accept(map) => map,
118        ApprovalDecision::Decline(reason) => {
119            return crate::jsonrpc::error_response_with_data(
120                id,
121                -32603,
122                &format!("Sampling declined: {reason}"),
123                json!({
124                    "type": "mcp.samplingDeclined",
125                    "method": SAMPLING_METHOD,
126                    "reason": reason,
127                }),
128            );
129        }
130    };
131
132    match run_llm_call(&parsed, overrides).await {
133        Ok(outcome) => crate::jsonrpc::response(id, build_spec_response(outcome, &parsed)),
134        Err(detail) => crate::jsonrpc::error_response_with_data(
135            id,
136            -32000,
137            &format!("Sampling failed: {detail}"),
138            json!({
139                "type": "mcp.samplingFailed",
140                "method": SAMPLING_METHOD,
141                "reason": detail,
142            }),
143        ),
144    }
145}
146
147/// Parse a `sampling/createMessage` params payload. Returns the
148/// `Err(detail)` shape (a flat string) so the caller can wrap it in
149/// the appropriate `-32602` JSON-RPC error.
150fn parse_sampling_request(params: &JsonValue) -> Result<SamplingRequest, String> {
151    let object = params
152        .as_object()
153        .ok_or_else(|| "sampling params must be a JSON object".to_string())?;
154
155    let messages = match object.get("messages") {
156        Some(JsonValue::Array(items)) => items.clone(),
157        Some(_) => return Err("sampling params 'messages' must be an array".into()),
158        None => return Err("sampling params 'messages' is required".into()),
159    };
160    if messages.is_empty() {
161        return Err("sampling params 'messages' must not be empty".into());
162    }
163    for (idx, message) in messages.iter().enumerate() {
164        let role = message
165            .get("role")
166            .and_then(|value| value.as_str())
167            .ok_or_else(|| format!("sampling messages[{idx}].role is required"))?;
168        if !matches!(role, "user" | "assistant" | "system") {
169            return Err(format!(
170                "sampling messages[{idx}].role must be 'user'/'assistant'/'system' (got {role:?})"
171            ));
172        }
173        if message.get("content").is_none() {
174            return Err(format!("sampling messages[{idx}].content is required"));
175        }
176    }
177
178    let system = object
179        .get("systemPrompt")
180        .and_then(|value| value.as_str())
181        .filter(|value| !value.is_empty())
182        .map(str::to_string);
183
184    let max_tokens = object
185        .get("maxTokens")
186        .and_then(|value| value.as_i64())
187        .ok_or_else(|| {
188            "sampling params 'maxTokens' is required and must be an integer".to_string()
189        })?;
190    if max_tokens <= 0 {
191        return Err(format!(
192            "sampling params 'maxTokens' must be positive (got {max_tokens})"
193        ));
194    }
195
196    let temperature =
197        match object.get("temperature") {
198            Some(JsonValue::Number(n)) => Some(n.as_f64().ok_or_else(|| {
199                "sampling params 'temperature' must be a finite number".to_string()
200            })?),
201            Some(JsonValue::Null) | None => None,
202            Some(_) => return Err("sampling params 'temperature' must be a number".into()),
203        };
204
205    let stop_sequences = match object.get("stopSequences") {
206        Some(JsonValue::Array(items)) => {
207            let mut out = Vec::with_capacity(items.len());
208            for (idx, item) in items.iter().enumerate() {
209                let s = item.as_str().ok_or_else(|| {
210                    format!("sampling params 'stopSequences[{idx}]' must be a string")
211                })?;
212                out.push(s.to_string());
213            }
214            Some(out)
215        }
216        Some(JsonValue::Null) | None => None,
217        Some(_) => return Err("sampling params 'stopSequences' must be an array".into()),
218    };
219
220    let include_context = object
221        .get("includeContext")
222        .and_then(|value| value.as_str())
223        .map(str::to_string);
224
225    // MCP spec uses camelCase for the 2025-11-25 sampling additions
226    // (`toolChoice`); accept the snake_case form as a tolerance for
227    // servers that follow Harn's own option naming.
228    let tool_choice = object
229        .get("toolChoice")
230        .or_else(|| object.get("tool_choice"))
231        .cloned();
232
233    Ok(SamplingRequest {
234        messages,
235        system,
236        max_tokens,
237        temperature,
238        stop_sequences,
239        model_preferences: object.get("modelPreferences").cloned(),
240        tools: object.get("tools").cloned(),
241        tool_choice,
242        thinking: object.get("thinking").cloned(),
243        metadata: object.get("metadata").cloned(),
244        include_context,
245    })
246}
247
248/// Ask the host bridge whether to honor the sampling request and what
249/// `llm_call` overrides to apply. The bridge sees the full original
250/// `params` payload so it can run its own approval UX or rate-limit
251/// against the originating server name.
252///
253/// Bridge response coercion (mirrors `mcp_elicit`):
254/// - `null` / no bridge → decline ("no host bridge")
255/// - `{action: "decline", message?}` → decline with the message
256/// - `{action: "accept", options?}` → accept with `options` as overrides
257/// - `{action: "accept"}` → accept with no overrides
258/// - bare dict → accept and treat the whole dict as overrides (so a
259///   minimal embedder that just wants to force `provider: "mock"` can
260///   return `{provider: "mock"}` without ceremony)
261async fn ask_host_approval(server_name: &str, params: &JsonValue) -> ApprovalDecision {
262    let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
263    bridge_params.put_str("server", server_name);
264    bridge_params.insert(crate::value::intern_key("params"), json_to_vm_value(params));
265
266    let result = dispatch_mock_host_call("mcp", "sample", &bridge_params)
267        .or_else(|| dispatch_host_call_bridge("mcp", "sample", &bridge_params));
268
269    let raw = match result {
270        Some(Ok(value)) => value,
271        Some(Err(error)) => {
272            return ApprovalDecision::Decline(host_error_to_string(error));
273        }
274        None => {
275            return ApprovalDecision::Decline(
276                "no host bridge installed for ('mcp', 'sample')".into(),
277            );
278        }
279    };
280
281    coerce_bridge_response(raw)
282}
283
284fn coerce_bridge_response(value: VmValue) -> ApprovalDecision {
285    match value {
286        VmValue::Nil => ApprovalDecision::Decline("host bridge returned nil".into()),
287        VmValue::Bool(false) => ApprovalDecision::Decline("host bridge declined".into()),
288        VmValue::Bool(true) => ApprovalDecision::Accept(crate::value::DictMap::new()),
289        VmValue::Dict(dict) => {
290            let map = dict.as_ref().clone();
291            match map.get("action").and_then(|v| match v {
292                VmValue::String(s) => Some(s.to_string()),
293                _ => None,
294            }) {
295                Some(action) if action == "decline" || action == "cancel" => {
296                    let reason = map
297                        .get("message")
298                        .or_else(|| map.get("reason"))
299                        .map(VmValue::display)
300                        .unwrap_or_else(|| "host bridge declined".to_string());
301                    ApprovalDecision::Decline(reason)
302                }
303                Some(action) if action == "accept" => {
304                    let overrides = map
305                        .get("options")
306                        .and_then(|v| match v {
307                            VmValue::Dict(d) => Some(d.as_ref().clone()),
308                            _ => None,
309                        })
310                        .unwrap_or_default();
311                    ApprovalDecision::Accept(overrides)
312                }
313                Some(other) => ApprovalDecision::Decline(format!(
314                    "host bridge returned unknown action {other:?}"
315                )),
316                None => {
317                    // No `action` field — treat the whole dict as a flat
318                    // overrides map. Keeps the trivial embedder happy.
319                    ApprovalDecision::Accept(map)
320                }
321            }
322        }
323        other => ApprovalDecision::Decline(format!(
324            "host bridge returned unsupported value: {}",
325            other.display()
326        )),
327    }
328}
329
330fn host_error_to_string(error: VmError) -> String {
331    match error {
332        VmError::Thrown(VmValue::String(s)) => s.to_string(),
333        VmError::Thrown(other) => other.display(),
334        VmError::Runtime(s) | VmError::TypeError(s) => s,
335        other => format!("{other:?}"),
336    }
337}
338
339/// Outcome of a successful `llm_call` for a sampling request — what
340/// the response builder needs to fill in `text` and the actual model
341/// the provider settled on.
342#[derive(Debug, Clone)]
343struct LlmOutcome {
344    text: String,
345    model: String,
346}
347
348/// Run Harn's `llm_call` against a parsed sampling request, returning
349/// the assistant text plus the actual model name. Errors are flattened
350/// to a short string so the dispatcher can wrap them in JSON-RPC.
351async fn run_llm_call(
352    parsed: &SamplingRequest,
353    overrides: crate::value::DictMap,
354) -> Result<LlmOutcome, String> {
355    let (vm_args, options_dict) = build_llm_call_args(parsed, overrides);
356
357    let opts = crate::llm::extract_llm_options(&vm_args).map_err(host_error_to_string)?;
358    let result = crate::llm::execute_llm_call(None, opts, Some(options_dict), None, None)
359        .await
360        .map_err(host_error_to_string)?;
361
362    extract_assistant_outcome(&result)
363}
364
365fn extract_assistant_outcome(result: &VmValue) -> Result<LlmOutcome, String> {
366    match result {
367        VmValue::String(s) => Ok(LlmOutcome {
368            text: s.to_string(),
369            model: String::new(),
370        }),
371        VmValue::Dict(d) => {
372            let text = match d.get("text") {
373                Some(VmValue::String(s)) => s.to_string(),
374                Some(other) => other.display(),
375                None => return Err("llm_call result missing 'text' field".into()),
376            };
377            let model = d.get("model").map(VmValue::display).unwrap_or_default();
378            Ok(LlmOutcome { text, model })
379        }
380        other => Ok(LlmOutcome {
381            text: other.display(),
382            model: String::new(),
383        }),
384    }
385}
386
387/// Build the `[prompt, system, options]` arg list that
388/// `extract_llm_options` consumes, plus a clone of the options map for
389/// `execute_llm_call`'s second parameter (it consults the same dict
390/// for retry/tool-format settings that aren't on `LlmCallOptions`).
391fn build_llm_call_args(
392    parsed: &SamplingRequest,
393    overrides: crate::value::DictMap,
394) -> (Vec<VmValue>, crate::value::DictMap) {
395    let mut options: crate::value::DictMap = crate::value::DictMap::new();
396
397    // Translate the sampling messages into the VM `messages` shape
398    // `extract_llm_options` accepts (a list of `{role, content}` dicts).
399    let messages_vm: Vec<VmValue> = parsed.messages.iter().map(json_to_vm_value).collect();
400    options.insert(
401        crate::value::intern_key("messages"),
402        VmValue::List(std::sync::Arc::new(messages_vm)),
403    );
404
405    options.insert(
406        crate::value::intern_key("max_tokens"),
407        VmValue::Int(parsed.max_tokens),
408    );
409
410    if let Some(temperature) = parsed.temperature {
411        options.insert(
412            crate::value::intern_key("temperature"),
413            VmValue::Float(temperature),
414        );
415    }
416
417    if let Some(stop) = parsed.stop_sequences.as_ref() {
418        let stop_vm: Vec<VmValue> = stop
419            .iter()
420            .map(|s| VmValue::String(arcstr::ArcStr::from(s.as_str())))
421            .collect();
422        options.insert(
423            crate::value::intern_key("stop"),
424            VmValue::List(std::sync::Arc::new(stop_vm)),
425        );
426    }
427
428    if let Some(hint) = pick_model_hint(parsed.model_preferences.as_ref()) {
429        options.put_str("model", hint.as_str());
430    }
431
432    if let Some(tools) = parsed.tools.as_ref() {
433        options.insert(crate::value::intern_key("tools"), json_to_vm_value(tools));
434    }
435    if let Some(tool_choice) = parsed.tool_choice.as_ref() {
436        options.insert(
437            crate::value::intern_key("tool_choice"),
438            json_to_vm_value(tool_choice),
439        );
440    }
441    if let Some(thinking) = parsed.thinking.as_ref() {
442        options.insert(
443            crate::value::intern_key("thinking"),
444            json_to_vm_value(thinking),
445        );
446    }
447
448    // Pass-through fields kept on the options map so transcripts and
449    // mocks see the original server's intent.
450    if let Some(metadata) = parsed.metadata.as_ref() {
451        options.insert(
452            crate::value::intern_key("metadata"),
453            json_to_vm_value(metadata),
454        );
455    }
456    if let Some(include_context) = parsed.include_context.as_ref() {
457        options.put_str("include_context", include_context.as_str());
458    }
459
460    // Host-bridge overrides win over request-derived defaults so an
461    // embedder can force `provider: "mock"` or rewrite the model.
462    for (key, value) in overrides {
463        options.insert(key, value);
464    }
465
466    let system_value = parsed
467        .system
468        .as_ref()
469        .map(|s| VmValue::String(arcstr::ArcStr::from(s.as_str())))
470        .unwrap_or(VmValue::Nil);
471
472    let args = vec![
473        VmValue::String(arcstr::ArcStr::from("")),
474        system_value,
475        VmValue::dict(options.clone()),
476    ];
477
478    (args, options)
479}
480
481/// Pick a model hint from the spec's `modelPreferences.hints` chain.
482/// We honor the first entry whose `name` is non-empty — that's the
483/// MCP convention for "the server suggests this model first".
484fn pick_model_hint(prefs: Option<&JsonValue>) -> Option<String> {
485    let prefs = prefs?;
486    let hints = prefs.get("hints")?.as_array()?;
487    for hint in hints {
488        if let Some(name) = hint.get("name").and_then(|value| value.as_str()) {
489            if !name.is_empty() {
490                return Some(name.to_string());
491            }
492        }
493    }
494    None
495}
496
497/// Build the spec response shape for a successful sampling exchange.
498/// `model` is the actual model the provider settled on (which may
499/// differ from the request's hint chain when the host bridge or
500/// router overrode it). `stopReason` defaults to `"endTurn"`; we
501/// surface `"stopSequence"` when the request set explicit stop
502/// strings, mirroring the reference SDK behavior — providers don't
503/// currently bubble a fine-grained reason up to `llm_call`'s result.
504fn build_spec_response(outcome: LlmOutcome, parsed: &SamplingRequest) -> JsonValue {
505    let stop_reason = if parsed.stop_sequences.is_some() {
506        "stopSequence"
507    } else {
508        "endTurn"
509    };
510
511    let model = if outcome.model.is_empty() {
512        pick_model_hint(parsed.model_preferences.as_ref()).unwrap_or_default()
513    } else {
514        outcome.model
515    };
516
517    json!({
518        "role": "assistant",
519        "content": {
520            "type": "text",
521            "text": outcome.text,
522        },
523        "model": model,
524        "stopReason": stop_reason,
525    })
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    use crate::value::VmDictExt;
533    use std::sync::Arc;
534
535    fn minimal_request() -> JsonValue {
536        json!({
537            "messages": [
538                {"role": "user", "content": {"type": "text", "text": "hi"}}
539            ],
540            "maxTokens": 64,
541        })
542    }
543
544    #[test]
545    fn parse_rejects_missing_messages() {
546        let err = parse_sampling_request(&json!({"maxTokens": 1})).unwrap_err();
547        assert!(err.contains("messages"));
548    }
549
550    #[test]
551    fn parse_rejects_empty_messages() {
552        let err = parse_sampling_request(&json!({"messages": [], "maxTokens": 1})).unwrap_err();
553        assert!(err.contains("must not be empty"));
554    }
555
556    #[test]
557    fn parse_rejects_missing_max_tokens() {
558        let err = parse_sampling_request(&json!({
559            "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}]
560        }))
561        .unwrap_err();
562        assert!(err.contains("maxTokens"));
563    }
564
565    #[test]
566    fn parse_rejects_zero_max_tokens() {
567        let err = parse_sampling_request(&json!({
568            "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}],
569            "maxTokens": 0,
570        }))
571        .unwrap_err();
572        assert!(err.contains("positive"));
573    }
574
575    #[test]
576    fn parse_rejects_unknown_role() {
577        let err = parse_sampling_request(&json!({
578            "messages": [{"role": "tool", "content": {}}],
579            "maxTokens": 1,
580        }))
581        .unwrap_err();
582        assert!(err.contains("'user'/'assistant'/'system'"));
583    }
584
585    #[test]
586    fn parse_extracts_optional_fields() {
587        let parsed = parse_sampling_request(&json!({
588            "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}],
589            "maxTokens": 32,
590            "systemPrompt": "be brief",
591            "temperature": 0.2,
592            "stopSequences": ["END"],
593            "modelPreferences": {"hints": [{"name": "claude-3-5-sonnet"}]},
594            "includeContext": "thisServer",
595            "metadata": {"trace": "abc"},
596        }))
597        .unwrap();
598        assert_eq!(parsed.max_tokens, 32);
599        assert_eq!(parsed.system.as_deref(), Some("be brief"));
600        assert_eq!(parsed.temperature, Some(0.2));
601        assert_eq!(
602            parsed.stop_sequences.as_deref(),
603            Some(&["END".to_string()][..])
604        );
605        assert_eq!(parsed.include_context.as_deref(), Some("thisServer"));
606        assert_eq!(
607            pick_model_hint(parsed.model_preferences.as_ref()),
608            Some("claude-3-5-sonnet".to_string())
609        );
610    }
611
612    #[test]
613    fn pick_model_hint_picks_first_non_empty() {
614        let prefs = json!({"hints": [{"name": ""}, {"name": "gpt-4"}]});
615        assert_eq!(pick_model_hint(Some(&prefs)), Some("gpt-4".to_string()));
616    }
617
618    #[test]
619    fn pick_model_hint_returns_none_for_empty_chain() {
620        assert!(pick_model_hint(None).is_none());
621        assert!(pick_model_hint(Some(&json!({"hints": []}))).is_none());
622        assert!(pick_model_hint(Some(&json!({}))).is_none());
623    }
624
625    #[test]
626    fn coerce_bridge_response_nil_declines() {
627        match coerce_bridge_response(VmValue::Nil) {
628            ApprovalDecision::Decline(_) => {}
629            other => panic!("expected decline, got {other:?}"),
630        }
631    }
632
633    #[test]
634    fn coerce_bridge_response_true_accepts_with_no_overrides() {
635        match coerce_bridge_response(VmValue::Bool(true)) {
636            ApprovalDecision::Accept(map) => assert!(map.is_empty()),
637            other => panic!("expected accept, got {other:?}"),
638        }
639    }
640
641    #[test]
642    fn coerce_bridge_response_accept_with_options() {
643        let mut dict = crate::value::DictMap::new();
644        dict.put_str("action", "accept");
645        let mut options = crate::value::DictMap::new();
646        options.put_str("provider", "mock");
647        dict.insert(crate::value::intern_key("options"), VmValue::dict(options));
648        match coerce_bridge_response(VmValue::dict(dict)) {
649            ApprovalDecision::Accept(map) => {
650                assert_eq!(
651                    map.get("provider").map(|v| v.display()).as_deref(),
652                    Some("mock")
653                );
654            }
655            other => panic!("expected accept, got {other:?}"),
656        }
657    }
658
659    #[test]
660    fn coerce_bridge_response_decline_with_message() {
661        let mut dict = crate::value::DictMap::new();
662        dict.put_str("action", "decline");
663        dict.put_str("message", "rate limit");
664        match coerce_bridge_response(VmValue::dict(dict)) {
665            ApprovalDecision::Decline(reason) => assert_eq!(reason, "rate limit"),
666            other => panic!("expected decline, got {other:?}"),
667        }
668    }
669
670    #[test]
671    fn coerce_bridge_response_bare_dict_is_overrides() {
672        let mut dict = crate::value::DictMap::new();
673        dict.put_str("provider", "mock");
674        match coerce_bridge_response(VmValue::dict(dict)) {
675            ApprovalDecision::Accept(map) => {
676                assert_eq!(
677                    map.get("provider").map(|v| v.display()).as_deref(),
678                    Some("mock")
679                );
680            }
681            other => panic!("expected accept, got {other:?}"),
682        }
683    }
684
685    fn outcome(text: &str, model: &str) -> LlmOutcome {
686        LlmOutcome {
687            text: text.to_string(),
688            model: model.to_string(),
689        }
690    }
691
692    #[test]
693    fn build_spec_response_flags_stop_sequence() {
694        let parsed = parse_sampling_request(&json!({
695            "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}],
696            "maxTokens": 4,
697            "stopSequences": ["END"],
698        }))
699        .unwrap();
700        let response = build_spec_response(outcome("done", "actual-model"), &parsed);
701        assert_eq!(response["stopReason"], json!("stopSequence"));
702        assert_eq!(response["role"], json!("assistant"));
703        assert_eq!(response["content"]["type"], json!("text"));
704        assert_eq!(response["content"]["text"], json!("done"));
705        assert_eq!(response["model"], json!("actual-model"));
706    }
707
708    #[test]
709    fn build_spec_response_default_stop_reason_is_end_turn() {
710        let parsed = parse_sampling_request(&minimal_request()).unwrap();
711        let response = build_spec_response(outcome("done", ""), &parsed);
712        assert_eq!(response["stopReason"], json!("endTurn"));
713    }
714
715    #[test]
716    fn build_spec_response_falls_back_to_hint_when_outcome_model_missing() {
717        let parsed = parse_sampling_request(&json!({
718            "messages": [{"role": "user", "content": {"type": "text", "text": "hi"}}],
719            "maxTokens": 4,
720            "modelPreferences": {"hints": [{"name": "claude-3-5-sonnet"}]},
721        }))
722        .unwrap();
723        let response = build_spec_response(outcome("done", ""), &parsed);
724        assert_eq!(response["model"], json!("claude-3-5-sonnet"));
725    }
726
727    #[tokio::test(flavor = "current_thread")]
728    async fn dispatch_with_no_bridge_declines() {
729        let request = json!({
730            "jsonrpc": "2.0",
731            "id": "s-1",
732            "method": SAMPLING_METHOD,
733            "params": minimal_request(),
734        });
735        let response = dispatch_inbound_sampling("mock", &request).await;
736        assert_eq!(response["id"], json!("s-1"));
737        assert_eq!(response["error"]["code"], json!(-32603));
738        assert_eq!(
739            response["error"]["data"]["type"],
740            json!("mcp.samplingDeclined")
741        );
742    }
743
744    #[tokio::test(flavor = "current_thread")]
745    async fn dispatch_with_invalid_params_returns_invalid_params() {
746        let request = json!({
747            "jsonrpc": "2.0",
748            "id": 1,
749            "method": SAMPLING_METHOD,
750            "params": {"messages": []},
751        });
752        let response = dispatch_inbound_sampling("mock", &request).await;
753        assert_eq!(response["id"], json!(1));
754        assert_eq!(response["error"]["code"], json!(-32602));
755    }
756
757    /// Test-only `HostCallBridge` that approves any sampling request
758    /// and injects the supplied options as overrides on the inbound
759    /// `llm_call`. Lets the integration test below avoid the private
760    /// `HostMock` registration API while still exercising the bridge
761    /// → llm_call path end-to-end.
762    struct ApproveSamplingBridge {
763        overrides: crate::value::DictMap,
764    }
765
766    impl crate::stdlib::host::HostCallBridge for ApproveSamplingBridge {
767        fn dispatch(
768            &self,
769            capability: &str,
770            operation: &str,
771            _params: &crate::value::DictMap,
772        ) -> Result<Option<VmValue>, VmError> {
773            if capability == "mcp" && operation == "sample" {
774                let mut envelope: crate::value::DictMap = crate::value::DictMap::new();
775                envelope.put_str("action", "accept");
776                envelope.insert(
777                    crate::value::intern_key("options"),
778                    VmValue::dict(self.overrides.clone()),
779                );
780                Ok(Some(VmValue::dict(envelope)))
781            } else {
782                Ok(None)
783            }
784        }
785    }
786
787    #[tokio::test(flavor = "current_thread")]
788    async fn dispatch_with_mock_bridge_routes_to_llm_call() {
789        // Reset before installing mocks so this test is order-independent.
790        crate::llm::mock::reset_llm_mock_state();
791
792        // Push a builtin LLM mock so `llm_call` returns deterministic
793        // text without hitting any real provider. Mock interception
794        // applies whenever any builtin mock is installed, regardless
795        // of the requested provider — see `MockProvider::should_intercept`.
796        crate::llm::mock::push_llm_mock(crate::llm::mock::LlmMock {
797            text: "sampled text".to_string(),
798            tool_calls: Vec::new(),
799            match_pattern: None,
800            consume_on_match: true,
801            input_tokens: None,
802            output_tokens: None,
803            cache_read_tokens: None,
804            cache_write_tokens: None,
805            thinking: None,
806            thinking_summary: None,
807            stop_reason: None,
808            model: "mock-model".to_string(),
809            provider: Some("mock".to_string()),
810            blocks: None,
811            logprobs: Vec::new(),
812            error: None,
813            stream_chunks: Vec::new(),
814        });
815
816        // Install an approving bridge that forces provider=mock so the
817        // call resolves through MockProvider deterministically.
818        let mut overrides: crate::value::DictMap = crate::value::DictMap::new();
819        overrides.put_str("provider", "mock");
820        overrides.put_str("model", "mock-model");
821        crate::stdlib::host::set_host_call_bridge(Arc::new(ApproveSamplingBridge { overrides }));
822
823        let request = json!({
824            "jsonrpc": "2.0",
825            "id": 7,
826            "method": SAMPLING_METHOD,
827            "params": {
828                "messages": [
829                    {"role": "user", "content": {"type": "text", "text": "ping"}}
830                ],
831                "maxTokens": 32,
832                "modelPreferences": {"hints": [{"name": "mock-model"}]},
833            },
834        });
835
836        let response = dispatch_inbound_sampling("test-server", &request).await;
837
838        crate::llm::mock::reset_llm_mock_state();
839        crate::stdlib::host::clear_host_call_bridge();
840
841        assert_eq!(response["id"], json!(7));
842        assert!(
843            response.get("result").is_some(),
844            "expected success result, got {response:?}"
845        );
846        assert_eq!(response["result"]["role"], json!("assistant"));
847        assert_eq!(response["result"]["content"]["type"], json!("text"));
848        assert_eq!(response["result"]["content"]["text"], json!("sampled text"));
849        assert_eq!(response["result"]["stopReason"], json!("endTurn"));
850        assert_eq!(response["result"]["model"], json!("mock-model"));
851    }
852}