Skip to main content

submilli_engine/runtime/
mcp.rs

1//! Outbound `@mcp/<server>` transport dispatch.
2//!
3//! Every `@mcp/<server>.<tool>(...)` call lowers (in codegen) to **one** host
4//! import — `submilli:mcp.call(server, tool, argsJson) -> unknown`. The host
5//! converts the transport's bounded JSON tree directly to guest objects; normal
6//! runtime casts validate declared tool return types.
7//!
8//! This module provides that host fn. It runs the `mcp.<server>` capability check
9//! (one per server, with the tool in the filter context) *before* dispatch (a
10//! denial throws before any network bytes leave), then
11//! calls the embedder-provided [`McpTransport`] — the actual JSON-RPC `tools/call`,
12//! auth injection, and refresh-on-`401` live in the embedder (submilli-server),
13//! keeping this crate free of its async/HTTP dependencies.
14
15use std::future::Future;
16use std::pin::Pin;
17
18use wasmtime::{FuncType, HeapType, Linker, RefType, Val, ValType};
19
20use crate::runtime::StoreData;
21use crate::runtime::call_log::{Payload, Side, record_payload};
22use crate::runtime::fuel;
23use crate::runtime::host::{intrinsic_string_type, read_string_arg, register_host_fn_async};
24use crate::stdlib::shared::check_security_call;
25use crate::{PackageDeclaration, Param, Span, Type, ValueKind, ValueSymbol};
26
27/// The single internal host module every MCP call dispatches through.
28pub const MCP_MODULE_NAME: &str = "submilli:mcp";
29pub const MCP_MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
30
31mod response;
32pub use response::McpResponse;
33
34/// Why an outbound MCP `tools/call` failed. The host fn maps each to a catchable
35/// script error (see [`mcp_error_to_throw`]).
36#[derive(Debug)]
37pub enum McpCallError {
38    /// The tool ran but its response exceeded the host's bounded output buffer.
39    ResponseTooLarge,
40    /// Internal initialization, allocation or serialization failure; never a guest exception.
41    Internal { message: &'static str },
42    /// OAuth authentication failed. A provider rejection may be transient;
43    /// surfaces to the script as a catchable `McpAuthExpiredError`.
44    AuthExpired,
45    /// The server reported a tool-level or JSON-RPC error.
46    Mcp { message: String },
47    /// A non-2xx HTTP response from the server.
48    Upstream { status: u16, body: String },
49    /// The request never completed (connection / transport failure).
50    Transport(String),
51    /// Refused by this host's own configuration before anything was sent: unavailable
52    /// credentials, or the network policy. Reads as a transport error to the script.
53    Local(String),
54}
55
56impl McpCallError {
57    /// The failure as the call log keeps it: a stable kind, a message, and the HTTP
58    /// status of an upstream refusal.
59    fn record(&self) -> serde_json::Value {
60        let (kind, message, status) = match self {
61            McpCallError::ResponseTooLarge => ("response-too-large", String::new(), None),
62            McpCallError::Internal { message } => ("internal", (*message).to_owned(), None),
63            McpCallError::AuthExpired => ("auth-expired", String::new(), None),
64            McpCallError::Mcp { message } => ("mcp", message.clone(), None),
65            McpCallError::Upstream { status, body } => ("upstream", body.clone(), Some(*status)),
66            McpCallError::Transport(detail) => ("transport", detail.clone(), None),
67            McpCallError::Local(detail) => ("local", detail.clone(), None),
68        };
69        serde_json::json!({ "kind": kind, "message": message, "status": status })
70    }
71}
72
73/// Completed transport work, retained even when the tool result is an error.
74pub struct McpOutcome {
75    pub result: Result<McpResponse, McpCallError>,
76    pub received_bytes: u64,
77    pub parsed_bytes: u64,
78}
79
80/// The embedder-provided outbound MCP transport: performs the JSON-RPC
81/// `tools/call` (resolving auth, refreshing on `401`, reusing connections) and
82/// returns a bounded JSON tree. Transport decoders must bound response bytes
83/// and nesting before allocating recursive JSON values.
84///
85/// Object-safe boxed-future trait (rather than `async fn`) so the interpreter
86/// stays free of the embedder's async stack while still `await`-ing the call from
87/// within the async host function.
88pub trait McpTransport: Send + Sync {
89    fn call<'a>(
90        &'a self,
91        server: &'a str,
92        tool: &'a str,
93        args_json: &'a str,
94    ) -> Pin<Box<dyn Future<Output = McpOutcome> + Send + 'a>>;
95}
96
97fn mcp_call_func_type(linker: &Linker<StoreData>) -> wasmtime::Result<FuncType> {
98    let engine = linker.engine();
99    let string_struct = intrinsic_string_type(engine)?;
100    let string = ValType::Ref(RefType::new(false, HeapType::ConcreteStruct(string_struct)));
101    let object = crate::runtime::intrinsic_types::build_intrinsic_types(engine)?.object;
102    let unknown = ValType::Ref(RefType::new(true, HeapType::ConcreteStruct(object)));
103    Ok(FuncType::new(
104        engine,
105        [string.clone(), string.clone(), string.clone()],
106        [unknown],
107    ))
108}
109
110/// Install the async `submilli:mcp.call` dispatch host fn. Reads `(server, tool,
111/// argsJson)`, runs the capability check, dispatches through
112/// [`StoreData::mcp_transport`], and builds guest values directly from its tree.
113pub fn install_mcp_async(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
114    let ty = mcp_call_func_type(linker)?;
115    register_host_fn_async(
116        linker,
117        MCP_MODULE_NAME,
118        crate::mangle::host(MCP_MODULE_NAME, "call"),
119        ty,
120        /* deterministic = */ false,
121        move |caller, params, results| {
122            Box::pin(async move {
123                let params: &[Val; 3] = params.try_into().map_err(|_| {
124                    crate::runtime::host::fatal_host_error("MCP argument count is invalid")
125                })?;
126                let result_slot = results.first_mut().ok_or_else(|| {
127                    crate::runtime::host::fatal_host_error("MCP result slot is missing")
128                })?;
129                let server = read_string_arg(&mut *caller, &params[0], "mcp.call (server)")?;
130                let tool = read_string_arg(&mut *caller, &params[1], "mcp.call (tool)")?;
131                let args_json = read_string_arg(&mut *caller, &params[2], "mcp.call (args)")?;
132
133                // Deny before any network bytes leave. One capability per server
134                // (`mcp.<server>`, known when the blueprint is written); the tool is
135                // in the filter context so a policy can constrain by tool.
136                let ticket = check_security_call(
137                    &mut *caller,
138                    &format!("mcp.{server}"),
139                    serde_json::json!({ "tool": tool, "transport": "streamable_http" }),
140                )?;
141                record_payload(&*caller, ticket, Side::Request, || {
142                    request_payload(&server, &tool, &args_json)
143                });
144
145                let transport = caller.data().mcp_transport.clone().ok_or_else(|| {
146                    wasmtime::Error::msg(format!(
147                        "@mcp/{server}.{tool}: MCP transport not configured"
148                    ))
149                })?;
150
151                fuel::charge(&mut *caller, fuel::IO, args_json.len() as u64)?;
152                let outcome = transport.call(&server, &tool, &args_json).await;
153                record_payload(&*caller, ticket, Side::Response, || {
154                    let payload = match &outcome.result {
155                        Ok(response) => Payload::meta(serde_json::Value::Null).with_owned_body(
156                            serde_json::to_vec(response.value()).unwrap_or_default(),
157                        ),
158                        Err(error) => Payload::meta(error.record()),
159                    };
160                    payload.with_size(outcome.received_bytes)
161                });
162                fuel::settle(&mut *caller, fuel::IO, outcome.received_bytes)?;
163                fuel::settle(&mut *caller, fuel::PARSE, outcome.parsed_bytes)?;
164                fuel::settle_result(caller, |caller| {
165                    let result = (|| {
166                        let response = outcome
167                            .result
168                            .map_err(|error| mcp_error_to_throw(&server, &tool, error))?;
169                        let value = allocate_response(caller, &response)?;
170                        *result_slot = value;
171                        Ok(())
172                    })();
173                    result.map_err(|error| crate::runtime::host::throw_host_error(caller, error))
174                })
175            })
176        },
177    )
178}
179
180/// A call's request as the recorder keeps it.
181fn request_payload<'a>(server: &str, tool: &str, args_json: &'a str) -> Payload<'a> {
182    Payload::meta(serde_json::json!({ "server": server, "tool": tool }))
183        .with_body(args_json.as_bytes())
184}
185
186/// The digest the call log records for a call's request: what a transport sees of it, so a
187/// transport can recognize a call it saw recorded.
188pub fn request_digest(server: &str, tool: &str, args_json: &str) -> String {
189    request_payload(server, tool, args_json).digest()
190}
191
192fn allocate_response(
193    caller: &mut wasmtime::Caller<'_, StoreData>,
194    response: &McpResponse,
195) -> wasmtime::Result<Val> {
196    fuel::settle_result(caller, |caller| {
197        fuel::settle(&mut *caller, fuel::ELEM, response.visited_nodes())?;
198        let allocator = crate::runtime::json::JsonUnknownAllocator::new(caller)?;
199        let value = crate::runtime::json::JsonValue::from(response.value());
200        allocator.allocate(caller, &value)
201    })
202}
203
204/// Codegen-facing definitions for the internal `submilli:mcp.call` host fn:
205/// `call(server: string, tool: string, args: string): unknown`. Lowered as
206/// `(ref $string) x3 -> (ref null $Object)` by the generic host-import path.
207pub fn mcp_call_package_declaration() -> PackageDeclaration {
208    let mut defs = PackageDeclaration::with_package(MCP_MODULE_NAME);
209    defs.values.insert(
210        "call".to_string(),
211        ValueSymbol {
212            name: "call".to_string(),
213            mangled_name: crate::mangle::host(MCP_MODULE_NAME, "call"),
214            declaration_span: Span::at(crate::FileId::MCP),
215            kind: ValueKind::Function {
216                generics: Vec::new(),
217                params: vec![
218                    Param::new("server", Type::String),
219                    Param::new("tool", Type::String),
220                    Param::new("args", Type::String),
221                ],
222                ret: Type::Unknown,
223                type_predicate: None,
224                doc: None,
225            },
226        },
227    );
228    defs
229}
230
231/// Map a transport failure to a catchable script `Error`. `McpAuthExpiredError` is
232/// a message-prefixed `Error` (typed error classes aren't landed yet — same shape
233/// as the permission-denied throw).
234fn mcp_error_to_throw(server: &str, tool: &str, err: McpCallError) -> wasmtime::Error {
235    let message = match err {
236        McpCallError::Internal { message, .. } => {
237            return crate::runtime::host::fatal_host_error(message);
238        }
239        McpCallError::ResponseTooLarge => {
240            return crate::runtime::host::range_error(format!(
241                "@mcp/{server}.{tool}: response exceeds the size, node or depth limit; request a smaller result"
242            ));
243        }
244        McpCallError::AuthExpired => format!(
245            "McpAuthExpiredError: @mcp/{server}.{tool}: OAuth authentication failed; \
246             retry later or authenticate the MCP server again"
247        ),
248        McpCallError::Mcp { message } => {
249            format!("@mcp/{server}.{tool}: {message}")
250        }
251        McpCallError::Upstream { status, body } => {
252            format!("@mcp/{server}.{tool}: server returned HTTP {status}: {body}")
253        }
254        McpCallError::Transport(detail) | McpCallError::Local(detail) => {
255            format!("@mcp/{server}.{tool}: transport error: {detail}")
256        }
257    };
258    wasmtime::Error::msg(message)
259}
260
261#[cfg(test)]
262mod tests {
263    use std::collections::BTreeMap;
264
265    use std::sync::{Arc, Mutex};
266
267    use wasmtime::{Linker, Module};
268
269    use super::{McpOutcome, McpResponse, McpTransport};
270    use crate::runtime::security::{CheckOutcome, SecurityCheck};
271    use crate::runtime::{
272        RuntimeConfig, StoreData, Vfs, dispatch_main_async, install_runtime_async,
273        install_tenant_limits,
274    };
275    use crate::{
276        ObjectField, PackageDeclaration, Param, Shape, Span, Type, ValueKind, ValueSymbol,
277        compile_script, mangle,
278    };
279
280    /// One `@mcp/test` package: `echo(args: { msg: string }): { reply: string }`
281    /// (a typed round-trip) plus `raw(): unknown` (the untyped fallback). Mirrors
282    /// what the catalog mapper produces — structural object types, with their
283    /// shapes registered so codegen emits the object machinery.
284    fn test_mcp_package() -> PackageDeclaration {
285        let pkg = "@mcp/test";
286        let mut defs = PackageDeclaration::with_package(pkg);
287        defs.mcp_server = Some("test".to_string());
288
289        let mut arg_fields = BTreeMap::new();
290        arg_fields.insert("msg".to_string(), ObjectField::required(Type::String));
291        let arg_obj = Type::Object {
292            index: None,
293            fields: arg_fields,
294        };
295        let mut ret_fields = BTreeMap::new();
296        ret_fields.insert("reply".to_string(), ObjectField::required(Type::String));
297        let ret_obj = Type::Object {
298            index: None,
299            fields: ret_fields,
300        };
301        let mut opt_fields = BTreeMap::new();
302        opt_fields.insert("user".to_string(), ObjectField::optional(Type::String));
303        let opt_arg_obj = Type::Object {
304            index: None,
305            fields: opt_fields,
306        };
307        defs.shapes.push(Shape::from_type(&arg_obj).unwrap());
308        defs.shapes.push(Shape::from_type(&ret_obj).unwrap());
309        defs.shapes.push(Shape::from_type(&opt_arg_obj).unwrap());
310
311        let mut insert_fn = |name: &str, params: Vec<Param>, ret: Type| {
312            defs.values.insert(
313                name.to_string(),
314                ValueSymbol {
315                    name: name.to_string(),
316                    mangled_name: mangle::host(pkg, name),
317                    declaration_span: Span::at(crate::FileId::MCP),
318                    kind: ValueKind::Function {
319                        generics: Vec::new(),
320                        params,
321                        ret,
322                        type_predicate: None,
323                        doc: None,
324                    },
325                },
326            );
327        };
328        insert_fn("echo", vec![Param::new("args", arg_obj)], ret_obj);
329        insert_fn("raw", Vec::new(), Type::Unknown);
330        // All-optional args: callable with zero args, the default fills in `{}`.
331        insert_fn(
332            "getTeams",
333            vec![Param::with_default(
334                "args",
335                opt_arg_obj,
336                crate::DefaultValue::EmptyObject,
337            )],
338            Type::Unknown,
339        );
340        defs
341    }
342
343    /// Records every call and returns a canned result.
344    struct MockTransport {
345        result: serde_json::Value,
346        calls: Arc<Mutex<Vec<(String, String, String)>>>,
347    }
348
349    impl McpTransport for MockTransport {
350        fn call<'a>(
351            &'a self,
352            server: &'a str,
353            tool: &'a str,
354            args_json: &'a str,
355        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
356            self.calls.lock().unwrap().push((
357                server.to_string(),
358                tool.to_string(),
359                args_json.to_string(),
360            ));
361            let mut result = self.result.clone();
362            Box::pin(async move {
363                McpOutcome {
364                    result: McpResponse::take(&mut result),
365                    received_bytes: 0,
366                    parsed_bytes: 0,
367                }
368            })
369        }
370    }
371
372    struct DenyAll;
373    impl SecurityCheck for DenyAll {
374        fn check(&self, _: &str, capability: &str, _: &serde_json::Value) -> CheckOutcome {
375            CheckOutcome::Deny {
376                rule: None,
377                reason: format!("denied {capability}"),
378            }
379        }
380    }
381
382    async fn run(
383        source: &str,
384        result: serde_json::Value,
385        security: Option<Arc<dyn SecurityCheck>>,
386    ) -> (
387        wasmtime::Result<Option<String>>,
388        Arc<Mutex<Vec<(String, String, String)>>>,
389    ) {
390        let pkg = test_mcp_package();
391        let compiled =
392            compile_script(source, "<test>", crate::FileId(0), &[], &[&pkg]).expect("compiles");
393
394        let cfg = RuntimeConfig::default();
395        let engine = cfg.engine().unwrap();
396        let calls = Arc::new(Mutex::new(Vec::new()));
397        let mut data = StoreData::with_vfs(Vfs::tempdir().unwrap());
398        data.install_type_info(compiled.type_info.clone());
399        data.mcp_transport = Some(Arc::new(MockTransport {
400            result,
401            calls: calls.clone(),
402        }));
403        if let Some(check) = security {
404            data.security_check = check;
405        }
406        let mut store = cfg.store_async(&engine, data).unwrap();
407        install_tenant_limits(&mut store);
408        let module = Module::new(&engine, &compiled.wasm).unwrap();
409        let mut linker = Linker::<StoreData>::new(&engine);
410        install_runtime_async(&mut linker, &mut store)
411            .await
412            .unwrap();
413        let inst = linker.instantiate_async(&mut store, &module).await.unwrap();
414        let out = dispatch_main_async(&mut store, &inst).await;
415        (out, calls)
416    }
417
418    struct FailingTransport {
419        internal: bool,
420    }
421
422    impl McpTransport for FailingTransport {
423        fn call<'a>(
424            &'a self,
425            _: &'a str,
426            _: &'a str,
427            _: &'a str,
428        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = McpOutcome> + Send + 'a>> {
429            Box::pin(async move {
430                McpOutcome {
431                    result: Err(if self.internal {
432                        super::McpCallError::Internal {
433                            message: "MCP HTTP client initialization failed",
434                        }
435                    } else {
436                        super::McpCallError::Transport("remote connection failed".into())
437                    }),
438                    received_bytes: 0,
439                    parsed_bytes: 0,
440                }
441            })
442        }
443    }
444
445    #[tokio::test]
446    async fn internal_client_failure_bypasses_guest_catch_and_keeps_runtime_usable() {
447        let package = test_mcp_package();
448        let source = r#"import test from "@mcp/test"; function main(): number { try { test.raw(); } catch (e) { return 7; } return 9; }"#;
449        let compiled =
450            compile_script(source, "<test>", crate::FileId(0), &[], &[&package]).unwrap();
451        let healthy = compile_script(
452            "function main(): number { return 42; }",
453            "<healthy>",
454            crate::FileId(0),
455            &[],
456            &[],
457        )
458        .unwrap();
459        let cfg = RuntimeConfig::default();
460        let engine = cfg.engine().unwrap();
461        let mut data = StoreData::with_vfs(Vfs::none());
462        data.install_type_info(compiled.type_info.clone());
463        data.mcp_transport = Some(Arc::new(FailingTransport { internal: true }));
464        let mut store = cfg.store_async(&engine, data).unwrap();
465        install_tenant_limits(&mut store);
466        let mut linker = Linker::<StoreData>::new(&engine);
467        install_runtime_async(&mut linker, &mut store)
468            .await
469            .unwrap();
470        let module = Module::new(&engine, &compiled.wasm).unwrap();
471        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
472        let error = dispatch_main_async(&mut store, &instance)
473            .await
474            .unwrap_err();
475        assert!(
476            error.is::<crate::runtime::host::FatalHostError>(),
477            "{error:?}"
478        );
479        store.data_mut().blocking_work.finish().await.unwrap();
480        store.data_mut().mcp_transport = Some(Arc::new(FailingTransport { internal: false }));
481        assert_eq!(
482            dispatch_main_async(&mut store, &instance).await.unwrap(),
483            Some("7".into())
484        );
485        store
486            .data_mut()
487            .install_type_info(healthy.type_info.clone());
488        let module = Module::new(&engine, &healthy.wasm).unwrap();
489        let instance = linker.instantiate_async(&mut store, &module).await.unwrap();
490        assert_eq!(
491            dispatch_main_async(&mut store, &instance).await.unwrap(),
492            Some("42".into())
493        );
494    }
495
496    #[tokio::test]
497    async fn completed_response_is_fully_allocated_before_exhausted_fuel_stops_execution() {
498        use wasmtime::{Func, FuncType, Val, ValType};
499        let config = RuntimeConfig::default();
500        let engine = config.engine().unwrap();
501        let mut store = config
502            .store_async(&engine, StoreData::with_vfs(Vfs::none()))
503            .unwrap();
504        let mut linker = Linker::new(&engine);
505        install_runtime_async(&mut linker, &mut store)
506            .await
507            .unwrap();
508        let complete = Func::new(
509            &mut store,
510            FuncType::new(&engine, [ValType::I32], [ValType::I32, ValType::F64]),
511            |mut caller, params, results| {
512                let count = params[0].i32().unwrap();
513                let mut value = serde_json::json!((0..count).collect::<Vec<_>>());
514                let response = McpResponse::take(&mut value).unwrap();
515                let value = super::allocate_response(&mut caller, &response)?;
516                let storage =
517                    crate::runtime::array_storage::ArrayStorage::read(&mut caller, &value)?;
518                results[0] = Val::I32(storage.len as i32);
519                let last = storage.get(&mut caller, (count - 1) as u32)?;
520                let Val::AnyRef(Some(last)) = last else {
521                    panic!("missing last value")
522                };
523                results[1] = last.unwrap_struct(&mut caller)?.field(&mut caller, 1)?;
524                Ok(())
525            },
526        );
527        let mut result = [Val::I32(0), Val::F64(0)];
528        for count in [128, 256] {
529            store.set_fuel(100_000).unwrap();
530            let before = store.data().host_fuel;
531            complete
532                .call_async(&mut store, &[Val::I32(count)], &mut result)
533                .await
534                .unwrap();
535            let actual = store.data().host_fuel - before;
536            assert_eq!(
537                actual,
538                2 * crate::runtime::fuel::ELEM.cost(count as u64 + 1)
539            );
540            // The former path additionally serialized and parsed all result bytes.
541            assert_eq!(result[0].i32(), Some(count));
542        }
543        store.set_fuel(1).unwrap();
544        complete
545            .call_async(&mut store, &[Val::I32(256)], &mut result)
546            .await
547            .unwrap();
548        assert_eq!(result[0].i32(), Some(256));
549        assert_eq!(result[1].f64(), Some(255.0));
550        assert_eq!(store.get_fuel().unwrap(), 0);
551        assert!(!store.data().settling_host_result);
552        assert!(crate::runtime::fuel::charge_call(&mut store).is_err());
553    }
554
555    #[tokio::test]
556    async fn typed_tool_round_trips_and_serializes_args() {
557        let (out, calls) = run(
558            r#"import test from "@mcp/test";
559               function main(): void {
560                 const r = test.echo({ msg: "hi" });
561                 assert(r.reply === "yo", "reply");
562               }"#,
563            serde_json::json!({ "reply": "yo" }),
564            None,
565        )
566        .await;
567        out.expect("typed MCP call must succeed");
568        let calls = calls.lock().unwrap();
569        assert_eq!(calls.len(), 1);
570        assert_eq!(calls[0].0, "test");
571        assert_eq!(calls[0].1, "echo");
572        assert!(
573            calls[0].2.contains("\"msg\":\"hi\""),
574            "args serialized to JSON: {}",
575            calls[0].2
576        );
577    }
578
579    #[tokio::test]
580    async fn all_optional_tool_callable_with_zero_args_serializes_empty_object() {
581        // The `args` param has a `{}` default, so the zero-arg call type-checks; the
582        // synthesized empty object serializes to `{}` (absent optional field skipped).
583        let (out, calls) = run(
584            r#"import test from "@mcp/test";
585               function main(): string {
586                 return test.getTeams() as string;
587               }"#,
588            serde_json::json!("[]"),
589            None,
590        )
591        .await;
592        out.expect("zero-arg MCP call must succeed");
593        let calls = calls.lock().unwrap();
594        assert_eq!(calls.len(), 1);
595        assert_eq!(calls[0].1, "getTeams");
596        assert_eq!(calls[0].2, "{}", "empty optional args serialize to {{}}");
597    }
598
599    #[tokio::test]
600    async fn named_import_also_routes_to_mcp_call() {
601        // `import { echo } from "@mcp/test"; echo(...)` resolves through the
602        // bare-identifier call path, not namespace member access — it must still
603        // become an McpCall (else codegen panics on an unknown top-level fn).
604        let (out, calls) = run(
605            r#"import { echo } from "@mcp/test";
606               function main(): void {
607                 const r = echo({ msg: "hi" });
608                 assert(r.reply === "yo", "reply");
609               }"#,
610            serde_json::json!({ "reply": "yo" }),
611            None,
612        )
613        .await;
614        out.expect("named-import MCP call must succeed");
615        assert_eq!(calls.lock().unwrap()[0].1, "echo");
616    }
617
618    #[tokio::test]
619    async fn untyped_tool_returns_unknown_json_value() {
620        let (out, _) = run(
621            r#"import test from "@mcp/test";
622               interface RawResult { k: number; }
623               function main(): void {
624                 const r = test.raw() as RawResult;
625                 assert(r.k === 1, "raw");
626               }"#,
627            serde_json::json!({ "k": 1 }),
628            None,
629        )
630        .await;
631        out.expect("untyped MCP call must succeed");
632    }
633
634    #[tokio::test]
635    async fn zero_arg_tool_sends_empty_object() {
636        let (_out, calls) = run(
637            r#"import test from "@mcp/test";
638               function main(): void { test.raw(); }"#,
639            serde_json::json!({ "k": 1 }),
640            None,
641        )
642        .await;
643        assert_eq!(calls.lock().unwrap()[0].2, "{}");
644    }
645
646    #[tokio::test]
647    async fn denied_call_throws_before_dispatch() {
648        let (out, calls) = run(
649            r#"import test from "@mcp/test";
650               function main(): void { test.echo({ msg: "hi" }); }"#,
651            serde_json::json!({ "reply": "yo" }),
652            Some(Arc::new(DenyAll)),
653        )
654        .await;
655        let err = out.expect_err("a denied capability must throw");
656        assert!(
657            err.to_string().contains("permission denied"),
658            "unexpected error: {err}"
659        );
660        assert!(
661            calls.lock().unwrap().is_empty(),
662            "transport must not be reached when denied"
663        );
664    }
665}