1use 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
27pub 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#[derive(Debug)]
37pub enum McpCallError {
38 ResponseTooLarge,
40 Internal { message: &'static str },
42 AuthExpired,
45 Mcp { message: String },
47 Upstream { status: u16, body: String },
49 Transport(String),
51 Local(String),
54}
55
56impl McpCallError {
57 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
73pub struct McpOutcome {
75 pub result: Result<McpResponse, McpCallError>,
76 pub received_bytes: u64,
77 pub parsed_bytes: u64,
78}
79
80pub 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
110pub 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 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, ¶ms[0], "mcp.call (server)")?;
130 let tool = read_string_arg(&mut *caller, ¶ms[1], "mcp.call (tool)")?;
131 let args_json = read_string_arg(&mut *caller, ¶ms[2], "mcp.call (args)")?;
132
133 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
180fn 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
186pub 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
204pub 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
231fn 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 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 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 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 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 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 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}